gsd-pi 2.10.1 → 2.10.2

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.
Files changed (46) hide show
  1. package/node_modules/@gsd/native/dist/diff/index.d.ts +33 -0
  2. package/node_modules/@gsd/native/dist/diff/index.js +38 -0
  3. package/node_modules/@gsd/native/dist/diff/types.d.ts +23 -0
  4. package/node_modules/@gsd/native/dist/diff/types.js +1 -0
  5. package/node_modules/@gsd/native/dist/index.d.ts +6 -0
  6. package/node_modules/@gsd/native/dist/index.js +3 -0
  7. package/node_modules/@gsd/native/dist/native.d.ts +11 -0
  8. package/node_modules/@gsd/native/dist/ttsr/index.d.ts +27 -0
  9. package/node_modules/@gsd/native/dist/ttsr/index.js +32 -0
  10. package/node_modules/@gsd/native/dist/ttsr/types.d.ts +9 -0
  11. package/node_modules/@gsd/native/dist/ttsr/types.js +1 -0
  12. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/edit-diff.d.ts +11 -5
  13. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/edit-diff.d.ts.map +1 -1
  14. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/edit-diff.js +19 -142
  15. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/edit-diff.js.map +1 -1
  16. package/node_modules/@gsd/pi-coding-agent/src/core/tools/edit-diff.ts +23 -157
  17. package/package.json +1 -1
  18. package/packages/native/dist/diff/index.d.ts +33 -0
  19. package/packages/native/dist/diff/index.js +38 -0
  20. package/packages/native/dist/diff/types.d.ts +23 -0
  21. package/packages/native/dist/diff/types.js +1 -0
  22. package/packages/native/dist/index.d.ts +6 -0
  23. package/packages/native/dist/index.js +3 -0
  24. package/packages/native/dist/native.d.ts +11 -0
  25. package/packages/native/dist/ttsr/index.d.ts +27 -0
  26. package/packages/native/dist/ttsr/index.js +32 -0
  27. package/packages/native/dist/ttsr/types.d.ts +9 -0
  28. package/packages/native/dist/ttsr/types.js +1 -0
  29. package/packages/native/src/__tests__/diff.test.mjs +189 -0
  30. package/packages/native/src/__tests__/ttsr.test.mjs +135 -0
  31. package/packages/native/src/diff/index.ts +61 -0
  32. package/packages/native/src/diff/types.ts +24 -0
  33. package/packages/native/src/gsd-parser/index.ts +98 -0
  34. package/packages/native/src/gsd-parser/types.ts +62 -0
  35. package/packages/native/src/index.ts +26 -0
  36. package/packages/native/src/native.ts +11 -0
  37. package/packages/native/src/ttsr/index.ts +39 -0
  38. package/packages/native/src/ttsr/types.ts +10 -0
  39. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts +11 -5
  40. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts.map +1 -1
  41. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js +19 -142
  42. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js.map +1 -1
  43. package/packages/pi-coding-agent/src/core/tools/edit-diff.ts +23 -157
  44. package/src/resources/extensions/gsd/files.ts +9 -0
  45. package/src/resources/extensions/gsd/native-parser-bridge.ts +135 -0
  46. package/src/resources/extensions/ttsr/ttsr-manager.ts +86 -0
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * Shared diff computation utilities for the edit tool.
3
3
  * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering).
4
+ *
5
+ * Hot-path functions (fuzzyFindText, normalizeForFuzzyMatch, generateDiffString)
6
+ * delegate to the native Rust engine for performance on large files.
4
7
  */
5
8
 
6
- import * as Diff from "diff";
9
+ import {
10
+ fuzzyFindText as nativeFuzzyFindText,
11
+ generateDiff as nativeGenerateDiff,
12
+ normalizeForFuzzyMatch as nativeNormalizeForFuzzyMatch,
13
+ } from "@gsd/native";
7
14
  import { constants } from "fs";
8
15
  import { access, readFile } from "fs/promises";
9
16
  import { resolveToCwd } from "./path-utils.js";
@@ -25,32 +32,14 @@ export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string
25
32
  }
26
33
 
