diffHighlight.ts
Raw
1import path from "node:path";
2import type { BundledLanguage } from "shiki";
3import config from "../config.ts";
4import { git } from "./git.ts";
5import { detectLang, getHighlighter } from "./highlight.ts";
6
7// ─── Types ───────────────────────────────────────────────────────────────────
8
9export type DiffStatus =
10 | "added"
11 | "deleted"
12 | "modified"
13 | "renamed"
14 | "copied";
15
16export interface RenderedRow {
17 type: "add" | "del" | "context";
18 oldLine: number | null;
19 newLine: number | null;
20 html: string;
21}
22
23export interface RenderedHunk {
24 header: string;
25 rows: RenderedRow[];
26}
27
28export interface RenderedDiffFile {
29 oldPath: string;
30 newPath: string;
31 status: DiffStatus;
32 added: number;
33 removed: number;
34 isBinary: boolean;
35 /** Byte sizes for binary files. Populated from GIT binary patch literals or blob lookups. */
36 binarySize?: { before: number; after: number };
37 hunks: RenderedHunk[];
38}
39
40// ─── Parser ──────────────────────────────────────────────────────────────────
41
42interface ParsedLine {
43 type: "add" | "del" | "context";
44 content: string;
45}
46
47interface ParsedHunk {
48 header: string;
49 oldStart: number;
50 newStart: number;
51 lines: ParsedLine[];
52}
53
54export interface ParsedFile {
55 oldPath: string;
56 newPath: string;
57 status: DiffStatus;
58 added: number;
59 removed: number;
60 isBinary: boolean;
61 binaryNewSize?: number;
62 binaryOldSize?: number;
63 hunks: ParsedHunk[];
64}
65
66export async function parseDiff(
67 raw: string,
68 repoName?: string,
69): Promise<ParsedFile[]> {
70 const files: ParsedFile[] = [];
71 const allLines = raw.split("\n");
72 let i = 0;
73
74 while (i < allLines.length) {
75 if (!allLines[i]!.startsWith("diff --git ")) {
76 i++;
77 continue;
78 }
79
80 const m = allLines[i]!.match(/^diff --git a\/(.+) b\/(.+)$/);
81 const fallback = m ? (m[2] ?? "") : "";
82 const file: ParsedFile = {
83 oldPath: fallback,
84 newPath: fallback,
85 status: "modified",
86 added: 0,
87 removed: 0,
88 isBinary: false,
89 hunks: [],
90 };
91 i++;
92
93 let oldBlob = "";
94 let newBlob = "";
95
96 while (i < allLines.length) {
97 const line = allLines[i]!;
98 if (line.startsWith("diff --git ") || line.startsWith("@@ ")) break;
99 if (line.startsWith("new file")) file.status = "added";
100 else if (line.startsWith("deleted file")) file.status = "deleted";
101 else if (line.startsWith("rename from ")) {
102 file.status = "renamed";
103 file.oldPath = line.slice(12);
104 } else if (line.startsWith("rename to ")) {
105 file.newPath = line.slice(10);
106 } else if (line.startsWith("copy from ")) {
107 file.status = "copied";
108 file.oldPath = line.slice(10);
109 } else if (line.startsWith("copy to ")) {
110 file.newPath = line.slice(8);
111 } else if (line.startsWith("--- ") && line !== "--- /dev/null")
112 file.oldPath = line.slice(6);
113 else if (line.startsWith("+++ ") && line !== "+++ /dev/null")
114 file.newPath = line.slice(6);
115 else if (line.startsWith("index ")) {
116 const idxm = line.match(/^index ([0-9a-f]+)\.\.([0-9a-f]+)/i);
117 if (idxm) {
118 oldBlob = idxm[1]!;
119 newBlob = idxm[2]!;
120 }
121 } else if (line.startsWith("Binary files ")) {
122 file.isBinary = true;
123 if (repoName) {
124 const [oldSize, newSize] = await Promise.all([
125 git.blobSize(repoName, oldBlob),
126 git.blobSize(repoName, newBlob),
127 ]);
128 file.binaryOldSize = oldSize;
129 file.binaryNewSize = newSize;
130 }
131 } else if (line === "GIT binary patch") {
132 file.isBinary = true;
133 } else if (file.isBinary && line.startsWith("literal ")) {
134 const size = parseInt(line.slice(8), 10);
135 if (file.binaryNewSize === undefined) file.binaryNewSize = size;
136 else if (file.binaryOldSize === undefined)
137 file.binaryOldSize = size;
138 }
139 i++;
140 }
141
142 while (i < allLines.length && allLines[i]!.startsWith("@@ ")) {
143 const hm = allLines[i]!.match(
144 /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/,
145 );
146 const hunk: ParsedHunk = {
147 header: allLines[i]!,
148 oldStart: hm ? parseInt(hm[1]!, 10) : 1,
149 newStart: hm ? parseInt(hm[2]!, 10) : 1,
150 lines: [],
151 };
152 i++;
153 while (
154 i < allLines.length &&
155 !allLines[i]!.startsWith("@@ ") &&
156 !allLines[i]!.startsWith("diff --git ")
157 ) {
158 const l = allLines[i]!;
159 if (l.startsWith("+")) {
160 hunk.lines.push({ type: "add", content: l.slice(1) });
161 file.added++;
162 } else if (l.startsWith("-")) {
163 hunk.lines.push({ type: "del", content: l.slice(1) });
164 file.removed++;
165 } else if (l.startsWith(" ")) {
166 hunk.lines.push({ type: "context", content: l.slice(1) });
167 }
168 // skip "\ No newline at end of file"
169 i++;
170 }
171 file.hunks.push(hunk);
172 }
173
174 files.push(file);
175 }
176
177 return files;
178}
179
180function _escapeHtml(s: string): string {
181 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
182}
183
184// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control char rendering
185const CTRL_RE = /[\x00-\x08\x0b\x0c\x0d\x0e-\x1f]/g;
186
187function escapeHtmlAndCtrl(s: string): string {
188 return s
189 .replace(/&/g, "&amp;")
190 .replace(/</g, "&lt;")
191 .replace(/>/g, "&gt;")
192 .replace(CTRL_RE, (ch) => {
193 const label = `^${String.fromCharCode(ch.charCodeAt(0) + 64)}`;
194 return `<span class="diff-ctrl">${label}</span>`;
195 });
196}
197
198// ─── Highlight a hunk ────────────────────────────────────────────────────────
199
200async function highlightHunk(
201 hunk: ParsedHunk,
202 lang: string,
203): Promise<string[]> {
204 if (hunk.lines.length === 0) return [];
205 // Strip trailing \r before joining with \n: Shiki normalises \r\n as a
206 // single newline and silently drops the \r from every line except the last.
207 const contents = hunk.lines.map((l) => l.content);
208 const trailingCR = contents.map((c) => c.endsWith("\r"));
209 const stripped = contents.map((c, i) =>
210 trailingCR[i] ? c.slice(0, -1) : c,
211 );
212 const code = stripped.join("\n");
213 try {
214 const h = await getHighlighter();
215 const tokensByLine = h.codeToTokensWithThemes(code, {
216 lang: lang as BundledLanguage,
217 themes: { light: "github-light", dark: "github-dark" },
218 });
219 const lines = tokensByLine.map((lineTokens, i) => {
220 const html = lineTokens
221 .map((token) => {
222 const style = Object.entries(token.variants)
223 .map(
224 ([theme, v]) =>
225 `--shiki-${theme}:${v.color ?? "inherit"}`,
226 )
227 .join(";");
228 return `<span style="${style}">${escapeHtmlAndCtrl(token.content)}</span>`;
229 })
230 .join("");
231 return trailingCR[i]
232 ? `${html}<span class="diff-ctrl">^M</span>`
233 : html;
234 });
235 while (lines.length < hunk.lines.length) lines.push("");
236 return lines;
237 } catch {
238 return hunk.lines.map((l) => escapeHtmlAndCtrl(l.content));
239 }
240}
241
242// ─── Build rendered rows ──────────────────────────────────────────────────────
243
244function buildRows(hunk: ParsedHunk, highlighted: string[]): RenderedRow[] {
245 const rows: RenderedRow[] = [];
246 let oldLine = hunk.oldStart;
247 let newLine = hunk.newStart;
248 for (let i = 0; i < hunk.lines.length; i++) {
249 const { type } = hunk.lines[i]!;
250 const html = highlighted[i] ?? "";
251 if (type === "context") {
252 rows.push({ type, oldLine: oldLine++, newLine: newLine++, html });
253 } else if (type === "add") {
254 rows.push({ type, oldLine: null, newLine: newLine++, html });
255 } else {
256 rows.push({ type, oldLine: oldLine++, newLine: null, html });
257 }
258 }
259 return rows;
260}
261
262// ─── Highlight a single parsed file ──────────────────────────────────────────
263
264export async function highlightFile(
265 file: ParsedFile,
266): Promise<RenderedDiffFile> {
267 const displayPath = file.newPath || file.oldPath;
268 const lang = detectLang(path.basename(displayPath));
269 const totalBytes = file.hunks.reduce(
270 (sum, h) => sum + h.lines.reduce((s, l) => s + l.content.length, 0),
271 0,
272 );
273 const highlightedHunks =
274 totalBytes > config.INLINE_MAX_BYTES
275 ? file.hunks.map((hunk) =>
276 hunk.lines.map((l) => escapeHtmlAndCtrl(l.content)),
277 )
278 : await Promise.all(
279 file.hunks.map((hunk) => highlightHunk(hunk, lang)),
280 );
281 const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
282 header: hunk.header,
283 rows: buildRows(hunk, highlightedHunks[i]!),
284 }));
285 let binarySize: RenderedDiffFile["binarySize"];
286 if (file.isBinary && file.binaryNewSize !== undefined) {
287 binarySize = {
288 after: file.binaryNewSize,
289 before: file.binaryOldSize ?? 0,
290 };
291 }
292 return {
293 oldPath: file.oldPath,
294 newPath: file.newPath,
295 status: file.status,
296 added: file.added,
297 removed: file.removed,
298 isBinary: file.isBinary,
299 binarySize,
300 hunks,
301 };
302}
303