From 82d7c52983c7d9c097205002ed7d816db7fe7cae Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:34:40 -0700 Subject: [PATCH] =?UTF-8?q?feat(f9):=20splitBlocksWithRanges=20=E2=80=94?= =?UTF-8?q?=20block=20offsets=20aligned=20to=20the=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/trackChangesModel.ts | 70 ++++++++++++++++++++++++++++++++++ test/trackChangesModel.test.ts | 21 +++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 43801d0..30c25d7 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -70,6 +70,76 @@ export function splitBlocks(text: string): Block[] { return blocks; } +export interface BlockWithRange extends Block { + /** char offset of the block's first char in the source string. */ + start: number; + /** char offset one past the block's last char (source.slice(start,end) === raw). */ + end: number; +} + +/** + * Like splitBlocks, but each block carries its [start,end) char range in the + * SOURCE string (offsets align with document.getText(), so F3 attribution + * offsets map directly). Lines are tracked with their true source offsets — a + * trailing CR stays in the line, so a CRLF source keeps correct offsets. + */ +export function splitBlocksWithRanges(text: string): BlockWithRange[] { + const lines: { raw: string; start: number }[] = []; + let from = 0; + for (let i = 0; i <= text.length; i++) { + if (i === text.length || text[i] === "\n") { + lines.push({ raw: text.slice(from, i), start: from }); + from = i + 1; + if (i === text.length) break; + } + } + const rangeOf = (lo: number, hi: number): { start: number; end: number } => ({ + start: lines[lo].start, + end: lines[hi].start + lines[hi].raw.length, + }); + const out: BlockWithRange[] = []; + let buf: number[] = []; + const flushProse = () => { + if (buf.length) { + const { start, end } = rangeOf(buf[0], buf[buf.length - 1]); + const raw = text.slice(start, end); + if (raw.trim()) out.push({ ...makeBlock(raw, "prose"), start, end }); + } + buf = []; + }; + let i = 0; + while (i < lines.length) { + const line = lines[i].raw; + const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); + if (fence) { + flushProse(); + const marker = fence[2][0]; + const info = fence[3].trim().split(/\s+/)[0].toLowerCase(); + const open = i; + i++; + while (i < lines.length) { + const closed = lines[i].raw.trim().startsWith(marker.repeat(3)); + i++; + if (closed) break; + } + const close = i - 1; + const { start, end } = rangeOf(open, close); + const raw = text.slice(start, end); + out.push({ ...makeBlock(raw, info === "mermaid" ? "mermaid" : "code"), start, end }); + continue; + } + if (line.trim() === "") { + flushProse(); + i++; + continue; + } + buf.push(i); + i++; + } + flushProse(); + return out; +} + export type BlockOp = | { kind: "unchanged"; block: Block } | { kind: "added"; block: Block } diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index d74414f..f97ea06 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { splitBlocks, diffBlocks, renderTrackChanges } from "../src/trackChangesModel"; +import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges } from "../src/trackChangesModel"; describe("splitBlocks", () => { it("splits prose paragraphs on blank lines, dropping empties", () => { @@ -137,3 +137,22 @@ describe("error chip (PUC-6)", () => { expect(html).toContain("

Good one.

"); // the healthy block still rendered }); }); + +describe("splitBlocksWithRanges — block offsets align with the source string", () => { + it("each block's [start,end) slices back to its raw from the source", () => { + const text = "# Title\n\nA prose paragraph.\n\n```js\ncode()\n```\n"; + const blocks = splitBlocksWithRanges(text); + expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "code"]); + for (const b of blocks) { + expect(text.slice(b.start, b.end)).toBe(b.raw); + } + expect(blocks[1].raw).toBe("A prose paragraph."); + }); + + it("marks a mermaid fence and preserves its source offsets", () => { + const text = "intro\n\n```mermaid\ngraph TD; A-->B;\n```\n"; + const blocks = splitBlocksWithRanges(text); + expect(blocks[1].type).toBe("mermaid"); + expect(text.slice(blocks[1].start, blocks[1].end)).toBe(blocks[1].raw); + }); +});