Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions src/tui/markdown-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test";
import { defined } from "../../testkit/defined.js";
import {
createMemoizedParseMarkdown,
nextStreamMarkdownState,
parseMarkdown,
splitAtSettledBlock,
splitAtSettledHeading,
withholdIncompleteHeading,
type StyledSegment,
Expand Down Expand Up @@ -567,3 +569,230 @@ describe("splitAtSettledHeading", () => {
expect(splitAtSettledHeading("# Title")).toBeNull();
});
});

describe("splitAtSettledBlock", () => {
test("freezes through the latest settled block (heading, fence, table)", () => {
const lines = [
"### Title",
"",
"```bash",
"echo hi",
"```",
"",
"| a | b |",
"|---|---|",
"| 1 | 2 |",
"",
"tail",
];
expect(splitAtSettledBlock(lines.join("\n"))).toEqual({
frozen: lines.slice(0, 9).join("\n"),
live: "tail",
gapRows: 1,
});
});

test("freezes a closed fence with prose after the closer", () => {
expect(splitAtSettledBlock("```bash\necho hi\n```\n\ntail")).toEqual({
frozen: "```bash\necho hi\n```",
live: "tail",
gapRows: 1,
});
});

test("supports tilde fences and indented openers up to three spaces", () => {
expect(splitAtSettledBlock("~~~\ncode\n~~~\n\ntail")).toEqual({
frozen: "~~~\ncode\n~~~",
live: "tail",
gapRows: 1,
});
expect(splitAtSettledBlock(" ```js\ncode\n ```\n\ntail")).toEqual({
frozen: " ```js\ncode\n ```",
live: "tail",
gapRows: 1,
});
});

test("a four-space indented fence is not a fence, so earlier text can settle", () => {
expect(splitAtSettledBlock("## T\n\n ```\nx")).toEqual({
frozen: "## T",
live: " ```\nx",
gapRows: 1,
});
});

test("an unclosed fence after settled text still leaves the settled split in place", () => {
expect(splitAtSettledBlock("### T\n\n```bash\necho hi")).toEqual({
frozen: "### T",
live: "```bash\necho hi",
gapRows: 1,
});
expect(splitAtSettledBlock("```bash\necho hi")).toBeNull();
});

test("a closed block at the last line falls back to the earlier split", () => {
expect(splitAtSettledBlock("## T\n\n```\ncode\n```")).toEqual({
frozen: "## T",
live: "```\ncode\n```",
gapRows: 1,
});
expect(splitAtSettledBlock("## T\n\n| h |\n|---|\n| r |")).toEqual({
frozen: "## T",
live: "| h |\n|---|\n| r |",
gapRows: 1,
});
expect(splitAtSettledBlock("```\ncode\n```")).toBeNull();
expect(splitAtSettledBlock("| h |\n|---|\n| r |")).toBeNull();
});

test("freezes through a completed separator table, not just the heading", () => {
const lines = [
"## T",
"",
"Name | Age",
"--- | ---",
"Ada | 36",
"",
"tail",
];
expect(splitAtSettledBlock(lines.join("\n"))).toEqual({
frozen: lines.slice(0, 5).join("\n"),
live: "tail",
gapRows: 1,
});
});

test("freezes through a completed borderless table", () => {
const lines = [
"## T",
"",
"| h1 | h2 |",
"| r1 | r2 |",
"| r3 | r4 |",
"",
"tail",
];
expect(splitAtSettledBlock(lines.join("\n"))).toEqual({
frozen: lines.slice(0, 5).join("\n"),
live: "tail",
gapRows: 1,
});
});

test("a table that reaches the last line keeps the row live", () => {
expect(splitAtSettledBlock("| h |\n|---|\n| r1 |\n| r2")).toBeNull();
expect(splitAtSettledBlock("## T\n\n| h |\n|---|\n| r1 |\n| r2")).toEqual({
frozen: "## T",
live: "| h |\n|---|\n| r1 |\n| r2",
gapRows: 1,
});
});

test("a table with no separator is not a table", () => {
expect(splitAtSettledBlock("a | b\n\ntail")).toBeNull();
});

test("agrees with splitAtSettledHeading on heading-only content", () => {
for (const text of ["# Title\nbody", "# Title", "plain"]) {
expect(splitAtSettledBlock(text)).toEqual(splitAtSettledHeading(text));
}
});
});

