singularity-forge/packages/native/src/__tests__/diff.test.mjs
Lex Christopherson 4c97d59536 feat: add native Rust diff engine for edit tool
Move the edit tool's hot-path diffing operations from JS to native Rust:
- `normalizeForFuzzyMatch`: single-pass Unicode normalization (smart quotes,
  dashes, special spaces, trailing whitespace)
- `fuzzyFindText`: exact-then-fuzzy substring search with UTF-16 index
  conversion for JS compatibility
- `generateDiff`: unified diff generation using the `similar` crate
  (Myers' algorithm with optimizations)

The Rust module at `native/crates/engine/src/diff.rs` exposes three napi
functions. The TypeScript wrapper at `packages/native/src/diff/` follows
the existing module pattern. `edit-diff.ts` now delegates to native
implementations while keeping line-ending handling and file I/O in JS.

18 tests covering normalization, fuzzy matching (including UTF-16 index
correctness with emoji/surrogate pairs), and diff generation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 14:11:40 -06:00

189 lines
6.3 KiB
JavaScript

import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// Load the native addon directly
const addonDir = path.resolve(
__dirname,
"..",
"..",
"..",
"..",
"native",
"addon",
);
const platformTag = `${process.platform}-${process.arch}`;
const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"),
];
let native;
for (const candidate of candidates) {
try {
native = require(candidate);
break;
} catch {
// try next
}
}
if (!native) {
console.error(
"Native addon not found. Run `npm run build:native -w @gsd/native` first.",
);
process.exit(1);
}
// ── normalizeForFuzzyMatch ──────────────────────────────────────────────
describe("normalizeForFuzzyMatch", () => {
test("strips trailing whitespace per line", () => {
assert.equal(native.normalizeForFuzzyMatch("hello \nworld "), "hello\nworld");
});
test("normalizes smart quotes to ASCII", () => {
assert.equal(
native.normalizeForFuzzyMatch("\u201Chello\u201D \u2018world\u2019"),
'"hello" \'world\'',
);
});
test("normalizes dashes to ASCII hyphen", () => {
assert.equal(native.normalizeForFuzzyMatch("a\u2013b\u2014c"), "a-b-c");
});
test("normalizes special spaces to regular space", () => {
assert.equal(native.normalizeForFuzzyMatch("a\u00A0b\u3000c"), "a b c");
});
test("handles empty string", () => {
assert.equal(native.normalizeForFuzzyMatch(""), "");
});
test("preserves leading whitespace", () => {
assert.equal(native.normalizeForFuzzyMatch(" hello "), " hello");
});
});
// ── fuzzyFindText ───────────────────────────────────────────────────────
describe("fuzzyFindText", () => {
test("finds exact match", () => {
const result = native.fuzzyFindText("hello world", "world");
assert.equal(result.found, true);
assert.equal(result.index, 6);
assert.equal(result.matchLength, 5);
assert.equal(result.usedFuzzyMatch, false);
assert.equal(result.contentForReplacement, "hello world");
});
test("finds fuzzy match with smart quotes", () => {
const content = 'let x = \u201Chello\u201D;';
const oldText = 'let x = "hello";';
const result = native.fuzzyFindText(content, oldText);
assert.equal(result.found, true);
assert.equal(result.usedFuzzyMatch, true);
});
test("returns not found for missing text", () => {
const result = native.fuzzyFindText("hello world", "xyz");
assert.equal(result.found, false);
assert.equal(result.index, -1);
assert.equal(result.matchLength, 0);
});
test("returns correct UTF-16 index for non-ASCII content", () => {
// Emoji U+1F600 is 2 UTF-16 code units (surrogate pair), 4 UTF-8 bytes
const content = "\u{1F600}hello";
const result = native.fuzzyFindText(content, "hello");
assert.equal(result.found, true);
// Emoji is 2 UTF-16 code units, so "hello" starts at index 2
assert.equal(result.index, 2);
assert.equal(result.matchLength, 5);
});
test("index is compatible with JS substring()", () => {
const content = "abc\u{1F600}def";
const result = native.fuzzyFindText(content, "def");
assert.equal(result.found, true);
// "abc" = 3, emoji = 2 UTF-16 code units → index 5
assert.equal(result.index, 5);
// Verify substring works correctly with the returned index
const extracted = result.contentForReplacement.substring(
result.index,
result.index + result.matchLength,
);
assert.equal(extracted, "def");
});
test("fuzzy match with trailing whitespace differences", () => {
const content = "hello \nworld ";
const oldText = "hello\nworld";
const result = native.fuzzyFindText(content, oldText);
assert.equal(result.found, true);
assert.equal(result.usedFuzzyMatch, true);
});
});
// ── generateDiff ────────────────────────────────────────────────────────
describe("generateDiff", () => {
test("generates diff for a line change", () => {
const old = "line1\nline2\nline3";
const newText = "line1\nmodified\nline3";
const result = native.generateDiff(old, newText);
assert.ok(result.diff.includes("line2"));
assert.ok(result.diff.includes("modified"));
assert.ok(result.diff.includes("-"));
assert.ok(result.diff.includes("+"));
assert.notEqual(result.firstChangedLine, null);
});
test("generates diff for an addition", () => {
const old = "line1\nline3";
const newText = "line1\nline2\nline3";
const result = native.generateDiff(old, newText);
assert.ok(result.diff.includes("+"));
assert.ok(result.diff.includes("line2"));
});
test("generates diff for a deletion", () => {
const old = "line1\nline2\nline3";
const newText = "line1\nline3";
const result = native.generateDiff(old, newText);
assert.ok(result.diff.includes("-"));
assert.ok(result.diff.includes("line2"));
});
test("returns empty diff for identical content", () => {
const result = native.generateDiff("same", "same");
assert.equal(result.diff, "");
// napi-rs maps Option::None to undefined (not null)
assert.equal(result.firstChangedLine, undefined);
});
test("respects context lines parameter", () => {
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`);
const old = lines.join("\n");
lines[10] = "modified";
const newText = lines.join("\n");
const result = native.generateDiff(old, newText, 2);
assert.ok(result.diff.includes("..."));
});
test("default context is 4 lines", () => {
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`);
const old = lines.join("\n");
lines[10] = "modified";
const newText = lines.join("\n");
const result = native.generateDiff(old, newText);
// Should show 4 context lines before and after
assert.ok(result.diff.length > 0);
});
});