@pi-archimedes/diff 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/diff",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
package/src/ansi.ts CHANGED
@@ -103,7 +103,7 @@ export function isLowContrastShikiFg(params: string): boolean {
103
103
  if (!params.startsWith("38;2;")) return false;
104
104
  const parts = params.split(";").map(Number);
105
105
  if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
106
- const [, , r, g, b] = parts;
106
+ const [, , r, g, b] = parts as [number, number, number, number, number];
107
107
  const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
108
108
  return luminance < 72;
109
109
  }
@@ -156,7 +156,7 @@ export function summarize(a: number, d: number): string {
156
156
  function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
157
157
  const esc = "\u001b";
158
158
  const m = ansi.match(new RegExp(`${esc}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
159
- return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
159
+ return m ? { r: +m[1]!, g: +m[2]!, b: +m[3]! } : null;
160
160
  }
161
161
 
162
162
  /** Mix an accent color into a base color at the given intensity (0.0–1.0). */
@@ -243,11 +243,31 @@ export function themeCacheKey(theme?: any): string {
243
243
  return parts.join("|");
244
244
  }
245
245
 
246
- let _didAutoDerive = false;
246
+ let _lastThemeKey: string | undefined;
247
+
248
+ /** Reset auto-derived colors — call when theme changes. */
249
+ export function resetDiffColors(): void {
250
+ _lastThemeKey = undefined;
251
+ // Restore hardcoded fallbacks
252
+ BG_ADD = "\x1b[48;2;22;38;32m";
253
+ BG_DEL = "\x1b[48;2;45;25;25m";
254
+ BG_ADD_W = "\x1b[48;2;35;75;50m";
255
+ BG_DEL_W = "\x1b[48;2;80;35;35m";
256
+ BG_GUTTER_ADD = "\x1b[48;2;18;32;26m";
257
+ BG_GUTTER_DEL = "\x1b[48;2;38;22;22m";
258
+ BG_EMPTY = "\x1b[48;2;18;18;18m";
259
+ BG_BASE = BG_DEFAULT;
260
+ RST = "\x1b[0m";
261
+ DIVIDER = `${FG_RULE}│${RST}`;
262
+ }
263
+
247
264
  export function resolveDiffColors(theme?: any): DiffColors {
248
- if (!_didAutoDerive && theme?.getFgAnsi) {
265
+ const themeKey = themeCacheKey(theme);
266
+
267
+ // Re-derive when theme changes (different key) or first call
268
+ if (themeKey !== _lastThemeKey && theme?.getFgAnsi) {
249
269
  autoDeriveBgFromTheme(theme);
250
- _didAutoDerive = true;
270
+ _lastThemeKey = themeKey;
251
271
  }
252
272
  if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
253
273
  try {
package/src/core/diff.ts CHANGED
@@ -22,16 +22,17 @@ export function parseDiff(oldContent: string, newContent: string, ctx = 3): Pars
22
22
 
23
23
  for (let hi = 0; hi < patch.hunks.length; hi++) {
24
24
  if (hi > 0) {
25
- const prev = patch.hunks[hi - 1];
26
- const gap = patch.hunks[hi].oldStart - (prev.oldStart + prev.oldLines);
25
+ const prev = patch.hunks[hi - 1]!;
26
+ const curr = patch.hunks[hi]!;
27
+ const gap = curr.oldStart - (prev.oldStart + prev.oldLines);
27
28
  lines.push({ type: "sep", oldNum: null, newNum: gap > 0 ? gap : null, content: "" });
28
29
  }
29
- const h = patch.hunks[hi];
30
+ const h = patch.hunks[hi]!;
30
31
  let oL = h.oldStart;
31
32
  let nL = h.newStart;
32
33
  for (const raw of h.lines) {
33
34
  if (raw === "\") continue;
34
- const ch = raw[0];
35
+ const ch = raw[0]!;
35
36
  const text = raw.slice(1);
36
37
  if (ch === "+") {
37
38
  lines.push({ type: "add", oldNum: null, newNum: nL++, content: text });
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ import * as Ansi from "./ansi.js";
13
13
  import { resolveDiffColors, themeCacheKey, DEFAULT_DIFF_COLORS } from "./ansi.js";
14
14
  import { setConfigGetter as setShikiConfig } from "./shiki.js";
15
15
  import { hlBlock, lang } from "./shiki.js";
16
- import { renderSplit } from "./render.js";
16
+ import { renderSplit, MAX_PREVIEW_LINES, MAX_RENDER_LINES } from "./render.js";
17
17
  import { setConfigGetter as setRenderConfig } from "./render.js";
18
18
 
19
19
  // ---------------------------------------------------------------------------
@@ -35,13 +35,6 @@ const DEFAULT_DIFF_CONFIG: DiffConfig = {
35
35
  let _readConfig: () => DiffConfig = () => DEFAULT_DIFF_CONFIG;
36
36
  function getConfig(): DiffConfig { return _readConfig(); }
37
37
 
38
- // ---------------------------------------------------------------------------
39
- // Constants
40
- // ---------------------------------------------------------------------------
41
-
42
- const MAX_PREVIEW_LINES = 60;
43
- const MAX_RENDER_LINES = 150;
44
-
45
38
  // ---------------------------------------------------------------------------
46
39
  // Settings
47
40
  // ---------------------------------------------------------------------------
@@ -296,7 +289,7 @@ export function registerDiffTools(
296
289
  try {
297
290
  if (fp && existsSync(fp)) {
298
291
  const f = readFileSync(fp, "utf-8");
299
- const idx = f.indexOf(operations[0].newText);
292
+ const idx = f.indexOf(operations[0]!.newText);
300
293
  if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
301
294
  }
302
295
  } catch { editLine = 0; }
@@ -332,7 +325,7 @@ export function registerDiffTools(
332
325
  const dc = resolveDiffColors(theme);
333
326
 
334
327
  if (operations.length === 1) {
335
- const diff = parseDiff(operations[0].oldText, operations[0].newText);
328
+ const diff = parseDiff(operations[0]!.oldText, operations[0]!.newText);
336
329
  renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
337
330
  .then((rendered) => {
338
331
  if (ctx.state._pk !== pk) return;
package/src/render.ts CHANGED
@@ -12,8 +12,8 @@ import type { DiffLine, ParsedDiff } from "./core/diff.js";
12
12
  // Constants
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
- const MAX_PREVIEW_LINES = 60;
16
- const MAX_RENDER_LINES = 150;
15
+ export const MAX_PREVIEW_LINES = 60;
16
+ export const MAX_RENDER_LINES = 150;
17
17
  const MAX_HL_CHARS = 80_000;
18
18
  const WORD_DIFF_MIN_SIM = 0.15;
19
19
  const SPLIT_MAX_WRAP_RATIO = 0.2;
@@ -187,7 +187,7 @@ export async function renderUnified(
187
187
  }
188
188
 
189
189
  while (idx < vis.length) {
190
- const l = vis[idx];
190
+ const l = vis[idx]!;
191
191
 
192
192
  if (l.type === "sep") {
193
193
  const gap = l.newNum;
@@ -208,30 +208,30 @@ export async function renderUnified(
208
208
  }
209
209
 
210
210
  const dels: Array<{ l: DiffLine; hl: string }> = [];
211
- while (idx < vis.length && vis[idx].type === "del") {
212
- dels.push({ l: vis[idx], hl: oldHL[oI] ?? vis[idx].content });
211
+ while (idx < vis.length && vis[idx]!.type === "del") {
212
+ dels.push({ l: vis[idx]!, hl: oldHL[oI] ?? vis[idx]!.content });
213
213
  oI++; idx++;
214
214
  }
215
215
  const adds: Array<{ l: DiffLine; hl: string }> = [];
216
- while (idx < vis.length && vis[idx].type === "add") {
217
- adds.push({ l: vis[idx], hl: newHL[nI] ?? vis[idx].content });
216
+ while (idx < vis.length && vis[idx]!.type === "add") {
217
+ adds.push({ l: vis[idx]!, hl: newHL[nI] ?? vis[idx]!.content });
218
218
  nI++; idx++;
219
219
  }
220
220
 
221
221
  const isPaired = dels.length === 1 && adds.length === 1;
222
- const wd = isPaired ? wordDiffAnalysis(dels[0].l.content, adds[0].l.content) : null;
222
+ const wd = isPaired ? wordDiffAnalysis(dels[0]!.l.content, adds[0]!.l.content) : null;
223
223
 
224
224
  if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
225
- const delBody = injectBg(dels[0].hl, wd.oldRanges, Ansi.BG_DEL, Ansi.BG_DEL_W);
226
- const addBody = injectBg(adds[0].hl, wd.newRanges, Ansi.BG_ADD, Ansi.BG_ADD_W);
227
- emitRow(dels[0].l.oldNum, "-", Ansi.BG_GUTTER_DEL, `${dc.fgDel}${Ansi.BOLD}`, delBody, Ansi.BG_DEL);
228
- emitRow(adds[0].l.newNum, "+", Ansi.BG_GUTTER_ADD, `${dc.fgAdd}${Ansi.BOLD}`, addBody, Ansi.BG_ADD);
225
+ const delBody = injectBg(dels[0]!.hl, wd.oldRanges, Ansi.BG_DEL, Ansi.BG_DEL_W);
226
+ const addBody = injectBg(adds[0]!.hl, wd.newRanges, Ansi.BG_ADD, Ansi.BG_ADD_W);
227
+ emitRow(dels[0]!.l.oldNum, "-", Ansi.BG_GUTTER_DEL, `${dc.fgDel}${Ansi.BOLD}`, delBody, Ansi.BG_DEL);
228
+ emitRow(adds[0]!.l.newNum, "+", Ansi.BG_GUTTER_ADD, `${dc.fgAdd}${Ansi.BOLD}`, addBody, Ansi.BG_ADD);
229
229
  continue;
230
230
  }
231
231
  if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
232
- const pwd = plainWordDiff(dels[0].l.content, adds[0].l.content);
233
- emitRow(dels[0].l.oldNum, "-", Ansi.BG_GUTTER_DEL, `${dc.fgDel}${Ansi.BOLD}`, `${Ansi.BG_DEL}${pwd.old}`, Ansi.BG_DEL);
234
- emitRow(adds[0].l.newNum, "+", Ansi.BG_GUTTER_ADD, `${dc.fgAdd}${Ansi.BOLD}`, `${Ansi.BG_ADD}${pwd.new}`, Ansi.BG_ADD);
232
+ const pwd = plainWordDiff(dels[0]!.l.content, adds[0]!.l.content);
233
+ emitRow(dels[0]!.l.oldNum, "-", Ansi.BG_GUTTER_DEL, `${dc.fgDel}${Ansi.BOLD}`, `${Ansi.BG_DEL}${pwd.old}`, Ansi.BG_DEL);
234
+ emitRow(adds[0]!.l.newNum, "+", Ansi.BG_GUTTER_ADD, `${dc.fgAdd}${Ansi.BOLD}`, `${Ansi.BG_ADD}${pwd.new}`, Ansi.BG_ADD);
235
235
  continue;
236
236
  }
237
237
 
@@ -270,11 +270,11 @@ export async function renderSplit(
270
270
  const rows: Row[] = [];
271
271
  let i = 0;
272
272
  while (i < diff.lines.length) {
273
- const l = diff.lines[i];
273
+ const l = diff.lines[i]!;
274
274
  if (l.type === "sep" || l.type === "ctx") { rows.push({ left: l, right: l }); i++; continue; }
275
275
  const dels: DiffLine[] = [], adds: DiffLine[] = [];
276
- while (i < diff.lines.length && diff.lines[i].type === "del") { dels.push(diff.lines[i]); i++; }
277
- while (i < diff.lines.length && diff.lines[i].type === "add") { adds.push(diff.lines[i]); i++; }
276
+ while (i < diff.lines.length && diff.lines[i]!.type === "del") { dels.push(diff.lines[i]!); i++; }
277
+ while (i < diff.lines.length && diff.lines[i]!.type === "add") { adds.push(diff.lines[i]!); i++; }
278
278
  const n = Math.max(dels.length, adds.length);
279
279
  for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
280
280
  }
package/src/shiki.ts CHANGED
@@ -52,12 +52,13 @@ async function ensureShiki(): Promise<void> {
52
52
  const _cache = new Map<string, string[]>();
53
53
 
54
54
  function _touch(k: string, v: string[]): string[] {
55
+ // True LRU: delete and re-insert to move to end (most recently used)
55
56
  _cache.delete(k);
56
57
  _cache.set(k, v);
57
58
  while (_cache.size > CACHE_LIMIT) {
58
- const first = _cache.keys().next().value;
59
- if (first === undefined) break;
60
- _cache.delete(first);
59
+ const oldest = _cache.keys().next().value;
60
+ if (oldest === undefined) break;
61
+ _cache.delete(oldest);
61
62
  }
62
63
  return v;
63
64
  }
@@ -71,7 +72,7 @@ export async function hlBlock(code: string, language: BundledLanguage | undefine
71
72
  const theme = _getConfig().diffTheme;
72
73
  const k = `${theme}\0${language}\0${code}`;
73
74
  const hit = _cache.get(k);
74
- if (hit) return _touch(k, hit);
75
+ if (hit) return _touch(k, hit); // Promote to most-recently-used
75
76
 
76
77
  try {
77
78
  const ansi = normalizeShikiContrast(await codeToANSI(code, language, theme as BundledTheme));
package/src/word-diff.ts CHANGED
@@ -52,6 +52,8 @@ export function wordDiffAnalysis(
52
52
  * Inject diff background into Shiki ANSI output.
53
53
  * `baseBg` on unchanged spans, `hlBg` on changed character ranges.
54
54
  * Re-injects bg after any full reset (\x1b[0m).
55
+ *
56
+ * Handles unsorted and overlapping ranges by checking all ranges per position.
55
57
  */
56
58
  export function injectBg(
57
59
  ansiLine: string,
@@ -61,6 +63,17 @@ export function injectBg(
61
63
  ): string {
62
64
  if (!ranges.length) return baseBg + ansiLine + Ansi.RST;
63
65
 
66
+ // Sort ranges and merge overlaps for efficient lookup
67
+ const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
68
+ const merged: Array<[number, number]> = [];
69
+ for (const [start, end] of sorted) {
70
+ if (merged.length > 0 && start <= merged[merged.length - 1]![1]) {
71
+ merged[merged.length - 1]![1] = Math.max(merged[merged.length - 1]![1], end);
72
+ } else {
73
+ merged.push([start, end]);
74
+ }
75
+ }
76
+
64
77
  let out = baseBg;
65
78
  let vis = 0;
66
79
  let inHL = false;
@@ -68,7 +81,7 @@ export function injectBg(
68
81
  let i = 0;
69
82
 
70
83
  while (i < ansiLine.length) {
71
- if (ansiLine[i] === "\x1b") {
84
+ if (ansiLine[i]! === "\x1b") {
72
85
  const m = ansiLine.indexOf("m", i);
73
86
  if (m !== -1) {
74
87
  const seq = ansiLine.slice(i, m + 1);
@@ -78,13 +91,15 @@ export function injectBg(
78
91
  continue;
79
92
  }
80
93
  }
81
- while (ri < ranges.length && vis >= ranges[ri][1]) ri++;
82
- const want = ri < ranges.length && vis >= ranges[ri][0] && vis < ranges[ri][1];
94
+ // Advance range index past completed ranges
95
+ while (ri < merged.length && vis >= merged[ri]![1]) ri++;
96
+ // Check if current position falls within the next range
97
+ const want = ri < merged.length && vis >= merged[ri]![0] && vis < merged[ri]![1];
83
98
  if (want !== inHL) {
84
99
  inHL = want;
85
100
  out += inHL ? hlBg : baseBg;
86
101
  }
87
- out += ansiLine[i];
102
+ out += ansiLine[i]!;
88
103
  vis++;
89
104
  i++;
90
105
  }