27
34
  /**
28
- * Normalize text for fuzzy matching. Applies progressive transformations:
35
+ * Normalize text for fuzzy matching (native Rust implementation).
29
36
  * - Strip trailing whitespace from each line
30
37
  * - Normalize smart quotes to ASCII equivalents
31
38
  * - Normalize Unicode dashes/hyphens to ASCII hyphen
32
39
  * - Normalize special Unicode spaces to regular space
33
40
  */
34
41
  export function normalizeForFuzzyMatch(text: string): string {
35
- return (
36
- text
37
- // Strip trailing whitespace per line
38
- .split("\n")
39
- .map((line) => line.trimEnd())
40
- .join("\n")
41
- // Smart single quotes → '
42
- .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
43
- // Smart double quotes → "
44
- .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
45
- // Various dashes/hyphens → -
46
- // U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
47
- // U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
48
- .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
49
- // Special spaces → regular space
50
- // U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
51
- // U+205F medium math space, U+3000 ideographic space
52
- .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
53
- );
42
+ return nativeNormalizeForFuzzyMatch(text);
54
43
  }
55
44
 
56
45
  export interface FuzzyMatchResult {
@@ -70,49 +59,14 @@ export interface FuzzyMatchResult {
70
59
  }
71
60
 
72
61
  /**
73
- * Find oldText in content, trying exact match first, then fuzzy match.
62
+ * Find oldText in content, trying exact match first, then fuzzy match
63
+ * (native Rust implementation).
64
+ *
74
65
  * When fuzzy matching is used, the returned contentForReplacement is the
75
- * fuzzy-normalized version of the content (trailing whitespace stripped,
76
- * Unicode quotes/dashes normalized to ASCII).
66
+ * fuzzy-normalized version of the content.
77
67
  */
78
68
  export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult {
79
- // Try exact match first
80
- const exactIndex = content.indexOf(oldText);
81
- if (exactIndex !== -1) {
82
- return {
83
- found: true,
84
- index: exactIndex,
85
- matchLength: oldText.length,
86
- usedFuzzyMatch: false,
87
- contentForReplacement: content,
88
- };
89
- }
90
-
91
- // Try fuzzy match - work entirely in normalized space
92
- const fuzzyContent = normalizeForFuzzyMatch(content);
93
- const fuzzyOldText = normalizeForFuzzyMatch(oldText);
94
- const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
95
-
96
- if (fuzzyIndex === -1) {
97
- return {
98
- found: false,
99
- index: -1,
100
- matchLength: 0,
101
- usedFuzzyMatch: false,
102
- contentForReplacement: content,
103
- };
104
- }
105
-
106
- // When fuzzy matching, we work in the normalized space for replacement.
107
- // This means the output will have normalized whitespace/quotes/dashes,
108
- // which is acceptable since we're fixing minor formatting differences anyway.
109
- return {
110
- found: true,
111
- index: fuzzyIndex,
112
- matchLength: fuzzyOldText.length,
113
- usedFuzzyMatch: true,
114
- contentForReplacement: fuzzyContent,
115
- };
69
+ return nativeFuzzyFindText(content, oldText);
116
70
  }
117
71
 
118
72
  /** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
@@ -121,7 +75,9 @@ export function stripBom(content: string): { bom: string; text: string } {
121
75
  }
122
76
 
123
77
  /**
124
- * Generate a unified diff string with line numbers and context.
78
+ * Generate a unified diff string with line numbers and context
79
+ * (native Rust implementation using Myers' algorithm via the `similar` crate).
80
+ *
125
81
  * Returns both the diff string and the first changed line number (in the new file).
126
82
  */
127
83
  export function generateDiffString(
@@ -129,101 +85,11 @@ export function generateDiffString(
129
85
  newContent: string,
130
86
  contextLines = 4,
131
87
  ): { diff: string; firstChangedLine: number | undefined } {
132
- const parts = Diff.diffLines(oldContent, newContent);
133
- const output: string[] = [];
134
-
135
- const oldLines = oldContent.split("\n");
136
- const newLines = newContent.split("\n");
137
- const maxLineNum = Math.max(oldLines.length, newLines.length);
138
- const lineNumWidth = String(maxLineNum).length;
139
-
140
- let oldLineNum = 1;
141
- let newLineNum = 1;
142
- let lastWasChange = false;
143
- let firstChangedLine: number | undefined;
144
-
145
- for (let i = 0; i < parts.length; i++) {
146
- const part = parts[i];
147
- const raw = part.value.split("\n");
148
- if (raw[raw.length - 1] === "") {
149
- raw.pop();
150
- }
151
-
152
- if (part.added || part.removed) {
153
- // Capture the first changed line (in the new file)
154
- if (firstChangedLine === undefined) {
155
- firstChangedLine = newLineNum;
156
- }
157
-
158
- // Show the change
159
- for (const line of raw) {
160
- if (part.added) {
161
- const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
162
- output.push(`+${lineNum} ${line}`);
163
- newLineNum++;
164
- } else {
165
- // removed
166
- const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
167
- output.push(`-${lineNum} ${line}`);
168
- oldLineNum++;
169
- }
170
- }
171
- lastWasChange = true;
172
- } else {
173
- // Context lines - only show a few before/after changes
174
- const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
175
-
176
- if (lastWasChange || nextPartIsChange) {
177
- // Show context
178
- let linesToShow = raw;
179
- let skipStart = 0;
180
- let skipEnd = 0;
181
-
182
- if (!lastWasChange) {
183
- // Show only last N lines as leading context
184
- skipStart = Math.max(0, raw.length - contextLines);
185
- linesToShow = raw.slice(skipStart);
186
- }
187
-
188
- if (!nextPartIsChange && linesToShow.length > contextLines) {
189
- // Show only first N lines as trailing context
190
- skipEnd = linesToShow.length - contextLines;
191
- linesToShow = linesToShow.slice(0, contextLines);
192
- }
193
-
194
- // Add ellipsis if we skipped lines at start
195
- if (skipStart > 0) {
196
- output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
197
- // Update line numbers for the skipped leading context
198
- oldLineNum += skipStart;
199
- newLineNum += skipStart;
200
- }
201
-
202
- for (const line of linesToShow) {
203
- const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
204
- output.push(` ${lineNum} ${line}`);
205
- oldLineNum++;
206
- newLineNum++;
207
- }
208
-
209
- // Add ellipsis if we skipped lines at end
210
- if (skipEnd > 0) {
211
- output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
212
- // Update line numbers for the skipped trailing context
213
- oldLineNum += skipEnd;
214
- newLineNum += skipEnd;
215
- }
216
- } else {
217
- // Skip these context lines entirely
218
- oldLineNum += raw.length;
219
- newLineNum += raw.length;
220
- }
221
-
222
- lastWasChange = false;
223
- }
224
- }
225
-
226
- return { diff: output.join("\n"), firstChangedLine };
88
+ const result = nativeGenerateDiff(oldContent, newContent, contextLines);
89
+ return {
90
+ diff: result.diff,
91
+ firstChangedLine: result.firstChangedLine ?? undefined,
92
+ };
227
93
  }
228
94
 
229
95
  export interface EditDiffResult {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.10.1",
3
+ "version": "2.10.2",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Native fuzzy text matching and diff generation for the edit tool.
3
+ *
4
+ * Uses the `similar` Rust crate (Myers' algorithm) for O(n+d) diffing,
5
+ * and single-pass Unicode normalization for fuzzy matching.
6
+ */
7
+ import type { DiffResult, FuzzyMatchResult } from "./types.js";
8
+ export type { DiffResult, FuzzyMatchResult };
9
+ /**
10
+ * Normalize text for fuzzy matching:
11
+ * - Strip trailing whitespace from each line
12
+ * - Smart quotes to ASCII equivalents
13
+ * - Unicode dashes/hyphens to ASCII hyphen
14
+ * - Special Unicode spaces to regular space
15
+ */
16
+ export declare function normalizeForFuzzyMatch(text: string): string;
17
+ /**
18
+ * Find `oldText` in `content`, trying exact match first, then fuzzy match.
19
+ *
20
+ * When fuzzy matching is used, `contentForReplacement` is the normalized
21
+ * version of `content`.
22
+ */
23
+ export declare function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult;
24
+ /**
25
+ * Generate a unified diff string with line numbers and context.
26
+ *
27
+ * Uses Myers' diff algorithm via the `similar` Rust crate.
28
+ *
29
+ * @param oldContent Original text
30
+ * @param newContent Modified text
31
+ * @param contextLines Number of context lines around changes (default: 4)
32
+ */
33
+ export declare function generateDiff(oldContent: string, newContent: string, contextLines?: number): DiffResult;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Native fuzzy text matching and diff generation for the edit tool.
3
+ *
4
+ * Uses the `similar` Rust crate (Myers' algorithm) for O(n+d) diffing,
5
+ * and single-pass Unicode normalization for fuzzy matching.
6
+ */
7
+ import { native } from "../native.js";
8
+ /**
9
+ * Normalize text for fuzzy matching:
10
+ * - Strip trailing whitespace from each line
11
+ * - Smart quotes to ASCII equivalents
12
+ * - Unicode dashes/hyphens to ASCII hyphen
13
+ * - Special Unicode spaces to regular space
14
+ */
15
+ export function normalizeForFuzzyMatch(text) {
16
+ return native.normalizeForFuzzyMatch(text);
17
+ }
18
+ /**
19
+ * Find `oldText` in `content`, trying exact match first, then fuzzy match.
20
+ *
21
+ * When fuzzy matching is used, `contentForReplacement` is the normalized
22
+ * version of `content`.
23
+ */
24
+ export function fuzzyFindText(content, oldText) {
25
+ return native.fuzzyFindText(content, oldText);
26
+ }
27
+ /**
28
+ * Generate a unified diff string with line numbers and context.
29
+ *
30
+ * Uses Myers' diff algorithm via the `similar` Rust crate.
31
+ *
32
+ * @param oldContent Original text
33
+ * @param newContent Modified text
34
+ * @param contextLines Number of context lines around changes (default: 4)
35
+ */
36
+ export function generateDiff(oldContent, newContent, contextLines) {
37
+ return native.generateDiff(oldContent, newContent, contextLines);
38
+ }
@@ -0,0 +1,23 @@
1
+ /** Result of fuzzy text matching (exact match tried first, then normalized). */
2
+ export interface FuzzyMatchResult {
3
+ /** Whether a match was found. */
4
+ found: boolean;
5
+ /** UTF-16 code unit index where the match starts (-1 if not found). */
6
+ index: number;
7
+ /** Length of the matched text in UTF-16 code units (0 if not found). */
8
+ matchLength: number;
9
+ /** Whether fuzzy (normalized) matching was used instead of exact. */
10
+ usedFuzzyMatch: boolean;
11
+ /**
12
+ * Content to use for replacement operations.
13
+ * Original content when exact match; normalized content when fuzzy match.
14
+ */
15
+ contentForReplacement: string;
16
+ }
17
+ /** Result of unified diff generation. */
18
+ export interface DiffResult {
19
+ /** The unified diff string with line numbers. */
20
+ diff: string;
21
+ /** Line number of the first change in the new file (undefined if no changes). */
22
+ firstChangedLine: number | undefined;
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -28,7 +28,13 @@ export { htmlToMarkdown } from "./html/index.js";
28
28
  export type { HtmlToMarkdownOptions } from "./html/index.js";
29
29
  export { wrapTextWithAnsi, truncateToWidth, sliceWithWidth, extractSegments, sanitizeText, visibleWidth, EllipsisKind, } from "./text/index.js";
30
30
  export type { SliceResult, ExtractSegmentsResult } from "./text/index.js";
31
+ export { normalizeForFuzzyMatch, fuzzyFindText, generateDiff, } from "./diff/index.js";
32
+ export type { FuzzyMatchResult, DiffResult } from "./diff/index.js";
31
33
  export { fuzzyFind } from "./fd/index.js";
32
34
  export type { FuzzyFindMatch, FuzzyFindOptions, FuzzyFindResult, } from "./fd/index.js";
33
35
  export { parseImage, ImageFormat, SamplingFilter } from "./image/index.js";
34
36
  export type { NativeImageHandle } from "./image/index.js";
37
+ export { ttsrCompileRules, ttsrCheckBuffer, ttsrFreeRules } from "./ttsr/index.js";
38
+ export type { TtsrHandle, TtsrRuleInput } from "./ttsr/index.js";
39
+ export { parseFrontmatter, extractSection as nativeExtractSection, extractAllSections, batchParseGsdFiles, parseRoadmapFile, } from "./gsd-parser/index.js";
40
+ export type { BatchParseResult, FrontmatterResult, NativeBoundaryMapEntry, NativeRoadmap, NativeRoadmapSlice, ParsedGsdFile, SectionResult, } from "./gsd-parser/index.js";
@@ -21,5 +21,8 @@ export { glob, invalidateFsScanCache } from "./glob/index.js";
21
21
  export { astGrep, astEdit } from "./ast/index.js";
22
22
  export { htmlToMarkdown } from "./html/index.js";
23
23
  export { wrapTextWithAnsi, truncateToWidth, sliceWithWidth, extractSegments, sanitizeText, visibleWidth, EllipsisKind, } from "./text/index.js";
24
+ export { normalizeForFuzzyMatch, fuzzyFindText, generateDiff, } from "./diff/index.js";
24
25
  export { fuzzyFind } from "./fd/index.js";
25
26
  export { parseImage, ImageFormat, SamplingFilter } from "./image/index.js";
27
+ export { ttsrCompileRules, ttsrCheckBuffer, ttsrFreeRules } from "./ttsr/index.js";
28
+ export { parseFrontmatter, extractSection as nativeExtractSection, extractAllSections, batchParseGsdFiles, parseRoadmapFile, } from "./gsd-parser/index.js";
@@ -29,5 +29,16 @@ export declare const native: {
29
29
  sanitizeText: (text: string) => string;
30
30
  visibleWidth: (text: string, tabWidth?: number) => number;
31
31
  fuzzyFind: (options: unknown) => unknown;
32
+ normalizeForFuzzyMatch: (text: string) => string;
33
+ fuzzyFindText: (content: string, oldText: string) => unknown;
34
+ generateDiff: (oldContent: string, newContent: string, contextLines?: number) => unknown;
32
35
  NativeImage: unknown;
36
+ ttsrCompileRules: (rules: unknown[]) => number;
37
+ ttsrCheckBuffer: (handle: number, buffer: string) => string[];
38
+ ttsrFreeRules: (handle: number) => void;
39
+ parseFrontmatter: (content: string) => unknown;
40
+ extractSection: (content: string, heading: string, level?: number) => unknown;
41
+ extractAllSections: (content: string, level?: number) => string;
42
+ batchParseGsdFiles: (directory: string) => unknown;
43
+ parseRoadmapFile: (content: string) => unknown;
33
44
  };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Native TTSR regex engine.
3
+ *
4
+ * Pre-compiles all rule condition patterns into a single Rust RegexSet for
5
+ * O(1)-style matching per buffer check, replacing per-rule JS regex iteration.
6
+ */
7
+ import type { TtsrHandle, TtsrRuleInput } from "./types.js";
8
+ export type { TtsrHandle, TtsrRuleInput };
9
+ /**
10
+ * Compile TTSR rules into an optimized native regex engine.
11
+ *
12
+ * Returns an opaque handle for use with `ttsrCheckBuffer` and `ttsrFreeRules`.
13
+ */
14
+ export declare function ttsrCompileRules(rules: TtsrRuleInput[]): TtsrHandle;
15
+ /**
16
+ * Check a buffer against compiled TTSR rules.
17
+ *
18
+ * Returns an array of unique rule names whose conditions matched.
19
+ * All patterns are tested in a single pass via Rust's RegexSet.
20
+ */
21
+ export declare function ttsrCheckBuffer(handle: TtsrHandle, buffer: string): string[];
22
+ /**
23
+ * Free a compiled TTSR rule set, releasing native memory.
24
+ *
25
+ * Call when rules are no longer needed (e.g., session end).
26
+ */
27
+ export declare function ttsrFreeRules(handle: TtsrHandle): void;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Native TTSR regex engine.
3
+ *
4
+ * Pre-compiles all rule condition patterns into a single Rust RegexSet for
5
+ * O(1)-style matching per buffer check, replacing per-rule JS regex iteration.
6
+ */
7
+ import { native } from "../native.js";
8
+ /**
9
+ * Compile TTSR rules into an optimized native regex engine.
10
+ *
11
+ * Returns an opaque handle for use with `ttsrCheckBuffer` and `ttsrFreeRules`.
12
+ */
13
+ export function ttsrCompileRules(rules) {
14
+ return native.ttsrCompileRules(rules);
15
+ }
16
+ /**
17
+ * Check a buffer against compiled TTSR rules.
18
+ *
19
+ * Returns an array of unique rule names whose conditions matched.
20
+ * All patterns are tested in a single pass via Rust's RegexSet.
21
+ */
22
+ export function ttsrCheckBuffer(handle, buffer) {
23
+ return native.ttsrCheckBuffer(handle, buffer);
24
+ }
25
+ /**
26
+ * Free a compiled TTSR rule set, releasing native memory.
27
+ *
28
+ * Call when rules are no longer needed (e.g., session end).
29
+ */
30
+ export function ttsrFreeRules(handle) {
31
+ native.ttsrFreeRules(handle);
32
+ }
@@ -0,0 +1,9 @@
1
+ /** Input rule for TTSR regex compilation. */
2
+ export interface TtsrRuleInput {
3
+ /** Unique rule name. */
4
+ name: string;
5
+ /** Regex condition patterns (any match triggers the rule). */
6
+ conditions: string[];
7
+ }
8
+ /** Opaque handle to a compiled TTSR rule set. */
9
+ export type TtsrHandle = number;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,189 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createRequire } from "node:module";
4
+ import * as path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const require = createRequire(import.meta.url);
9
+
10
+ // Load the native addon directly
11
+ const addonDir = path.resolve(
12
+ __dirname,
13
+ "..",
14
+ "..",
15
+ "..",
16
+ "..",
17
+ "native",
18
+ "addon",
19
+ );
20
+ const platformTag = `${process.platform}-${process.arch}`;
21
+ const candidates = [
22
+ path.join(addonDir, `gsd_engine.${platformTag}.node`),
23
+ path.join(addonDir, "gsd_engine.dev.node"),
24
+ ];
25
+
26
+ let native;
27
+ for (const candidate of candidates) {
28
+ try {
29
+ native = require(candidate);
30
+ break;
31
+ } catch {
32
+ // try next
33
+ }
34
+ }
35
+
36
+ if (!native) {
37
+ console.error(
38
+ "Native addon not found. Run `npm run build:native -w @gsd/native` first.",
39
+ );
40
+ process.exit(1);
41
+ }
42
+
43
+ // ── normalizeForFuzzyMatch ──────────────────────────────────────────────
44
+
45
+ describe("normalizeForFuzzyMatch", () => {
46
+ test("strips trailing whitespace per line", () => {
47
+ assert.equal(native.normalizeForFuzzyMatch("hello \nworld "), "hello\nworld");
48
+ });
49
+
50
+ test("normalizes smart quotes to ASCII", () => {
51
+ assert.equal(
52
+ native.normalizeForFuzzyMatch("\u201Chello\u201D \u2018world\u2019"),
53
+ '"hello" \'world\'',
54
+ );
55
+ });
56
+
57
+ test("normalizes dashes to ASCII hyphen", () => {
58
+ assert.equal(native.normalizeForFuzzyMatch("a\u2013b\u2014c"), "a-b-c");
59
+ });
60
+
61
+ test("normalizes special spaces to regular space", () => {
62
+ assert.equal(native.normalizeForFuzzyMatch("a\u00A0b\u3000c"), "a b c");
63
+ });
64
+
65
+ test("handles empty string", () => {
66
+ assert.equal(native.normalizeForFuzzyMatch(""), "");
67
+ });
68
+
69
+ test("preserves leading whitespace", () => {
70
+ assert.equal(native.normalizeForFuzzyMatch(" hello "), " hello");
71
+ });
72
+ });
73
+
74
+ // ── fuzzyFindText ───────────────────────────────────────────────────────
75
+
76
+ describe("fuzzyFindText", () => {
77
+ test("finds exact match", () => {
78
+ const result = native.fuzzyFindText("hello world", "world");
79
+ assert.equal(result.found, true);
80
+ assert.equal(result.index, 6);
81
+ assert.equal(result.matchLength, 5);
82
+ assert.equal(result.usedFuzzyMatch, false);
83
+ assert.equal(result.contentForReplacement, "hello world");
84
+ });
85
+
86
+ test("finds fuzzy match with smart quotes", () => {
87
+ const content = 'let x = \u201Chello\u201D;';
88
+ const oldText = 'let x = "hello";';
89
+ const result = native.fuzzyFindText(content, oldText);
90
+ assert.equal(result.found, true);
91
+ assert.equal(result.usedFuzzyMatch, true);
92
+ });
93
+
94
+ test("returns not found for missing text", () => {
95
+ const result = native.fuzzyFindText("hello world", "xyz");
96
+ assert.equal(result.found, false);
97
+ assert.equal(result.index, -1);
98
+ assert.equal(result.matchLength, 0);
99
+ });
100
+
101
+ test("returns correct UTF-16 index for non-ASCII content", () => {
102
+ // Emoji U+1F600 is 2 UTF-16 code units (surrogate pair), 4 UTF-8 bytes
103
+ const content = "\u{1F600}hello";
104
+ const result = native.fuzzyFindText(content, "hello");
105
+ assert.equal(result.found, true);
106
+ // Emoji is 2 UTF-16 code units, so "hello" starts at index 2
107
+ assert.equal(result.index, 2);
108
+ assert.equal(result.matchLength, 5);
109
+ });
110
+
111
+ test("index is compatible with JS substring()", () => {
112
+ const content = "abc\u{1F600}def";
113
+ const result = native.fuzzyFindText(content, "def");
114
+ assert.equal(result.found, true);
115
+ // "abc" = 3, emoji = 2 UTF-16 code units → index 5
116
+ assert.equal(result.index, 5);
117
+ // Verify substring works correctly with the returned index
118
+ const extracted = result.contentForReplacement.substring(
119
+ result.index,
120
+ result.index + result.matchLength,
121
+ );
122
+ assert.equal(extracted, "def");
123
+ });
124
+
125
+ test("fuzzy match with trailing whitespace differences", () => {
126
+ const content = "hello \nworld ";
127
+ const oldText = "hello\nworld";
128
+ const result = native.fuzzyFindText(content, oldText);
129
+ assert.equal(result.found, true);
130
+ assert.equal(result.usedFuzzyMatch, true);
131
+ });
132
+ });
133
+
134
+ // ── generateDiff ────────────────────────────────────────────────────────
135
+
136
+ describe("generateDiff", () => {
137
+ test("generates diff for a line change", () => {
138
+ const old = "line1\nline2\nline3";
139
+ const newText = "line1\nmodified\nline3";
140
+ const result = native.generateDiff(old, newText);
141
+ assert.ok(result.diff.includes("line2"));
142
+ assert.ok(result.diff.includes("modified"));
143
+ assert.ok(result.diff.includes("-"));
144
+ assert.ok(result.diff.includes("+"));
145
+ assert.notEqual(result.firstChangedLine, null);
146
+ });
147
+
148
+ test("generates diff for an addition", () => {
149
+ const old = "line1\nline3";
150
+ const newText = "line1\nline2\nline3";
151
+ const result = native.generateDiff(old, newText);
152
+ assert.ok(result.diff.includes("+"));
153
+ assert.ok(result.diff.includes("line2"));
154
+ });
155
+
156
+ test("generates diff for a deletion", () => {
157
+ const old = "line1\nline2\nline3";
158
+ const newText = "line1\nline3";
159
+ const result = native.generateDiff(old, newText);
160
+ assert.ok(result.diff.includes("-"));
161
+ assert.ok(result.diff.includes("line2"));
162
+ });
163
+
164
+ test("returns empty diff for identical content", () => {
165
+ const result = native.generateDiff("same", "same");
166
+ assert.equal(result.diff, "");
167
+ // napi-rs maps Option::None to undefined (not null)
168
+ assert.equal(result.firstChangedLine, undefined);
169
+ });
170
+
171
+ test("respects context lines parameter", () => {
172
+ const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`);
173
+ const old = lines.join("\n");
174
+ lines[10] = "modified";
175
+ const newText = lines.join("\n");
176
+ const result = native.generateDiff(old, newText, 2);
177
+ assert.ok(result.diff.includes("..."));
178
+ });
179
+
180
+ test("default context is 4 lines", () => {
181
+ const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`);
182
+ const old = lines.join("\n");
183
+ lines[10] = "modified";
184
+ const newText = lines.join("\n");
185
+ const result = native.generateDiff(old, newText);
186
+ // Should show 4 context lines before and after
187
+ assert.ok(result.diff.length > 0);
188
+ });
189
+ });