describe("nextStreamMarkdownState", () => {
const frozen = "### T";
const base = {
content: `${frozen}\n\ntail`,
frozen,
width: 100,
streaming: true,
};

test("a first paint always repaints", () => {
expect(
nextStreamMarkdownState(
null,
base.content,
base.frozen,
base.width,
true,
),
).toEqual({
state: base,
paintFrozen: true,
});
});

test("tail-only growth skips the frozen repaint", () => {
const grown = { ...base, content: `${frozen}\n\ntail grows` };
const next = nextStreamMarkdownState(
base,
grown.content,
grown.frozen,
grown.width,
true,
);
expect(next.paintFrozen).toBe(false);
expect(next.state.content).toBe(grown.content);
});

test("a moved boundary repaints the frozen node", () => {
const moved = { ...base, frozen: `${frozen}\n\nsettled` };
expect(
nextStreamMarkdownState(
base,
base.content,
moved.frozen,
base.width,
true,
).paintFrozen,
).toBe(true);
});

test("width and streaming flips repaint the frozen node", () => {
expect(
nextStreamMarkdownState(
base,
base.content,
base.frozen,
base.width + 1,
true,
).paintFrozen,
).toBe(true);
expect(
nextStreamMarkdownState(
base,
base.content,
base.frozen,
base.width,
false,
).paintFrozen,
).toBe(true);
});

test("mid-stream edits repaint the frozen node", () => {
const edited = `${frozen}\n\nrewritten`;
const prev = { ...base, content: `${frozen}\n\ntail` };
const next = nextStreamMarkdownState(
prev,
edited,
frozen,
base.width,
true,
);
expect(next.paintFrozen).toBe(true);
expect(next.state).toBeDefined();
});

test("identical content still skips the frozen repaint", () => {
const next = nextStreamMarkdownState(
base,
base.content,
base.frozen,
base.width,
true,
);
expect(next.paintFrozen).toBe(false);
expect(next.state).toEqual(base);
});
});
116 changes: 116 additions & 0 deletions src/tui/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,3 +854,119 @@ export function splitAtSettledHeading(text: string): MarkdownSplit | null {
gapRows: firstContent > 0 ? 1 : 0,
};
}

/**
* A markdown body split at the last settled block boundary: a settled ATX
* heading line, the closing fence of a completed fenced block, or the last
* row of a table the renderer would consume. Returns null when no boundary
* has settled yet (or when the only boundary is the last line, leaving no
* live tail).
*
* The walk below mirrors parseMarkdown's dispatch order line for line —
* loose fence pairing (`FENCE_OPEN_RE`/`FENCE_CLOSE_RE`), indented-code runs,
* then `parseTableBlock`'s start/consume/shape rules — so every recorded
* boundary is a block boundary in the one-shot parse, and the frozen prefix
* plus the live suffix render the same apart as they do together. An
* unclosed fence consumes to the end of the row, so nothing after its opener
* can settle. A single non-blank line between a boundary and the live tail
* still yields `gapRows: 0`; blank lines collapse to one gap row, as before.
*/
export function splitAtSettledBlock(withheld: string): MarkdownSplit | null {
const lines = withheld.split("\n");
const boundaries = new Set<number>();
let i = 0;
while (i < lines.length) {
const line = lines[i] ?? "";
if (FENCE_OPEN_RE.test(line)) {
let closer = -1;
for (let j = i + 1; j < lines.length; j += 1) {
if (FENCE_CLOSE_RE.test(lines[j] ?? "")) {
closer = j;
break;
}
}
if (closer === -1) break;
boundaries.add(closer);
i = closer + 1;
continue;
}
if (INDENTED_CODE_RE.test(line)) {
i += 1;
while (i < lines.length && INDENTED_CODE_RE.test(lines[i] ?? "")) i += 1;
continue;
}
if (looksLikeTableRow(line)) {
const next = lines[i + 1];
const hasSeparator = next !== undefined && isTableSeparator(next);
const rawRows: string[][] = [extractTableCells(line)];
let consumed = 1;
if (hasSeparator) {
rawRows.push(extractTableCells(next ?? ""));
consumed = 2;
}
while (i + consumed < lines.length) {
const rowLine = lines[i + consumed] ?? "";
if (!looksLikeTableRow(rowLine) || isTableSeparator(rowLine)) break;
rawRows.push(extractTableCells(rowLine));
consumed += 1;
}
const firstWidth = rawRows[0]?.length ?? 0;
const valid =
hasSeparator ||
(rawRows.length >= 2 &&
firstWidth >= 2 &&
rawRows.every((row) => row.length === firstWidth));
if (valid) {
boundaries.add(i + consumed - 1);
i += consumed;
continue;
}
}
if (ATX_HEADING_LINE_RE.test(line)) boundaries.add(i);
i += 1;
}
const ordered = [...boundaries].sort((a, b) => b - a);
for (const boundary of ordered) {
if (boundary >= lines.length - 1) continue;
const rest = lines.slice(boundary + 1);
const firstContent = rest.findIndex((entry) => entry.trim().length > 0);
if (firstContent === -1) continue;
return {
frozen: lines.slice(0, boundary + 1).join("\n"),
live: rest.slice(firstContent).join("\n"),
gapRows: firstContent > 0 ? 1 : 0,
};
}
return null;
}

/** Paint state for one streaming markdown row across repaints. */
export interface StreamMarkdownSnapshot {
readonly content: string;
readonly frozen: string | null;
readonly width: number;
readonly streaming: boolean;
}

/**
* Decide whether a streaming markdown repaint must repaint the frozen node.
* The same frozen text at the same width, still streaming a pure append,
* needs no frozen repaint; a moved boundary, a resize, a streaming-flag
* flip, or a non-append edit (rollback, rewrite) repaints it.
*/
export function nextStreamMarkdownState(
prev: StreamMarkdownSnapshot | null,
content: string,
frozen: string | null,
width: number,
streaming: boolean,
): { state: StreamMarkdownSnapshot; paintFrozen: boolean } {
const state: StreamMarkdownSnapshot = { content, frozen, width, streaming };
if (prev === null) return { state, paintFrozen: true };
const paintFrozen =
prev.frozen !== frozen ||
prev.width !== width ||
prev.streaming !== streaming ||
!content.startsWith(prev.content);
return { state, paintFrozen };
}
Loading
Loading