forked from
oppi.li/claude-code
source dump of claude code
1/**
2 * Pure TypeScript port of vendor/color-diff-src.
3 *
4 * The Rust version uses syntect+bat for syntax highlighting and the similar
5 * crate for word diffing. This port uses highlight.js (already a dep via
6 * cli-highlight) and the diff npm package's diffArrays.
7 *
8 * API matches vendor/color-diff-src/index.d.ts exactly so callers don't change.
9 *
10 * Key semantic differences from the native module:
11 * - Syntax highlighting uses highlight.js. Scope colors were measured from
12 * syntect's output so most tokens match, but hljs's grammar has gaps:
13 * plain identifiers and operators like `=` `:` aren't scoped, so they
14 * render in default fg instead of white/pink. Output structure (line
15 * numbers, markers, backgrounds, word-diff) is identical.
16 * - BAT_THEME env support is a stub: highlight.js has no bat theme set, so
17 * getSyntaxTheme always returns the default for the given Claude theme.
18 */
19
20import { diffArrays } from 'diff'
21import type * as hljsNamespace from 'highlight.js'
22import { basename, extname } from 'path'
23
24// Lazy: defers loading highlight.js until first render. The full bundle
25// registers 190+ language grammars at require time (~50MB, 100-200ms on
26// macOS, several× that on Windows). With a top-level import, any caller
27// chunk that reaches this module — including test/preload.ts via
28// StructuredDiff.tsx → colorDiff.ts — pays that cost at module-eval time
29// and carries the heap for the rest of the process. On Windows CI this
30// pushed later tests in the same shard into GC-pause territory and a
31// beforeEach/afterEach hook timeout (officialRegistry.test.ts, PR #24150).
32// Same lazy pattern the NAPI wrapper used for dlopen.
33type HLJSApi = typeof hljsNamespace
34let cachedHljs: HLJSApi | null = null
35function hljs(): HLJSApi {
36 if (cachedHljs) return cachedHljs
37 // eslint-disable-next-line @typescript-eslint/no-require-imports
38 const mod = require('highlight.js')
39 // highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it
40 // in .default; under node CJS the module IS the API. Check at runtime.
41 cachedHljs = 'default' in mod && mod.default ? mod.default : mod
42 return cachedHljs!
43}
44
45import { stringWidth } from '../../ink/stringWidth.js'
46import { logError } from '../../utils/log.js'
47
48// ---------------------------------------------------------------------------
49// Public API types (match vendor/color-diff-src/index.d.ts)
50// ---------------------------------------------------------------------------
51
52export type Hunk = {
53 oldStart: number
54 oldLines: number
55 newStart: number
56 newLines: number
57 lines: string[]
58}
59
60export type SyntaxTheme = {
61 theme: string
62 source: string | null
63}
64
65export type NativeModule = {
66 ColorDiff: typeof ColorDiff
67 ColorFile: typeof ColorFile
68 getSyntaxTheme: (themeName: string) => SyntaxTheme
69}
70
71// ---------------------------------------------------------------------------
72// Color / ANSI escape helpers
73// ---------------------------------------------------------------------------
74
75type Color = { r: number; g: number; b: number; a: number }
76type Style = { foreground: Color; background: Color }
77type Block = [Style, string]
78type ColorMode = 'truecolor' | 'color256' | 'ansi'
79
80const RESET = '\x1b[0m'
81const DIM = '\x1b[2m'
82const UNDIM = '\x1b[22m'
83
84function rgb(r: number, g: number, b: number): Color {
85 return { r, g, b, a: 255 }
86}
87
88function ansiIdx(index: number): Color {
89 return { r: index, g: 0, b: 0, a: 0 }
90}
91
92// Sentinel: a=1 means "terminal default" (matches bat convention)
93const DEFAULT_BG: Color = { r: 0, g: 0, b: 0, a: 1 }
94
95function detectColorMode(theme: string): ColorMode {
96 if (theme.includes('ansi')) return 'ansi'
97 const ct = process.env.COLORTERM ?? ''
98 return ct === 'truecolor' || ct === '24bit' ? 'truecolor' : 'color256'
99}
100
101// Port of ansi_colours::ansi256_from_rgb — approximates RGB to the xterm-256
102// palette (6x6x6 cube + 24 greys). Picks the perceptually closest index by
103// comparing cube vs grey-ramp candidates, like the Rust crate.
104const CUBE_LEVELS = [0, 95, 135, 175, 215, 255]
105function ansi256FromRgb(r: number, g: number, b: number): number {
106 const q = (c: number) =>
107 c < 48 ? 0 : c < 115 ? 1 : c < 155 ? 2 : c < 195 ? 3 : c < 235 ? 4 : 5
108 const qr = q(r)
109 const qg = q(g)
110 const qb = q(b)
111 const cubeIdx = 16 + 36 * qr + 6 * qg + qb
112 // Grey ramp candidate (232-255, levels 8..238 step 10). Beyond the ramp's
113 // range the cube corner is the only option — ansi_colours snaps 248,248,242
114 // to 231 (cube white), not 255 (ramp top).
115 const grey = Math.round((r + g + b) / 3)
116 if (grey < 5) return 16
117 if (grey > 244 && qr === qg && qg === qb) return cubeIdx
118 const greyLevel = Math.max(0, Math.min(23, Math.round((grey - 8) / 10)))
119 const greyIdx = 232 + greyLevel
120 const greyRgb = 8 + greyLevel * 10
121 const cr = CUBE_LEVELS[qr]!
122 const cg = CUBE_LEVELS[qg]!
123 const cb = CUBE_LEVELS[qb]!
124 const dCube = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2
125 const dGrey = (r - greyRgb) ** 2 + (g - greyRgb) ** 2 + (b - greyRgb) ** 2
126 return dGrey < dCube ? greyIdx : cubeIdx
127}
128
129function colorToEscape(c: Color, fg: boolean, mode: ColorMode): string {
130 // alpha=0: palette index encoded in .r (bat's ansi-theme convention)
131 if (c.a === 0) {
132 const idx = c.r
133 if (idx < 8) return `\x1b[${(fg ? 30 : 40) + idx}m`
134 if (idx < 16) return `\x1b[${(fg ? 90 : 100) + (idx - 8)}m`
135 return `\x1b[${fg ? 38 : 48};5;${idx}m`
136 }
137 // alpha=1: terminal default
138 if (c.a === 1) return fg ? '\x1b[39m' : '\x1b[49m'
139
140 const codeType = fg ? 38 : 48
141 if (mode === 'truecolor') {
142 return `\x1b[${codeType};2;${c.r};${c.g};${c.b}m`
143 }
144 return `\x1b[${codeType};5;${ansi256FromRgb(c.r, c.g, c.b)}m`
145}
146
147function asTerminalEscaped(
148 blocks: readonly Block[],
149 mode: ColorMode,
150 skipBackground: boolean,
151 dim: boolean,
152): string {
153 let out = dim ? RESET + DIM : RESET
154 for (const [style, text] of blocks) {
155 out += colorToEscape(style.foreground, true, mode)
156 if (!skipBackground) {
157 out += colorToEscape(style.background, false, mode)
158 }
159 out += text
160 }
161 return out + RESET
162}
163
164// ---------------------------------------------------------------------------
165// Theme
166// ---------------------------------------------------------------------------
167
168type Marker = '+' | '-' | ' '
169
170type Theme = {
171 addLine: Color
172 addWord: Color
173 addDecoration: Color
174 deleteLine: Color
175 deleteWord: Color
176 deleteDecoration: Color
177 foreground: Color
178 background: Color
179 scopes: Record<string, Color>
180}
181
182function defaultSyntaxThemeName(themeName: string): string {
183 if (themeName.includes('ansi')) return 'ansi'
184 if (themeName.includes('dark')) return 'Monokai Extended'
185 return 'GitHub'
186}
187
188// highlight.js scope → syntect Monokai Extended foreground (measured from the
189// Rust module's output so colors match the original exactly)
190const MONOKAI_SCOPES: Record<string, Color> = {
191 keyword: rgb(249, 38, 114),
192 _storage: rgb(102, 217, 239),
193 built_in: rgb(166, 226, 46),
194 type: rgb(166, 226, 46),
195 literal: rgb(190, 132, 255),
196 number: rgb(190, 132, 255),
197 string: rgb(230, 219, 116),
198 title: rgb(166, 226, 46),
199 'title.function': rgb(166, 226, 46),
200 'title.class': rgb(166, 226, 46),
201 'title.class.inherited': rgb(166, 226, 46),
202 params: rgb(253, 151, 31),
203 comment: rgb(117, 113, 94),
204 meta: rgb(117, 113, 94),
205 attr: rgb(166, 226, 46),
206 attribute: rgb(166, 226, 46),
207 variable: rgb(255, 255, 255),
208 'variable.language': rgb(255, 255, 255),
209 property: rgb(255, 255, 255),
210 operator: rgb(249, 38, 114),
211 punctuation: rgb(248, 248, 242),
212 symbol: rgb(190, 132, 255),
213 regexp: rgb(230, 219, 116),
214 subst: rgb(248, 248, 242),
215}
216
217// highlight.js scope → syntect GitHub-light foreground (measured from Rust)
218const GITHUB_SCOPES: Record<string, Color> = {
219 keyword: rgb(167, 29, 93),
220 _storage: rgb(167, 29, 93),
221 built_in: rgb(0, 134, 179),
222 type: rgb(0, 134, 179),
223 literal: rgb(0, 134, 179),
224 number: rgb(0, 134, 179),
225 string: rgb(24, 54, 145),
226 title: rgb(121, 93, 163),
227 'title.function': rgb(121, 93, 163),
228 'title.class': rgb(0, 0, 0),
229 'title.class.inherited': rgb(0, 0, 0),
230 params: rgb(0, 134, 179),
231 comment: rgb(150, 152, 150),
232 meta: rgb(150, 152, 150),
233 attr: rgb(0, 134, 179),
234 attribute: rgb(0, 134, 179),
235 variable: rgb(0, 134, 179),
236 'variable.language': rgb(0, 134, 179),
237 property: rgb(0, 134, 179),
238 operator: rgb(167, 29, 93),
239 punctuation: rgb(51, 51, 51),
240 symbol: rgb(0, 134, 179),
241 regexp: rgb(24, 54, 145),
242 subst: rgb(51, 51, 51),
243}
244
245// Keywords that syntect scopes as storage.type rather than keyword.control.
246// highlight.js lumps these under "keyword"; we re-split so const/function/etc.
247// get the cyan storage color instead of pink.
248const STORAGE_KEYWORDS = new Set([
249 'const',
250 'let',
251 'var',
252 'function',
253 'class',
254 'type',
255 'interface',
256 'enum',
257 'namespace',
258 'module',
259 'def',
260 'fn',
261 'func',
262 'struct',
263 'trait',
264 'impl',
265])
266
267const ANSI_SCOPES: Record<string, Color> = {
268 keyword: ansiIdx(13),
269 _storage: ansiIdx(14),
270 built_in: ansiIdx(14),
271 type: ansiIdx(14),
272 literal: ansiIdx(12),
273 number: ansiIdx(12),
274 string: ansiIdx(10),
275 title: ansiIdx(11),
276 'title.function': ansiIdx(11),
277 'title.class': ansiIdx(11),
278 comment: ansiIdx(8),
279 meta: ansiIdx(8),
280}
281
282function buildTheme(themeName: string, mode: ColorMode): Theme {
283 const isDark = themeName.includes('dark')
284 const isAnsi = themeName.includes('ansi')
285 const isDaltonized = themeName.includes('daltonized')
286 const tc = mode === 'truecolor'
287
288 if (isAnsi) {
289 return {
290 addLine: DEFAULT_BG,
291 addWord: DEFAULT_BG,
292 addDecoration: ansiIdx(10),
293 deleteLine: DEFAULT_BG,
294 deleteWord: DEFAULT_BG,
295 deleteDecoration: ansiIdx(9),
296 foreground: ansiIdx(7),
297 background: DEFAULT_BG,
298 scopes: ANSI_SCOPES,
299 }
300 }
301
302 if (isDark) {
303 const fg = rgb(248, 248, 242)
304 const deleteLine = rgb(61, 1, 0)
305 const deleteWord = rgb(92, 2, 0)
306 const deleteDecoration = rgb(220, 90, 90)
307 if (isDaltonized) {
308 return {
309 addLine: tc ? rgb(0, 27, 41) : ansiIdx(17),
310 addWord: tc ? rgb(0, 48, 71) : ansiIdx(24),
311 addDecoration: rgb(81, 160, 200),
312 deleteLine,
313 deleteWord,
314 deleteDecoration,
315 foreground: fg,
316 background: DEFAULT_BG,
317 scopes: MONOKAI_SCOPES,
318 }
319 }
320 return {
321 addLine: tc ? rgb(2, 40, 0) : ansiIdx(22),
322 addWord: tc ? rgb(4, 71, 0) : ansiIdx(28),
323 addDecoration: rgb(80, 200, 80),
324 deleteLine,
325 deleteWord,
326 deleteDecoration,
327 foreground: fg,
328 background: DEFAULT_BG,
329 scopes: MONOKAI_SCOPES,
330 }
331 }
332
333 // light
334 const fg = rgb(51, 51, 51)
335 const deleteLine = rgb(255, 220, 220)
336 const deleteWord = rgb(255, 199, 199)
337 const deleteDecoration = rgb(207, 34, 46)
338 if (isDaltonized) {
339 return {
340 addLine: rgb(219, 237, 255),
341 addWord: rgb(179, 217, 255),
342 addDecoration: rgb(36, 87, 138),
343 deleteLine,
344 deleteWord,
345 deleteDecoration,
346 foreground: fg,
347 background: DEFAULT_BG,
348 scopes: GITHUB_SCOPES,
349 }
350 }
351 return {
352 addLine: rgb(220, 255, 220),
353 addWord: rgb(178, 255, 178),
354 addDecoration: rgb(36, 138, 61),
355 deleteLine,
356 deleteWord,
357 deleteDecoration,
358 foreground: fg,
359 background: DEFAULT_BG,
360 scopes: GITHUB_SCOPES,
361 }
362}
363
364function defaultStyle(theme: Theme): Style {
365 return { foreground: theme.foreground, background: theme.background }
366}
367
368function lineBackground(marker: Marker, theme: Theme): Color {
369 switch (marker) {
370 case '+':
371 return theme.addLine
372 case '-':
373 return theme.deleteLine
374 case ' ':
375 return theme.background
376 }
377}
378
379function wordBackground(marker: Marker, theme: Theme): Color {
380 switch (marker) {
381 case '+':
382 return theme.addWord
383 case '-':
384 return theme.deleteWord
385 case ' ':
386 return theme.background
387 }
388}
389
390function decorationColor(marker: Marker, theme: Theme): Color {
391 switch (marker) {
392 case '+':
393 return theme.addDecoration
394 case '-':
395 return theme.deleteDecoration
396 case ' ':
397 return theme.foreground
398 }
399}
400
401// ---------------------------------------------------------------------------
402// Syntax highlighting via highlight.js
403// ---------------------------------------------------------------------------
404
405// hljs 10.x uses `kind`; 11.x uses `scope`. Handle both.
406type HljsNode = {
407 scope?: string
408 kind?: string
409 children: (HljsNode | string)[]
410}
411
412// Filename-based and extension-based language detection (approximates bat's
413// SyntaxMapping + syntect's find_syntax_by_extension)
414const FILENAME_LANGS: Record<string, string> = {
415 Dockerfile: 'dockerfile',
416 Makefile: 'makefile',
417 Rakefile: 'ruby',
418 Gemfile: 'ruby',
419 CMakeLists: 'cmake',
420}
421
422function detectLanguage(
423 filePath: string,
424 firstLine: string | null,
425): string | null {
426 const base = basename(filePath)
427 const ext = extname(filePath).slice(1)
428
429 // Filename-based lookup (handles Dockerfile, Makefile, CMakeLists.txt, etc.)
430 const stem = base.split('.')[0] ?? ''
431 const byName = FILENAME_LANGS[base] ?? FILENAME_LANGS[stem]
432 if (byName && hljs().getLanguage(byName)) return byName
433 if (ext) {
434 const lang = hljs().getLanguage(ext)
435 if (lang) return ext
436 }
437 // Shebang / first-line detection (strip UTF-8 BOM)
438 if (firstLine) {
439 const line = firstLine.startsWith('\ufeff') ? firstLine.slice(1) : firstLine
440 if (line.startsWith('#!')) {
441 if (line.includes('bash') || line.includes('/sh')) return 'bash'
442 if (line.includes('python')) return 'python'
443 if (line.includes('node')) return 'javascript'
444 if (line.includes('ruby')) return 'ruby'
445 if (line.includes('perl')) return 'perl'
446 }
447 if (line.startsWith('<?php')) return 'php'
448 if (line.startsWith('<?xml')) return 'xml'
449 }
450 return null
451}
452
453function scopeColor(
454 scope: string | undefined,
455 text: string,
456 theme: Theme,
457): Color {
458 if (!scope) return theme.foreground
459 if (scope === 'keyword' && STORAGE_KEYWORDS.has(text.trim())) {
460 return theme.scopes['_storage'] ?? theme.foreground
461 }
462 return (
463 theme.scopes[scope] ??
464 theme.scopes[scope.split('.')[0]!] ??
465 theme.foreground
466 )
467}
468
469function flattenHljs(
470 node: HljsNode | string,
471 theme: Theme,
472 parentScope: string | undefined,
473 out: Block[],
474): void {
475 if (typeof node === 'string') {
476 const fg = scopeColor(parentScope, node, theme)
477 out.push([{ foreground: fg, background: theme.background }, node])
478 return
479 }
480 const scope = node.scope ?? node.kind ?? parentScope
481 for (const child of node.children) {
482 flattenHljs(child, theme, scope, out)
483 }
484}
485
486// result.emitter is in the public HighlightResult type, but rootNode is
487// internal to TokenTreeEmitter. Type guard validates the shape once so we
488// fail loudly (via logError) instead of a silent try/catch swallow — the
489// prior `as unknown as` cast hid a version mismatch (_emitter vs emitter,
490// scope vs kind) behind a silent gray fallback.
491function hasRootNode(emitter: unknown): emitter is { rootNode: HljsNode } {
492 return (
493 typeof emitter === 'object' &&
494 emitter !== null &&
495 'rootNode' in emitter &&
496 typeof emitter.rootNode === 'object' &&
497 emitter.rootNode !== null &&
498 'children' in emitter.rootNode
499 )
500}
501
502let loggedEmitterShapeError = false
503
504function highlightLine(
505 state: { lang: string | null; stack: unknown },
506 line: string,
507 theme: Theme,
508): Block[] {
509 // syntect-parity: feed a trailing \n so line comments terminate, then strip
510 const code = line + '\n'
511 if (!state.lang) {
512 return [[defaultStyle(theme), code]]
513 }
514 let result
515 try {
516 result = hljs().highlight(code, {
517 language: state.lang,
518 ignoreIllegals: true,
519 })
520 } catch {
521 // hljs throws on unknown language despite ignoreIllegals
522 return [[defaultStyle(theme), code]]
523 }
524 if (!hasRootNode(result.emitter)) {
525 if (!loggedEmitterShapeError) {
526 loggedEmitterShapeError = true
527 logError(
528 new Error(
529 `color-diff: hljs emitter shape mismatch (keys: ${Object.keys(result.emitter).join(',')}). Syntax highlighting disabled.`,
530 ),
531 )
532 }
533 return [[defaultStyle(theme), code]]
534 }
535 const blocks: Block[] = []
536 flattenHljs(result.emitter.rootNode, theme, undefined, blocks)
537 return blocks
538}
539
540// ---------------------------------------------------------------------------
541// Word diff
542// ---------------------------------------------------------------------------
543
544type Range = { start: number; end: number }
545
546const CHANGE_THRESHOLD = 0.4
547
548// Tokenize into word runs, whitespace runs, and single punctuation chars —
549// matches the Rust tokenize() which mirrors diffWordsWithSpace's splitting.
550function tokenize(text: string): string[] {
551 const tokens: string[] = []
552 let i = 0
553 while (i < text.length) {
554 const ch = text[i]!
555 if (/[\p{L}\p{N}_]/u.test(ch)) {
556 let j = i + 1
557 while (j < text.length && /[\p{L}\p{N}_]/u.test(text[j]!)) j++
558 tokens.push(text.slice(i, j))
559 i = j
560 } else if (/\s/.test(ch)) {
561 let j = i + 1
562 while (j < text.length && /\s/.test(text[j]!)) j++
563 tokens.push(text.slice(i, j))
564 i = j
565 } else {
566 // advance one codepoint (handle surrogate pairs)
567 const cp = text.codePointAt(i)!
568 const len = cp > 0xffff ? 2 : 1
569 tokens.push(text.slice(i, i + len))
570 i += len
571 }
572 }
573 return tokens
574}
575
576function findAdjacentPairs(markers: Marker[]): [number, number][] {
577 const pairs: [number, number][] = []
578 let i = 0
579 while (i < markers.length) {
580 if (markers[i] === '-') {
581 const delStart = i
582 let delEnd = i
583 while (delEnd < markers.length && markers[delEnd] === '-') delEnd++
584 let addEnd = delEnd
585 while (addEnd < markers.length && markers[addEnd] === '+') addEnd++
586 const delCount = delEnd - delStart
587 const addCount = addEnd - delEnd
588 if (delCount > 0 && addCount > 0) {
589 const n = Math.min(delCount, addCount)
590 for (let k = 0; k < n; k++) {
591 pairs.push([delStart + k, delEnd + k])
592 }
593 i = addEnd
594 } else {
595 i = delEnd
596 }
597 } else {
598 i++
599 }
600 }
601 return pairs
602}
603
604function wordDiffStrings(oldStr: string, newStr: string): [Range[], Range[]] {
605 const oldTokens = tokenize(oldStr)
606 const newTokens = tokenize(newStr)
607 const ops = diffArrays(oldTokens, newTokens)
608
609 const totalLen = oldStr.length + newStr.length
610 let changedLen = 0
611 const oldRanges: Range[] = []
612 const newRanges: Range[] = []
613 let oldOff = 0
614 let newOff = 0
615
616 for (const op of ops) {
617 const len = op.value.reduce((s, t) => s + t.length, 0)
618 if (op.removed) {
619 changedLen += len
620 oldRanges.push({ start: oldOff, end: oldOff + len })
621 oldOff += len
622 } else if (op.added) {
623 changedLen += len
624 newRanges.push({ start: newOff, end: newOff + len })
625 newOff += len
626 } else {
627 oldOff += len
628 newOff += len
629 }
630 }
631
632 if (totalLen > 0 && changedLen / totalLen > CHANGE_THRESHOLD) {
633 return [[], []]
634 }
635 return [oldRanges, newRanges]
636}
637
638// ---------------------------------------------------------------------------
639// Highlight (per-line transform pipeline)
640// ---------------------------------------------------------------------------
641
642type Highlight = {
643 marker: Marker | null
644 lineNumber: number
645 lines: Block[][]
646}
647
648function removeNewlines(h: Highlight): void {
649 h.lines = h.lines.map(line =>
650 line.flatMap(([style, text]) =>
651 text
652 .split('\n')
653 .filter(p => p.length > 0)
654 .map((p): Block => [style, p]),
655 ),
656 )
657}
658
659function charWidth(ch: string): number {
660 return stringWidth(ch)
661}
662
663function wrapText(h: Highlight, width: number, theme: Theme): void {
664 const newLines: Block[][] = []
665 for (const line of h.lines) {
666 const queue: Block[] = line.slice()
667 let cur: Block[] = []
668 let curW = 0
669 while (queue.length > 0) {
670 const [style, text] = queue.shift()!
671 const tw = stringWidth(text)
672 if (curW + tw <= width) {
673 cur.push([style, text])
674 curW += tw
675 } else {
676 const remaining = width - curW
677 let bytePos = 0
678 let accW = 0
679 // iterate by codepoint
680 for (const ch of text) {
681 const cw = charWidth(ch)
682 if (accW + cw > remaining) break
683 accW += cw
684 bytePos += ch.length
685 }
686 if (bytePos === 0) {
687 if (curW === 0) {
688 // Fresh line and first char still doesn't fit — force one codepoint
689 // to guarantee forward progress (overflows, but prevents infinite loop)
690 const firstCp = text.codePointAt(0)!
691 bytePos = firstCp > 0xffff ? 2 : 1
692 } else {
693 // Line has content and next char doesn't fit — finish this line,
694 // re-queue the whole block for a fresh line
695 newLines.push(cur)
696 queue.unshift([style, text])
697 cur = []
698 curW = 0
699 continue
700 }
701 }
702 cur.push([style, text.slice(0, bytePos)])
703 newLines.push(cur)
704 queue.unshift([style, text.slice(bytePos)])
705 cur = []
706 curW = 0
707 }
708 }
709 newLines.push(cur)
710 }
711 h.lines = newLines
712
713 // Pad changed lines so background extends to edge
714 if (h.marker && h.marker !== ' ') {
715 const bg = lineBackground(h.marker, theme)
716 const padStyle: Style = { foreground: theme.foreground, background: bg }
717 for (const line of h.lines) {
718 const curW = line.reduce((s, [, t]) => s + stringWidth(t), 0)
719 if (curW < width) {
720 line.push([padStyle, ' '.repeat(width - curW)])
721 }
722 }
723 }
724}
725
726function addLineNumber(
727 h: Highlight,
728 theme: Theme,
729 maxDigits: number,
730 fullDim: boolean,
731): void {
732 const style: Style = {
733 foreground: h.marker ? decorationColor(h.marker, theme) : theme.foreground,
734 background: h.marker ? lineBackground(h.marker, theme) : theme.background,
735 }
736 const shouldDim = h.marker === null || h.marker === ' '
737 for (let i = 0; i < h.lines.length; i++) {
738 const prefix =
739 i === 0
740 ? ` ${String(h.lineNumber).padStart(maxDigits)} `
741 : ' '.repeat(maxDigits + 2)
742 const wrapped = shouldDim && !fullDim ? `${DIM}${prefix}${UNDIM}` : prefix
743 h.lines[i]!.unshift([style, wrapped])
744 }
745}
746
747function addMarker(h: Highlight, theme: Theme): void {
748 if (!h.marker) return
749 const style: Style = {
750 foreground: decorationColor(h.marker, theme),
751 background: lineBackground(h.marker, theme),
752 }
753 for (const line of h.lines) {
754 line.unshift([style, h.marker])
755 }
756}
757
758function dimContent(h: Highlight): void {
759 for (const line of h.lines) {
760 if (line.length > 0) {
761 line[0]![1] = DIM + line[0]![1]
762 const last = line.length - 1
763 line[last]![1] = line[last]![1] + UNDIM
764 }
765 }
766}
767
768function applyBackground(h: Highlight, theme: Theme, ranges: Range[]): void {
769 if (!h.marker) return
770 const lineBg = lineBackground(h.marker, theme)
771 const wordBg = wordBackground(h.marker, theme)
772
773 let rangeIdx = 0
774 let byteOff = 0
775 for (let li = 0; li < h.lines.length; li++) {
776 const newLine: Block[] = []
777 for (const [style, text] of h.lines[li]!) {
778 const textStart = byteOff
779 const textEnd = byteOff + text.length
780
781 while (rangeIdx < ranges.length && ranges[rangeIdx]!.end <= textStart) {
782 rangeIdx++
783 }
784 if (rangeIdx >= ranges.length) {
785 newLine.push([{ ...style, background: lineBg }, text])
786 byteOff = textEnd
787 continue
788 }
789
790 let remaining = text
791 let pos = textStart
792 while (remaining.length > 0 && rangeIdx < ranges.length) {
793 const r = ranges[rangeIdx]!
794 const inRange = pos >= r.start && pos < r.end
795 let next: number
796 if (inRange) {
797 next = Math.min(r.end, textEnd)
798 } else if (r.start > pos && r.start < textEnd) {
799 next = r.start
800 } else {
801 next = textEnd
802 }
803 const segLen = next - pos
804 const seg = remaining.slice(0, segLen)
805 newLine.push([{ ...style, background: inRange ? wordBg : lineBg }, seg])
806 remaining = remaining.slice(segLen)
807 pos = next
808 if (pos >= r.end) rangeIdx++
809 }
810 if (remaining.length > 0) {
811 newLine.push([{ ...style, background: lineBg }, remaining])
812 }
813 byteOff = textEnd
814 }
815 h.lines[li] = newLine
816 }
817}
818
819function intoLines(
820 h: Highlight,
821 dim: boolean,
822 skipBg: boolean,
823 mode: ColorMode,
824): string[] {
825 return h.lines.map(line => asTerminalEscaped(line, mode, skipBg, dim))
826}
827
828// ---------------------------------------------------------------------------
829// Public API
830// ---------------------------------------------------------------------------
831
832function maxLineNumber(hunk: Hunk): number {
833 const oldEnd = Math.max(0, hunk.oldStart + hunk.oldLines - 1)
834 const newEnd = Math.max(0, hunk.newStart + hunk.newLines - 1)
835 return Math.max(oldEnd, newEnd)
836}
837
838function parseMarker(s: string): Marker {
839 return s === '+' || s === '-' ? s : ' '
840}
841
842export class ColorDiff {
843 private hunk: Hunk
844 private filePath: string
845 private firstLine: string | null
846 private prefixContent: string | null
847
848 constructor(
849 hunk: Hunk,
850 firstLine: string | null,
851 filePath: string,
852 prefixContent?: string | null,
853 ) {
854 this.hunk = hunk
855 this.filePath = filePath
856 this.firstLine = firstLine
857 this.prefixContent = prefixContent ?? null
858 }
859
860 render(themeName: string, width: number, dim: boolean): string[] | null {
861 const mode = detectColorMode(themeName)
862 const theme = buildTheme(themeName, mode)
863 const lang = detectLanguage(this.filePath, this.firstLine)
864 const hlState = { lang, stack: null }
865
866 // Warm highlighter with prefix lines (highlight.js is stateless per call,
867 // so this is a no-op for now — preserved for API parity)
868 void this.prefixContent
869
870 const maxDigits = String(maxLineNumber(this.hunk)).length
871 let oldLine = this.hunk.oldStart
872 let newLine = this.hunk.newStart
873 const effectiveWidth = Math.max(1, width - maxDigits - 2 - 1)
874
875 // First pass: assign markers + line numbers
876 type Entry = { lineNumber: number; marker: Marker; code: string }
877 const entries: Entry[] = this.hunk.lines.map(rawLine => {
878 const marker = parseMarker(rawLine.slice(0, 1))
879 const code = rawLine.slice(1)
880 let lineNumber: number
881 switch (marker) {
882 case '+':
883 lineNumber = newLine++
884 break
885 case '-':
886 lineNumber = oldLine++
887 break
888 case ' ':
889 lineNumber = newLine
890 oldLine++
891 newLine++
892 break
893 }
894 return { lineNumber, marker, code }
895 })
896
897 // Word-diff ranges (skip when dim — too loud)
898 const ranges: Range[][] = entries.map(() => [])
899 if (!dim) {
900 const markers = entries.map(e => e.marker)
901 for (const [delIdx, addIdx] of findAdjacentPairs(markers)) {
902 const [delR, addR] = wordDiffStrings(
903 entries[delIdx]!.code,
904 entries[addIdx]!.code,
905 )
906 ranges[delIdx] = delR
907 ranges[addIdx] = addR
908 }
909 }
910
911 // Second pass: highlight + transform pipeline
912 const out: string[] = []
913 for (let i = 0; i < entries.length; i++) {
914 const { lineNumber, marker, code } = entries[i]!
915 const tokens: Block[] =
916 marker === '-'
917 ? [[defaultStyle(theme), code]]
918 : highlightLine(hlState, code, theme)
919
920 const h: Highlight = { marker, lineNumber, lines: [tokens] }
921 removeNewlines(h)
922 applyBackground(h, theme, ranges[i]!)
923 wrapText(h, effectiveWidth, theme)
924 if (mode === 'ansi' && marker === '-') {
925 dimContent(h)
926 }
927 addMarker(h, theme)
928 addLineNumber(h, theme, maxDigits, dim)
929 out.push(...intoLines(h, dim, false, mode))
930 }
931 return out
932 }
933}
934
935export class ColorFile {
936 private code: string
937 private filePath: string
938
939 constructor(code: string, filePath: string) {
940 this.code = code
941 this.filePath = filePath
942 }
943
944 render(themeName: string, width: number, dim: boolean): string[] | null {
945 const mode = detectColorMode(themeName)
946 const theme = buildTheme(themeName, mode)
947 const lines = this.code.split('\n')
948 // Rust .lines() drops trailing empty line from trailing \n
949 if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
950 const firstLine = lines[0] ?? null
951 const lang = detectLanguage(this.filePath, firstLine)
952 const hlState = { lang, stack: null }
953
954 const maxDigits = String(lines.length).length
955 const effectiveWidth = Math.max(1, width - maxDigits - 2)
956
957 const out: string[] = []
958 for (let i = 0; i < lines.length; i++) {
959 const tokens = highlightLine(hlState, lines[i]!, theme)
960 const h: Highlight = { marker: null, lineNumber: i + 1, lines: [tokens] }
961 removeNewlines(h)
962 wrapText(h, effectiveWidth, theme)
963 addLineNumber(h, theme, maxDigits, dim)
964 out.push(...intoLines(h, dim, true, mode))
965 }
966 return out
967 }
968}
969
970export function getSyntaxTheme(themeName: string): SyntaxTheme {
971 // highlight.js has no bat theme set, so env vars can't select alternate
972 // syntect themes. We still report the env var if set, for diagnostics.
973 const envTheme =
974 process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT ?? process.env.BAT_THEME
975 void envTheme
976 return { theme: defaultSyntaxThemeName(themeName), source: null }
977}
978
979// Lazy loader to match vendor/color-diff-src/index.ts API
980let cachedModule: NativeModule | null = null
981
982export function getNativeModule(): NativeModule | null {
983 if (cachedModule) return cachedModule
984 cachedModule = { ColorDiff, ColorFile, getSyntaxTheme }
985 return cachedModule
986}
987
988export type { ColorDiff as ColorDiffClass, ColorFile as ColorFileClass }
989
990// Exported for testing
991export const __test = {
992 tokenize,
993 findAdjacentPairs,
994 wordDiffStrings,
995 ansi256FromRgb,
996 colorToEscape,
997 detectColorMode,
998 detectLanguage,
999}