source dump of claude code
at main 999 lines 30 kB view raw
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}