/ native-ts / color-diff / index.ts
index.ts
  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  
 20  import { diffArrays } from 'diff'
 21  import type * as hljsNamespace from 'highlight.js'
 22  import { 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.
 33  type HLJSApi = typeof hljsNamespace
 34  let cachedHljs: HLJSApi | null = null
 35  function 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  
 45  import { stringWidth } from '../../ink/stringWidth.js'
 46  import { logError } from '../../utils/log.js'
 47  
 48  // ---------------------------------------------------------------------------
 49  // Public API types (match vendor/color-diff-src/index.d.ts)
 50  // ---------------------------------------------------------------------------
 51  
 52  export type Hunk = {
 53    oldStart: number
 54    oldLines: number
 55    newStart: number
 56    newLines: number
 57    lines: string[]
 58  }
 59  
 60  export type SyntaxTheme = {
 61    theme: string
 62    source: string | null
 63  }
 64  
 65  export 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  
 75  type Color = { r: number; g: number; b: number; a: number }
 76  type Style = { foreground: Color; background: Color }
 77  type Block = [Style, string]
 78  type ColorMode = 'truecolor' | 'color256' | 'ansi'
 79  
 80  const RESET = '\x1b[0m'
 81  const DIM = '\x1b[2m'
 82  const UNDIM = '\x1b[22m'
 83  
 84  function rgb(r: number, g: number, b: number): Color {
 85    return { r, g, b, a: 255 }
 86  }
 87  
 88  function 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)
 93  const DEFAULT_BG: Color = { r: 0, g: 0, b: 0, a: 1 }
 94  
 95  function 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.
104  const CUBE_LEVELS = [0, 95, 135, 175, 215, 255]
105  function 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  
129  function 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  
147  function 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  
168  type Marker = '+' | '-' | ' '
169  
170  type 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  
182  function 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)
190  const 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)
218  const 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.
248  const 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  
267  const 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  
282  function 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  
364  function defaultStyle(theme: Theme): Style {
365    return { foreground: theme.foreground, background: theme.background }
366  }
367  
368  function 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  
379  function 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  
390  function 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.
406  type 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)
414  const FILENAME_LANGS: Record<string, string> = {
415    Dockerfile: 'dockerfile',
416    Makefile: 'makefile',
417    Rakefile: 'ruby',
418    Gemfile: 'ruby',
419    CMakeLists: 'cmake',
420  }
421  
422  function 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  
453  function 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  
469  function 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.
491  function 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  
502  let loggedEmitterShapeError = false
503  
504  function 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  
544  type Range = { start: number; end: number }
545  
546  const 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.
550  function 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  
576  function 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  
604  function 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  
642  type Highlight = {
643    marker: Marker | null
644    lineNumber: number
645    lines: Block[][]
646  }
647  
648  function 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  
659  function charWidth(ch: string): number {
660    return stringWidth(ch)
661  }
662  
663  function 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  
726  function 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  
747  function 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  
758  function 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  
768  function 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  
819  function 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  
832  function 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  
838  function parseMarker(s: string): Marker {
839    return s === '+' || s === '-' ? s : ' '
840  }
841  
842  export 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  
935  export 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  
970  export 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
980  let cachedModule: NativeModule | null = null
981  
982  export function getNativeModule(): NativeModule | null {
983    if (cachedModule) return cachedModule
984    cachedModule = { ColorDiff, ColorFile, getSyntaxTheme }
985    return cachedModule
986  }
987  
988  export type { ColorDiff as ColorDiffClass, ColorFile as ColorFileClass }
989  
990  // Exported for testing
991  export const __test = {
992    tokenize,
993    findAdjacentPairs,
994    wordDiffStrings,
995    ansi256FromRgb,
996    colorToEscape,
997    detectColorMode,
998    detectLanguage,
999  }