@pi-archimedes/diff 1.6.0 → 1.7.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": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,213 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { strip, tabs, fit, ansiState, isLowContrastShikiFg, normalizeShikiContrast, lnum, shortPath, summarize } from "./manip.js";
3
+ import * as C from "./codes.js";
4
+
5
+ // ── strip ───────────────────────────────────────────────────────────────────
6
+
7
+ describe("strip", () => {
8
+ it("removes SGR sequences", () => {
9
+ expect(strip("\x1b[31mred\x1b[0m")).toBe("red");
10
+ expect(strip("\x1b[1m\x1b[31mhi\x1b[0m")).toBe("hi");
11
+ });
12
+
13
+ it("empty string stays empty", () => {
14
+ expect(strip("")).toBe("");
15
+ });
16
+ });
17
+
18
+ // ── tabs ────────────────────────────────────────────────────────────────────
19
+
20
+ describe("tabs", () => {
21
+ it("replaces \\t with 2 spaces", () => {
22
+ expect(tabs("a\tb")).toBe("a b");
23
+ expect(tabs("\t")).toBe(" ");
24
+ expect(tabs("no\ttabs\there")).toBe("no tabs here");
25
+ });
26
+ });
27
+
28
+ // ── fit ─────────────────────────────────────────────────────────────────────
29
+
30
+ describe("fit", () => {
31
+ it("truncates to width (ASCII)", () => {
32
+ const result = fit("hello world", 8);
33
+ expect(result).toContain("hello");
34
+ // Should end with truncation indicator
35
+ expect(result).toContain("›");
36
+ });
37
+
38
+ it("truncates to width (wide chars — never splits grapheme)", () => {
39
+ // "中日" is 4 columns. fit to 3 should drop "日" (2 cols) since it
40
+ // would overflow, keeping only "中" (2 cols)
41
+ const result = fit("中日", 3);
42
+ expect(result).toContain("中");
43
+ // "日" should not appear since it's 2 cols and would overflow budget of 2 (3-1)
44
+ expect(result).not.toContain("日");
45
+ });
46
+
47
+ it("pads short strings", () => {
48
+ const result = fit("hi", 5);
49
+ expect(result).toBe("hi ");
50
+ });
51
+
52
+ it("width 0 returns empty", () => {
53
+ expect(fit("hello", 0)).toBe("");
54
+ });
55
+
56
+ it("wide grapheme wider than budget is dropped", () => {
57
+ // Budget of 1 — wide char "中" (2 cols) can't fit, should be dropped
58
+ const result = fit("中", 1);
59
+ expect(result).not.toContain("中");
60
+ });
61
+ });
62
+
63
+ // ── ansiState ───────────────────────────────────────────────────────────────
64
+
65
+ describe("ansiState", () => {
66
+ it("extracts last fg code (truecolor)", () => {
67
+ const state = ansiState("\x1b[38;2;255;0;0mhello");
68
+ expect(state).toContain("\x1b[38;2;255;0;0m");
69
+ });
70
+
71
+ it("extracts last bg code (truecolor)", () => {
72
+ const state = ansiState("\x1b[48;2;0;255;0mhello");
73
+ expect(state).toContain("\x1b[48;2;0;255;0m");
74
+ });
75
+
76
+ it("returns bg + fg (background first!)", () => {
77
+ const state = ansiState("\x1b[38;2;255;0;0m\x1b[48;2;0;0;255mtext");
78
+ // bg should come before fg in the return value
79
+ const bgIdx = state.indexOf("\x1b[48;2;0;0;255m");
80
+ const fgIdx = state.indexOf("\x1b[38;2;255;0;0m");
81
+ expect(bgIdx).toBeLessThan(fgIdx);
82
+ });
83
+
84
+ it("\\x1b[0m resets both", () => {
85
+ const state = ansiState("\x1b[38;2;255;0;0m\x1b[0m");
86
+ expect(state).toBe("");
87
+ });
88
+
89
+ it("truecolor fg/bg", () => {
90
+ const state = ansiState("\x1b[38;2;255;0;0m\x1b[48;2;0;0;255mtext");
91
+ expect(state).toContain("\x1b[38;2;255;0;0m");
92
+ expect(state).toContain("\x1b[48;2;0;0;255m");
93
+ });
94
+
95
+ it("\\x1b[39m resets only fg", () => {
96
+ const state = ansiState("\x1b[38;2;255;0;0m\x1b[48;2;0;0;255m\x1b[39mtext");
97
+ expect(state).toContain("\x1b[48;2;0;0;255m");
98
+ expect(state).not.toContain("\x1b[38;2;255;0;0m");
99
+ });
100
+
101
+ it("plain ANSI codes (31, 42) are not tracked — Shiki uses truecolor", () => {
102
+ // ansiState only tracks 38;..., 48;..., 39, 0 — plain codes like 31/42 pass through
103
+ const state = ansiState("\x1b[31m\x1b[42mhello");
104
+ expect(state).toBe("");
105
+ });
106
+ });
107
+
108
+ // ── isLowContrastShikiFg ────────────────────────────────────────────────────
109
+
110
+ describe("isLowContrastShikiFg", () => {
111
+ it('"30" = true (dark black)', () => {
112
+ expect(isLowContrastShikiFg("30")).toBe(true);
113
+ });
114
+
115
+ it('"90" = true (dark gray)', () => {
116
+ expect(isLowContrastShikiFg("90")).toBe(true);
117
+ });
118
+
119
+ it('"38;5;0" = true (256 palette black)', () => {
120
+ expect(isLowContrastShikiFg("38;5;0")).toBe(true);
121
+ });
122
+
123
+ it('"38;5;8" = true (256 palette dark gray)', () => {
124
+ expect(isLowContrastShikiFg("38;5;8")).toBe(true);
125
+ });
126
+
127
+ it('"38;2;0;0;0" = true (truecolor black)', () => {
128
+ expect(isLowContrastShikiFg("38;2;0;0;0")).toBe(true);
129
+ });
130
+
131
+ it('"38;2;255;255;255" = false (truecolor white)', () => {
132
+ expect(isLowContrastShikiFg("38;2;255;255;255")).toBe(false);
133
+ });
134
+
135
+ it('"0" = false (reset, not a fg code)', () => {
136
+ expect(isLowContrastShikiFg("0")).toBe(false);
137
+ });
138
+
139
+ it('"38;5;5" = false (256 palette not-dark)', () => {
140
+ expect(isLowContrastShikiFg("38;5;5")).toBe(false);
141
+ });
142
+
143
+ it('"48;2;0;0;0" = false (background not foreground)', () => {
144
+ expect(isLowContrastShikiFg("48;2;0;0;0")).toBe(false);
145
+ });
146
+ });
147
+
148
+ // ── normalizeShikiContrast ──────────────────────────────────────────────────
149
+
150
+ describe("normalizeShikiContrast", () => {
151
+ it("replaces low-contrast codes", () => {
152
+ // \x1b[30m is dark black — should be replaced
153
+ const result = normalizeShikiContrast("\x1b[30mtext\x1b[0m");
154
+ expect(result).toContain(C.FG_SAFE_MUTED);
155
+ expect(result).not.toContain("\x1b[30m");
156
+ });
157
+
158
+ it("leaves high-contrast alone", () => {
159
+ // \x1b[37m is white — should pass through
160
+ const result = normalizeShikiContrast("\x1b[37mtext\x1b[0m");
161
+ expect(result).toContain("\x1b[37m");
162
+ });
163
+ });
164
+
165
+ // ── lnum ────────────────────────────────────────────────────────────────────
166
+
167
+ describe("lnum", () => {
168
+ it("formats number right-padded", () => {
169
+ const result = lnum(42, 5);
170
+ expect(result).toContain("42");
171
+ // Should have spaces before the number
172
+ const stripped = result.replace(C.RST, "").replace(C.FG_LNUM, "");
173
+ expect(stripped).toBe(" 42");
174
+ });
175
+
176
+ it("null produces spaces", () => {
177
+ const result = lnum(null, 5);
178
+ expect(result).toBe(" ");
179
+ });
180
+ });
181
+
182
+ // ── shortPath ───────────────────────────────────────────────────────────────
183
+
184
+ describe("shortPath", () => {
185
+ it("relative to cwd returns relative", () => {
186
+ const result = shortPath("/home/user/project", "/home/user", "/home/user/project/src/file.ts");
187
+ expect(result).toBe("src/file.ts");
188
+ });
189
+
190
+ it("outside cwd with home returns ~", () => {
191
+ const result = shortPath("/home/user/project", "/home/user", "/home/user/other/file.ts");
192
+ expect(result).toBe("~/other/file.ts");
193
+ });
194
+
195
+ it("empty returns empty", () => {
196
+ expect(shortPath("/cwd", "/home", "")).toBe("");
197
+ });
198
+ });
199
+
200
+ // ── summarize ───────────────────────────────────────────────────────────────
201
+
202
+ describe("summarize", () => {
203
+ it("+N -M format", () => {
204
+ const result = summarize(3, 2);
205
+ expect(result).toContain("+3");
206
+ expect(result).toContain("-2");
207
+ });
208
+
209
+ it("zero changes returns 'no changes'", () => {
210
+ const result = summarize(0, 0);
211
+ expect(result).toContain("no changes");
212
+ });
213
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { graphemeWidth, tokenize, visibleWidth } from "./width.js";
3
+
4
+ // ── graphemeWidth ───────────────────────────────────────────────────────────
5
+
6
+ describe("graphemeWidth", () => {
7
+ it("ASCII letter = 1", () => {
8
+ expect(graphemeWidth("a")).toBe(1);
9
+ expect(graphemeWidth("Z")).toBe(1);
10
+ });
11
+
12
+ it("CJK character = 2", () => {
13
+ expect(graphemeWidth("中")).toBe(2);
14
+ expect(graphemeWidth("日")).toBe(2);
15
+ expect(graphemeWidth("韩")).toBe(2);
16
+ });
17
+
18
+ it("emoji = 2", () => {
19
+ expect(graphemeWidth("✅")).toBe(2);
20
+ expect(graphemeWidth("🔥")).toBe(2);
21
+ expect(graphemeWidth("😀")).toBe(2);
22
+ });
23
+
24
+ it("zero-width combining marks = 0", () => {
25
+ // Combining acute accent (U+0301) — zero width on its own
26
+ expect(graphemeWidth("\u0301")).toBe(0);
27
+ // Combining tilde (U+0303)
28
+ expect(graphemeWidth("\u0303")).toBe(0);
29
+ });
30
+
31
+ it("tab = 2", () => {
32
+ expect(graphemeWidth("\t")).toBe(2);
33
+ });
34
+ });
35
+
36
+ // ── tokenize ────────────────────────────────────────────────────────────────
37
+
38
+ describe("tokenize", () => {
39
+ it("plain text produces single tokens", () => {
40
+ const tokens = tokenize("ab");
41
+ expect(tokens).toHaveLength(2);
42
+ expect(tokens[0]).toEqual({ ansi: "", text: "a", width: 1 });
43
+ expect(tokens[1]).toEqual({ ansi: "", text: "b", width: 1 });
44
+ });
45
+
46
+ it("SGR sequence produces ansi+text pairs", () => {
47
+ // \x1b[31m is red fg, followed by "a"
48
+ const tokens = tokenize("\x1b[31ma");
49
+ expect(tokens).toHaveLength(1);
50
+ expect(tokens[0]).toEqual({ ansi: "\x1b[31m", text: "a", width: 1 });
51
+ });
52
+
53
+ it("bare ESC without m terminator handled gracefully", () => {
54
+ // ESC (0x1b) with no 'm' after it — should not hang
55
+ const tokens = tokenize("\x1ba");
56
+ // The bare ESC produces a zero-width token, then "a" is a normal token
57
+ expect(tokens.length).toBeGreaterThanOrEqual(2);
58
+ // Last token should be the "a"
59
+ expect(tokens[tokens.length - 1]!.text).toBe("a");
60
+ });
61
+
62
+ it("mixed ANSI + text", () => {
63
+ const tokens = tokenize("\x1b[31mred\x1b[0mplain");
64
+ // "red" under \x1b[31m, then "plain" after reset
65
+ const redToken = tokens.find((t) => t.text === "r");
66
+ expect(redToken?.ansi).toBe("\x1b[31m");
67
+
68
+ const plainToken = tokens.find((t) => t.text === "p");
69
+ expect(plainToken?.ansi).toBe("\x1b[0m");
70
+ });
71
+ });
72
+
73
+ // ── visibleWidth ────────────────────────────────────────────────────────────
74
+
75
+ describe("visibleWidth", () => {
76
+ it("plain ASCII length matches", () => {
77
+ expect(visibleWidth("hello")).toBe(5);
78
+ expect(visibleWidth("abc")).toBe(3);
79
+ });
80
+
81
+ it("wide chars counted correctly", () => {
82
+ expect(visibleWidth("中")).toBe(2);
83
+ expect(visibleWidth("中日")).toBe(4);
84
+ expect(visibleWidth("a中b")).toBe(4); // 1 + 2 + 1
85
+ });
86
+
87
+ it("ANSI escapes don't add width", () => {
88
+ expect(visibleWidth("\x1b[31mhello\x1b[0m")).toBe(5);
89
+ expect(visibleWidth("\x1b[1m\x1b[31mhi\x1b[0m")).toBe(2);
90
+ });
91
+
92
+ it("empty string = 0", () => {
93
+ expect(visibleWidth("")).toBe(0);
94
+ });
95
+ });
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { wordDiffAnalysis, injectBg, plainWordDiff } from "./word-diff.js";
3
+ import * as Ansi from "./ansi/index.js";
4
+ import type { DiffBg } from "./ansi/index.js";
5
+
6
+ // ── wordDiffAnalysis ────────────────────────────────────────────────────────
7
+
8
+ describe("wordDiffAnalysis", () => {
9
+ it("identical strings → similarity 1, empty ranges", () => {
10
+ const result = wordDiffAnalysis("hello world", "hello world");
11
+ expect(result.similarity).toBe(1);
12
+ expect(result.oldRanges).toEqual([]);
13
+ expect(result.newRanges).toEqual([]);
14
+ });
15
+
16
+ it("completely different → similarity 0, full ranges", () => {
17
+ const result = wordDiffAnalysis("abc", "xyz");
18
+ expect(result.similarity).toBe(0);
19
+ expect(result.oldRanges).toEqual([[0, 3]]);
20
+ expect(result.newRanges).toEqual([[0, 3]]);
21
+ });
22
+
23
+ it("partial overlap → correct similarity and ranges", () => {
24
+ const result = wordDiffAnalysis("hello world", "hello earth");
25
+ expect(result.similarity).toBeGreaterThan(0);
26
+ expect(result.similarity).toBeLessThan(1);
27
+ // "world" changed to "earth" — both 5 chars, same position in new string
28
+ expect(result.oldRanges.length).toBeGreaterThan(0);
29
+ expect(result.newRanges.length).toBeGreaterThan(0);
30
+ });
31
+
32
+ it("one empty string", () => {
33
+ const result = wordDiffAnalysis("hello", "");
34
+ expect(result.similarity).toBe(0);
35
+ expect(result.oldRanges).toEqual([[0, 5]]);
36
+ expect(result.newRanges).toEqual([]);
37
+ });
38
+
39
+ it("both empty → similarity 1", () => {
40
+ const result = wordDiffAnalysis("", "");
41
+ expect(result.similarity).toBe(1);
42
+ expect(result.oldRanges).toEqual([]);
43
+ expect(result.newRanges).toEqual([]);
44
+ });
45
+ });
46
+
47
+ // ── injectBg ────────────────────────────────────────────────────────────────
48
+
49
+ describe("injectBg", () => {
50
+ const baseBg = "\x1b[48;2;10;20;30m";
51
+ const hlBg = "\x1b[48;2;200;50;50m";
52
+
53
+ it("no ranges returns baseBg + ansiLine + Ansi.RST (trailing reset!)", () => {
54
+ const result = injectBg("hello", [], baseBg, hlBg);
55
+ expect(result).toBe(baseBg + "hello" + Ansi.RST);
56
+ });
57
+
58
+ it("single range with concrete example", () => {
59
+ // "hello" with highlight on [2,4] → "he" + highlight "ll" + "o"
60
+ const result = injectBg("hello", [[2, 4]], baseBg, hlBg);
61
+ expect(result).toBe(
62
+ baseBg + "he" + hlBg + "ll" + baseBg + "o" + Ansi.RST
63
+ );
64
+ });
65
+
66
+ it("overlapping ranges merged", () => {
67
+ // [[1, 4], [3, 6]] should merge to [[1, 6]]
68
+ const result = injectBg("abcdef", [[1, 4], [3, 6]], baseBg, hlBg);
69
+ // "a" is base, "bcdef" is highlighted
70
+ expect(result).toBe(
71
+ baseBg + "a" + hlBg + "bcdef" + Ansi.RST
72
+ );
73
+ });
74
+
75
+ it("\\x1b[0m triggers bg re-injection", () => {
76
+ // When a reset appears mid-string, bg should be re-injected after it
77
+ const ansiLine = "he\x1b[0mllo";
78
+ const result = injectBg(ansiLine, [[2, 4]], baseBg, hlBg);
79
+ // After \x1b[0m, the bg should be re-injected
80
+ expect(result).toContain(baseBg);
81
+ // The reset should still be present
82
+ expect(result).toContain("\x1b[0m");
83
+ });
84
+ });
85
+
86
+ // ── plainWordDiff ───────────────────────────────────────────────────────────
87
+
88
+ describe("plainWordDiff", () => {
89
+ const dbg: DiffBg = Ansi.DEFAULT_DIFF_BG;
90
+
91
+ it("removed text gets del bg", () => {
92
+ const result = plainWordDiff("hello world", "hello", dbg);
93
+ expect(result.old).toContain(dbg.bgDelW);
94
+ expect(result.old).toContain("world");
95
+ expect(result.new).toBe("hello");
96
+ });
97
+
98
+ it("added text gets add bg", () => {
99
+ const result = plainWordDiff("hello", "hello world", dbg);
100
+ expect(result.new).toContain(dbg.bgAddW);
101
+ expect(result.new).toContain("world");
102
+ // diffWords treats the space as unchanged, so old includes trailing space
103
+ expect(result.old).toBe("hello ");
104
+ });
105
+
106
+ it("unchanged passed through", () => {
107
+ const result = plainWordDiff("hello", "hello", dbg);
108
+ expect(result.old).toBe("hello");
109
+ expect(result.new).toBe("hello");
110
+ });
111
+ });