Skip to content

Commit d36e4c1

Browse files
committed
test(formatters): add coverage for text-table, markdown blocks/inline, colors, table plain mode
New test files: - text-table.test.ts: 27 tests covering renderTextTable (border styles, alignment, column fitting proportional/balanced, cell wrapping, ANSI-aware width, header separator, multi-column structure) - colors.test.ts: 15 tests for statusColor, levelColor, fixabilityColor, terminalLink Expanded existing files: - markdown.test.ts: +33 tests for colorTag, escapeMarkdownInline, safeCodeSpan, divider, renderMarkdown blocks (headings, code, blockquote, lists, hr, tables), renderInlineMarkdown tokens (italic, links, strikethrough, color tags, unknown tags) - table.test.ts: +2 tests for plain-mode markdown table output Fix: renderInline now handles paired color tags that marked emits as separate html tokens (<red>, text, </red>) by buffering inner tokens until the close tag and applying the color function.
1 parent 3d53708 commit d36e4c1

File tree

5 files changed

+776
-37
lines changed

5 files changed

+776
-37
lines changed

src/lib/formatters/markdown.ts

Lines changed: 87 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -273,51 +273,101 @@ function flattenInline(token: Token): Token[] {
273273
return [token];
274274
}
275275

276+
/**
277+
* Render a single inline token to an ANSI string.
278+
*/
279+
function renderOneInline(token: Token): string {
280+
switch (token.type) {
281+
case "strong":
282+
return chalk.bold(renderInline((token as Tokens.Strong).tokens));
283+
case "em":
284+
return chalk.italic(renderInline((token as Tokens.Em).tokens));
285+
case "codespan":
286+
return chalk.hex(COLORS.yellow)((token as Tokens.Codespan).text);
287+
case "link": {
288+
const link = token as Tokens.Link;
289+
const linkText = renderInline(link.tokens);
290+
const styled = chalk.hex(COLORS.blue)(linkText);
291+
return link.href ? terminalLink(styled, link.href) : styled;
292+
}
293+
case "del":
294+
return chalk.dim.gray.strikethrough(
295+
renderInline((token as Tokens.Del).tokens)
296+
);
297+
case "br":
298+
return "\n";
299+
case "escape":
300+
return (token as Tokens.Escape).text;
301+
case "text":
302+
if ("tokens" in token && (token as Tokens.Text).tokens) {
303+
return renderInline((token as Tokens.Text).tokens ?? []);
304+
}
305+
return (token as Tokens.Text).text;
306+
case "html": {
307+
const raw = (token as Tokens.HTML).raw ?? (token as Tokens.HTML).text;
308+
return renderHtmlToken(raw);
309+
}
310+
default:
311+
return (token as { raw?: string }).raw ?? "";
312+
}
313+
}
314+
276315
/**
277316
* Render an array of inline tokens into an ANSI-styled string.
278317
*
279-
* Handles: strong, em, codespan, link, text, br, del, escape, html.
318+
* Handles paired color tags (`<red>\u2026</red>`) that `marked` emits as
319+
* separate `html` tokens (open, inner tokens, close). Buffers inner
320+
* tokens until the matching close tag, then applies the color function.
321+
*
322+
* Also handles: strong, em, codespan, link, text, br, del, escape.
280323
*/
324+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: paired color tag buffering
281325
function renderInline(tokens: Token[]): string {
282-
return tokens
283-
.map((token) => {
284-
switch (token.type) {
285-
case "strong":
286-
return chalk.bold(renderInline((token as Tokens.Strong).tokens));
287-
case "em":
288-
return chalk.italic(renderInline((token as Tokens.Em).tokens));
289-
case "codespan":
290-
return chalk.hex(COLORS.yellow)((token as Tokens.Codespan).text);
291-
case "link": {
292-
const link = token as Tokens.Link;
293-
const linkText = renderInline(link.tokens);
294-
const styled = chalk.hex(COLORS.blue)(linkText);
295-
return link.href ? terminalLink(styled, link.href) : styled;
296-
}
297-
case "del":
298-
return chalk.dim.gray.strikethrough(
299-
renderInline((token as Tokens.Del).tokens)
300-
);
301-
case "br":
302-
return "\n";
303-
case "escape":
304-
return (token as Tokens.Escape).text;
305-
case "text":
306-
// Text tokens may themselves contain sub-tokens (e.g. from
307-
// autolinked URLs or inline markup inside list items)
308-
if ("tokens" in token && (token as Tokens.Text).tokens) {
309-
return renderInline((token as Tokens.Text).tokens ?? []);
326+
const parts: string[] = [];
327+
let i = 0;
328+
329+
while (i < tokens.length) {
330+
const token = tokens[i] as Token;
331+
332+
// Check for color tag open: <red>, <green>, etc.
333+
if (token.type === "html") {
334+
const raw = (
335+
(token as Tokens.HTML).raw ?? (token as Tokens.HTML).text
336+
).trim();
337+
const openMatch = RE_OPEN_TAG.exec(raw);
338+
if (openMatch) {
339+
const tagName = (openMatch[1] ?? "").toLowerCase();
340+
const colorFn = COLOR_TAGS[tagName];
341+
if (colorFn) {
342+
// Collect inner tokens until matching </tag>
343+
const closeTag = `</${openMatch[1]}>`;
344+
const inner: Token[] = [];
345+
i += 1;
346+
while (i < tokens.length) {
347+
const t = tokens[i] as Token;
348+
if (
349+
t.type === "html" &&
350+
((t as Tokens.HTML).raw ?? (t as Tokens.HTML).text)
351+
.trim()
352+
.toLowerCase() === closeTag.toLowerCase()
353+
) {
354+
i += 1; // consume close tag
355+
break;
356+
}
357+
inner.push(t);
358+
i += 1;
310359
}
311-
return (token as Tokens.Text).text;
312-
case "html": {
313-
const raw = (token as Tokens.HTML).raw ?? (token as Tokens.HTML).text;
314-
return renderHtmlToken(raw);
360+
parts.push(colorFn(renderInline(inner)));
361+
continue;
315362
}
316-
default:
317-
return (token as { raw?: string }).raw ?? "";
318363
}
319-
})
320-
.join("");
364+
}
365+
366+
parts.push(renderOneInline(token));
367+
i += 1;
368+
}
369+
370+
return parts.join("");
321371
}
322372

323373
// ──────────────────────── Block token rendering ──────────────────────

test/lib/formatters/colors.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Tests for terminal color utilities.
3+
*
4+
* Covers: statusColor, levelColor, fixabilityColor, terminalLink,
5+
* and the base color functions.
6+
*/
7+
8+
import { describe, expect, test } from "bun:test";
9+
import chalk from "chalk";
10+
import {
11+
fixabilityColor,
12+
levelColor,
13+
statusColor,
14+
terminalLink,
15+
} from "../../../src/lib/formatters/colors.js";
16+
17+
// Force chalk colors even in test environment
18+
chalk.level = 3;
19+
20+
/** Strip ANSI escape codes for content assertions */
21+
function stripAnsi(str: string): string {
22+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars
23+
return str.replace(/\x1b\[[0-9;]*m/g, "");
24+
}
25+
26+
describe("statusColor", () => {
27+
test("resolved → green-styled text", () => {
28+
const result = statusColor("text", "resolved");
29+
expect(result).toContain("\x1b[");
30+
expect(stripAnsi(result)).toBe("text");
31+
});
32+
33+
test("unresolved → yellow-styled text", () => {
34+
const result = statusColor("text", "unresolved");
35+
expect(result).toContain("\x1b[");
36+
expect(stripAnsi(result)).toBe("text");
37+
});
38+
39+
test("ignored → muted-styled text", () => {
40+
const result = statusColor("text", "ignored");
41+
expect(result).toContain("\x1b[");
42+
expect(stripAnsi(result)).toBe("text");
43+
});
44+
45+
test("undefined defaults to unresolved styling", () => {
46+
const result = statusColor("text", undefined);
47+
expect(result).toContain("\x1b[");
48+
expect(stripAnsi(result)).toBe("text");
49+
});
50+
51+
test("RESOLVED works case-insensitively", () => {
52+
const result = statusColor("text", "RESOLVED");
53+
expect(result).toContain("\x1b[");
54+
expect(stripAnsi(result)).toBe("text");
55+
});
56+
});
57+
58+
describe("levelColor", () => {
59+
test("fatal → colored text", () => {
60+
const result = levelColor("text", "fatal");
61+
expect(result).toContain("\x1b[");
62+
expect(stripAnsi(result)).toBe("text");
63+
});
64+
65+
test("error → colored text", () => {
66+
const result = levelColor("text", "error");
67+
expect(result).toContain("\x1b[");
68+
});
69+
70+
test("warning → colored text", () => {
71+
const result = levelColor("text", "warning");
72+
expect(result).toContain("\x1b[");
73+
});
74+
75+
test("info → colored text", () => {
76+
const result = levelColor("text", "info");
77+
expect(result).toContain("\x1b[");
78+
});
79+
80+
test("debug → colored text", () => {
81+
const result = levelColor("text", "debug");
82+
expect(result).toContain("\x1b[");
83+
});
84+
85+
test("unknown level returns uncolored text", () => {
86+
const result = levelColor("text", "unknown");
87+
expect(result).toBe("text");
88+
});
89+
90+
test("undefined returns uncolored text", () => {
91+
const result = levelColor("text", undefined);
92+
expect(result).toBe("text");
93+
});
94+
});
95+
96+
describe("fixabilityColor", () => {
97+
test("high → green-styled text", () => {
98+
const result = fixabilityColor("text", "high");
99+
expect(result).toContain("\x1b[");
100+
expect(stripAnsi(result)).toBe("text");
101+
});
102+
103+
test("med → yellow-styled text", () => {
104+
const result = fixabilityColor("text", "med");
105+
expect(result).toContain("\x1b[");
106+
expect(stripAnsi(result)).toBe("text");
107+
});
108+
109+
test("low → red-styled text", () => {
110+
const result = fixabilityColor("text", "low");
111+
expect(result).toContain("\x1b[");
112+
expect(stripAnsi(result)).toBe("text");
113+
});
114+
});
115+
116+
describe("terminalLink", () => {
117+
test("wraps text in OSC 8 escape sequences", () => {
118+
const result = terminalLink("click me", "https://example.com");
119+
expect(result).toContain("]8;;https://example.com");
120+
expect(result).toContain("click me");
121+
expect(result).toContain("]8;;");
122+
});
123+
124+
test("preserves display text", () => {
125+
const result = terminalLink("display", "https://url.com");
126+
// biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 uses control chars
127+
const stripped = result.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
128+
expect(stripped).toBe("display");
129+
});
130+
});

0 commit comments

Comments
 (0)