diffHighlight.ts
Raw
1import path from "node:path";
2import type { BundledLanguage } from "shiki";
3import { INLINE_MAX_BYTES } 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// ─── Highlight a hunk ────────────────────────────────────────────────────────
185
186async function highlightHunk(
187 hunk: ParsedHunk,
188 lang: string,
189): Promise<string[]> {
190 if (hunk.lines.length === 0) return [];
191 const code = hunk.lines.map((l) => l.content).join("\n");
192 try {
193 const h = await getHighlighter();
194 const tokensByLine = h.codeToTokensWithThemes(code, {
195 lang: lang as BundledLanguage,
196 themes: { light: "github-light", dark: "github-dark" },
197 });
198 const lines = tokensByLine.map((lineTokens) =>
199 lineTokens
200 .map((token) => {
201 const style = Object.entries(token.variants)
202 .map(
203 ([theme, v]) =>
204 `--shiki-${theme}:${v.color ?? "inherit"}`,
205 )
206 .join(";");
207 return `<span style="${style}">${escapeHtml(token.content)}</span>`;
208 })
209 .join(""),
210 );
211 while (lines.length < hunk.lines.length) lines.push("");
212 return lines;
213 } catch {
214 return hunk.lines.map((l) => escapeHtml(l.content));
215 }
216}
217
218// ─── Build rendered rows ──────────────────────────────────────────────────────
219
220function buildRows(hunk: ParsedHunk, highlighted: string[]): RenderedRow[] {
221 const rows: RenderedRow[] = [];
222 let oldLine = hunk.oldStart;
223 let newLine = hunk.newStart;
224 for (let i = 0; i < hunk.lines.length; i++) {
225 const { type } = hunk.lines[i]!;
226 const html = highlighted[i] ?? "";
227 if (type === "context") {
228 rows.push({ type, oldLine: oldLine++, newLine: newLine++, html });
229 } else if (type === "add") {
230 rows.push({ type, oldLine: null, newLine: newLine++, html });
231 } else {
232 rows.push({ type, oldLine: oldLine++, newLine: null, html });
233 }
234 }
235 return rows;
236}
237
238// ─── Highlight a single parsed file ──────────────────────────────────────────
239
240export async function highlightFile(
241 file: ParsedFile,
242): Promise<RenderedDiffFile> {
243 const displayPath = file.newPath || file.oldPath;
244 const lang = detectLang(path.basename(displayPath));
245 const totalBytes = file.hunks.reduce(
246 (sum, h) => sum + h.lines.reduce((s, l) => s + l.content.length, 0),
247 0,
248 );
249 const highlightedHunks =
250 totalBytes > INLINE_MAX_BYTES
251 ? file.hunks.map((hunk) =>
252 hunk.lines.map((l) => escapeHtml(l.content)),
253 )
254 : await Promise.all(
255 file.hunks.map((hunk) => highlightHunk(hunk, lang)),
256 );
257 const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
258 header: hunk.header,
259 rows: buildRows(hunk, highlightedHunks[i]!),
260 }));
261 let binarySize: RenderedDiffFile["binarySize"];
262 if (file.isBinary && file.binaryNewSize !== undefined) {
263 binarySize = {
264 after: file.binaryNewSize,
265 before: file.binaryOldSize ?? 0,
266 };
267 }
268 return {
269 oldPath: file.oldPath,
270 newPath: file.newPath,
271 status: file.status,
272 added: file.added,
273 removed: file.removed,
274 isBinary: file.isBinary,
275 binarySize,
276 hunks,
277 };
278}
279