@pi-archimedes/diff 2.0.1 → 2.1.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/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  Shiki-powered diff rendering for the [Pi coding agent](https://github.com/earendil-works/pi).
4
4
 
5
- ## Features
5
+ Syntax-highlighted, word-level diffs make reviewing code changes effortless. By rendering clear side-by-side or unified diffs directly in your terminal with themes matched to your environment, code modifications can be inspected at a glance before committing.
6
+
7
+ ## What you get
6
8
 
7
9
  - **Split and unified views** — side-by-side split diff or traditional unified view, auto-selected based on terminal width
8
10
  - **Shiki syntax highlighting** — full syntax highlighting powered by [Shiki](https://shiki.style), with auto-derived theme colors
@@ -18,16 +20,16 @@ Side-by-side diff with syntax highlighting, word-level emphasis on changed chara
18
20
 
19
21
  ![diff edit](../../docs/images/diff-edit.png)
20
22
 
21
- ## Installation
23
+ ## Install
22
24
 
23
25
  ```bash
24
- pi install @pi-archimedes/diff
26
+ pi install npm:@pi-archimedes/diff
25
27
  ```
26
28
 
27
- Or install the full [pi-archimedes](../..) meta package for the integrated experience:
29
+ Or install full meta package:
28
30
 
29
31
  ```bash
30
- pi install pi-archimedes
32
+ pi install npm:pi-archimedes
31
33
  ```
32
34
 
33
35
  ## Settings
@@ -43,3 +45,5 @@ Settings are stored in `~/.pi/agent/settings.json` under the `archimedes.diff` n
43
45
  ## Integration
44
46
 
45
47
  When installed via `pi-archimedes` (the meta package), the diff tools are registered with callbacks to the current theme and config, ensuring colors match your active Pi theme. Standalone installs use default dark theme colors.
48
+
49
+ ← Back to [pi-archimedes](../../README.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/diff",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,261 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as fc from "fast-check";
3
+ import {
4
+ RST,
5
+ BOLD,
6
+ DIM,
7
+ FG_ADD,
8
+ FG_DEL,
9
+ FG_DIM,
10
+ FG_LNUM,
11
+ FG_RULE,
12
+ FG_SAFE_MUTED,
13
+ FG_STRIPE,
14
+ BORDER_BAR,
15
+ ANSI_RE,
16
+ ANSI_CAPTURE_RE,
17
+ ANSI_PARAM_CAPTURE_RE,
18
+ DEFAULT_DIFF_COLORS,
19
+ DEFAULT_DIFF_BG,
20
+ themeCacheKey,
21
+ resolveDiffColors,
22
+ resetDiffColors,
23
+ } from "./codes.js";
24
+
25
+ // ── Exported constants ──────────────────────────────────────────────────────
26
+
27
+ describe("ANSI constants", () => {
28
+ it("all exported constants are non-empty strings", () => {
29
+ expect(typeof RST).toBe("string");
30
+ expect(RST.length).toBeGreaterThan(0);
31
+ expect(typeof BOLD).toBe("string");
32
+ expect(BOLD.length).toBeGreaterThan(0);
33
+ expect(typeof DIM).toBe("string");
34
+ expect(DIM.length).toBeGreaterThan(0);
35
+ expect(typeof FG_ADD).toBe("string");
36
+ expect(FG_ADD.length).toBeGreaterThan(0);
37
+ expect(typeof FG_DEL).toBe("string");
38
+ expect(FG_DEL.length).toBeGreaterThan(0);
39
+ expect(typeof FG_DIM).toBe("string");
40
+ expect(FG_DIM.length).toBeGreaterThan(0);
41
+ expect(typeof FG_LNUM).toBe("string");
42
+ expect(FG_LNUM.length).toBeGreaterThan(0);
43
+ expect(typeof FG_RULE).toBe("string");
44
+ expect(FG_RULE.length).toBeGreaterThan(0);
45
+ expect(typeof FG_SAFE_MUTED).toBe("string");
46
+ expect(FG_SAFE_MUTED.length).toBeGreaterThan(0);
47
+ expect(typeof FG_STRIPE).toBe("string");
48
+ expect(FG_STRIPE.length).toBeGreaterThan(0);
49
+ expect(typeof BORDER_BAR).toBe("string");
50
+ expect(BORDER_BAR.length).toBeGreaterThan(0);
51
+ });
52
+
53
+ it("RST is reset escape", () => {
54
+ expect(RST).toBe("\x1b[0m");
55
+ });
56
+
57
+ it("BOLD is bold escape", () => {
58
+ expect(BOLD).toBe("\x1b[1m");
59
+ });
60
+
61
+ it("DIM is dim escape", () => {
62
+ expect(DIM).toBe("\x1b[2m");
63
+ });
64
+
65
+ it("FG_ADD is truecolor green-ish", () => {
66
+ expect(FG_ADD).toBe("\x1b[38;2;100;180;120m");
67
+ });
68
+
69
+ it("FG_DEL is truecolor red-ish", () => {
70
+ expect(FG_DEL).toBe("\x1b[38;2;200;100;100m");
71
+ });
72
+ });
73
+
74
+ // ── Regex patterns ──────────────────────────────────────────────────────────
75
+
76
+ describe("ANSI_RE", () => {
77
+ it("matches standard ANSI escapes", () => {
78
+ // Use exec on a copy to avoid lastIndex state issues with global flag
79
+ const re = new RegExp(ANSI_RE.source, "g");
80
+ expect(re.test("\x1b[0m")).toBe(true);
81
+ re.lastIndex = 0;
82
+ expect(re.test("\x1b[31m")).toBe(true);
83
+ re.lastIndex = 0;
84
+ expect(re.test("\x1b[38;2;255;128;64m")).toBe(true);
85
+ });
86
+
87
+ it("matches multiple escapes in a string", () => {
88
+ const matches = "\x1b[31mred\x1b[0m".match(ANSI_RE);
89
+ expect(matches).toHaveLength(2);
90
+ });
91
+
92
+ it("does not match plain text", () => {
93
+ expect("hello world".match(ANSI_RE)).toBeNull();
94
+ });
95
+ });
96
+
97
+ describe("ANSI_CAPTURE_RE", () => {
98
+ it("captures parameters between [ and m", () => {
99
+ // Use exec() for capture groups with global regex
100
+ const re = new RegExp(ANSI_CAPTURE_RE.source, "g");
101
+ const match = re.exec("\x1b[38;2;255;128;64m");
102
+ expect(match).not.toBeNull();
103
+ expect(match![1]).toBe("38;2;255;128;64");
104
+ });
105
+
106
+ it("captures empty params for reset", () => {
107
+ const re = new RegExp(ANSI_CAPTURE_RE.source, "g");
108
+ const match = re.exec("\x1b[0m");
109
+ expect(match).not.toBeNull();
110
+ expect(match![1]).toBe("0");
111
+ });
112
+ });
113
+
114
+ describe("ANSI_PARAM_CAPTURE_RE", () => {
115
+ it("captures numeric params", () => {
116
+ const re = new RegExp(ANSI_PARAM_CAPTURE_RE.source, "g");
117
+ const match = re.exec("\x1b[38;2;255;128;64m");
118
+ expect(match).not.toBeNull();
119
+ expect(match![1]).toBe("38;2;255;128;64");
120
+ });
121
+
122
+ it("captures simple numeric code", () => {
123
+ const re = new RegExp(ANSI_PARAM_CAPTURE_RE.source, "g");
124
+ const match = re.exec("\x1b[31m");
125
+ expect(match).not.toBeNull();
126
+ expect(match![1]).toBe("31");
127
+ });
128
+ });
129
+
130
+ // ── DEFAULT_DIFF_COLORS ─────────────────────────────────────────────────────
131
+
132
+ describe("DEFAULT_DIFF_COLORS", () => {
133
+ it("has all required fields", () => {
134
+ expect(DEFAULT_DIFF_COLORS.fgAdd).toBe(FG_ADD);
135
+ expect(DEFAULT_DIFF_COLORS.fgDel).toBe(FG_DEL);
136
+ expect(DEFAULT_DIFF_COLORS.fgCtx).toBe(FG_DIM);
137
+ });
138
+ });
139
+
140
+ // ── themeCacheKey ───────────────────────────────────────────────────────────
141
+
142
+ describe("themeCacheKey", () => {
143
+ it("returns 'no-theme' for null theme", () => {
144
+ expect(themeCacheKey(null)).toBe("no-theme");
145
+ });
146
+
147
+ it("returns 'no-theme' for undefined theme", () => {
148
+ expect(themeCacheKey(undefined)).toBe("no-theme");
149
+ });
150
+
151
+ it("returns 'no-theme' for theme without fg", () => {
152
+ expect(themeCacheKey({})).toBe("no-theme");
153
+ });
154
+
155
+ it("returns deterministic string for same theme", () => {
156
+ const theme = {
157
+ fg: (token: string, fallback: string) => `fg-${token}`,
158
+ bg: (token: string, fallback: string) => `bg-${token}`,
159
+ };
160
+ const key1 = themeCacheKey(theme);
161
+ const key2 = themeCacheKey(theme);
162
+ expect(key1).toBe(key2);
163
+ expect(typeof key1).toBe("string");
164
+ expect(key1.length).toBeGreaterThan(0);
165
+ });
166
+
167
+ it("property: deterministic — themeCacheKey(a) === themeCacheKey(a)", () => {
168
+ fc.assert(
169
+ fc.property(
170
+ fc.record({
171
+ fg: fc.constant((t: string, f: string) => `${t}-${f}`),
172
+ bg: fc.constant((t: string, f: string) => `bg-${t}`),
173
+ }),
174
+ (theme) => {
175
+ const key1 = themeCacheKey(theme);
176
+ const key2 = themeCacheKey(theme);
177
+ return key1 === key2;
178
+ },
179
+ ),
180
+ );
181
+ });
182
+ });
183
+
184
+ // ── resolveDiffColors ───────────────────────────────────────────────────────
185
+
186
+ describe("resolveDiffColors", () => {
187
+ it("returns DEFAULT_DIFF_COLORS when no theme", () => {
188
+ resetDiffColors();
189
+ const result = resolveDiffColors(undefined);
190
+ expect(result).toEqual(DEFAULT_DIFF_COLORS);
191
+ });
192
+
193
+ it("returns DEFAULT_DIFF_COLORS when theme lacks getFgAnsi", () => {
194
+ resetDiffColors();
195
+ const result = resolveDiffColors({ fg: () => "" });
196
+ expect(result).toEqual(DEFAULT_DIFF_COLORS);
197
+ });
198
+
199
+ it("returns theme-derived colors when theme has getFgAnsi", () => {
200
+ resetDiffColors();
201
+ const theme = {
202
+ getFgAnsi: (token: string) => {
203
+ if (token === "toolDiffAdded") return "\x1b[38;2;50;200;50m";
204
+ if (token === "toolDiffRemoved") return "\x1b[38;2;200;50;50m";
205
+ if (token === "toolDiffContext") return "\x1b[38;2;100;100;100m";
206
+ return "";
207
+ },
208
+ };
209
+ const result = resolveDiffColors(theme);
210
+ expect(result.fgAdd).toBe("\x1b[38;2;50;200;50m");
211
+ expect(result.fgDel).toBe("\x1b[38;2;200;50;50m");
212
+ expect(result.fgCtx).toBe("\x1b[38;2;100;100;100m");
213
+ });
214
+
215
+ it("falls back to defaults when getFgAnsi returns empty string", () => {
216
+ resetDiffColors();
217
+ const theme = {
218
+ getFgAnsi: () => "",
219
+ };
220
+ const result = resolveDiffColors(theme);
221
+ expect(result.fgAdd).toBe(FG_ADD);
222
+ expect(result.fgDel).toBe(FG_DEL);
223
+ expect(result.fgCtx).toBe(FG_DIM);
224
+ });
225
+ });
226
+
227
+ // ── resetDiffColors ─────────────────────────────────────────────────────────
228
+
229
+ describe("resetDiffColors", () => {
230
+ it("clears cache so subsequent call re-derives", () => {
231
+ const theme = {
232
+ getFgAnsi: (token: string) => {
233
+ if (token === "toolDiffAdded") return "\x1b[38;2;50;200;50m";
234
+ if (token === "toolDiffRemoved") return "\x1b[38;2;200;50;50m";
235
+ if (token === "toolDiffContext") return "\x1b[38;2;100;100;100m";
236
+ return "";
237
+ },
238
+ };
239
+
240
+ // First call derives colors
241
+ const result1 = resolveDiffColors(theme);
242
+ expect(result1.fgAdd).toBe("\x1b[38;2;50;200;50m");
243
+
244
+ // Reset clears cache
245
+ resetDiffColors();
246
+
247
+ // Now change the theme
248
+ const newTheme = {
249
+ getFgAnsi: (token: string) => {
250
+ if (token === "toolDiffAdded") return "\x1b[38;2;10;100;10m";
251
+ if (token === "toolDiffRemoved") return "\x1b[38;2;100;10;10m";
252
+ if (token === "toolDiffContext") return "\x1b[38;2;50;50;50m";
253
+ return "";
254
+ },
255
+ };
256
+
257
+ // After reset, new theme should be picked up
258
+ const result2 = resolveDiffColors(newTheme);
259
+ expect(result2.fgAdd).toBe("\x1b[38;2;10;100;10m");
260
+ });
261
+ });
@@ -0,0 +1,246 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as fc from "fast-check";
3
+ import { deriveBgFromTheme } from "./colors.js";
4
+ import { DEFAULT_DIFF_BG, FG_RULE } from "./codes.js";
5
+
6
+ // ── deriveBgFromTheme ───────────────────────────────────────────────────────
7
+
8
+ describe("deriveBgFromTheme", () => {
9
+ it("returns DEFAULT_DIFF_BG when theme is null", () => {
10
+ const result = deriveBgFromTheme(null);
11
+ expect(result).toEqual(DEFAULT_DIFF_BG);
12
+ });
13
+
14
+ it("returns DEFAULT_DIFF_BG when theme is undefined", () => {
15
+ const result = deriveBgFromTheme(undefined);
16
+ expect(result).toEqual(DEFAULT_DIFF_BG);
17
+ });
18
+
19
+ it("returns DEFAULT_DIFF_BG when theme lacks getFgAnsi", () => {
20
+ const result = deriveBgFromTheme({});
21
+ expect(result).toEqual(DEFAULT_DIFF_BG);
22
+ });
23
+
24
+ it("returns DEFAULT_DIFF_BG when getFgAnsi returns non-parseable values", () => {
25
+ const theme = {
26
+ getFgAnsi: () => "not-an-ansi-code",
27
+ };
28
+ const result = deriveBgFromTheme(theme);
29
+ expect(result).toEqual(DEFAULT_DIFF_BG);
30
+ });
31
+
32
+ it("derives colors from theme with valid getFgAnsi", () => {
33
+ const theme = {
34
+ getFgAnsi: (token: string) => {
35
+ if (token === "toolDiffAdded") return "\x1b[38;2;100;200;150m";
36
+ if (token === "toolDiffRemoved") return "\x1b[38;2;200;100;100m";
37
+ return "";
38
+ },
39
+ };
40
+ const result = deriveBgFromTheme(theme);
41
+ // bgAdd should be a mix of black (base) + green accent at 0.08 intensity
42
+ expect(result.bgAdd).toMatch(/\x1b\[48;2;\d+;\d+;\d+m/);
43
+ expect(result.bgDel).toMatch(/\x1b\[48;2;\d+;\d+;\d+m/);
44
+ // Should differ from defaults since we have a theme
45
+ expect(result.bgAdd).not.toBe(DEFAULT_DIFF_BG.bgAdd);
46
+ });
47
+
48
+ it("uses bgBase from theme when getBgAnsi is available", () => {
49
+ const theme = {
50
+ getFgAnsi: (token: string) => {
51
+ if (token === "toolDiffAdded") return "\x1b[38;2;100;200;150m";
52
+ if (token === "toolDiffRemoved") return "\x1b[38;2;200;100;100m";
53
+ return "";
54
+ },
55
+ getBgAnsi: (token: string) => {
56
+ if (token === "toolSuccessBg") return "\x1b[48;2;20;40;30m";
57
+ if (token === "toolErrorBg") return "\x1b[48;2;40;20;20m";
58
+ return "";
59
+ },
60
+ };
61
+ const result = deriveBgFromTheme(theme);
62
+ // bgBase should come from the theme's success bg
63
+ expect(result.bgBase).toBe("\x1b[48;2;20;40;30m");
64
+ // bgEmpty should equal bgBase
65
+ expect(result.bgEmpty).toBe(result.bgBase);
66
+ });
67
+
68
+ it("returns DiffBg with all required fields", () => {
69
+ const theme = {
70
+ getFgAnsi: (token: string) => {
71
+ if (token === "toolDiffAdded") return "\x1b[38;2;100;200;150m";
72
+ if (token === "toolDiffRemoved") return "\x1b[38;2;200;100;100m";
73
+ return "";
74
+ },
75
+ };
76
+ const result = deriveBgFromTheme(theme);
77
+ expect(typeof result.bgAdd).toBe("string");
78
+ expect(typeof result.bgDel).toBe("string");
79
+ expect(typeof result.bgAddW).toBe("string");
80
+ expect(typeof result.bgDelW).toBe("string");
81
+ expect(typeof result.bgGutterAdd).toBe("string");
82
+ expect(typeof result.bgGutterDel).toBe("string");
83
+ expect(typeof result.bgEmpty).toBe("string");
84
+ expect(typeof result.bgBase).toBe("string");
85
+ expect(typeof result.rst).toBe("string");
86
+ expect(typeof result.divider).toBe("string");
87
+ });
88
+
89
+ it("bg colors are ANSI truecolor escapes (48;2;r;g;b format)", () => {
90
+ const theme = {
91
+ getFgAnsi: (token: string) => {
92
+ if (token === "toolDiffAdded") return "\x1b[38;2;100;200;150m";
93
+ if (token === "toolDiffRemoved") return "\x1b[38;2;200;100;100m";
94
+ return "";
95
+ },
96
+ };
97
+ const result = deriveBgFromTheme(theme);
98
+ const bgFields = [
99
+ result.bgAdd,
100
+ result.bgDel,
101
+ result.bgAddW,
102
+ result.bgDelW,
103
+ result.bgGutterAdd,
104
+ result.bgGutterDel,
105
+ ];
106
+ for (const bg of bgFields) {
107
+ expect(bg).toMatch(/\x1b\[48;2;\d+;\d+;\d+m/);
108
+ }
109
+ });
110
+
111
+ it("divider contains FG_RULE character", () => {
112
+ const theme = {
113
+ getFgAnsi: (token: string) => {
114
+ if (token === "toolDiffAdded") return "\x1b[38;2;100;200;150m";
115
+ if (token === "toolDiffRemoved") return "\x1b[38;2;200;100;100m";
116
+ return "";
117
+ },
118
+ };
119
+ const result = deriveBgFromTheme(theme);
120
+ expect(result.divider).toContain(FG_RULE);
121
+ });
122
+
123
+ it("rst is reset when no bgBase from theme", () => {
124
+ const theme = {
125
+ getFgAnsi: (token: string) => {
126
+ if (token === "toolDiffAdded") return "\x1b[38;2;100;200;150m";
127
+ if (token === "toolDiffRemoved") return "\x1b[38;2;200;100;100m";
128
+ return "";
129
+ },
130
+ };
131
+ const result = deriveBgFromTheme(theme);
132
+ // Without getBgAnsi, bgBase is \x1b[49m, so rst is just \x1b[0m
133
+ expect(result.rst).toBe("\x1b[0m");
134
+ });
135
+
136
+ it("rst includes bgBase reset when theme provides getBgAnsi", () => {
137
+ const theme = {
138
+ getFgAnsi: (token: string) => {
139
+ if (token === "toolDiffAdded") return "\x1b[38;2;100;200;150m";
140
+ if (token === "toolDiffRemoved") return "\x1b[38;2;200;100;100m";
141
+ return "";
142
+ },
143
+ getBgAnsi: (token: string) => {
144
+ if (token === "toolSuccessBg") return "\x1b[48;2;20;40;30m";
145
+ return "";
146
+ },
147
+ };
148
+ const result = deriveBgFromTheme(theme);
149
+ // With getBgAnsi, rst should be \x1b[0m + bgBase
150
+ expect(result.rst).toBe("\x1b[0m\x1b[48;2;20;40;30m");
151
+ });
152
+
153
+ it("handles getFgAnsi that throws gracefully", () => {
154
+ const theme = {
155
+ getFgAnsi: () => {
156
+ throw new Error("theme error");
157
+ },
158
+ };
159
+ const result = deriveBgFromTheme(theme);
160
+ expect(result).toEqual(DEFAULT_DIFF_BG);
161
+ });
162
+
163
+ it("handles getBgAnsi that throws gracefully", () => {
164
+ const theme = {
165
+ getFgAnsi: (token: string) => {
166
+ if (token === "toolDiffAdded") return "\x1b[38;2;100;200;150m";
167
+ if (token === "toolDiffRemoved") return "\x1b[38;2;200;100;100m";
168
+ return "";
169
+ },
170
+ getBgAnsi: () => {
171
+ throw new Error("bg error");
172
+ },
173
+ };
174
+ const result = deriveBgFromTheme(theme);
175
+ // Should still produce valid output, falling back to default bgBase
176
+ expect(result.bgBase).toBe("\x1b[49m");
177
+ expect(result.rst).toBe("\x1b[0m");
178
+ });
179
+
180
+ it("property: rst always contains reset escape", () => {
181
+ fc.assert(
182
+ fc.property(
183
+ fc.array(
184
+ fc.tuple(
185
+ fc.constant("toolDiffAdded"),
186
+ fc.string({ maxLength: 20 }),
187
+ ),
188
+ { minLength: 0, maxLength: 5 },
189
+ ),
190
+ (pairs) => {
191
+ const map = new Map(pairs);
192
+ const theme = {
193
+ getFgAnsi: (token: any) => map.get(token) || "",
194
+ };
195
+ const result = deriveBgFromTheme(theme);
196
+ return result.rst.includes("\x1b[0m");
197
+ },
198
+ ),
199
+ );
200
+ });
201
+
202
+ it("property: all bg fields are valid truecolor bg escapes or defaults", () => {
203
+ fc.assert(
204
+ fc.property(
205
+ fc.string({ maxLength: 50 }),
206
+ (addColor) => {
207
+ const theme = {
208
+ getFgAnsi: (token: string) => {
209
+ if (token === "toolDiffAdded") return addColor;
210
+ if (token === "toolDiffRemoved") return addColor;
211
+ return "";
212
+ },
213
+ };
214
+ const result = deriveBgFromTheme(theme);
215
+ // Either we get valid truecolor bg escapes, or we get defaults
216
+ const isTruecolorBg = (s: string) => /\x1b\[48;2;\d+;\d+;\d+m/.test(s);
217
+ const allValid = [
218
+ result.bgAdd,
219
+ result.bgDel,
220
+ result.bgAddW,
221
+ result.bgDelW,
222
+ result.bgGutterAdd,
223
+ result.bgGutterDel,
224
+ ].every((bg) => isTruecolorBg(bg));
225
+ const isDefault = result === DEFAULT_DIFF_BG || result.bgAdd === DEFAULT_DIFF_BG.bgAdd;
226
+ return allValid || isDefault;
227
+ },
228
+ ),
229
+ );
230
+ });
231
+
232
+ it("property: divider always contains the rule character", () => {
233
+ fc.assert(
234
+ fc.property(
235
+ fc.string({ maxLength: 100 }),
236
+ (rawColor) => {
237
+ const theme = {
238
+ getFgAnsi: () => rawColor,
239
+ };
240
+ const result = deriveBgFromTheme(theme);
241
+ return result.divider.includes(FG_RULE);
242
+ },
243
+ ),
244
+ );
245
+ });
246
+ });
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as fc from "fast-check";
3
+ import { parseDiff } from "./diff.js";
4
+
5
+ // ── Multiline string generator for property tests ────────────────────────────
6
+
7
+ const lineArb = fc.string({ maxLength: 80 });
8
+ const multilineArb = fc.array(lineArb, { maxLength: 20 }).map(lines => lines.join("\n"));
9
+
10
+ // ── parseDiff ────────────────────────────────────────────────────────────────
11
+
12
+ describe("parseDiff", () => {
13
+ it("identical content returns empty lines and zero counts", () => {
14
+ const content = "line1\nline2\nline3";
15
+ const result = parseDiff(content, content);
16
+ expect(result.lines).toEqual([]);
17
+ expect(result.added).toBe(0);
18
+ expect(result.removed).toBe(0);
19
+ expect(result.chars).toBe(0);
20
+ });
21
+
22
+ it("single line addition", () => {
23
+ const old = "line1\n";
24
+ const newContent = "line1\nline2\n";
25
+ const result = parseDiff(old, newContent);
26
+ expect(result.added).toBe(1);
27
+ expect(result.removed).toBe(0);
28
+ const addLine = result.lines.find(l => l.type === "add");
29
+ expect(addLine).not.toBeUndefined();
30
+ expect(addLine!.content).toBe("line2");
31
+ });
32
+
33
+ it("single line deletion", () => {
34
+ const old = "line1\nline2\n";
35
+ const newContent = "line1\n";
36
+ const result = parseDiff(old, newContent);
37
+ expect(result.added).toBe(0);
38
+ expect(result.removed).toBe(1);
39
+ const delLine = result.lines.find(l => l.type === "del");
40
+ expect(delLine).not.toBeUndefined();
41
+ expect(delLine!.content).toBe("line2");
42
+ });
43
+
44
+ it("mixed add/delete/context", () => {
45
+ const old = "line1\nline2\nline3";
46
+ const newContent = "line1\nmodified\nline3\nline4";
47
+ const result = parseDiff(old, newContent);
48
+ expect(result.added).toBeGreaterThan(0);
49
+ expect(result.removed).toBeGreaterThan(0);
50
+ expect(result.lines.some(l => l.type === "ctx")).toBe(true);
51
+ expect(result.lines.some(l => l.type === "add")).toBe(true);
52
+ expect(result.lines.some(l => l.type === "del")).toBe(true);
53
+ });
54
+
55
+ it("multiple hunks produce separator", () => {
56
+ // Create content with changes far apart to trigger multiple hunks
57
+ const old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np";
58
+ const newContent = "A\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\nP";
59
+ const result = parseDiff(old, newContent);
60
+ // With context=3, changes at start and end should create 2 hunks with a separator
61
+ expect(result.lines.some(l => l.type === "sep")).toBe(true);
62
+ });
63
+
64
+ it("context lines have both oldNum and newNum", () => {
65
+ const old = "line1\nline2\nline3";
66
+ const newContent = "line1\nmodified\nline3";
67
+ const result = parseDiff(old, newContent);
68
+ const ctxLines = result.lines.filter(l => l.type === "ctx");
69
+ for (const line of ctxLines) {
70
+ expect(line.oldNum).not.toBeNull();
71
+ expect(line.newNum).not.toBeNull();
72
+ }
73
+ });
74
+
75
+ it("added lines have only newNum", () => {
76
+ const old = "line1";
77
+ const newContent = "line1\nadded";
78
+ const result = parseDiff(old, newContent);
79
+ const addLines = result.lines.filter(l => l.type === "add");
80
+ for (const line of addLines) {
81
+ expect(line.oldNum).toBeNull();
82
+ expect(line.newNum).not.toBeNull();
83
+ }
84
+ });
85
+
86
+ it("deleted lines have only oldNum", () => {
87
+ const old = "line1\ndeleted";
88
+ const newContent = "line1";
89
+ const result = parseDiff(old, newContent);
90
+ const delLines = result.lines.filter(l => l.type === "del");
91
+ for (const line of delLines) {
92
+ expect(line.oldNum).not.toBeNull();
93
+ expect(line.newNum).toBeNull();
94
+ }
95
+ });
96
+
97
+ it("separator lines have null oldNum", () => {
98
+ const old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np";
99
+ const newContent = "A\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\nP";
100
+ const result = parseDiff(old, newContent);
101
+ const sepLines = result.lines.filter(l => l.type === "sep");
102
+ for (const line of sepLines) {
103
+ expect(line.oldNum).toBeNull();
104
+ }
105
+ });
106
+
107
+ it("chars equals oldContent.length + newContent.length", () => {
108
+ const old = "hello";
109
+ const newContent = "world!!";
110
+ const result = parseDiff(old, newContent);
111
+ expect(result.chars).toBe(old.length + newContent.length);
112
+ });
113
+
114
+ it("handles empty strings", () => {
115
+ const result = parseDiff("", "");
116
+ expect(result.lines).toEqual([]);
117
+ expect(result.added).toBe(0);
118
+ expect(result.removed).toBe(0);
119
+ });
120
+
121
+ it("handles adding to empty", () => {
122
+ const result = parseDiff("", "new line");
123
+ expect(result.added).toBe(1);
124
+ expect(result.removed).toBe(0);
125
+ });
126
+
127
+ it("handles removing everything", () => {
128
+ const result = parseDiff("old line", "");
129
+ expect(result.added).toBe(0);
130
+ expect(result.removed).toBe(1);
131
+ });
132
+
133
+ // ── Property tests ───────────────────────────────────────────────────
134
+
135
+ it("property: reflexive — parseDiff(x, x).lines.length === 0", () => {
136
+ fc.assert(
137
+ fc.property(multilineArb, x => {
138
+ const result = parseDiff(x, x);
139
+ return result.lines.length === 0;
140
+ }),
141
+ );
142
+ });
143
+
144
+ it("property: non-negative — added >= 0 && removed >= 0", () => {
145
+ fc.assert(
146
+ fc.property(multilineArb, multilineArb, (a, b) => {
147
+ const result = parseDiff(a, b);
148
+ return result.added >= 0 && result.removed >= 0;
149
+ }),
150
+ );
151
+ });
152
+
153
+ it("property: chars equals sum of input lengths", () => {
154
+ fc.assert(
155
+ fc.property(multilineArb, multilineArb, (a, b) => {
156
+ const result = parseDiff(a, b);
157
+ if (a === b) {
158
+ return result.chars === 0;
159
+ }
160
+ return result.chars === a.length + b.length;
161
+ }),
162
+ );
163
+ });
164
+ });
@@ -0,0 +1,263 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import * as fc from "fast-check";
3
+
4
+ // ── Mocks ────────────────────────────────────────────────────────────────────
5
+
6
+ // Mock @shikijs/cli to avoid actual Shiki initialization
7
+ vi.mock("@shikijs/cli", () => ({
8
+ codeToANSI: vi.fn().mockResolvedValue("mocked"),
9
+ }));
10
+
11
+ // Mock the ANSI normalization
12
+ vi.mock("./ansi/index.js", () => ({
13
+ normalizeShikiContrast: vi.fn((s: string[]) => s),
14
+ }));
15
+
16
+ // ── Imports ──────────────────────────────────────────────────────────────────
17
+
18
+ import { lang, setConfigGetter } from "./shiki.js";
19
+
20
+ // ── lang ─────────────────────────────────────────────────────────────────────
21
+
22
+ describe("lang", () => {
23
+ it("resolves .ts to typescript", () => {
24
+ expect(lang("file.ts")).toBe("typescript");
25
+ });
26
+
27
+ it("resolves .tsx to tsx", () => {
28
+ expect(lang("file.tsx")).toBe("tsx");
29
+ });
30
+
31
+ it("resolves .js to javascript", () => {
32
+ expect(lang("file.js")).toBe("javascript");
33
+ });
34
+
35
+ it("resolves .jsx to jsx", () => {
36
+ expect(lang("file.jsx")).toBe("jsx");
37
+ });
38
+
39
+ it("resolves .mjs to javascript", () => {
40
+ expect(lang("file.mjs")).toBe("javascript");
41
+ });
42
+
43
+ it("resolves .cjs to javascript", () => {
44
+ expect(lang("file.cjs")).toBe("javascript");
45
+ });
46
+
47
+ it("resolves .py to python", () => {
48
+ expect(lang("script.py")).toBe("python");
49
+ });
50
+
51
+ it("resolves .rb to ruby", () => {
52
+ expect(lang("script.rb")).toBe("ruby");
53
+ });
54
+
55
+ it("resolves .rs to rust", () => {
56
+ expect(lang("lib.rs")).toBe("rust");
57
+ });
58
+
59
+ it("resolves .go to go", () => {
60
+ expect(lang("main.go")).toBe("go");
61
+ });
62
+
63
+ it("resolves .java to java", () => {
64
+ expect(lang("Main.java")).toBe("java");
65
+ });
66
+
67
+ it("resolves .c to c", () => {
68
+ expect(lang("main.c")).toBe("c");
69
+ });
70
+
71
+ it("resolves .cpp to cpp", () => {
72
+ expect(lang("main.cpp")).toBe("cpp");
73
+ });
74
+
75
+ it("resolves .h to c", () => {
76
+ expect(lang("header.h")).toBe("c");
77
+ });
78
+
79
+ it("resolves .hpp to cpp", () => {
80
+ expect(lang("header.hpp")).toBe("cpp");
81
+ });
82
+
83
+ it("resolves .cs to csharp", () => {
84
+ expect(lang("Program.cs")).toBe("csharp");
85
+ });
86
+
87
+ it("resolves .swift to swift", () => {
88
+ expect(lang("main.swift")).toBe("swift");
89
+ });
90
+
91
+ it("resolves .kt to kotlin", () => {
92
+ expect(lang("Main.kt")).toBe("kotlin");
93
+ });
94
+
95
+ it("resolves .html to html", () => {
96
+ expect(lang("index.html")).toBe("html");
97
+ });
98
+
99
+ it("resolves .css to css", () => {
100
+ expect(lang("styles.css")).toBe("css");
101
+ });
102
+
103
+ it("resolves .scss to scss", () => {
104
+ expect(lang("styles.scss")).toBe("scss");
105
+ });
106
+
107
+ it("resolves .json to json", () => {
108
+ expect(lang("package.json")).toBe("json");
109
+ });
110
+
111
+ it("resolves .yaml to yaml", () => {
112
+ expect(lang("config.yaml")).toBe("yaml");
113
+ });
114
+
115
+ it("resolves .yml to yaml", () => {
116
+ expect(lang("config.yml")).toBe("yaml");
117
+ });
118
+
119
+ it("resolves .toml to toml", () => {
120
+ expect(lang("Cargo.toml")).toBe("toml");
121
+ });
122
+
123
+ it("resolves .md to markdown", () => {
124
+ expect(lang("README.md")).toBe("markdown");
125
+ });
126
+
127
+ it("resolves .sql to sql", () => {
128
+ expect(lang("query.sql")).toBe("sql");
129
+ });
130
+
131
+ it("resolves .sh to bash", () => {
132
+ expect(lang("script.sh")).toBe("bash");
133
+ });
134
+
135
+ it("resolves .bash to bash", () => {
136
+ expect(lang("script.bash")).toBe("bash");
137
+ });
138
+
139
+ it("resolves .zsh to bash", () => {
140
+ expect(lang("script.zsh")).toBe("bash");
141
+ });
142
+
143
+ it("resolves .lua to lua", () => {
144
+ expect(lang("init.lua")).toBe("lua");
145
+ });
146
+
147
+ it("resolves .php to php", () => {
148
+ expect(lang("index.php")).toBe("php");
149
+ });
150
+
151
+ it("resolves .dart to dart", () => {
152
+ expect(lang("main.dart")).toBe("dart");
153
+ });
154
+
155
+ it("resolves .xml to xml", () => {
156
+ expect(lang("config.xml")).toBe("xml");
157
+ });
158
+
159
+ it("resolves .graphql to graphql", () => {
160
+ expect(lang("schema.graphql")).toBe("graphql");
161
+ });
162
+
163
+ it("resolves .svelte to svelte", () => {
164
+ expect(lang("App.svelte")).toBe("svelte");
165
+ });
166
+
167
+ it("resolves .vue to vue", () => {
168
+ expect(lang("App.vue")).toBe("vue");
169
+ });
170
+
171
+ it("returns undefined for unknown extensions", () => {
172
+ expect(lang("file.unknown")).toBeUndefined();
173
+ });
174
+
175
+ it("returns undefined for no extension", () => {
176
+ expect(lang("Makefile")).toBeUndefined();
177
+ });
178
+
179
+ it("returns undefined for empty string", () => {
180
+ expect(lang("")).toBeUndefined();
181
+ });
182
+
183
+ it("is case-insensitive for extensions", () => {
184
+ expect(lang("file.TS")).toBe("typescript");
185
+ expect(lang("file.Ts")).toBe("typescript");
186
+ expect(lang("file.PY")).toBe("python");
187
+ expect(lang("file.JS")).toBe("javascript");
188
+ });
189
+
190
+ it("handles deep paths", () => {
191
+ expect(lang("/some/deep/path/to/file.ts")).toBe("typescript");
192
+ });
193
+
194
+ it("handles paths with dots in filename", () => {
195
+ expect(lang("my.file.name.ts")).toBe("typescript");
196
+ });
197
+
198
+ // ── Property tests ───────────────────────────────────────────────────
199
+
200
+ it("property: lang(x) returns consistent result for same extension", () => {
201
+ fc.assert(
202
+ fc.property(
203
+ fc.string({ minLength: 1, maxLength: 100 }),
204
+ filename => {
205
+ const result1 = lang(filename);
206
+ const result2 = lang(filename);
207
+ return result1 === result2;
208
+ },
209
+ ),
210
+ );
211
+ });
212
+
213
+ it("property: lang only returns known languages or undefined", () => {
214
+ const knownLanguages = new Set([
215
+ "typescript", "tsx", "javascript", "jsx", "python", "ruby",
216
+ "rust", "go", "java", "c", "cpp", "csharp", "swift", "kotlin",
217
+ "html", "css", "scss", "json", "yaml", "toml", "markdown",
218
+ "sql", "bash", "lua", "php", "dart", "xml", "graphql",
219
+ "svelte", "vue",
220
+ ]);
221
+
222
+ fc.assert(
223
+ fc.property(
224
+ fc.string({ minLength: 1, maxLength: 100 }),
225
+ filename => {
226
+ const result = lang(filename);
227
+ return result === undefined || knownLanguages.has(result);
228
+ },
229
+ ),
230
+ );
231
+ });
232
+ });
233
+
234
+ // ── setConfigGetter ──────────────────────────────────────────────────────────
235
+
236
+ describe("setConfigGetter", () => {
237
+ it("changes the config getter", async () => {
238
+ // Import hlBlock to test config getter effect
239
+ const { hlBlock } = await import("./shiki.js");
240
+
241
+ setConfigGetter(() => ({ diffTheme: "github-light" }));
242
+
243
+ // The config getter should now return the new theme
244
+ // We can't directly inspect _getConfig, but we can verify hlBlock
245
+ // doesn't throw and uses the new config
246
+ const result = await hlBlock("const x = 1;", "javascript");
247
+ expect(Array.isArray(result)).toBe(true);
248
+ });
249
+
250
+ it("accepts custom theme", async () => {
251
+ setConfigGetter(() => ({ diffTheme: "nord" }));
252
+
253
+ const { hlBlock } = await import("./shiki.js");
254
+ const result = await hlBlock("print('hello')", "python");
255
+ expect(Array.isArray(result)).toBe(true);
256
+ });
257
+
258
+ it("restores default behavior after setting", () => {
259
+ setConfigGetter(() => ({ diffTheme: "github-dark" }));
260
+ // Should not throw
261
+ expect(() => setConfigGetter(() => ({ diffTheme: "one-dark" }))).not.toThrow();
262
+ });
263
+ });