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
118 changes: 0 additions & 118 deletions src/tui/markdown-parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { describe, expect, test } from "bun:test";
import { defined } from "../../testkit/defined.js";
import {
createMemoizedParseMarkdown,
nextStreamMarkdownState,
parseMarkdown,
splitAtSettledBlock,
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
Expand Down
109 changes: 14 additions & 95 deletions src/tui/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "▏ ";

Expand Down Expand Up @@ -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,
Expand All @@ -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<string, StyledSegment[][]>();

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(
Expand All @@ -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;
}

Expand Down Expand Up @@ -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");
Expand Down
Loading