diffHighlight.ts
Raw
1import path from "node:path";
2import type { BundledLanguage } from "shiki";
3import { MAX_DIFF_CACHE } from "../constants.ts";
4import { detectLang, getHighlighter } from "./highlight.ts";
5
6// ─── Types ───────────────────────────────────────────────────────────────────
7
8export type DiffStatus =
9 | "added"
10 | "deleted"
11 | "modified"
12 | "renamed"
13 | "copied";
14
15export interface RenderedRow {
16 type: "add" | "del" | "context";
17 oldLine: number | null;
18 newLine: number | null;
19 html: string;
20}
21
22export interface RenderedHunk {
23 header: string;
24 rows: RenderedRow[];
25}
26
27export interface RenderedDiffFile {
28 oldPath: string;
29 newPath: string;
30 status: DiffStatus;
31 added: number;
32 removed: number;
33 isBinary: boolean;
34 hunks: RenderedHunk[];
35}
36
37// ─── Parser ──────────────────────────────────────────────────────────────────
38
39interface ParsedLine {
40 type: "add" | "del" | "context";
41 content: string;
42}
43
44interface ParsedHunk {
45 header: string;
46 oldStart: number;
47 newStart: number;
48 lines: ParsedLine[];
49}
50
51export interface ParsedFile {
52 oldPath: string;
53 newPath: string;
54 status: DiffStatus;
55 added: number;
56 removed: number;
57 isBinary: boolean;
58 hunks: ParsedHunk[];
59}
60
61export function parseDiff(raw: string): ParsedFile[] {
62 const files: ParsedFile[] = [];
63 const allLines = raw.split("\n");
64 let i = 0;
65
66 while (i < allLines.length) {
67 if (!allLines[i]!.startsWith("diff --git ")) {
68 i++;
69 continue;
70 }
71
72 const m = allLines[i]!.match(/^diff --git a\/(.+) b\/(.+)$/);
73 const fallback = m ? (m[2] ?? "") : "";
74 const file: ParsedFile = {
75 oldPath: fallback,
76 newPath: fallback,
77 status: "modified",
78 added: 0,
79 removed: 0,
80 isBinary: false,
81 hunks: [],
82 };
83 i++;
84
85 while (i < allLines.length) {
86 const line = allLines[i]!;
87 if (line.startsWith("diff --git ") || line.startsWith("@@ ")) break;
88 if (line.startsWith("new file")) file.status = "added";
89 else if (line.startsWith("deleted file")) file.status = "deleted";
90 else if (line.startsWith("rename from ")) {
91 file.status = "renamed";
92 file.oldPath = line.slice(12);
93 } else if (line.startsWith("rename to ")) {
94 file.newPath = line.slice(10);
95 } else if (line.startsWith("copy from ")) {
96 file.status = "copied";
97 file.oldPath = line.slice(10);
98 } else if (line.startsWith("copy to ")) {
99 file.newPath = line.slice(8);
100 } else if (line.startsWith("--- ") && line !== "--- /dev/null")
101 file.oldPath = line.slice(6);
102 else if (line.startsWith("+++ ") && line !== "+++ /dev/null")
103 file.newPath = line.slice(6);
104 else if (line.startsWith("Binary files ")) file.isBinary = true;
105 i++;
106 }
107
108 while (i < allLines.length && allLines[i]!.startsWith("@@ ")) {
109 const hm = allLines[i]!.match(
110 /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/,
111 );
112 const hunk: ParsedHunk = {
113 header: allLines[i]!,
114 oldStart: hm ? parseInt(hm[1]!, 10) : 1,
115 newStart: hm ? parseInt(hm[2]!, 10) : 1,
116 lines: [],
117 };
118 i++;
119 while (
120 i < allLines.length &&
121 !allLines[i]!.startsWith("@@ ") &&
122 !allLines[i]!.startsWith("diff --git ")
123 ) {
124 const l = allLines[i]!;
125 if (l.startsWith("+")) {
126 hunk.lines.push({ type: "add", content: l.slice(1) });
127 file.added++;
128 } else if (l.startsWith("-")) {
129 hunk.lines.push({ type: "del", content: l.slice(1) });
130 file.removed++;
131 } else if (l.startsWith(" ")) {
132 hunk.lines.push({ type: "context", content: l.slice(1) });
133 }
134 // skip "\ No newline at end of file"
135 i++;
136 }
137 file.hunks.push(hunk);
138 }
139
140 files.push(file);
141 }
142 return files;
143}
144
145function escapeHtml(s: string): string {
146 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
147}
148
149// ─── Highlight a hunk ────────────────────────────────────────────────────────
150
151async function highlightHunk(
152 hunk: ParsedHunk,
153 lang: string,
154): Promise<string[]> {
155 if (hunk.lines.length === 0) return [];
156 const code = hunk.lines.map((l) => l.content).join("\n");
157 try {
158 const h = await getHighlighter();
159 const tokensByLine = h.codeToTokensWithThemes(code, {
160 lang: lang as BundledLanguage,
161 themes: { light: "github-light", dark: "github-dark" },
162 });
163 const lines = tokensByLine.map((lineTokens) =>
164 lineTokens
165 .map((token) => {
166 const style = Object.entries(token.variants)
167 .map(
168 ([theme, v]) =>
169 `--shiki-${theme}:${v.color ?? "inherit"}`,
170 )
171 .join(";");
172 return `<span style="${style}">${escapeHtml(token.content)}</span>`;
173 })
174 .join(""),
175 );
176 while (lines.length < hunk.lines.length) lines.push("");
177 return lines;
178 } catch {
179 return hunk.lines.map((l) => escapeHtml(l.content));
180 }
181}
182
183// ─── Build rendered rows ──────────────────────────────────────────────────────
184
185function buildRows(hunk: ParsedHunk, highlighted: string[]): RenderedRow[] {
186 const rows: RenderedRow[] = [];
187 let oldLine = hunk.oldStart;
188 let newLine = hunk.newStart;
189 for (let i = 0; i < hunk.lines.length; i++) {
190 const { type } = hunk.lines[i]!;
191 const html = highlighted[i] ?? "";
192 if (type === "context") {
193 rows.push({ type, oldLine: oldLine++, newLine: newLine++, html });
194 } else if (type === "add") {
195 rows.push({ type, oldLine: null, newLine: newLine++, html });
196 } else {
197 rows.push({ type, oldLine: oldLine++, newLine: null, html });
198 }
199 }
200 return rows;
201}
202
203// ─── Highlight a single parsed file ──────────────────────────────────────────
204
205export async function highlightFile(
206 file: ParsedFile,
207): Promise<RenderedDiffFile> {
208 const displayPath = file.newPath || file.oldPath;
209 const lang = detectLang(path.basename(displayPath));
210 const highlightedHunks = await Promise.all(
211 file.hunks.map((hunk) => highlightHunk(hunk, lang)),
212 );
213 const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
214 header: hunk.header,
215 rows: buildRows(hunk, highlightedHunks[i]!),
216 }));
217 return {
218 oldPath: file.oldPath,
219 newPath: file.newPath,
220 status: file.status,
221 added: file.added,
222 removed: file.removed,
223 isBinary: file.isBinary,
224 hunks,
225 };
226}
227
228// ─── Public API ──────────────────────────────────────────────────────────────
229
230const diffCache = new Map<string, RenderedDiffFile[]>();
231
232export async function prepareDiff(
233 rawDiff: string,
234 cacheKey: string,
235): Promise<RenderedDiffFile[]> {
236 const cached = diffCache.get(cacheKey);
237 if (cached) return cached;
238
239 const parsed = parseDiff(rawDiff);
240 const result = await Promise.all(
241 parsed.map(async (file) => {
242 const displayPath = file.newPath || file.oldPath;
243 const lang = detectLang(path.basename(displayPath));
244 const highlightedHunks = await Promise.all(
245 file.hunks.map((hunk) => highlightHunk(hunk, lang)),
246 );
247 const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
248 header: hunk.header,
249 rows: buildRows(hunk, highlightedHunks[i]!),
250 }));
251 return {
252 oldPath: file.oldPath,
253 newPath: file.newPath,
254 status: file.status,
255 added: file.added,
256 removed: file.removed,
257 isBinary: file.isBinary,
258 hunks,
259 };
260 }),
261 );
262 if (cacheKey) {
263 if (diffCache.size >= MAX_DIFF_CACHE)
264 diffCache.delete(diffCache.keys().next().value!);
265 diffCache.set(cacheKey, result);
266 }
267 return result;
268}
269