diff --git a/bun.lock b/bun.lock index c13f39d..8caea9e 100644 --- a/bun.lock +++ b/bun.lock @@ -4,22 +4,21 @@ "workspaces": { "": { "name": "review-codecommit", - "dependencies": { - "@aws-sdk/client-codecommit": "3.990.0", - "@smithy/node-http-handler": "4.4.10", - "ink": "6.7.0", - "ink-text-input": "6.0.0", - "react": "19.2.4", - }, "devDependencies": { + "@aws-sdk/client-codecommit": "3.990.0", "@biomejs/biome": "^2.3.15", + "@smithy/node-http-handler": "4.4.10", "@types/bun": "^1.2.0", "@types/react": "^19.2.14", "@vitest/coverage-v8": "^4.0.18", + "emphasize": "^7.0.0", "fast-check": "^3.0.0", + "ink": "6.7.0", "ink-testing-library": "^4.0.0", + "ink-text-input": "6.0.0", "knip": "^5.83.1", "oxlint": "^0.15.0", + "react": "19.2.4", "typescript": "^5.7.0", "vitest": "^4.0.18", }, @@ -387,10 +386,14 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="], "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], @@ -443,8 +446,14 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emphasize": ["emphasize@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "chalk": "^5.0.0", "highlight.js": "~11.9.0", "lowlight": "~3.1.0" } }, "sha512-jdFCDyt+YetBXO12VwK4AiLsMCvkZ3IBxMVIJddB+25EwIL0VETBgpvPkJl63+JyAgaQ5Wja10qWMoXXC95JNg=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], @@ -483,6 +492,8 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "highlight.js": ["highlight.js@11.9.0", "", {}, "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -517,6 +528,8 @@ "knip": ["knip@5.83.1", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "jiti": "^2.6.0", "js-yaml": "^4.1.1", "minimist": "^1.2.8", "oxc-resolver": "^11.15.0", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.5.2", "strip-json-comments": "5.0.3", "zod": "^4.1.11" }, "peerDependencies": { "@types/node": ">=18", "typescript": ">=5.0.4 <7" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-av3ZG/Nui6S/BNL8Tmj12yGxYfTnwWnslouW97m40him7o8MwiMjZBY9TPvlEWUci45aVId0/HbgTwSKIDGpMw=="], + "lowlight": ["lowlight@3.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.9.0" } }, "sha512-CEbNVoSikAxwDMDPjXlqlFYiZLkDJHwyGu/MfOsJnF3d7f3tds5J3z8s/l9TMXhzfsJCCJEAsD78842mwmg0PQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], diff --git a/package.json b/package.json index b72a9d8..426d986 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/bun": "^1.2.0", "@types/react": "^19.2.14", "@vitest/coverage-v8": "^4.0.18", + "emphasize": "^7.0.0", "fast-check": "^3.0.0", "ink": "6.7.0", "ink-testing-library": "^4.0.0", diff --git a/src/components/DiffLine.test.tsx b/src/components/DiffLine.test.tsx new file mode 100644 index 0000000..d5222c6 --- /dev/null +++ b/src/components/DiffLine.test.tsx @@ -0,0 +1,169 @@ +import { render } from "ink-testing-library"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { DisplayLine } from "../utils/formatDiff.js"; +import * as highlightCodeModule from "../utils/highlightCode.js"; +import { renderDiffLine } from "./DiffLine.js"; + +function renderLine(line: DisplayLine, isCursor = false): string { + const { lastFrame } = render(<>{renderDiffLine(line, isCursor)}); + return lastFrame() ?? ""; +} + +describe("renderDiffLine with syntax highlighting", () => { + it("highlights add line with known language", () => { + const line: DisplayLine = { + type: "add", + text: "+const x = 42;", + filePath: "src/index.ts", + }; + const output = renderLine(line); + expect(output).toContain("+"); + expect(output).toContain("const"); + expect(output).toContain("42"); + }); + + it("highlights delete line with known language", () => { + const line: DisplayLine = { + type: "delete", + text: "-let y = 0;", + filePath: "src/index.ts", + }; + const output = renderLine(line); + expect(output).toContain("-"); + expect(output).toContain("let"); + }); + + it("highlights context line with known language", () => { + const line: DisplayLine = { + type: "context", + text: " return x;", + filePath: "src/index.ts", + }; + const output = renderLine(line); + expect(output).toContain("return"); + }); + + it("falls back for unknown file extensions", () => { + const line: DisplayLine = { + type: "add", + text: "+some data", + filePath: "data.xyz", + }; + const output = renderLine(line); + expect(output).toContain("+some data"); + }); + + it("falls back when no filePath", () => { + const line: DisplayLine = { + type: "add", + text: "+hello", + }; + const output = renderLine(line); + expect(output).toContain("+hello"); + }); + + it("applies bold when cursor is active", () => { + const line: DisplayLine = { + type: "add", + text: "+const x = 1;", + filePath: "src/app.ts", + }; + const output = renderLine(line, true); + expect(output).toContain("const"); + }); + + it("renders context line without prefixColor", () => { + const line: DisplayLine = { + type: "context", + text: " const y = 2;", + filePath: "src/app.ts", + }; + const output = renderLine(line); + expect(output).toContain("const"); + expect(output).toContain("2"); + }); + + it("handles empty content after prefix", () => { + const line: DisplayLine = { + type: "add", + text: "+", + filePath: "src/app.ts", + }; + const output = renderLine(line); + expect(output).toContain("+"); + }); + + it("highlights Python code", () => { + const line: DisplayLine = { + type: "add", + text: "+def hello():", + filePath: "main.py", + }; + const output = renderLine(line); + expect(output).toContain("def"); + }); + + it("highlights JSON", () => { + const line: DisplayLine = { + type: "context", + text: ' "name": "test"', + filePath: "package.json", + }; + const output = renderLine(line); + expect(output).toContain("name"); + }); + + it("renders segments with bold and dim attributes", () => { + const line: DisplayLine = { + type: "add", + text: "+import React from 'react';", + filePath: "src/app.tsx", + }; + const output = renderLine(line); + expect(output).toContain("import"); + expect(output).toContain("React"); + }); + + it("renders non-highlighted line types unchanged", () => { + const header: DisplayLine = { type: "header", text: "src/auth.ts" }; + expect(renderLine(header)).toContain("src/auth.ts"); + + const sep: DisplayLine = { type: "separator", text: "──────" }; + expect(renderLine(sep)).toContain("──────"); + + const trunc: DisplayLine = { type: "truncation", text: "[t] show more" }; + expect(renderLine(trunc)).toContain("[t] show more"); + }); + + it("renders segments with bold, dim, and italic attributes", () => { + const spy = vi.spyOn(highlightCodeModule, "highlightLine").mockReturnValueOnce([ + { text: "/* ", dim: true }, + { text: "important", bold: true }, + { text: "comment", italic: true }, + { text: " */", dim: true }, + ]); + const line: DisplayLine = { + type: "context", + text: " /* comment */", + filePath: "src/index.ts", + }; + const output = renderLine(line); + expect(output).toContain("comment"); + spy.mockRestore(); + }); + + it("renders single plain segment without wrapping", () => { + const spy = vi + .spyOn(highlightCodeModule, "highlightLine") + .mockReturnValueOnce([{ text: "plain text" }]); + const line: DisplayLine = { + type: "add", + text: "+plain text", + filePath: "src/index.ts", + }; + const output = renderLine(line); + expect(output).toContain("plain text"); + spy.mockRestore(); + }); +}); diff --git a/src/components/DiffLine.tsx b/src/components/DiffLine.tsx index 99f369f..1fbafdb 100644 --- a/src/components/DiffLine.tsx +++ b/src/components/DiffLine.tsx @@ -1,6 +1,55 @@ import { Text } from "ink"; import React from "react"; import type { DisplayLine } from "../utils/formatDiff.js"; +import { detectLanguage, type HighlightSegment, highlightLine } from "../utils/highlightCode.js"; + +function renderSegments(segments: HighlightSegment[]): React.ReactNode { + if (segments.length === 1 && !segments[0]?.color && !segments[0]?.bold && !segments[0]?.dim) { + /* v8 ignore next -- segments always have at least one element here */ + return segments[0]?.text ?? ""; + } + let offset = 0; + return segments.map((seg) => { + const key = `s${offset}`; + offset += seg.text.length; + return ( + + {seg.text} + + ); + }); +} + +function renderCodeLine( + line: DisplayLine, + prefixColor: string | undefined, + bold: boolean, +): React.ReactNode { + const prefix = line.text.slice(0, 1); + const content = line.text.slice(1); + const lang = line.filePath ? detectLanguage(line.filePath) : undefined; + + if (lang && content) { + const segments = highlightLine(content, lang); + return ( + + {prefixColor ? {prefix} : {prefix}} + {renderSegments(segments)} + + ); + } + return ( + + {line.text} + + ); +} export function renderDiffLine(line: DisplayLine, isCursor = false): React.ReactNode { const bold = isCursor; @@ -14,19 +63,11 @@ export function renderDiffLine(line: DisplayLine, isCursor = false): React.React case "separator": return {line.text}; case "add": - return ( - - {line.text} - - ); + return renderCodeLine(line, "green", bold); case "delete": - return ( - - {line.text} - - ); + return renderCodeLine(line, "red", bold); case "context": - return {line.text}; + return renderCodeLine(line, undefined, bold); case "truncate-context": return {line.text}; case "truncation": diff --git a/src/utils/highlightCode.test.ts b/src/utils/highlightCode.test.ts new file mode 100644 index 0000000..7c2817e --- /dev/null +++ b/src/utils/highlightCode.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { detectLanguage, highlightLine, parseAnsiSegments } from "./highlightCode.js"; + +describe("detectLanguage", () => { + it("detects TypeScript by .ts extension", () => { + expect(detectLanguage("src/utils/helper.ts")).toBe("typescript"); + }); + + it("detects TypeScript by .tsx extension", () => { + expect(detectLanguage("components/App.tsx")).toBe("typescript"); + }); + + it("detects JavaScript by .js extension", () => { + expect(detectLanguage("lib/index.js")).toBe("javascript"); + }); + + it("detects Python by .py extension", () => { + expect(detectLanguage("scripts/build.py")).toBe("python"); + }); + + it("detects Makefile by filename", () => { + expect(detectLanguage("Makefile")).toBe("makefile"); + expect(detectLanguage("src/Makefile")).toBe("makefile"); + }); + + it("detects Dockerfile as bash", () => { + expect(detectLanguage("Dockerfile")).toBe("bash"); + }); + + it("returns undefined for unknown extensions", () => { + expect(detectLanguage("data.xyz")).toBeUndefined(); + }); + + it("returns undefined for files without extension", () => { + expect(detectLanguage("LICENSE")).toBeUndefined(); + }); + + it("detects language from various extensions", () => { + const cases: [string, string][] = [ + ["style.css", "css"], + ["config.json", "json"], + ["deploy.yaml", "yaml"], + ["setup.yml", "yaml"], + ["main.go", "go"], + ["App.java", "java"], + ["lib.rs", "rust"], + ["script.sh", "bash"], + ["query.sql", "sql"], + ["page.html", "xml"], + ["schema.graphql", "graphql"], + ["config.ini", "ini"], + ]; + for (const [file, expected] of cases) { + expect(detectLanguage(file)).toBe(expected); + } + }); +}); + +describe("parseAnsiSegments", () => { + it("returns plain text when no ANSI codes", () => { + const segments = parseAnsiSegments("hello world"); + expect(segments).toEqual([{ text: "hello world" }]); + }); + + it("parses foreground color codes", () => { + // green "const" then reset + const ansi = "\x1b[32mconst\x1b[39m x"; + const segments = parseAnsiSegments(ansi); + expect(segments).toEqual([{ text: "const", color: "green" }, { text: " x" }]); + }); + + it("parses multiple color segments", () => { + const ansi = "\x1b[32mconst\x1b[39m \x1b[33mx\x1b[39m = \x1b[36m42\x1b[39m;"; + const segments = parseAnsiSegments(ansi); + expect(segments).toEqual([ + { text: "const", color: "green" }, + { text: " " }, + { text: "x", color: "yellow" }, + { text: " = " }, + { text: "42", color: "cyan" }, + { text: ";" }, + ]); + }); + + it("parses bold attribute", () => { + const ansi = "\x1b[1mhello\x1b[22m world"; + const segments = parseAnsiSegments(ansi); + expect(segments).toEqual([{ text: "hello", bold: true }, { text: " world" }]); + }); + + it("parses dim attribute", () => { + const ansi = "\x1b[2mfaded\x1b[22m normal"; + const segments = parseAnsiSegments(ansi); + expect(segments).toEqual([{ text: "faded", dim: true }, { text: " normal" }]); + }); + + it("parses italic attribute", () => { + const ansi = "\x1b[3mitalic\x1b[23m normal"; + const segments = parseAnsiSegments(ansi); + expect(segments).toEqual([{ text: "italic", italic: true }, { text: " normal" }]); + }); + + it("handles combined SGR codes", () => { + const ansi = "\x1b[1;32mbold green\x1b[0m rest"; + const segments = parseAnsiSegments(ansi); + expect(segments).toEqual([ + { text: "bold green", color: "green", bold: true }, + { text: " rest" }, + ]); + }); + + it("handles reset code (0)", () => { + const ansi = "\x1b[32m\x1b[1mhello\x1b[0m world"; + const segments = parseAnsiSegments(ansi); + expect(segments).toEqual([{ text: "hello", color: "green", bold: true }, { text: " world" }]); + }); + + it("returns empty array for empty string", () => { + expect(parseAnsiSegments("")).toEqual([]); + }); + + it("parses bright color codes", () => { + const ansi = "\x1b[90mgray\x1b[39m"; + const segments = parseAnsiSegments(ansi); + expect(segments).toEqual([{ text: "gray", color: "gray" }]); + }); + + it("ignores unrecognized SGR codes", () => { + // Code 48 (background color) is not handled, should be ignored + const ansi = "\x1b[48mtext\x1b[0m rest"; + const segments = parseAnsiSegments(ansi); + expect(segments).toEqual([{ text: "text" }, { text: " rest" }]); + }); +}); + +describe("highlightLine", () => { + it("highlights TypeScript code", () => { + const segments = highlightLine("const x = 42;", "typescript"); + expect(segments.length).toBeGreaterThan(1); + + const text = segments.map((s) => s.text).join(""); + expect(text).toBe("const x = 42;"); + + const hasColor = segments.some((s) => s.color !== undefined); + expect(hasColor).toBe(true); + }); + + it("highlights Python code", () => { + const segments = highlightLine("def hello():", "python"); + const text = segments.map((s) => s.text).join(""); + expect(text).toBe("def hello():"); + expect(segments.some((s) => s.color !== undefined)).toBe(true); + }); + + it("returns plain text for empty input", () => { + const segments = highlightLine("", "typescript"); + expect(segments).toEqual([{ text: "" }]); + }); + + it("preserves original text content", () => { + const code = ' const greeting: string = "Hello, World!";'; + const segments = highlightLine(code, "typescript"); + const reconstructed = segments.map((s) => s.text).join(""); + expect(reconstructed).toBe(code); + }); + + it("handles code with special characters", () => { + const code = "arr.map((x) => x * 2);"; + const segments = highlightLine(code, "javascript"); + const reconstructed = segments.map((s) => s.text).join(""); + expect(reconstructed).toBe(code); + }); + + it("returns plain text for unknown language", () => { + const code = "hello world"; + const segments = highlightLine(code, "nonexistent_language_xyz"); + expect(segments).toEqual([{ text: code }]); + }); +}); diff --git a/src/utils/highlightCode.ts b/src/utils/highlightCode.ts new file mode 100644 index 0000000..eaeb488 --- /dev/null +++ b/src/utils/highlightCode.ts @@ -0,0 +1,184 @@ +import { common, createEmphasize } from "emphasize"; + +const emphasize = createEmphasize(common); + +const EXT_TO_LANG: Record = { + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".py": "python", + ".rb": "ruby", + ".rs": "rust", + ".go": "go", + ".java": "java", + ".kt": "kotlin", + ".swift": "swift", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".hpp": "cpp", + ".cc": "cpp", + ".cs": "csharp", + ".css": "css", + ".scss": "scss", + ".less": "less", + ".html": "xml", + ".xml": "xml", + ".json": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".md": "markdown", + ".sql": "sql", + ".sh": "bash", + ".bash": "bash", + ".zsh": "bash", + ".lua": "lua", + ".r": "r", + ".pl": "perl", + ".php": "php", + ".graphql": "graphql", + ".gql": "graphql", + ".ini": "ini", + ".toml": "ini", + ".mk": "makefile", +}; + +const FILENAME_TO_LANG: Record = { + Makefile: "makefile", + Dockerfile: "bash", +}; + +export function detectLanguage(filePath: string): string | undefined { + const slash = filePath.lastIndexOf("/"); + const filename = slash === -1 ? filePath : filePath.slice(slash + 1); + + const filenameLang = FILENAME_TO_LANG[filename]; + if (filenameLang) return filenameLang; + + const dot = filename.lastIndexOf("."); + if (dot === -1) return undefined; + return EXT_TO_LANG[filename.slice(dot)]; +} + +export interface HighlightSegment { + text: string; + color?: string; + bold?: boolean; + dim?: boolean; + italic?: boolean; +} + +const ESC = String.fromCharCode(0x1b); + +function createAnsiSgrRegex(): RegExp { + return new RegExp(`${ESC}\\[(\\d+(?:;\\d+)*)m`, "g"); +} + +const ANSI_FG: Record = { + 30: "black", + 31: "red", + 32: "green", + 33: "yellow", + 34: "blue", + 35: "magenta", + 36: "cyan", + 37: "white", + 90: "gray", + 91: "redBright", + 92: "greenBright", + 93: "yellowBright", + 94: "blueBright", + 95: "magentaBright", + 96: "cyanBright", + 97: "whiteBright", +}; + +function buildSegment( + text: string, + color: string | undefined, + bold: boolean, + dim: boolean, + italic: boolean, +): HighlightSegment { + const seg: HighlightSegment = { text }; + if (color) seg.color = color; + if (bold) seg.bold = true; + if (dim) seg.dim = true; + if (italic) seg.italic = true; + return seg; +} + +export function parseAnsiSegments(ansi: string): HighlightSegment[] { + const segments: HighlightSegment[] = []; + let color: string | undefined; + let bold = false; + let dim = false; + let italic = false; + let lastIndex = 0; + + const re = createAnsiSgrRegex(); + let match: RegExpExecArray | null; + + while ((match = re.exec(ansi)) !== null) { + /* v8 ignore start -- defensive guards: text always non-empty when index > lastIndex, regex always captures group 1 */ + if (match.index > lastIndex) { + const text = ansi.slice(lastIndex, match.index); + if (text) { + segments.push(buildSegment(text, color, bold, dim, italic)); + } + } + lastIndex = match.index + match[0].length; + + const rawCodes = match[1]; + if (!rawCodes) continue; + /* v8 ignore stop */ + const codes = rawCodes.split(";").map(Number); + for (const code of codes) { + if (code === 0) { + color = undefined; + bold = false; + dim = false; + italic = false; + } else if (code === 1) { + bold = true; + } else if (code === 2) { + dim = true; + } else if (code === 3) { + italic = true; + } else if (code === 22) { + bold = false; + dim = false; + } else if (code === 23) { + italic = false; + } else if (code === 39) { + color = undefined; + } else if (code in ANSI_FG) { + color = ANSI_FG[code]; + } + } + } + + if (lastIndex < ansi.length) { + const text = ansi.slice(lastIndex); + /* v8 ignore start -- text is always non-empty when lastIndex < ansi.length */ + if (text) { + segments.push(buildSegment(text, color, bold, dim, italic)); + } + /* v8 ignore stop */ + } + + return segments; +} + +export function highlightLine(text: string, language: string): HighlightSegment[] { + if (!text) return [{ text }]; + try { + const result = emphasize.highlight(language, text); + return parseAnsiSegments(result.value); + } catch { + return [{ text }]; + } +}