1import { createHighlighterCore, type GrammarState, type HighlighterCore } from "@shikijs/core";
2import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript";
3import bashLanguage from "@shikijs/langs/bash";
4import javascriptLanguage from "@shikijs/langs/javascript";
5import jsonLanguage from "@shikijs/langs/json";
6import jsxLanguage from "@shikijs/langs/jsx";
7import tsxLanguage from "@shikijs/langs/tsx";
8import typescriptLanguage from "@shikijs/langs/typescript";
9import yamlLanguage from "@shikijs/langs/yaml";
10import githubDarkDefault from "@shikijs/themes/github-dark-default";
11import githubLightDefault from "@shikijs/themes/github-light-default";
12import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName";
13import * as Schema from "effect/Schema";
14
15import {
16 resolveReviewHighlighterEngine,
17 resolveReviewHighlighterEnginePreference,
18} from "./reviewHighlighterEngine";
19import { createIncrementalSnippet } from "./incrementalSnippet";
20import type { ReviewRenderableLineRow } from "./reviewModel";
21import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";
22
23export type ReviewDiffTheme = "light" | "dark";
24
25export class ReviewHighlighterEngineInitializationError extends Schema.TaggedError<ReviewHighlighterEngineInitializationError>()(
26 "ReviewHighlighterEngineInitializationError",
27 {
28 preferredEngine: Schema.Literals(["native", "javascript"]),
29 attemptedEngine: Schema.Literals(["native", "javascript"]),
30 cause: Schema.Defect(),
31 },
32) {
33 override get message(): string {
34 return `Failed to initialize the ${this.attemptedEngine} review highlighter with ${this.preferredEngine} preferred.`;
35 }
36}
37
38import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
39
40export type { ReviewHighlightedToken } from "./reviewHighlightedToken.types";
41
42const SHIKI_THEME_NAME_BY_SCHEME = {
43 light: "github-light-default",
44 dark: "github-dark-default",
45} as const;
46const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE =
47 process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ??
48 (process.env.NODE_ENV === "test" ? "javascript" : "native");
49const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference(
50 REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE,
51);
52const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8;
53// Bounds each tokenizing call by characters, not lines, so long-line files
54// still yield to touches and renders between short batches.
55const REVIEW_HIGHLIGHT_CHUNK_CHARACTERS = 2_000;
56// A yield waits for the next frame, so batches run back to back until about one
57// frame of work has passed instead of yielding after every batch.
58const REVIEW_HIGHLIGHT_YIELD_AFTER_MS = 16;
59const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000;
60const REVIEW_INITIAL_LANGUAGE_MODULES = [
61 bashLanguage,
62 javascriptLanguage,
63 jsonLanguage,
64 jsxLanguage,
65 tsxLanguage,
66 typescriptLanguage,
67 yamlLanguage,
68] satisfies Parameters<typeof createHighlighterCore>[0]["langs"];
69const loadedLanguages = new Set<string>([
70 "text",
71 "bash",
72 "javascript",
73 "json",
74 "jsx",
75 "tsx",
76 "typescript",
77 "yaml",
78]);
79const languageLoadingPromises = new Map<string, Promise<boolean>>();
80const languageImports: Partial<Record<string, () => Promise<unknown>>> = {
81 javascript: () => import("@shikijs/langs/javascript"),
82 typescript: () => import("@shikijs/langs/typescript"),
83 jsx: () => import("@shikijs/langs/jsx"),
84 tsx: () => import("@shikijs/langs/tsx"),
85 python: () => import("@shikijs/langs/python"),
86 rust: () => import("@shikijs/langs/rust"),
87 go: () => import("@shikijs/langs/go"),
88 java: () => import("@shikijs/langs/java"),
89 kotlin: () => import("@shikijs/langs/kotlin"),
90 swift: () => import("@shikijs/langs/swift"),
91 "objective-c": () => import("@shikijs/langs/objective-c"),
92 c: () => import("@shikijs/langs/c"),
93 cpp: () => import("@shikijs/langs/cpp"),
94 csharp: () => import("@shikijs/langs/csharp"),
95 php: () => import("@shikijs/langs/php"),
96 ruby: () => import("@shikijs/langs/ruby"),
97 lua: () => import("@shikijs/langs/lua"),
98 perl: () => import("@shikijs/langs/perl"),
99 r: () => import("@shikijs/langs/r"),
100 dart: () => import("@shikijs/langs/dart"),
101 scala: () => import("@shikijs/langs/scala"),
102 elixir: () => import("@shikijs/langs/elixir"),
103 haskell: () => import("@shikijs/langs/haskell"),
104 clojure: () => import("@shikijs/langs/clojure"),
105 ocaml: () => import("@shikijs/langs/ocaml"),
106 fsharp: () => import("@shikijs/langs/fsharp"),
107 erlang: () => import("@shikijs/langs/erlang"),
108 zig: () => import("@shikijs/langs/zig"),
109 nim: () => import("@shikijs/langs/nim"),
110 html: () => import("@shikijs/langs/html"),
111 css: () => import("@shikijs/langs/css"),
112 scss: () => import("@shikijs/langs/scss"),
113 less: () => import("@shikijs/langs/less"),
114 xml: () => import("@shikijs/langs/xml"),
115 svg: () => import("@shikijs/langs/xml"),
116 vue: () => import("@shikijs/langs/vue"),
117 svelte: () => import("@shikijs/langs/svelte"),
118 astro: () => import("@shikijs/langs/astro"),
119 json: () => import("@shikijs/langs/json"),
120 jsonc: () => import("@shikijs/langs/jsonc"),
121 yaml: () => import("@shikijs/langs/yaml"),
122 toml: () => import("@shikijs/langs/toml"),
123 ini: () => import("@shikijs/langs/ini"),
124 bash: () => import("@shikijs/langs/bash"),
125 shellscript: () => import("@shikijs/langs/shellscript"),
126 powershell: () => import("@shikijs/langs/powershell"),
127 fish: () => import("@shikijs/langs/fish"),
128 sql: () => import("@shikijs/langs/sql"),
129 graphql: () => import("@shikijs/langs/graphql"),
130 prisma: () => import("@shikijs/langs/prisma"),
131 docker: () => import("@shikijs/langs/docker"),
132 hcl: () => import("@shikijs/langs/hcl"),
133 nix: () => import("@shikijs/langs/nix"),
134 markdown: () => import("@shikijs/langs/markdown"),
135 mdx: () => import("@shikijs/langs/mdx"),
136 tex: () => import("@shikijs/langs/tex"),
137 diff: () => import("@shikijs/langs/diff"),
138 regex: () => import("@shikijs/langs/regex"),
139 viml: () => import("@shikijs/langs/viml"),
140 makefile: () => import("@shikijs/langs/makefile"),
141 cmake: () => import("@shikijs/langs/cmake"),
142 groovy: () => import("@shikijs/langs/groovy"),
143};
144
145const languageAliases: Record<string, string> = {
146 js: "javascript",
147 mjs: "javascript",
148 cjs: "javascript",
149 ts: "typescript",
150 mts: "typescript",
151 cts: "typescript",
152 py: "python",
153 rb: "ruby",
154 rs: "rust",
155 sh: "bash",
156 zsh: "bash",
157 shell: "shellscript",
158 yml: "yaml",
159 md: "markdown",
160 "c++": "cpp",
161 "c#": "csharp",
162 cs: "csharp",
163 dockerfile: "docker",
164 vim: "viml",
165 objc: "objective-c",
166 objectivec: "objective-c",
167 "obj-c": "objective-c",
168 ps1: "powershell",
169 pwsh: "powershell",
170 hs: "haskell",
171 ex: "elixir",
172 exs: "elixir",
173 erl: "erlang",
174 clj: "clojure",
175 ml: "ocaml",
176 fs: "fsharp",
177 tf: "hcl",
178 make: "makefile",
179 plain: "text",
180 plaintext: "text",
181 txt: "text",
182};
183let highlighterPromise: Promise<HighlighterCore> | null = null;
184
185type LoadedLanguageModule = {
186 default: Parameters<HighlighterCore["loadLanguage"]>[0];
187};
188
189function isReviewHighlighterDebugLoggingEnabled(): boolean {
190 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
191}
192
193function logReviewHighlighterDiagnostic(message: string, details?: Record<string, unknown>): void {
194 if (!isReviewHighlighterDebugLoggingEnabled()) {
195 return;
196 }
197
198 if (details) {
199 console.log(`[review-highlighter] ${message}`, details);
200 return;
201 }
202
203 console.log(`[review-highlighter] ${message}`);
204}
205
206function logReviewHighlighterDiagnosticError(message: string, error: unknown): void {
207 if (!isReviewHighlighterDebugLoggingEnabled()) {
208 return;
209 }
210 console.error(`[review-highlighter] ${message}`, error);
211}
212
213function stripTrailingNewline(value: string): string {
214 return value.endsWith("\n") ? value.slice(0, -1) : value;
215}
216
217function joinPatchLines(lines: ReadonlyArray<string>): string {
218 return lines.map(stripTrailingNewline).join("\n");
219}
220
221function waitForNextFrame(): Promise<void> {
222 return new Promise((resolve) => {
223 setTimeout(resolve, 0);
224 });
225}
226
227async function getHighlighter(): Promise<HighlighterCore> {
228 if (!highlighterPromise) {
229 const configuredHighlighterPromise = (async () => {
230 let nativeEngineAvailable = false;
231 let nativeInitializationError: ReviewHighlighterEngineInitializationError | undefined;
232
233 logReviewHighlighterDiagnostic("initializing", {
234 configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE,
235 preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,
236 });
237
238 const themes = [githubLightDefault, githubDarkDefault];
239
240 if (REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE !== "javascript") {
241 try {
242 const nativeEngineModule = await import("react-native-shiki-engine");
243 nativeEngineAvailable = nativeEngineModule.isNativeEngineAvailable();
244 logReviewHighlighterDiagnostic("checked native engine availability", {
245 nativeEngineAvailable,
246 });
247
248 if (nativeEngineAvailable) {
249 logReviewHighlighterDiagnostic("creating native regex engine");
250 const highlighter = await createHighlighterCore({
251 themes,
252 langs: REVIEW_INITIAL_LANGUAGE_MODULES,
253 engine: nativeEngineModule.createNativeEngine(),
254 });
255 logReviewHighlighterDiagnostic("using native engine");
256 return highlighter;
257 }
258 } catch (error) {
259 nativeInitializationError = new ReviewHighlighterEngineInitializationError({
260 preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,
261 attemptedEngine: "native",
262 cause: error,
263 });
264 logReviewHighlighterDiagnosticError(
265 "native engine initialization failed; falling back to javascript",
266 nativeInitializationError,
267 );
268 nativeEngineAvailable = false;
269 }
270 } else {
271 logReviewHighlighterDiagnostic("skipping native engine probe", {
272 reason: "preference-forced-javascript",
273 });
274 }
275
276 const engine = resolveReviewHighlighterEngine(
277 REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,
278 nativeEngineAvailable,
279 );
280 let highlighter: HighlighterCore;
281 try {
282 highlighter = await createHighlighterCore({
283 themes,
284 langs: REVIEW_INITIAL_LANGUAGE_MODULES,
285 engine: createJavaScriptRegexEngine(),
286 });
287 } catch (cause) {
288 const javascriptError = new ReviewHighlighterEngineInitializationError({
289 preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,
290 attemptedEngine: "javascript",
291 cause,
292 });
293 if (!nativeInitializationError) throw javascriptError;
294 throw new ReviewHighlighterEngineInitializationError({
295 preferredEngine: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,
296 attemptedEngine: "javascript",
297 cause: new AggregateError(
298 [nativeInitializationError, javascriptError],
299 "Native and JavaScript review highlighter initialization failed.",
300 { cause: nativeInitializationError },
301 ),
302 });
303 }
304 logReviewHighlighterDiagnostic("using javascript engine", {
305 resolvedEngine: engine,
306 });
307 return highlighter;
308 })();
309
310 highlighterPromise = configuredHighlighterPromise.catch((error) => {
311 highlighterPromise = null;
312 throw error;
313 });
314 }
315
316 return highlighterPromise;
317}
318
319function resolveLanguageAlias(language: string): string {
320 const normalized = language.toLowerCase();
321 return languageAliases[normalized] ?? normalized;
322}
323
324function resolveLoadedLanguageFromPath(
325 path: string,
326 languageHint: string | null = null,
327): string | null {
328 const detectedLanguage = languageHint ?? getFiletypeFromFileName(path);
329 if (!detectedLanguage) {
330 return "text";
331 }
332
333 const candidate = resolveLanguageAlias(detectedLanguage);
334 if (candidate === "text" || candidate === "ansi") {
335 return "text";
336 }
337
338 if (!(candidate in languageImports)) {
339 return "text";
340 }
341
342 return loadedLanguages.has(candidate) ? candidate : null;
343}
344
345async function loadSingleLanguage(
346 highlighter: HighlighterCore,
347 language: string,
348): Promise<boolean> {
349 if (loadedLanguages.has(language)) {
350 return true;
351 }
352
353 const existingPromise = languageLoadingPromises.get(language);
354 if (existingPromise) {
355 return existingPromise;
356 }
357
358 const importer = languageImports[language];
359 if (!importer) {
360 return false;
361 }
362
363 const loadingPromise = (async () => {
364 try {
365 const languageModule = (await importer()) as LoadedLanguageModule;
366 await highlighter.loadLanguage(languageModule.default);
367 loadedLanguages.add(language);
368 return true;
369 } catch {
370 return false;
371 } finally {
372 languageLoadingPromises.delete(language);
373 }
374 })();
375
376 languageLoadingPromises.set(language, loadingPromise);
377 return loadingPromise;
378}
379
380async function resolveLanguageFromPath(
381 path: string,
382 languageHint: string | null = null,
383): Promise<string> {
384 const loadedLanguage = resolveLoadedLanguageFromPath(path, languageHint);
385 if (loadedLanguage) {
386 return loadedLanguage;
387 }
388
389 const detectedLanguage = languageHint ?? getFiletypeFromFileName(path);
390 if (!detectedLanguage) {
391 return "text";
392 }
393
394 const candidate = resolveLanguageAlias(detectedLanguage);
395 if (candidate === "text" || candidate === "ansi") {
396 return "text";
397 }
398
399 if (!(candidate in languageImports)) {
400 return "text";
401 }
402
403 if (loadedLanguages.has(candidate)) {
404 return candidate;
405 }
406
407 const highlighter = await getHighlighter();
408 const loaded = await loadSingleLanguage(highlighter, candidate);
409 if (!loaded) {
410 return "text";
411 }
412
413 return candidate;
414}
415
416type RawHighlightedLine = ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>;
417const normalizedLines = new WeakMap<RawHighlightedLine, ReadonlyArray<ReviewHighlightedToken>>();
418
419function normalizeHighlightedLines(
420 tokenLines: ReadonlyArray<RawHighlightedLine>,
421): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> {
422 return tokenLines.map((line) => {
423 const cached = normalizedLines.get(line);
424 if (cached) return cached;
425 const normalized = line.map((token) => ({
426 content: token.content,
427 color: token.color ?? null,
428 fontStyle: token.fontStyle ?? null,
429 }));
430 normalizedLines.set(line, normalized);
431 return normalized;
432 });
433}
434
435function applyWordAltDiffHighlightsToSelectedLines(input: {
436 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
437 readonly tokenMap: Record<string, ReadonlyArray<ReviewHighlightedToken>>;
438}): Record<string, ReadonlyArray<ReviewHighlightedToken>> {
439 const additionLineByTokenIndex = new Map<number, ReviewRenderableLineRow>();
440 const deletionLineByTokenIndex = new Map<number, ReviewRenderableLineRow>();
441
442 input.lines.forEach((line) => {
443 if (line.change === "add" && line.additionTokenIndex !== null) {
444 additionLineByTokenIndex.set(line.additionTokenIndex, line);
445 }
446 if (line.change === "delete" && line.deletionTokenIndex !== null) {
447 deletionLineByTokenIndex.set(line.deletionTokenIndex, line);
448 }
449 });
450
451 const nextTokenMap = { ...input.tokenMap };
452 const processedPairs = new Set<string>();
453
454 input.lines.forEach((line) => {
455 if (line.change === "context" || !line.comparison) {
456 return;
457 }
458
459 const pairedDeletionLine =
460 line.change === "delete"
461 ? line
462 : line.comparison.change === "delete"
463 ? deletionLineByTokenIndex.get(line.comparison.tokenIndex)
464 : undefined;
465 const pairedAdditionLine =
466 line.change === "add"
467 ? line
468 : line.comparison.change === "add"
469 ? additionLineByTokenIndex.get(line.comparison.tokenIndex)
470 : undefined;
471
472 if (
473 !pairedDeletionLine ||
474 !pairedAdditionLine ||
475 pairedDeletionLine.deletionTokenIndex === null ||
476 pairedAdditionLine.additionTokenIndex === null
477 ) {
478 return;
479 }
480
481 const pairKey = `${pairedDeletionLine.deletionTokenIndex}:${pairedAdditionLine.additionTokenIndex}`;
482 if (processedPairs.has(pairKey)) {
483 return;
484 }
485 processedPairs.add(pairKey);
486
487 const ranges = computeWordAltDiffRanges({
488 deletionLine: pairedDeletionLine.content,
489 additionLine: pairedAdditionLine.content,
490 });
491
492 if (ranges.deletion.length > 0) {
493 nextTokenMap[pairedDeletionLine.id] = applyDiffRangesToTokens(
494 nextTokenMap[pairedDeletionLine.id] ?? [],
495 ranges.deletion,
496 );
497 }
498 if (ranges.addition.length > 0) {
499 nextTokenMap[pairedAdditionLine.id] = applyDiffRangesToTokens(
500 nextTokenMap[pairedAdditionLine.id] ?? [],
501 ranges.addition,
502 );
503 }
504 });
505
506 return nextTokenMap;
507}
508
509async function highlightLines(
510 code: string,
511 language: string,
512 theme: string,
513): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> {
514 if (code.length === 0) {
515 return [];
516 }
517
518 const highlighter = await getHighlighter();
519 const sourceLines = code.split("\n");
520 const highlightedLines: Array<ReadonlyArray<ReviewHighlightedToken>> = [];
521 // Batches resume from the previous batch's grammar state, so a comment or
522 // template string that spans a batch boundary keeps its colors.
523 let grammarState: GrammarState | undefined;
524 let start = 0;
525 let sliceStartedAt = performance.now();
526
527 while (start < sourceLines.length) {
528 // A skipped line leaves its ending state unknown; resume from a fresh state.
529 if (sourceLines[start]!.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) {
530 highlightedLines.push([{ content: sourceLines[start]!, color: null, fontStyle: null }]);
531 grammarState = undefined;
532 start += 1;
533 } else {
534 let end = start;
535 let characters = 0;
536 while (end < sourceLines.length) {
537 const length = sourceLines[end]!.length;
538 if (
539 length > REVIEW_TOKENIZE_MAX_LINE_LENGTH ||
540 (end > start && characters + length + 1 > REVIEW_HIGHLIGHT_CHUNK_CHARACTERS)
541 ) {
542 break;
543 }
544 characters += length + (end > start ? 1 : 0);
545 end += 1;
546 }
547
548 const tokenLines = highlighter.codeToTokensBase(sourceLines.slice(start, end).join("\n"), {
549 lang: language,
550 theme,
551 grammarState,
552 });
553 grammarState = highlighter.getLastGrammarState(tokenLines);
554 highlightedLines.push(...normalizeHighlightedLines(tokenLines));
555 start = end;
556 }
557
558 if (
559 sourceLines.length > REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD &&
560 start < sourceLines.length &&
561 performance.now() - sliceStartedAt >= REVIEW_HIGHLIGHT_YIELD_AFTER_MS
562 ) {
563 await waitForNextFrame();
564 sliceStartedAt = performance.now();
565 }
566 }
567
568 return highlightedLines;
569}
570
571const snippetSessions = new WeakMap<
572 object,
573 { language: string; theme: string; highlight: ReturnType<typeof createIncrementalSnippet> }
574>();
575
576export async function highlightCodeSnippet(input: {
577 readonly session?: object;
578 readonly code: string;
579 readonly language?: string | null;
580 readonly theme: ReviewDiffTheme;
581}): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> {
582 const languageHint = input.language?.trim() || "text";
583 const language = await resolveLanguageFromPath(`snippet.${languageHint}`, languageHint);
584 const theme = SHIKI_THEME_NAME_BY_SCHEME[input.theme];
585 // Bound retained text and preserve the existing plain-text/long-line fallback.
586 if (
587 !input.session ||
588 language === "text" ||
589 input.code.length === 0 ||
590 input.code.length > 100_000 ||
591 input.code.includes("\r") ||
592 input.code.split("\n").some((line) => line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH)
593 ) {
594 if (input.session) snippetSessions.delete(input.session);
595 return highlightLines(input.code, language, theme);
596 }
597 const highlighter = await getHighlighter();
598 let session = snippetSessions.get(input.session);
599 if (!session || session.language !== language || session.theme !== theme) {
600 session = {
601 language,
602 theme,
603 highlight: createIncrementalSnippet(highlighter, language, theme),
604 };
605 snippetSessions.set(input.session, session);
606 }
607 return normalizeHighlightedLines(await session.highlight(input.code));
608}
609
610highlightCodeSnippet.read = (input: Parameters<typeof highlightCodeSnippet>[0]) => {
611 if (!input.session || !input.code || input.code.length > 100_000 || input.code.includes("\r"))
612 return undefined;
613 const session = snippetSessions.get(input.session);
614 const hint = input.language?.trim() || "text";
615 const language = resolveLoadedLanguageFromPath(`snippet.${hint}`, hint);
616 if (
617 !session ||
618 session.language !== language ||
619 session.theme !== SHIKI_THEME_NAME_BY_SCHEME[input.theme] ||
620 input.code.split("\n").some((line) => line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH)
621 )
622 return undefined;
623 const tokens = session.highlight.read(input.code);
624 return tokens ? normalizeHighlightedLines(tokens) : undefined;
625};
626
627export async function highlightSourceFile(input: {
628 readonly path: string;
629 readonly contents: string;
630 readonly theme: ReviewDiffTheme;
631}): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> {
632 const language = await resolveLanguageFromPath(input.path);
633 return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]);
634}
635
636export async function highlightReviewSelectedLines(input: {
637 readonly filePath: string;
638 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
639 readonly theme: ReviewDiffTheme;
640 readonly languageHint?: string | null;
641}): Promise<Record<string, ReadonlyArray<ReviewHighlightedToken>>> {
642 if (input.lines.length === 0) {
643 return {};
644 }
645
646 const loadedLanguage = resolveLoadedLanguageFromPath(input.filePath, input.languageHint ?? null);
647 const language =
648 loadedLanguage ?? (await resolveLanguageFromPath(input.filePath, input.languageHint ?? null));
649 const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[input.theme];
650 const additionLikeLines: string[] = [];
651 const deletionLines: string[] = [];
652 for (const line of input.lines) {
653 if (line.change === "delete") {
654 deletionLines.push(`${line.content}\n`);
655 } else {
656 additionLikeLines.push(`${line.content}\n`);
657 }
658 }
659 const [additionTokens, deletionTokens] = await Promise.all([
660 highlightLines(joinPatchLines(additionLikeLines), language, shikiTheme),
661 highlightLines(joinPatchLines(deletionLines), language, shikiTheme),
662 ]);
663
664 const tokenMap: Record<string, ReadonlyArray<ReviewHighlightedToken>> = {};
665 let additionIndex = 0;
666 let deletionIndex = 0;
667
668 input.lines.forEach((line) => {
669 if (line.change === "delete") {
670 tokenMap[line.id] = deletionTokens[deletionIndex] ?? [];
671 deletionIndex += 1;
672 return;
673 }
674
675 tokenMap[line.id] = additionTokens[additionIndex] ?? [];
676 additionIndex += 1;
677 });
678
679 return applyWordAltDiffHighlightsToSelectedLines({
680 lines: input.lines,
681 tokenMap,
682 });
683}