feat(f9): splitBlocksWithRanges — block offsets aligned to the source

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-11 14:34:40 -07:00
parent 0eaca37d5e
commit 82d7c52983
2 changed files with 90 additions and 1 deletions
+70
View File
@@ -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 }
+20 -1
View File
@@ -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("<p>Good one.</p>"); // 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);
});
});