Skip to content

Commit a6449db

Browse files
authored
fix: textdiff preserve whitespaces (#51)
1 parent e06d45b commit a6449db

9 files changed

Lines changed: 490 additions & 61 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,11 +572,12 @@ Compares two texts and returns a structured diff at a character, word, or senten
572572
previousText: string | null | undefined,
573573
currentText: string | null | undefined,
574574
options?: {
575-
separation?: "character" | "word" | "sentence", // "word" by default
575+
separation?: "character" | "word" | "sentence" // "word" by default
576576
accuracy?: "normal" | "high", // "normal" by default
577577
detectMoves?: boolean // false by default
578578
ignoreCase?: boolean, // false by default
579579
ignorePunctuation?: boolean, // false by default
580+
preserveWhitespace?: boolean, // false by default
580581
locale?: Intl.Locale | string // undefined by default
581582
}
582583
```
@@ -592,6 +593,7 @@ Compares two texts and returns a structured diff at a character, word, or senten
592593
- `true`: semantically precise, but noisier — a single insertion shifts all following tokens, breaking equality.
593594
- `ignoreCase`: if `true`, `hello` and `HELLO` are considered equal.
594595
- `ignorePunctuation`: if `true`, `hello!` and `hello` are considered equal.
596+
- `preserveWhitespace`: if `true`, each token keeps the whitespace preceding it, so the original text can be rebuilt from the diff and whitespace-only edits are detected. Only available in normal accuracy mode.
595597
- `locale`: the locale of your text. Enables locale‑aware segmentation in high accuracy mode.
596598

597599
**Output**

benchmark/texts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function runTextBench10KSentences() {
5050

5151
const diff = bench("diff", 1, () => diffSentences(prev, curr, {}));
5252
const superdiff = bench("Superdiff", 1, () => {
53-
getTextDiff(prev, curr, { separation: "sentences" });
53+
getTextDiff(prev, curr, { separation: "sentence" });
5454
});
5555
return { superdiff, diff };
5656
}

package.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@donedeal0/superdiff",
3-
"version": "4.2.3",
3+
"version": "4.2.4",
44
"type": "module",
55
"description": "Superdiff provides a rich and readable diff for arrays, objects, texts and coordinates. It supports stream and file inputs for handling large datasets efficiently, is battle-tested, has zero dependencies, and offers a top-tier performance.",
66
"main": "dist/index.js",
@@ -11,7 +11,16 @@
1111
"dist"
1212
],
1313
"exports": {
14-
".": "./dist/index.js",
14+
".": {
15+
"import": {
16+
"types": "./dist/index.d.ts",
17+
"default": "./dist/index.js"
18+
},
19+
"require": {
20+
"types": "./dist/index.d.cts",
21+
"default": "./dist/index.cjs"
22+
}
23+
},
1524
"./client": "./dist/client.js",
1625
"./server": "./dist/server.cjs"
1726
},

src/lib/text-diff/lcs/myers.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ type MyersEdit =
55
| { status: TextStatus.ADDED; curr: number }
66
| { status: TextStatus.DELETED; prev: number };
77

8-
function backtrack(
9-
trace: Map<number, number>[],
10-
a: TextToken[],
11-
b: TextToken[],
12-
): MyersEdit[] {
8+
type Trace = Int32Array[];
9+
10+
function readDiagonal(trace: Int32Array, k: number, d: number): number {
11+
const index = k + (d + 1);
12+
if (index < 0 || index >= trace.length) return 0;
13+
return trace[index];
14+
}
15+
16+
function backtrack(trace: Trace, a: TextToken[], b: TextToken[]): MyersEdit[] {
1317
let x = a.length;
1418
let y = b.length;
1519
const edits: MyersEdit[] = [];
@@ -19,13 +23,16 @@ function backtrack(
1923
const k = x - y;
2024

2125
let prevK: number;
22-
if (k === -d || (k !== d && (v.get(k - 1) ?? 0) < (v.get(k + 1) ?? 0))) {
26+
if (
27+
k === -d ||
28+
(k !== d && readDiagonal(v, k - 1, d) < readDiagonal(v, k + 1, d))
29+
) {
2330
prevK = k + 1;
2431
} else {
2532
prevK = k - 1;
2633
}
2734

28-
const prevX = v.get(prevK) ?? 0;
35+
const prevX = readDiagonal(v, prevK, d);
2936
const prevY = prevX - prevK;
3037

3138
while (x > prevX && y > prevY) {
@@ -63,20 +70,20 @@ export function myersDiff(a: TextToken[], b: TextToken[]): MyersEdit[] {
6370
const M = b.length;
6471
const max = N + M;
6572

66-
const trace: Map<number, number>[] = [];
67-
const v = new Map<number, number>();
68-
v.set(1, 0);
73+
const trace: Trace = [];
74+
const offset = max + 1;
75+
const v = new Int32Array(2 * max + 3);
6976

7077
for (let d = 0; d <= max; d++) {
71-
const vSnapshot = new Map(v);
78+
trace.push(v.slice(offset - d - 1, offset + d + 2));
7279

7380
for (let k = -d; k <= d; k += 2) {
7481
let x: number;
7582

76-
if (k === -d || (k !== d && (v.get(k - 1) ?? 0) < (v.get(k + 1) ?? 0))) {
77-
x = v.get(k + 1) ?? 0;
83+
if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) {
84+
x = v[offset + k + 1];
7885
} else {
79-
x = (v.get(k - 1) ?? 0) + 1;
86+
x = v[offset + k - 1] + 1;
8087
}
8188

8289
let y = x - k;
@@ -86,15 +93,12 @@ export function myersDiff(a: TextToken[], b: TextToken[]): MyersEdit[] {
8693
y++;
8794
}
8895

89-
v.set(k, x);
96+
v[offset + k] = x;
9097

9198
if (x >= N && y >= M) {
92-
trace.push(vSnapshot);
9399
return backtrack(trace, a, b);
94100
}
95101
}
96-
97-
trace.push(vSnapshot);
98102
}
99103

100104
return [];

src/lib/text-diff/text-diff.test.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5565,3 +5565,221 @@ describe("getTextDiff – with moves detection", () => {
55655565
).toStrictEqual(resultFrench);
55665566
});
55675567
});
5568+
5569+
describe("getTextDiff - preserveWhitespace", () => {
5570+
const CODE_LINES: [string, string][] = [
5571+
[" const foo = bar(1);", " const foo = baz(2);"],
5572+
["var myVar = 2;", "var myVariable = 3;"],
5573+
["a b\tc", "a b\tc d"],
5574+
["\t\tif (a && b) { ", "\t\tif (a || b) { "],
5575+
[" return x;", " return x;"],
5576+
["\tif (a) {", " if (a) {"],
5577+
[" ", " "],
5578+
["", " hello "],
5579+
[" hello ", ""],
5580+
[" hello ", " hello "],
5581+
];
5582+
5583+
const rebuildBothTexts = (diff: ReturnType<typeof getTextDiff>) => {
5584+
let previous = "";
5585+
let current = "";
5586+
for (const part of diff.diff) {
5587+
if (part.status === "updated") {
5588+
previous += part.previousValue ?? "";
5589+
current += part.value;
5590+
continue;
5591+
}
5592+
if (part.status !== "added") previous += part.value;
5593+
if (part.status !== "deleted") current += part.value;
5594+
}
5595+
return { previous, current };
5596+
};
5597+
5598+
it("keeps the indentation on the surrounding tokens", () => {
5599+
expect(
5600+
getTextDiff(" const a = 1;", " const b = 1;", {
5601+
separation: "word",
5602+
preserveWhitespace: true,
5603+
}),
5604+
).toStrictEqual({
5605+
type: "text",
5606+
status: "updated",
5607+
diff: [
5608+
{ value: " const", index: 0, previousIndex: 0, status: "equal" },
5609+
{ value: " a", index: null, previousIndex: 1, status: "deleted" },
5610+
{ value: " b", index: 1, previousIndex: null, status: "added" },
5611+
{ value: " =", index: 2, previousIndex: 2, status: "equal" },
5612+
{ value: " 1;", index: 3, previousIndex: 3, status: "equal" },
5613+
],
5614+
});
5615+
});
5616+
5617+
it("reports a re-indentation that is invisible by default", () => {
5618+
expect(
5619+
getTextDiff(" return x;", " return x;", { separation: "word" }),
5620+
).toStrictEqual({
5621+
type: "text",
5622+
status: "equal",
5623+
diff: [
5624+
{ value: "return", index: 0, previousIndex: 0, status: "equal" },
5625+
{ value: "x;", index: 1, previousIndex: 1, status: "equal" },
5626+
],
5627+
});
5628+
5629+
expect(
5630+
getTextDiff(" return x;", " return x;", {
5631+
separation: "word",
5632+
preserveWhitespace: true,
5633+
}),
5634+
).toStrictEqual({
5635+
type: "text",
5636+
status: "updated",
5637+
diff: [
5638+
{ value: " return", index: null, previousIndex: 0, status: "deleted" },
5639+
{
5640+
value: " return",
5641+
index: 0,
5642+
previousIndex: null,
5643+
status: "added",
5644+
},
5645+
{ value: " x;", index: 1, previousIndex: 1, status: "equal" },
5646+
],
5647+
});
5648+
});
5649+
5650+
it("reports tabs converted to spaces", () => {
5651+
expect(
5652+
getTextDiff("\tif (a) {", " if (a) {", {
5653+
separation: "word",
5654+
preserveWhitespace: true,
5655+
}).status,
5656+
).toBe("updated");
5657+
});
5658+
5659+
it("stays equal when the spacing is untouched", () => {
5660+
expect(
5661+
getTextDiff(" a b ", " a b ", {
5662+
separation: "word",
5663+
preserveWhitespace: true,
5664+
}).status,
5665+
).toBe("equal");
5666+
});
5667+
5668+
it.each(CODE_LINES)("rebuilds %j and %j - word", (previous, current) => {
5669+
expect(
5670+
rebuildBothTexts(
5671+
getTextDiff(previous, current, {
5672+
separation: "word",
5673+
preserveWhitespace: true,
5674+
}),
5675+
),
5676+
).toEqual({ previous, current });
5677+
});
5678+
5679+
it.each(CODE_LINES)("rebuilds %j and %j - character", (previous, current) => {
5680+
expect(
5681+
rebuildBothTexts(
5682+
getTextDiff(previous, current, {
5683+
separation: "character",
5684+
preserveWhitespace: true,
5685+
}),
5686+
),
5687+
).toEqual({ previous, current });
5688+
});
5689+
5690+
it.each(CODE_LINES)(
5691+
"rebuilds %j and %j - detectMoves",
5692+
(previous, current) => {
5693+
expect(
5694+
rebuildBothTexts(
5695+
getTextDiff(previous, current, {
5696+
separation: "word",
5697+
preserveWhitespace: true,
5698+
detectMoves: true,
5699+
}),
5700+
),
5701+
).toEqual({ previous, current });
5702+
},
5703+
);
5704+
5705+
it("rebuilds sentences", () => {
5706+
const previous = " Hello world. How are you? ";
5707+
const current = " Hello there. How are you? ";
5708+
expect(
5709+
rebuildBothTexts(
5710+
getTextDiff(previous, current, {
5711+
separation: "sentence",
5712+
preserveWhitespace: true,
5713+
}),
5714+
),
5715+
).toEqual({ previous, current });
5716+
});
5717+
5718+
it("keeps surrogate pairs intact", () => {
5719+
const previous = " a😀b";
5720+
const current = " a😀c";
5721+
const diff = getTextDiff(previous, current, {
5722+
separation: "character",
5723+
preserveWhitespace: true,
5724+
});
5725+
5726+
expect(rebuildBothTexts(diff)).toEqual({ previous, current });
5727+
expect(diff.diff.some((part) => part.value === "😀")).toBe(true);
5728+
});
5729+
5730+
it("reports the same statuses as the default when the spacing is unchanged", () => {
5731+
const previous = " const foo = bar(1);";
5732+
const current = " const foo = baz(2);";
5733+
const statuses = (diff: ReturnType<typeof getTextDiff>) =>
5734+
diff.diff.map((part) => part.status);
5735+
5736+
expect(
5737+
statuses(
5738+
getTextDiff(previous, current, {
5739+
separation: "word",
5740+
preserveWhitespace: true,
5741+
}),
5742+
),
5743+
).toEqual(statuses(getTextDiff(previous, current, { separation: "word" })));
5744+
});
5745+
5746+
it("still honours ignoreCase", () => {
5747+
expect(
5748+
getTextDiff(" Hello", " hello", {
5749+
separation: "word",
5750+
preserveWhitespace: true,
5751+
ignoreCase: true,
5752+
}).status,
5753+
).toBe("equal");
5754+
});
5755+
});
5756+
5757+
describe("getTextDiff - large dissimilar inputs", () => {
5758+
it("diffs two large, mostly dissimilar texts without exhausting memory", () => {
5759+
const previous = Array.from(
5760+
{ length: 900 },
5761+
(_unused, i) => `alpha_${i}(x${i}).beta;`,
5762+
).join(" ");
5763+
const current = Array.from(
5764+
{ length: 900 },
5765+
(_unused, i) => `gamma${i % 7}[y${i}] = delta_${i} + 1;`,
5766+
).join(" ");
5767+
5768+
const diff = getTextDiff(previous, current, {
5769+
separation: "word",
5770+
preserveWhitespace: true,
5771+
});
5772+
5773+
expect(diff.status).toBe("updated");
5774+
5775+
let rebuiltPrevious = "";
5776+
let rebuiltCurrent = "";
5777+
for (const part of diff.diff) {
5778+
if (part.status !== "added") rebuiltPrevious += part.value;
5779+
if (part.status !== "deleted") rebuiltCurrent += part.value;
5780+
}
5781+
5782+
expect(rebuiltPrevious).toBe(previous);
5783+
expect(rebuiltCurrent).toBe(current);
5784+
}, 15_000);
5785+
});

0 commit comments

Comments
 (0)