diff --git a/src/tui/markdown-parser.test.ts b/src/tui/markdown-parser.test.ts index 1131181f6..50997934f 100644 --- a/src/tui/markdown-parser.test.ts +++ b/src/tui/markdown-parser.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { defined } from "../../testkit/defined.js"; import { - createMemoizedParseMarkdown, nextStreamMarkdownState, parseMarkdown, splitAtSettledBlock, @@ -116,74 +114,6 @@ describe("block elements", () => { expect(segs[0]?.rule).toBe(true); expect(segs[0]?.text).not.toContain("-"); }); - - test("fenced code block renders inner lines as code without inline parsing", () => { - const lines = parseMarkdown("```\nconst x = **not bold**\n```"); - const body = lines.flat().find((s) => s.text.includes("const x")); - expect(body?.code).toBe(true); - expect(body?.text).toContain("const x = **not bold**"); - expect(lines[0]?.[0]?.text).toContain("╭"); - }); - - test("a fenced block keeps its language cap with a plain body", () => { - const lines = parseMarkdown('```js\nconst x = "hi";\n```'); - const body = lines.flat().find((s) => s.text.includes("const x")); - expect(body?.code).toBe(true); - expect(body?.color).toBeUndefined(); - expect(lines[0]?.map((s) => s.text).join("")).toContain("js"); - }); - - test("an unclosed streaming fence keeps a plain body", () => { - const lines = parseMarkdown("```js\nconst x = 1;"); - const body = lines.flat().find((s) => s.text.includes("const x")); - expect(body?.code).toBe(true); - expect(body?.color).toBeUndefined(); - }); - - test("drops a half-typed closing fence from the streaming tail", () => { - const lines = parseMarkdown("```js\nconst x = 1;\n``"); - const rendered = lines - .flat() - .map((s) => s.text) - .join(""); - expect(rendered).not.toContain("``"); - expect(rendered).toContain("const"); - }); - - test("closing fence streamed one character at a time never shrinks the block", () => { - // The newline after the body starts a fresh, empty line that could become - // the closing fence. Streaming ` then `` then ``` across it must never - // remove an already-visible line (shrink reads as flicker) — only hold - // steady or grow as the fence completes. - const base = "```js\nconst x = 1;\n"; - const steps = [base, `${base}\``, `${base}\`\``, `${base}\`\`\``]; - const lineCounts = steps.map((content) => parseMarkdown(content).length); - for (let i = 1; i < lineCounts.length; i++) { - expect(lineCounts[i]).toBeGreaterThanOrEqual(defined(lineCounts[i - 1])); - } - }); - - test("blank lines inside a fenced block keep a continuous gutter", () => { - const lines = parseMarkdown("```ts\nconst a = 1;\n\nconst b = 2;\n```"); - // Cap, body, blank, body, foot — every body row carries the gutter so the - // frame does not fragment. - const gutterLines = lines.filter((line) => - line.some((s) => s.text.includes("▏")), - ); - expect(gutterLines.length).toBeGreaterThanOrEqual(3); - // The blank body line is not an empty segment array — it still paints ▏. - const blankBody = lines.find( - (line) => - line.length === 1 && - line[0]?.text === "▏ " && - line[0]?.codeFence === true, - ); - expect(blankBody).toBeDefined(); - expect(lines[0]?.some((s) => s.text.includes("╭"))).toBe(true); - expect(lines[lines.length - 1]?.some((s) => s.text.includes("╰"))).toBe( - true, - ); - }); }); describe("bullets", () => { @@ -495,54 +425,6 @@ test("link with an empty URL still renders as styled text, not raw characters", expect(segs.some((s) => s.text.includes("("))).toBe(false); }); -describe("createMemoizedParseMarkdown", () => { - test("returns the same array reference for a repeated (text, width) call", () => { - const memoized = createMemoizedParseMarkdown(); - const first = memoized("hello **world**", 80); - const second = memoized("hello **world**", 80); - expect(second).toBe(first); - }); - - test("misses on a different width even with identical text", () => { - const memoized = createMemoizedParseMarkdown(); - const at80 = memoized("some prose", 80); - const at40 = memoized("some prose", 40); - expect(at40).not.toBe(at80); - expect(at40).toEqual(parseMarkdown("some prose", 40)); - }); - - test("misses on different text at the same width", () => { - const memoized = createMemoizedParseMarkdown(); - const a = memoized("first", 80); - const b = memoized("second", 80); - expect(b).not.toBe(a); - }); - - test("evicts the least recently used entry once over capacity", () => { - const memoized = createMemoizedParseMarkdown(2); - const a = memoized("a", 80); - const b = memoized("b", 80); - // Touch "a" again so "b" becomes the least recently used entry. - expect(memoized("a", 80)).toBe(a); - const c = memoized("c", 80); // over capacity: evicts "b" - - // "b" was evicted: same content, but freshly parsed (not identical). - expect(memoized("b", 80)).not.toBe(b); - // "c" is still warm — inserted more recently than "a", which the re-fetch - // of "b" evicted. - expect(memoized("c", 80)).toBe(c); - }); - - test("clear() drops every cached entry", () => { - const memoized = createMemoizedParseMarkdown(); - const first = memoized("hello", 80); - memoized.clear(); - const second = memoized("hello", 80); - expect(second).not.toBe(first); - expect(second).toEqual(first); - }); -}); - describe("withholdIncompleteHeading", () => { test("strips a trailing bare heading marker while streaming", () => { expect(withholdIncompleteHeading("hello\n####")).toBe("hello\n"); diff --git a/src/tui/markdown-parser.ts b/src/tui/markdown-parser.ts index 51ac8b6c8..23d446b8c 100644 --- a/src/tui/markdown-parser.ts +++ b/src/tui/markdown-parser.ts @@ -200,10 +200,6 @@ function parseLine(line: string): StyledSegment[] { const FENCE_OPEN_RE = /^\s*(```+|~~~+)/; const FENCE_CLOSE_RE = /^\s*(```+|~~~+)\s*$/; -// A partially-typed closing fence: zero to two lone fence chars on a line -// (zero = the empty line right after the body). Stripped from the streaming -// tail so the block does not flicker while the fence is typed. -const PARTIAL_FENCE_RE = /^\s*[`~]{0,2}\s*$/; const INDENTED_CODE_RE = /^(?: {4}|\t)(.*)$/; const CODE_GUTTER = "▏ "; @@ -232,48 +228,6 @@ function fencedFoot(): StyledSegment[] { return [{ text: "╰", dim: true, codeFence: true }]; } -// Collect a fenced block from `start`: highlight the body by the fence's -// language token and frame it with cap/gutter glyphs. An unclosed streaming -// block drops a partial closing fence so the tail re-highlights cleanly. -function parseFencedBlock( - input: string[], - start: number, - width: number, -): FencedBlock { - const openerLine = input[start]; - if (openerLine == null) throw new Error("fence opener missing"); - const language = - openerLine.match(/^\s*(?:```+|~~~+)\s*([^\s`]*)/)?.[1] || undefined; - const body: string[] = []; - let i = start + 1; - let closed = false; - for (; i < input.length; i++) { - const line = input[i]; - if (line == null) throw new Error("fence line missing"); - if (FENCE_CLOSE_RE.test(line)) { - closed = true; - break; - } - body.push(line); - } - const consumed = closed ? i - start + 1 : i - start; - - const last = body[body.length - 1]; - if (!closed && last != null && PARTIAL_FENCE_RE.test(last)) { - body.pop(); - } - - const capLabel = language && language.length > 0 ? language : "code"; - const lines: StyledSegment[][] = [fencedCap(capLabel)]; - if (body.length > 0) { - lines.push( - ...highlightCode(body.join("\n"), language, width).map(codeGutterPrefix), - ); - } - if (closed) lines.push(fencedFoot()); - return { lines, consumed }; -} - function parseIndentedCodeBlock( input: string[], start: number, @@ -298,47 +252,6 @@ function parseIndentedCodeBlock( return { lines, consumed: i - start }; } -export type MemoizedParseMarkdown = (( - text: string, - width?: number, -) => StyledSegment[][]) & { - clear: () => void; -}; - -const DEFAULT_MARKDOWN_CACHE_ENTRIES = 32; - -// Segments never change for a fixed (text, width) pair and resize-free -// re-renders re-ask for the same pair, so a small bounded LRU makes those -// hits. Callers clear() it with the per-block line cache (event-log.tsx / -// app.tsx) so it does not grow across a session. -export function createMemoizedParseMarkdown( - maxEntries = DEFAULT_MARKDOWN_CACHE_ENTRIES, -): MemoizedParseMarkdown { - const cache = new Map(); - - const memoized = (text: string, width = Infinity): StyledSegment[][] => { - const key = `${width}\x1f${text}`; - const cached = cache.get(key); - if (cached !== undefined) { - // Bump recency by re-inserting at the end of Map's iteration order. - cache.delete(key); - cache.set(key, cached); - return cached; - } - - const result = parseMarkdown(text, width); - cache.set(key, result); - if (cache.size > maxEntries) { - const oldestKey = cache.keys().next().value; - if (oldestKey !== undefined) cache.delete(oldestKey); - } - return result; - }; - - memoized.clear = () => cache.clear(); - return memoized; -} - // `width` is the column budget the event log wraps to; tables lay out to it // (default Infinity = natural width). export function parseMarkdown( @@ -352,12 +265,17 @@ export function parseMarkdown( const line = input[i]; if (line == null) throw new Error("markdown line missing"); - // Fenced code block (``` or ~~~): collect it whole so the body can be - // syntax-highlighted by the fence's language token. + // Fenced code (``` / ~~~) paints through MarkdownRenderable. This walker + // still has to skip the body so a fence is not re-read as a table or list. if (FENCE_OPEN_RE.test(line)) { - const block = parseFencedBlock(input, i, width); - lines.push(...block.lines); - i += block.consumed - 1; + let closedAt = -1; + for (let j = i + 1; j < input.length; j++) { + if (FENCE_CLOSE_RE.test(input[j] ?? "")) { + closedAt = j; + break; + } + } + i = closedAt === -1 ? input.length - 1 : closedAt; continue; } @@ -820,9 +738,10 @@ export function splitAtSettledHeading(text: string): MarkdownSplit | null { /** * Split at the last settled block boundary: heading, fence closer, or table - * row. Null when none has content behind it. The walk mirrors parseMarkdown's - * dispatch order, so halves render the same apart as together. An unclosed - * fence consumes to the end; blank lines before the tail collapse to one. + * row. Null when none has content behind it. Fence, indented-code, table, and + * heading checks stay in that order so a settled block can freeze while the + * tail is still streaming. An unclosed fence consumes to the end; blank lines + * before the tail collapse to one. */ export function splitAtSettledBlock(withheld: string): MarkdownSplit | null { const lines = withheld.split("\n");