1import { createHighlighterCore, type GrammarState, type HighlighterCore } from "@shikijs/core";
2import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript";
3import bashLanguage from "@shikijs/langs/bash";
4import diffLanguage from "@shikijs/langs/diff";
5import javascriptLanguage from "@shikijs/langs/javascript";
6import jsonLanguage from "@shikijs/langs/json";
7import jsxLanguage from "@shikijs/langs/jsx";
8import tsxLanguage from "@shikijs/langs/tsx";
9import typescriptLanguage from "@shikijs/langs/typescript";
10import yamlLanguage from "@shikijs/langs/yaml";
11import * as Schema from "effect/Schema";
12
13import type { NativeReviewDiffFile, NativeReviewDiffLanguage } from "./nativeReviewDiffTypes";
14import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewDiffSurface";
15
16export type NativeReviewDiffHighlightScheme = "light" | "dark";
17export type NativeReviewDiffHighlightEngine = "native" | "javascript";
18
19export class NativeReviewDiffHighlighterUnavailableError extends Schema.TaggedError<NativeReviewDiffHighlighterUnavailableError>()(
20 "NativeReviewDiffHighlighterUnavailableError",
21 {},
22) {
23 override get message(): string {
24 return "The native review diff highlighter is unavailable in this build.";
25 }
26}
27
28export const isNativeReviewDiffHighlighterUnavailableError = Schema.is(
29 NativeReviewDiffHighlighterUnavailableError,
30);
31
32export class NativeReviewDiffHighlighterInitializationError extends Schema.TaggedError<NativeReviewDiffHighlighterInitializationError>()(
33 "NativeReviewDiffHighlighterInitializationError",
34 {
35 requestedEngine: Schema.Literals(["native", "javascript"]),
36 attemptedEngine: Schema.Literals(["native", "javascript"]),
37 cause: Schema.Defect(),
38 },
39) {
40 override get message(): string {
41 return `Failed to initialize the ${this.attemptedEngine} review diff highlighter requested as ${this.requestedEngine}.`;
42 }
43}
44
45export interface NativeReviewDiffHighlighterHandle {
46 readonly engine: NativeReviewDiffHighlightEngine;
47 readonly tokenize: (
48 code: string,
49 options: {
50 readonly lang: NativeReviewDiffLanguage;
51 readonly theme: string;
52 readonly signal?: AbortSignal;
53 },
54 ) => Promise<ReadonlyArray<ReadonlyArray<NativeReviewDiffToken>>>;
55}
56
57interface NativeReviewDiffLineRow extends NativeReviewDiffRow {
58 readonly kind: "line";
59 readonly fileId: string;
60 readonly content: string;
61}
62
63interface IndexedNativeReviewDiffLineRow {
64 readonly row: NativeReviewDiffLineRow;
65 readonly rowIndex: number;
66}
67
68export interface HighlightNativeReviewDiffVisibleRowsInput {
69 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
70 readonly files: ReadonlyArray<NativeReviewDiffFile>;
71 readonly scheme: NativeReviewDiffHighlightScheme;
72 readonly engine?: NativeReviewDiffHighlightEngine;
73 readonly firstRowIndex: number;
74 readonly lastRowIndex: number;
75 readonly overscanRows?: number;
76 readonly maxRows?: number;
77 readonly alreadyHighlightedRowIds?: ReadonlySet<string>;
78 readonly signal?: AbortSignal;
79}
80
81const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160;
82const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360;
83const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH = 1_000;
84const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_CHARACTERS = 8_000;
85
86const NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME = {
87 dark: "t3-pierre-dark",
88 light: "t3-pierre-light",
89} as const;
90
91const PIERRE_LIGHT_SHIKI_THEME = {
92 name: NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME.light,
93 type: "light" as const,
94 fg: "#070707",
95 bg: "#ffffff",
96 settings: [
97 { settings: { foreground: "#070707", background: "#ffffff" } },
98 { scope: "comment, punctuation.definition.comment", settings: { foreground: "#84848A" } },
99 {
100 scope: "keyword, storage, storage.type, keyword.operator.expression",
101 settings: { foreground: "#FC2B73" },
102 },
103 {
104 scope: "entity.name.function, support.function, meta.function-call",
105 settings: { foreground: "#7B43F8" },
106 },
107 {
108 scope: "entity.name.type, support.type, support.class",
109 settings: { foreground: "#C635E4" },
110 },
111 {
112 scope: "string, constant.character, punctuation.definition.string",
113 settings: { foreground: "#199F43" },
114 },
115 {
116 scope: "constant.numeric, constant.language, constant.other",
117 settings: { foreground: "#1CA1C7" },
118 },
119 {
120 scope: "variable.parameter, variable.other.readwrite, meta.object-literal.key",
121 settings: { foreground: "#D47628" },
122 },
123 {
124 scope: "entity.name.tag, support.class.component",
125 settings: { foreground: "#199F43" },
126 },
127 {
128 scope: "punctuation, meta.brace, meta.delimiter",
129 settings: { foreground: "#79797F" },
130 },
131 { scope: "invalid", settings: { foreground: "#D52C36" } },
132 ],
133};
134
135const PIERRE_DARK_SHIKI_THEME = {
136 name: NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME.dark,
137 type: "dark" as const,
138 fg: "#adadb1",
139 bg: "#0a0a0a",
140 settings: [
141 { settings: { foreground: "#adadb1", background: "#0a0a0a" } },
142 { scope: "comment, punctuation.definition.comment", settings: { foreground: "#84848A" } },
143 {
144 scope: "keyword, storage, storage.type, keyword.operator.expression",
145 settings: { foreground: "#FF678D" },
146 },
147 {
148 scope: "entity.name.function, support.function, meta.function-call",
149 settings: { foreground: "#9D6AFB" },
150 },
151 {
152 scope: "entity.name.type, support.type, support.class",
153 settings: { foreground: "#D568EA" },
154 },
155 {
156 scope: "string, constant.character, punctuation.definition.string",
157 settings: { foreground: "#5ECC71" },
158 },
159 {
160 scope: "constant.numeric, constant.language, constant.other",
161 settings: { foreground: "#68CDF2" },
162 },
163 {
164 scope: "variable.parameter, variable.other.readwrite, meta.object-literal.key",
165 settings: { foreground: "#FFA359" },
166 },
167 {
168 scope: "entity.name.tag, support.class.component",
169 settings: { foreground: "#5ECC71" },
170 },
171 {
172 scope: "punctuation, meta.brace, meta.delimiter",
173 settings: { foreground: "#79797F" },
174 },
175 { scope: "invalid", settings: { foreground: "#FF6762" } },
176 ],
177};
178
179const NATIVE_REVIEW_DIFF_SHIKI_THEMES = [
180 PIERRE_LIGHT_SHIKI_THEME,
181 PIERRE_DARK_SHIKI_THEME,
182] satisfies Parameters<typeof createHighlighterCore>[0]["themes"];
183
184const NATIVE_REVIEW_DIFF_LANGUAGES = [
185 bashLanguage,
186 diffLanguage,
187 javascriptLanguage,
188 jsonLanguage,
189 jsxLanguage,
190 tsxLanguage,
191 typescriptLanguage,
192 yamlLanguage,
193] satisfies Parameters<typeof createHighlighterCore>[0]["langs"];
194
195let nativeHighlighterPromise: Promise<NativeReviewDiffHighlighterHandle> | null = null;
196let javascriptHighlighterPromise: Promise<NativeReviewDiffHighlighterHandle> | null = null;
197
198function waitForNextFrame(): Promise<void> {
199 return new Promise((resolve) => setTimeout(resolve, 0));
200}
201
202function normalizeTokens(
203 tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>,
204): ReadonlyArray<ReadonlyArray<NativeReviewDiffToken>> {
205 return tokenLines.map((line) =>
206 line.map((token) => ({
207 content: token.content,
208 color: token.color ?? null,
209 fontStyle: token.fontStyle ?? null,
210 })),
211 );
212}
213
214function createHighlighterHandle(
215 highlighter: HighlighterCore,
216 engine: NativeReviewDiffHighlightEngine,
217): NativeReviewDiffHighlighterHandle {
218 return {
219 engine,
220 async tokenize(code, { lang, theme, signal }) {
221 const lines = code.split("\n");
222 const highlighted: Array<ReadonlyArray<NativeReviewDiffToken>> = [];
223 let grammarState: GrammarState | undefined;
224 let start = 0;
225
226 while (start < lines.length) {
227 if (signal?.aborted) return [];
228
229 // Skipping this line leaves its ending grammar state unknown. Resume
230 // from a fresh state rather than leaving the rest of the segment plain:
231 // highlighted rows are cached for the sheet's lifetime, so a plain tail
232 // would stick, and which rows it covered would depend on where the
233 // first visible window happened to start.
234 if (lines[start]!.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH) {
235 highlighted.push([{ content: lines[start] || " ", color: null, fontStyle: null }]);
236 grammarState = undefined;
237 start += 1;
238 continue;
239 }
240
241 let end = start;
242 let characters = 0;
243 while (end < lines.length) {
244 const length = lines[end]!.length;
245 const nextCharacters = characters + length + (end > start ? 1 : 0);
246 if (
247 length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH ||
248 nextCharacters > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_CHARACTERS
249 ) {
250 break;
251 }
252 characters = nextCharacters;
253 end += 1;
254 }
255
256 const tokens = highlighter.codeToTokensBase(lines.slice(start, end).join("\n"), {
257 lang,
258 theme,
259 grammarState,
260 });
261 grammarState = highlighter.getLastGrammarState(tokens);
262 highlighted.push(...normalizeTokens(tokens));
263 start = end;
264 if (start < lines.length) await waitForNextFrame();
265 }
266
267 return signal?.aborted ? [] : highlighted;
268 },
269 };
270}
271
272async function createNativeReviewDiffHighlighter(): Promise<NativeReviewDiffHighlighterHandle> {
273 const nativeEngineModule = await import("react-native-shiki-engine");
274 if (!nativeEngineModule.isNativeEngineAvailable()) {
275 throw new NativeReviewDiffHighlighterUnavailableError();
276 }
277
278 const highlighter = await createHighlighterCore({
279 langs: NATIVE_REVIEW_DIFF_LANGUAGES,
280 themes: NATIVE_REVIEW_DIFF_SHIKI_THEMES,
281 engine: nativeEngineModule.createNativeEngine(),
282 });
283
284 return createHighlighterHandle(highlighter, "native");
285}
286
287async function createJavascriptReviewDiffHighlighter(): Promise<NativeReviewDiffHighlighterHandle> {
288 const highlighter: HighlighterCore = await createHighlighterCore({
289 langs: NATIVE_REVIEW_DIFF_LANGUAGES,
290 themes: NATIVE_REVIEW_DIFF_SHIKI_THEMES,
291 engine: createJavaScriptRegexEngine(),
292 });
293
294 return createHighlighterHandle(highlighter, "javascript");
295}
296
297export async function getNativeReviewDiffHighlighter(
298 engine: NativeReviewDiffHighlightEngine = "native",
299): Promise<NativeReviewDiffHighlighterHandle> {
300 if (engine === "javascript") {
301 try {
302 javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter();
303 return await javascriptHighlighterPromise;
304 } catch (cause) {
305 javascriptHighlighterPromise = null;
306 throw new NativeReviewDiffHighlighterInitializationError({
307 requestedEngine: engine,
308 attemptedEngine: "javascript",
309 cause,
310 });
311 }
312 }
313
314 nativeHighlighterPromise ??= createNativeReviewDiffHighlighter()
315 .catch(async (cause: unknown) => {
316 const nativeError = isNativeReviewDiffHighlighterUnavailableError(cause)
317 ? cause
318 : new NativeReviewDiffHighlighterInitializationError({
319 requestedEngine: engine,
320 attemptedEngine: "native",
321 cause,
322 });
323 console.warn("[debug-native-diff] native highlighter unavailable", {
324 error: nativeError,
325 });
326 try {
327 javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter();
328 return await javascriptHighlighterPromise;
329 } catch (fallbackCause) {
330 javascriptHighlighterPromise = null;
331 throw new NativeReviewDiffHighlighterInitializationError({
332 requestedEngine: engine,
333 attemptedEngine: "javascript",
334 cause: new AggregateError(
335 [nativeError, fallbackCause],
336 "Native and JavaScript review diff highlighter initialization failed.",
337 { cause: nativeError },
338 ),
339 });
340 }
341 })
342 .catch((error) => {
343 nativeHighlighterPromise = null;
344 throw error;
345 });
346 return await nativeHighlighterPromise;
347}
348
349function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDiffLineRow {
350 return row.kind === "line" && typeof row.fileId === "string" && typeof row.content === "string";
351}
352
353function hasConsecutiveLineNumbers(
354 previous: number | null | undefined,
355 next: number | null | undefined,
356): boolean {
357 return typeof previous === "number" && typeof next === "number" && next === previous + 1;
358}
359
360function hasOnlyCommentRowsBetween(
361 rows: ReadonlyArray<NativeReviewDiffRow>,
362 previousRowIndex: number,
363 nextRowIndex: number,
364): boolean {
365 for (let rowIndex = previousRowIndex + 1; rowIndex < nextRowIndex; rowIndex += 1) {
366 if (rows[rowIndex]?.kind !== "comment") {
367 return false;
368 }
369 }
370 return true;
371}
372
373function canShareGrammarContext(
374 previous: IndexedNativeReviewDiffLineRow,
375 next: IndexedNativeReviewDiffLineRow,
376 rows: ReadonlyArray<NativeReviewDiffRow>,
377): boolean {
378 if (
379 next.row.fileId !== previous.row.fileId ||
380 !hasOnlyCommentRowsBetween(rows, previous.rowIndex, next.rowIndex)
381 ) {
382 return false;
383 }
384
385 if (previous.row.change === "delete" || next.row.change === "delete") {
386 return (
387 previous.row.change !== "add" &&
388 next.row.change !== "add" &&
389 hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber)
390 );
391 }
392
393 if (previous.row.change === "add" || next.row.change === "add") {
394 return hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber);
395 }
396
397 return (
398 previous.row.change === "context" &&
399 next.row.change === "context" &&
400 hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) &&
401 hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber)
402 );
403}
404
405function createFileMap(files: ReadonlyArray<NativeReviewDiffFile>) {
406 return new Map(files.map((file) => [file.id, file]));
407}
408
409function clampRowIndex(index: number, rows: ReadonlyArray<NativeReviewDiffRow>) {
410 if (rows.length === 0) {
411 return 0;
412 }
413
414 return Math.min(rows.length - 1, Math.max(0, Math.floor(index)));
415}
416
417function makePlainTokenFallback(
418 row: NativeReviewDiffLineRow,
419): ReadonlyArray<NativeReviewDiffToken> {
420 return [{ content: row.content || " ", color: null, fontStyle: null }];
421}
422
423export async function highlightNativeReviewDiffVisibleRows(
424 input: HighlightNativeReviewDiffVisibleRowsInput,
425): Promise<{
426 readonly engine: NativeReviewDiffHighlightEngine;
427 readonly tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>>;
428 readonly rowCount: number;
429 readonly durationMs: number;
430}> {
431 const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native");
432 if (input.signal?.aborted) {
433 return { engine: highlighter.engine, tokensByRowId: {}, rowCount: 0, durationMs: 0 };
434 }
435
436 const startedAt = performance.now();
437 const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme];
438 const fileMap = createFileMap(input.files);
439 const overscanRows = input.overscanRows ?? NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS;
440 const maxRows = input.maxRows ?? NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS;
441 const startIndex = clampRowIndex(input.firstRowIndex - overscanRows, input.rows);
442 const endIndex = clampRowIndex(input.lastRowIndex + overscanRows, input.rows);
443 const selectedRows: IndexedNativeReviewDiffLineRow[] = [];
444
445 for (
446 let rowIndex = startIndex;
447 rowIndex <= endIndex && selectedRows.length < maxRows;
448 rowIndex += 1
449 ) {
450 const row = input.rows[rowIndex];
451 if (
452 row &&
453 isHighlightableLineRow(row) &&
454 !input.alreadyHighlightedRowIds?.has(row.id) &&
455 fileMap.has(row.fileId)
456 ) {
457 selectedRows.push({ row, rowIndex });
458 }
459 }
460
461 const tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>> = {};
462 let segmentRows: IndexedNativeReviewDiffLineRow[] = [];
463 let segmentFile: NativeReviewDiffFile | undefined;
464 let charactersSinceYield = 0;
465
466 const flushSegment = async () => {
467 if (!segmentFile || segmentRows.length === 0 || input.signal?.aborted) {
468 segmentRows = [];
469 segmentFile = undefined;
470 return;
471 }
472
473 const code = segmentRows.map(({ row }) => row.content).join("\n");
474 if (
475 charactersSinceYield > 0 &&
476 charactersSinceYield + code.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_CHARACTERS
477 ) {
478 await waitForNextFrame();
479 charactersSinceYield = 0;
480 if (input.signal?.aborted) return;
481 }
482 const tokenLines = await highlighter.tokenize(code, {
483 lang: segmentFile.language,
484 theme,
485 signal: input.signal,
486 });
487 charactersSinceYield += code.length;
488 segmentRows.forEach(({ row }, rowIndex) => {
489 tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row);
490 });
491 segmentRows = [];
492 segmentFile = undefined;
493 };
494
495 for (const selectedRow of selectedRows) {
496 if (input.signal?.aborted) break;
497 const { row } = selectedRow;
498 const file = fileMap.get(row.fileId);
499 if (!file) {
500 continue;
501 }
502
503 const previousRow = segmentRows.at(-1);
504 if (
505 segmentFile &&
506 (segmentFile.id !== file.id ||
507 (previousRow !== undefined &&
508 !canShareGrammarContext(previousRow, selectedRow, input.rows)))
509 ) {
510 await flushSegment();
511 }
512
513 segmentFile = file;
514 segmentRows.push(selectedRow);
515 }
516 await flushSegment();
517
518 if (input.signal?.aborted) {
519 return { engine: highlighter.engine, tokensByRowId: {}, rowCount: 0, durationMs: 0 };
520 }
521
522 return {
523 engine: highlighter.engine,
524 tokensByRowId,
525 rowCount: selectedRows.length,
526 durationMs: Math.round(performance.now() - startedAt),
527 };
528}