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 }];
+ }
+}