From 3434611e64e7f716b48f6e0812be66fec65746ca Mon Sep 17 00:00:00 2001 From: Antoine Lanoe Date: Wed, 2 Sep 2026 21:52:58 +0200 Subject: [PATCH] feat: getCodeDiff --- .gitignore | 1 + README.md | 155 ++- assets/get-code-diff-demo.png | Bin 0 -> 70479 bytes benchmark/code.ts | 75 ++ benchmark/index.ts | 15 +- benchmark/texts.ts | 40 +- jest.config.mjs | 3 +- package-lock.json | 4 +- package.json | 29 +- .../lcs/myers.ts => core/myers/index.ts} | 17 +- src/core/myers/myers.test.ts | 163 +++ src/index.ts | 2 + src/lib/code-diff/code-diff.test.ts | 1123 +++++++++++++++++ src/lib/code-diff/diff.ts | 132 ++ src/lib/code-diff/index.ts | 41 + src/lib/code-diff/lcs/index.ts | 48 + src/lib/code-diff/lines.ts | 8 + src/lib/code-diff/pair.ts | 66 + src/lib/code-diff/tokenize.ts | 46 + src/lib/code-diff/utils/index.ts | 18 + src/lib/text-diff/index.ts | 2 +- src/lib/text-diff/lcs/index.ts | 17 +- src/lib/text-diff/positional/index.ts | 13 +- src/lib/text-diff/tokenize/normal.ts | 10 +- src/lib/text-diff/tokenize/strict.ts | 6 +- src/models/code/index.ts | 27 + src/models/lcs/index.ts | 33 + src/models/text/index.ts | 14 - tsconfig.json | 1 + 29 files changed, 2013 insertions(+), 96 deletions(-) create mode 100644 assets/get-code-diff-demo.png create mode 100644 benchmark/code.ts rename src/{lib/text-diff/lcs/myers.ts => core/myers/index.ts} (78%) create mode 100644 src/core/myers/myers.test.ts create mode 100644 src/lib/code-diff/code-diff.test.ts create mode 100644 src/lib/code-diff/diff.ts create mode 100644 src/lib/code-diff/index.ts create mode 100644 src/lib/code-diff/lcs/index.ts create mode 100644 src/lib/code-diff/lines.ts create mode 100644 src/lib/code-diff/pair.ts create mode 100644 src/lib/code-diff/tokenize.ts create mode 100644 src/lib/code-diff/utils/index.ts create mode 100644 src/models/code/index.ts create mode 100644 src/models/lcs/index.ts diff --git a/.gitignore b/.gitignore index ffc653a..6dec064 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ dist .DS_Store *.tsbuildinfo +*.tgz # Ignore generated worker files src/lib/stream-list-diff/server/worker/node-worker.cjs \ No newline at end of file diff --git a/README.md b/README.md index 00a687b..6af3ca9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ # WHAT IS IT? -**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**. +**Superdiff** provides a rich and readable diff for **arrays**, **objects**, **code**, **text** and **coordinates**. It supports **stream** and file inputs for handling large datasets efficiently, is battle-tested, has zero dependencies, and offers **top-tier performance**. ℹ️ The documentation is also available on our [website](https://superdiff.gitbook.io/donedeal0-superdiff)! @@ -19,12 +19,13 @@ ## FEATURES -**Superdiff** exports 5 functions: +**Superdiff** exports 6 functions: - [getObjectDiff](#getobjectdiff) - recursively diff nested objects - [getListDiff](#getlistdiff) - detect additions, deletions, updates and moves in arrays - [streamListDiff](#streamlistdiff) - diff large lists incrementally via streams - [getTextDiff](#gettextdiff) - diff text by character, word or sentence +- [getCodeDiff](#getcodediff) - diff code line by line, then token by token - [getGeoDiff](#getgeodiff) - detect coordinate changes, distance and direction
@@ -62,6 +63,14 @@
+![superdiff-get-code-diff-demo](https://raw.githubusercontent.com/DoneDeal0/superdiff/main/assets/get-code-diff-demo.png) + +

+Track code changes with getCodeDiff +

+ +
+
## ⚔ COMPETITORS @@ -72,6 +81,7 @@ | List diff | ✅ | ❌ | ⚠️ | ❌ | ⚠️ | | Text diff | ✅ | ❌ | ✅ | ✅ | ❌ | | Coordinates diff | ✅ | ❌ | ❌ | ❌ | ❌ | +| Code diff | ✅ | ❌ | ❌ | ✅ | ❌ | | Streaming for huge datasets | ✅ | ❌ | ❌ | ❌ | ❌ | | Move detection | ✅ | ❌ | ❌ | ❌ | ❌ | | Output refinement | ✅ | ❌ | ❌ | ❌ | ❌ | @@ -104,13 +114,23 @@ Method: Warm up runs, then each script is executed 20 times, and we keep the med | Scenario | superdiff | diff | | ----------------------- | ------------ | ---------- | -| 10k words | **1.38 ms** | 3.86 ms | -| 100k words | **21.68 ms** | 45.93 ms | -| 10k sentences | **2.30 ms** | 5.61 ms | -| 100k sentences | **21.95 ms** | 62.03 ms | +| 10k words | **1.32 ms** | 3.86 ms | +| 100k words | **12.09 ms** | 45.64 ms | +| 10k sentences | **1.58 ms** | 4.53 ms | +| 100k sentences | **22.31 ms** | 55.60 ms | (Superdiff uses its `normal` accuracy settings to match diff's behavior) +### Code diff + +| Scenario | superdiff | diff | +| ---------- | ------------- | ---------- | +| 1k lines | **0.36 ms** | 1.14 ms | +| 10k lines | **9.55 ms** | 67.21 ms | +| 100k lines | **911.80 ms** | 6995.84 ms | + +(diff has no line + token API, so both passes are run; its line pass alone is 66 ms at 10k lines) + > 👉 Despite providing a full structural diff with a richer output, **Superdiff consistently matches or outperforms the fastest alternatives tested**. It also scales linearly, even with deeply nested data.
@@ -752,6 +772,129 @@ getTextDiff(
+### getCodeDiff + +```js +import { getCodeDiff } from "@donedeal0/superdiff"; +``` + +Compares two codes and returns a structured diff: lines first, then the tokens of each changed line. Whitespace is kept, so indentation changes are reported too. + +> ℹ️ To diff files, read them first: `getCodeDiff(await previousFile.text(), await currentFile.text())`. + +#### FORMAT + +**Input** + +```ts + previousCode: string | null | undefined, + currentCode: string | null | undefined, +``` + +**Output** + +```ts +type CodeDiff = { + type: "code"; + status: "added" | "deleted" | "equal" | "updated"; + diff: { + value: string; + previousValue?: string; + line: number | null; + previousLine: number | null; + status: "added" | "deleted" | "equal" | "updated"; + diff?: { + value: string; + previousValue?: string; + status: "added" | "deleted" | "equal" | "updated"; + }[]; + }[]; +}; +``` + +Lines start at 1. `line` is `null` if the line was deleted, `previousLine` is `null` if it was added. `diff` holds the token changes of an `updated` line. + +#### USAGE + +**Input** + +```diff +getCodeDiff( +- "const MAX = 280;\n\nfunction preview(post) {\n return post.body;\n}", ++ "const MAX_LENGTH = 320;\n\nfunction preview(post) {\n const text = post.body;\n return text.slice(0, MAX_LENGTH);\n}" +); +``` + +**Output** + +```diff +{ + type: "code", ++ status: "updated", + diff: [ + { ++ value: "const MAX_LENGTH = 320;", ++ previousValue: "const MAX = 280;", + line: 1, + previousLine: 1, ++ status: "updated", + diff: [ + { value: "const", status: "equal" }, ++ { value: " MAX_LENGTH", previousValue: " MAX", status: "updated" }, + { value: " =", status: "equal" }, ++ { value: " 320", previousValue: " 280", status: "updated" }, + { value: ";", status: "equal" }, + ], + }, + { + value: "", + previousValue: "", + line: 2, + previousLine: 2, + status: "equal", + }, + { + value: "function preview(post) {", + previousValue: "function preview(post) {", + line: 3, + previousLine: 3, + status: "equal", + }, + { ++ value: " const text = post.body;", ++ previousValue: " return post.body;", + line: 4, + previousLine: 4, ++ status: "updated", + diff: [ ++ { value: " const", previousValue: " return", status: "updated" }, ++ { value: " text", status: "added" }, ++ { value: " =", status: "added" }, + { value: " post", status: "equal" }, + { value: ".", status: "equal" }, + { value: "body", status: "equal" }, + { value: ";", status: "equal" }, + ], + }, + { ++ value: " return text.slice(0, MAX_LENGTH);", + line: 5, + previousLine: null, ++ status: "added", + }, + { + value: "}", + previousValue: "}", + line: 6, + previousLine: 5, + status: "equal", + }, + ], +} +``` + +
+ ### getGeoDiff ```js diff --git a/assets/get-code-diff-demo.png b/assets/get-code-diff-demo.png new file mode 100644 index 0000000000000000000000000000000000000000..e1dd5321f00b060f62a191d158a63fe803d3b8d3 GIT binary patch literal 70479 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 bZ=?R{ZDv*~ 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�gpJa_O!V)?>bkSvxW7nY#}Dp1{gnw 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 VkwULhJ=XtTRgzPat(7(p`yZv?;s^i$ literal 0 HcmV?d00001 diff --git a/benchmark/code.ts b/benchmark/code.ts new file mode 100644 index 0000000..849fe8f --- /dev/null +++ b/benchmark/code.ts @@ -0,0 +1,75 @@ +import { diffLines, diffWordsWithSpace } from "diff"; +import { getCodeDiff } from "../src"; +import { bench } from "./utils"; + +function generateCode(functionCount: number): string { + const lines: string[] = [ + "import { helper } from './helper';", + "", + "const MAX_LENGTH = 280;", + "", + ]; + + for (let i = 0; i < functionCount; i++) { + lines.push(`export function process${i}(input: Item${i}): Result${i} {`); + lines.push(` const cache = new Map();`); + lines.push(` for (const entry of input.entries) {`); + lines.push(` if (entry.weight > ${i % 997}) {`); + lines.push(` cache.set(entry.id, helper(entry, MAX_LENGTH));`); + lines.push(` }`); + lines.push(` }`); + lines.push(` return { id: ${i}, cache, total: cache.size };`); + lines.push(`}`); + lines.push(""); + } + + return lines.join("\n"); +} + +function mutateCode(code: string, changeRate: number): string { + return code + .split("\n") + .map((line, i) => { + if (i % changeRate !== 0 || !line.trim()) return line; + return `${line.replace(/cache/g, "store").replace(/entry/g, "record")} // reviewed`; + }) + .join("\n"); +} + +function runCodeBench(functionCount: number, label: string, runs = 20) { + const previous = generateCode(functionCount); + const current = mutateCode(previous, 20); + console.log(`\nCode diff – ${label}`); + + // diff has no line + token API, so the equivalent of Superdiff's output is a + // line diff followed by a word diff on each rewritten line. + const jsdiff = bench("diff", runs, () => { + const parts = diffLines(previous, current); + for (let i = 0; i < parts.length; i++) { + const removed = parts[i]; + const added = parts[i + 1]; + if (removed.removed && added?.added) { + diffWordsWithSpace(removed.value, added.value); + i++; + } + } + }); + + const superdiff = bench("Superdiff", runs, () => { + getCodeDiff(previous, current); + }); + + return { superdiff, jsdiff }; +} + +export function runCodeBench1K() { + return runCodeBench(100, "1k lines"); +} + +export function runCodeBench10K() { + return runCodeBench(1_000, "10k lines"); +} + +export function runCodeBench100K() { + return runCodeBench(10_000, "100k lines", 3); +} diff --git a/benchmark/index.ts b/benchmark/index.ts index 076de05..a91fc0a 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -4,7 +4,13 @@ import { runNestedObjectBench, } from "./objects"; import { runListBench100K, runListBench10K } from "./lists"; -import { runTextBench10KWords, runTextBench10KSentences } from "./texts"; +import { + runTextBench10KWords, + runTextBench100KWords, + runTextBench10KSentences, + runTextBench100KSentences, +} from "./texts"; +import { runCodeBench1K, runCodeBench10K, runCodeBench100K } from "./code"; // Method: Warm up runs, then each script is executed 20 times, and we keep the median time. // To guarantee a fair assessment, all scenarios must be run individually, with a clean heap memory. @@ -23,6 +29,13 @@ runListBench100K(); // Text runTextBench10KWords(); +runTextBench100KWords(); runTextBench10KSentences(); +runTextBench100KSentences(); + +// Code +runCodeBench1K(); +runCodeBench10K(); +runCodeBench100K(); console.log("\n- BENCHMARK COMPLETE -"); diff --git a/benchmark/texts.ts b/benchmark/texts.ts index 6223a93..4aef898 100644 --- a/benchmark/texts.ts +++ b/benchmark/texts.ts @@ -31,26 +31,42 @@ function generateSentences(sentenceCount: number, mutate = false): string { return mutated.join(" "); } -export function runTextBench10KWords() { - const prev = generateText(10_000); - const curr = generateText(10_000, true); - console.log("\nText diff – 10k words"); +function runWordsBench(wordCount: number, label: string) { + const prev = generateText(wordCount); + const curr = generateText(wordCount, true); + console.log(`\nText diff – ${label} words`); - const diff = bench("diff", 1, () => diffWords(prev, curr)); - const superdiff = bench("Superdiff", 1, () => { + const diff = bench("diff", 20, () => diffWords(prev, curr)); + const superdiff = bench("Superdiff", 20, () => { getTextDiff(prev, curr, { separation: "word" }); }); return { superdiff, diff }; } -export function runTextBench10KSentences() { - const prev = generateSentences(10_000); - const curr = generateSentences(10_000, true); - console.log("\nText diff – 10k sentences"); +function runSentencesBench(sentenceCount: number, label: string) { + const prev = generateSentences(sentenceCount); + const curr = generateSentences(sentenceCount, true); + console.log(`\nText diff – ${label} sentences`); - const diff = bench("diff", 1, () => diffSentences(prev, curr, {})); - const superdiff = bench("Superdiff", 1, () => { + const diff = bench("diff", 20, () => diffSentences(prev, curr, {})); + const superdiff = bench("Superdiff", 20, () => { getTextDiff(prev, curr, { separation: "sentence" }); }); return { superdiff, diff }; } + +export function runTextBench10KWords() { + return runWordsBench(10_000, "10k"); +} + +export function runTextBench100KWords() { + return runWordsBench(100_000, "100k"); +} + +export function runTextBench10KSentences() { + return runSentencesBench(10_000, "10k"); +} + +export function runTextBench100KSentences() { + return runSentencesBench(100_000, "100k"); +} diff --git a/jest.config.mjs b/jest.config.mjs index c8184af..2b03a1c 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -12,9 +12,10 @@ const config = { dynamicImport: true, }, paths: { + "@core/*": ["./src/core/*"], + "@lib/*": ["./src/lib/*"], "@mocks/*": ["./src/mocks/*"], "@models/*": ["./src/models/*"], - "@lib/*": ["./src/lib/*"], }, target: "esnext", }, diff --git a/package-lock.json b/package-lock.json index d888ab2..bb25254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@donedeal0/superdiff", - "version": "4.2.2", + "version": "5.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@donedeal0/superdiff", - "version": "4.2.2", + "version": "5.0.0", "license": "ISC", "devDependencies": { "@swc/core": "^1.15.47", diff --git a/package.json b/package.json index e416de6..5a373df 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@donedeal0/superdiff", - "version": "4.2.4", + "version": "5.0.0", "type": "module", - "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.", + "description": "Superdiff provides a rich and readable diff for arrays, objects, code, text and coordinates. It supports stream and file inputs for handling large datasets efficiently, is battle-tested, has zero dependencies, and offers top-tier performance.", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", @@ -54,40 +54,17 @@ ] }, "keywords": [ - "array-comparison", "array-diff", - "chunks", "code-diff", - "compare", - "comparison-tool", - "coordinates", - "data-diff", - "deep-comparison", - "deep-diff", - "deep-object-diff", "diff", - "file-diff", "geo-diff", - "geo-distance", "haversine", - "isequal", "json-diff", - "json", - "lat-long", - "lcs", "list-diff", - "object-comparison", "object-diff", - "object-difference", - "object", "stream-diff", - "streaming-diff", - "streaming", "string-diff", - "text-diff", - "textdiff", - "vincenty", - "word-diff" + "text-diff" ], "scripts": { "benchmark": "tsx benchmark/index.ts", diff --git a/src/lib/text-diff/lcs/myers.ts b/src/core/myers/index.ts similarity index 78% rename from src/lib/text-diff/lcs/myers.ts rename to src/core/myers/index.ts index cdc07f6..36725ee 100644 --- a/src/lib/text-diff/lcs/myers.ts +++ b/src/core/myers/index.ts @@ -1,9 +1,4 @@ -import { TextStatus, TextToken } from "@models/text"; - -type MyersEdit = - | { status: TextStatus.EQUAL; prev: number; curr: number } - | { status: TextStatus.ADDED; curr: number } - | { status: TextStatus.DELETED; prev: number }; +import { LCSStatus, MyersEdit, Token } from "@models/lcs"; type Trace = Int32Array[]; @@ -13,7 +8,7 @@ function readDiagonal(trace: Int32Array, k: number, d: number): number { return trace[index]; } -function backtrack(trace: Trace, a: TextToken[], b: TextToken[]): MyersEdit[] { +function backtrack(trace: Trace, a: Token[], b: Token[]): MyersEdit[] { let x = a.length; let y = b.length; const edits: MyersEdit[] = []; @@ -37,7 +32,7 @@ function backtrack(trace: Trace, a: TextToken[], b: TextToken[]): MyersEdit[] { while (x > prevX && y > prevY) { edits.push({ - status: TextStatus.EQUAL, + status: LCSStatus.EQUAL, prev: x - 1, curr: y - 1, }); @@ -49,13 +44,13 @@ function backtrack(trace: Trace, a: TextToken[], b: TextToken[]): MyersEdit[] { if (x === prevX) { edits.push({ - status: TextStatus.ADDED, + status: LCSStatus.ADDED, curr: y - 1, }); y--; } else { edits.push({ - status: TextStatus.DELETED, + status: LCSStatus.DELETED, prev: x - 1, }); x--; @@ -65,7 +60,7 @@ function backtrack(trace: Trace, a: TextToken[], b: TextToken[]): MyersEdit[] { return edits.reverse(); } -export function myersDiff(a: TextToken[], b: TextToken[]): MyersEdit[] { +export function myersDiff(a: Token[], b: Token[]): MyersEdit[] { const N = a.length; const M = b.length; const max = N + M; diff --git a/src/core/myers/myers.test.ts b/src/core/myers/myers.test.ts new file mode 100644 index 0000000..96307f5 --- /dev/null +++ b/src/core/myers/myers.test.ts @@ -0,0 +1,163 @@ +import { myersDiff } from "@core/myers"; +import { LCSStatus, Token } from "@models/lcs"; + +const tok = (values: string[]): Token[] => + values.map((value, index) => ({ value, normalizedValue: value, index })); + +describe("myersDiff", () => { + it("returns no edit for two empty token lists", () => { + expect(myersDiff(tok([]), tok([]))).toStrictEqual([]); + }); + + it("marks every token equal for identical lists", () => { + expect(myersDiff(tok(["a", "b"]), tok(["a", "b"]))).toStrictEqual([ + { status: LCSStatus.EQUAL, prev: 0, curr: 0 }, + { status: LCSStatus.EQUAL, prev: 1, curr: 1 }, + ]); + }); + + it("marks every token added when previous is empty", () => { + expect(myersDiff(tok([]), tok(["a", "b"]))).toStrictEqual([ + { status: LCSStatus.ADDED, curr: 0 }, + { status: LCSStatus.ADDED, curr: 1 }, + ]); + }); + + it("marks every token deleted when current is empty", () => { + expect(myersDiff(tok(["a", "b"]), tok([]))).toStrictEqual([ + { status: LCSStatus.DELETED, prev: 0 }, + { status: LCSStatus.DELETED, prev: 1 }, + ]); + }); + + it("reports a single insertion", () => { + expect(myersDiff(tok(["a", "c"]), tok(["a", "b", "c"]))).toStrictEqual([ + { status: LCSStatus.EQUAL, prev: 0, curr: 0 }, + { status: LCSStatus.ADDED, curr: 1 }, + { status: LCSStatus.EQUAL, prev: 1, curr: 2 }, + ]); + }); + + it("reports a single deletion", () => { + expect(myersDiff(tok(["a", "b", "c"]), tok(["a", "c"]))).toStrictEqual([ + { status: LCSStatus.EQUAL, prev: 0, curr: 0 }, + { status: LCSStatus.DELETED, prev: 1 }, + { status: LCSStatus.EQUAL, prev: 2, curr: 1 }, + ]); + }); + + it("reports a replacement", () => { + expect(myersDiff(tok(["a", "b", "c"]), tok(["a", "x", "c"]))).toStrictEqual( + [ + { status: LCSStatus.EQUAL, prev: 0, curr: 0 }, + { status: LCSStatus.DELETED, prev: 1 }, + { status: LCSStatus.ADDED, curr: 1 }, + { status: LCSStatus.EQUAL, prev: 2, curr: 2 }, + ], + ); + }); + + it("keeps a common prefix and suffix equal", () => { + expect( + myersDiff(tok(["a", "b", "c", "d"]), tok(["a", "x", "y", "d"])), + ).toStrictEqual([ + { status: LCSStatus.EQUAL, prev: 0, curr: 0 }, + { status: LCSStatus.DELETED, prev: 1 }, + { status: LCSStatus.DELETED, prev: 2 }, + { status: LCSStatus.ADDED, curr: 1 }, + { status: LCSStatus.ADDED, curr: 2 }, + { status: LCSStatus.EQUAL, prev: 3, curr: 3 }, + ]); + }); + + it("handles two lists with nothing in common", () => { + expect(myersDiff(tok(["a", "b"]), tok(["x", "y"]))).toStrictEqual([ + { status: LCSStatus.DELETED, prev: 0 }, + { status: LCSStatus.DELETED, prev: 1 }, + { status: LCSStatus.ADDED, curr: 0 }, + { status: LCSStatus.ADDED, curr: 1 }, + ]); + }); + + it("handles a repeated token", () => { + expect(myersDiff(tok(["a", "a", "b"]), tok(["a", "b"]))).toStrictEqual([ + { status: LCSStatus.EQUAL, prev: 0, curr: 0 }, + { status: LCSStatus.DELETED, prev: 1 }, + { status: LCSStatus.EQUAL, prev: 2, curr: 1 }, + ]); + }); + + it("compares on normalizedValue, not on value", () => { + const previous: Token[] = [ + { value: "Foo", normalizedValue: "foo", index: 0 }, + ]; + const current: Token[] = [ + { value: "FOO", normalizedValue: "foo", index: 0 }, + ]; + + expect(myersDiff(previous, current)).toStrictEqual([ + { status: LCSStatus.EQUAL, prev: 0, curr: 0 }, + ]); + }); + + it("produces an edit script that rebuilds both token lists", () => { + let seed = 99; + const rnd = () => + (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; + const alphabet = "abcde"; + const make = () => { + const size = Math.floor(rnd() * 12); + const values: string[] = []; + for (let i = 0; i < size; i++) { + values.push(alphabet[Math.floor(rnd() * alphabet.length)]); + } + return tok(values); + }; + + for (let i = 0; i < 500; i++) { + const previous = make(); + const current = make(); + const edits = myersDiff(previous, current); + + let rebuiltPrevious = ""; + let rebuiltCurrent = ""; + for (const edit of edits) { + if (edit.status === LCSStatus.EQUAL) { + rebuiltPrevious += previous[edit.prev].value; + rebuiltCurrent += current[edit.curr].value; + } else if (edit.status === LCSStatus.ADDED) { + rebuiltCurrent += current[edit.curr].value; + } else { + rebuiltPrevious += previous[edit.prev].value; + } + } + + expect(rebuiltPrevious).toBe(previous.map((t) => t.value).join("")); + expect(rebuiltCurrent).toBe(current.map((t) => t.value).join("")); + } + }); + + it("walks indexes forward without repeating or skipping", () => { + const previous = tok(["a", "b", "c", "d", "e"]); + const current = tok(["a", "x", "c", "y", "e"]); + const edits = myersDiff(previous, current); + + const prevIndexes: number[] = []; + const currIndexes: number[] = []; + for (const edit of edits) { + if (edit.status !== LCSStatus.ADDED) prevIndexes.push(edit.prev); + if (edit.status !== LCSStatus.DELETED) currIndexes.push(edit.curr); + } + + expect(prevIndexes).toStrictEqual([0, 1, 2, 3, 4]); + expect(currIndexes).toStrictEqual([0, 1, 2, 3, 4]); + }); + + it("stays linear on a long identical list", () => { + const values = Array.from({ length: 5000 }, (_unused, i) => `t${i}`); + const edits = myersDiff(tok(values), tok(values)); + + expect(edits).toHaveLength(5000); + expect(edits.every((edit) => edit.status === LCSStatus.EQUAL)).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index 648e741..4824cd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,10 @@ export { getListDiff } from "./lib/list-diff"; export { getObjectDiff } from "./lib/object-diff"; export { getTextDiff } from "./lib/text-diff"; export { getGeoDiff } from "./lib/geo-diff"; +export { getCodeDiff } from "./lib/code-diff"; export * from "./models/list"; export * from "./models/object"; export * from "./models/stream"; export * from "./models/text"; export * from "./models/geo"; +export * from "./models/code"; diff --git a/src/lib/code-diff/code-diff.test.ts b/src/lib/code-diff/code-diff.test.ts new file mode 100644 index 0000000..86cdec2 --- /dev/null +++ b/src/lib/code-diff/code-diff.test.ts @@ -0,0 +1,1123 @@ +import { getCodeDiff } from "@lib/code-diff"; +import { CodeDiff } from "@models/code"; + +function rebuild(diff: CodeDiff): { previous: string; current: string } { + const previous: string[] = []; + const current: string[] = []; + for (const line of diff.diff) { + switch (line.status) { + case "equal": + previous.push(line.previousValue ?? line.value); + current.push(line.value); + break; + case "updated": + previous.push(line.previousValue ?? ""); + current.push(line.value); + break; + case "deleted": + previous.push(line.value); + break; + case "added": + current.push(line.value); + break; + } + } + return { previous: previous.join("\n"), current: current.join("\n") }; +} + +describe("getCodeDiff - general", () => { + it("returns equal for two empty codes", () => { + expect(getCodeDiff(null, null)).toStrictEqual({ + type: "code", + status: "equal", + diff: [], + }); + }); + + it("marks every line added when there is no previous code", () => { + expect(getCodeDiff(null, "const a = 1;\nconst b = 2;")).toStrictEqual({ + type: "code", + status: "added", + diff: [ + { + value: "const a = 1;", + line: 1, + previousLine: null, + status: "added", + }, + { + value: "const b = 2;", + line: 2, + previousLine: null, + status: "added", + }, + ], + }); + }); + + it("marks every line deleted when there is no current code", () => { + expect(getCodeDiff("const a = 1;\nconst b = 2;", null)).toStrictEqual({ + type: "code", + status: "deleted", + diff: [ + { + value: "const a = 1;", + line: null, + previousLine: 1, + status: "deleted", + }, + { + value: "const b = 2;", + line: null, + previousLine: 2, + status: "deleted", + }, + ], + }); + }); + + it("returns equal for identical code", () => { + expect(getCodeDiff("const a = 1;", "const a = 1;")).toStrictEqual({ + type: "code", + status: "equal", + diff: [ + { + value: "const a = 1;", + previousValue: "const a = 1;", + line: 1, + previousLine: 1, + status: "equal", + }, + ], + }); + }); +}); + +describe("getCodeDiff - lines", () => { + it("numbers the lines of a mixed change", () => { + expect( + getCodeDiff("a();\nb();\nc();", "a();\nb2();\nc();\nd();"), + ).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "a();", + previousValue: "a();", + line: 1, + previousLine: 1, + status: "equal", + }, + { + value: "b2();", + previousValue: "b();", + line: 2, + previousLine: 2, + status: "updated", + diff: [ + { value: "b2", previousValue: "b", status: "updated" }, + { value: "(", status: "equal" }, + { value: ")", status: "equal" }, + { value: ";", status: "equal" }, + ], + }, + { + value: "c();", + previousValue: "c();", + line: 3, + previousLine: 3, + status: "equal", + }, + { + value: "d();", + line: 4, + previousLine: null, + status: "added", + }, + ], + }); + }); + + it("reports an inserted line without touching its neighbours", () => { + expect(getCodeDiff("a();\nc();", "a();\nb();\nc();")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "a();", + previousValue: "a();", + line: 1, + previousLine: 1, + status: "equal", + }, + { + value: "b();", + line: 2, + previousLine: null, + status: "added", + }, + { + value: "c();", + previousValue: "c();", + line: 3, + previousLine: 2, + status: "equal", + }, + ], + }); + }); + + it("reports a deleted line", () => { + expect(getCodeDiff("a();\nb();\nc();", "a();\nc();")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "a();", + previousValue: "a();", + line: 1, + previousLine: 1, + status: "equal", + }, + { + value: "b();", + line: null, + previousLine: 2, + status: "deleted", + }, + { + value: "c();", + previousValue: "c();", + line: 2, + previousLine: 3, + status: "equal", + }, + ], + }); + }); + + it("reports a line ending change", () => { + expect(getCodeDiff("a\r\nb", "a\nb")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "a\r", + line: null, + previousLine: 1, + status: "deleted", + }, + { + value: "a", + line: 1, + previousLine: null, + status: "added", + }, + { + value: "b", + previousValue: "b", + line: 2, + previousLine: 2, + status: "equal", + }, + ], + }); + }); +}); + +describe("getCodeDiff - tokens", () => { + it("reports the changed tokens of an updated line", () => { + expect(getCodeDiff("foo(a).bar;", "foo(b).bar;")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "foo(b).bar;", + previousValue: "foo(a).bar;", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "foo", status: "equal" }, + { value: "(", status: "equal" }, + { value: "b", previousValue: "a", status: "updated" }, + { value: ")", status: "equal" }, + { value: ".", status: "equal" }, + { value: "bar", status: "equal" }, + { value: ";", status: "equal" }, + ], + }, + ], + }); + }); +}); + +describe("getCodeDiff - deletions inside a line", () => { + it("reports a removed argument while the line stays", () => { + expect(getCodeDiff("foo(a, b);", "foo(a);")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "foo(a);", + previousValue: "foo(a, b);", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "foo", status: "equal" }, + { value: "(", status: "equal" }, + { value: "a", status: "equal" }, + { value: ",", status: "deleted" }, + { value: " b", status: "deleted" }, + { value: ")", status: "equal" }, + { value: ";", status: "equal" }, + ], + }, + ], + }); + }); + + it("reports a removed trailing comment while the line stays", () => { + expect(getCodeDiff("const x = 1; // note", "const x = 1;")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "const x = 1;", + previousValue: "const x = 1; // note", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "const", status: "equal" }, + { value: " x", status: "equal" }, + { value: " =", status: "equal" }, + { value: " 1", status: "equal" }, + { value: ";", status: "equal" }, + { value: " /", status: "deleted" }, + { value: "/", status: "deleted" }, + { value: " note", status: "deleted" }, + ], + }, + ], + }); + }); + + it("keeps the neighbouring lines equal", () => { + expect( + getCodeDiff("a();\nfoo(a, b);\nc();", "a();\nfoo(a);\nc();"), + ).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "a();", + previousValue: "a();", + line: 1, + previousLine: 1, + status: "equal", + }, + { + value: "foo(a);", + previousValue: "foo(a, b);", + line: 2, + previousLine: 2, + status: "updated", + diff: [ + { value: "foo", status: "equal" }, + { value: "(", status: "equal" }, + { value: "a", status: "equal" }, + { value: ",", status: "deleted" }, + { value: " b", status: "deleted" }, + { value: ")", status: "equal" }, + { value: ";", status: "equal" }, + ], + }, + { + value: "c();", + previousValue: "c();", + line: 3, + previousLine: 3, + status: "equal", + }, + ], + }); + }); +}); + +describe("getCodeDiff - whitespace", () => { + it("reports a re-indentation", () => { + expect(getCodeDiff(" return x;", " return x;")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: " return x;", + previousValue: " return x;", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { + value: " return", + previousValue: " return", + status: "updated", + }, + { value: " x", status: "equal" }, + { value: ";", status: "equal" }, + ], + }, + ], + }); + }); + + it("reports tabs converted to spaces", () => { + expect(getCodeDiff("\tif (a) {", " if (a) {")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: " if (a) {", + previousValue: "\tif (a) {", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: " if", previousValue: "\tif", status: "updated" }, + { value: " (", status: "equal" }, + { value: "a", status: "equal" }, + { value: ")", status: "equal" }, + { value: " {", status: "equal" }, + ], + }, + ], + }); + }); +}); + +describe("getCodeDiff - languages", () => { + it("python - added default argument", () => { + expect( + getCodeDiff("def load(path, mode):", "def load(path, mode='r'):"), + ).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "def load(path, mode='r'):", + previousValue: "def load(path, mode):", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "def", status: "equal" }, + { value: " load", status: "equal" }, + { value: "(", status: "equal" }, + { value: "path", status: "equal" }, + { value: ",", status: "equal" }, + { value: " mode", status: "equal" }, + { value: "=", status: "added" }, + { value: "'", status: "added" }, + { value: "r", status: "added" }, + { value: "'", status: "added" }, + { value: ")", status: "equal" }, + { value: ":", status: "equal" }, + ], + }, + ], + }); + }); + + it("css - hex colour", () => { + expect( + getCodeDiff(".btn { color: #fff; }", ".btn { color: #000; }"), + ).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: ".btn { color: #000; }", + previousValue: ".btn { color: #fff; }", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: ".", status: "equal" }, + { value: "btn", status: "equal" }, + { value: " {", status: "equal" }, + { value: " color", status: "equal" }, + { value: ":", status: "equal" }, + { value: " #", status: "equal" }, + { value: "000", previousValue: "fff", status: "updated" }, + { value: ";", status: "equal" }, + { value: " }", status: "equal" }, + ], + }, + ], + }); + }); + + it("chinese - identifier kept whole", () => { + expect( + getCodeDiff( + "const \u7528\u6237\u540d = '\u5f20\u4e09';", + "const \u7528\u6237\u540d = '\u674e\u56db';", + ), + ).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "const \u7528\u6237\u540d = '\u674e\u56db';", + previousValue: "const \u7528\u6237\u540d = '\u5f20\u4e09';", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "const", status: "equal" }, + { value: " \u7528\u6237\u540d", status: "equal" }, + { value: " =", status: "equal" }, + { value: " '", status: "equal" }, + { + value: "\u674e\u56db", + previousValue: "\u5f20\u4e09", + status: "updated", + }, + { value: "'", status: "equal" }, + { value: ";", status: "equal" }, + ], + }, + ], + }); + }); +}); + +describe("getCodeDiff - line pairing", () => { + it("pairs a rewritten line with its replacement, not with its neighbour", () => { + const previous = ["let sum = 0;", "return sum;"].join("\n"); + const current = [ + "let sum = 0;", + "const tax = sum * rate;", + "return sum + tax;", + ].join("\n"); + + const diff = getCodeDiff(previous, current); + + expect( + diff.diff.map((line) => [ + line.status, + line.previousValue ?? null, + line.value, + ]), + ).toStrictEqual([ + ["equal", "let sum = 0;", "let sum = 0;"], + ["added", null, "const tax = sum * rate;"], + ["updated", "return sum;", "return sum + tax;"], + ]); + }); + + it("reports unrelated lines as a deletion and an insertion", () => { + const diff = getCodeDiff("const a = 1;", "import x from 'y';"); + + expect(diff.diff.map((line) => line.status)).toStrictEqual([ + "deleted", + "added", + ]); + }); +}); + +describe("getCodeDiff - unicode safety", () => { + const GRAPHEMES: [string, string][] = [ + ["decomposed accent", "cafe\u0301"], + ["devanagari", "\u0915\u094D\u0937\u0924\u094D\u0930\u093F\u092F"], + ["hebrew with niqqud", "\u05E9\u05B8\u05C1\u05DC\u05D5\u05B9\u05DD"], + ["zwj family", "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}"], + ["flag", "\u{1F1EB}\u{1F1F7}"], + ["skin tone", "\u{1F44D}\u{1F3FD}"], + ["keycap", "1\uFE0F\u20E3"], + ]; + + it.each(GRAPHEMES)("keeps %s in a single token", (_name, code) => { + const diff = getCodeDiff(code, code + ";"); + const tokens = diff.diff[0].diff ?? []; + + expect(tokens.map((token) => token.value)).toStrictEqual([code, ";"]); + }); + + it("never splits a grapheme cluster across a random corpus", () => { + const SCRIPTS = [ + "abcXYZ_$", + "0123", + "(){}[];:.,=+-*/<>!?&|^~@#%'\"`\\", + "cafe\u0301", + "\u4E2D\u6587", + "\u65E5\u672C\u8A9E", + "\u0645\u0631\u062D\u0628\u0627", + "\u0915\u094D\u0937\u0924\u094D\u0930\u093F\u092F", + "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}", + "\u{1F1EB}\u{1F1F7}", + "1\uFE0F\u20E3", + "\u{1F600}", + " ", + "\t", + " ", + ]; + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); + + let seed = 12345; + const rnd = () => + (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; + + for (let i = 0; i < 500; i++) { + let code = ""; + const parts = Math.floor(rnd() * 10); + for (let j = 0; j < parts; j++) { + code += SCRIPTS[Math.floor(rnd() * SCRIPTS.length)]; + } + // A shared prefix keeps the two lines similar enough to be paired, so + // there is a token level diff to inspect. + const previous = "const a = " + code; + const current = "const b = " + code; + + const tokens = getCodeDiff(previous, current).diff[0].diff ?? []; + const values = tokens.map((token) => token.value); + expect(values.join("")).toBe(current); + + const boundaries = new Set([0]); + let end = 0; + for (const grapheme of segmenter.segment(current)) { + end += grapheme.segment.length; + boundaries.add(end); + } + + let offset = 0; + for (const value of values) { + offset += value.length; + expect(boundaries.has(offset)).toBe(true); + } + } + }); +}); + +describe("getCodeDiff - large code snippet", () => { + const PREVIOUS = [ + "import { useState, useEffect } from 'react';", + "import { fetchPosts } from '../api/posts';", + "", + "const MAX_LENGTH = 280;", + "", + "export function PostList({ userId, isSocialPost }) {", + " const [posts, setPosts] = useState([]);", + " const [loading, setLoading] = useState(true);", + "", + " useEffect(() => {", + " let cancelled = false;", + " fetchPosts(userId).then((result) => {", + " if (!cancelled) {", + " setPosts(result.items);", + " setLoading(false);", + " }", + " });", + " return () => {", + " cancelled = true;", + " };", + " }, [userId]);", + "", + " if (loading) {", + " return ;", + " }", + "", + " return (", + "
    ", + " {posts.map((post) => {", + " const hitSentence = post.body;", + " const showMoreButton = isSocialPost && hitSentence?.length > MAX_LENGTH;", + " return
  • {hitSentence}
  • ;", + " })}", + "
", + " );", + "}", + ].join("\n"); + + const CURRENT = [ + "import { useState, useEffect, useMemo } from 'react';", + "import { fetchPosts } from '../api/posts';", + "", + "const SOCIAL_TEXT_MAX_LENGTH = 320;", + "", + "export function PostList({ userId, isYoutubePost }) {", + " const [posts, setPosts] = useState([]);", + " const [loading, setLoading] = useState(true);", + "", + " useEffect(() => {", + " let cancelled = false;", + " fetchPosts(userId).then((result) => {", + " if (!cancelled) {", + " setPosts(result.items);", + " setLoading(false);", + " }", + " });", + " return () => {", + " cancelled = true;", + " };", + " }, [userId]);", + "", + " if (loading) {", + " return ;", + " }", + "", + " return (", + "
    ", + " {posts.map((post) => {", + " const hitSentence = post.body;", + " const showMoreButton = isYoutubePost && hitSentence?.length > SOCIAL_TEXT_MAX_LENGTH;", + " return
  • {hitSentence}
  • ;", + " })}", + "
", + " );", + "}", + ].join("\n"); + + const diff = getCodeDiff(PREVIOUS, CURRENT); + + it("rebuilds both files exactly", () => { + expect(rebuild(diff)).toEqual({ previous: PREVIOUS, current: CURRENT }); + }); + + it("reports the file as updated", () => { + expect(diff.status).toBe("updated"); + }); + + it("leaves every untouched line equal", () => { + const changed = diff.diff.filter((line) => line.status !== "equal"); + + expect(changed).toHaveLength(5); + expect(changed.every((line) => line.status === "updated")).toBe(true); + }); + + it("numbers every line of both files", () => { + expect(diff.diff).toHaveLength(PREVIOUS.split("\n").length); + expect(diff.diff.map((line) => line.line)).toStrictEqual( + diff.diff.map((_unused, i) => i + 1), + ); + expect(diff.diff.map((line) => line.previousLine)).toStrictEqual( + diff.diff.map((_unused, i) => i + 1), + ); + }); + + it("highlights only the renamed identifiers inside the changed lines", () => { + const showMore = diff.diff.find((line) => + line.value.includes("showMoreButton"), + ); + + expect(showMore?.status).toBe("updated"); + expect( + (showMore?.diff ?? []) + .filter((token) => token.status !== "equal") + .map((token) => [token.previousValue, token.value]), + ).toStrictEqual([ + [" isSocialPost", " isYoutubePost"], + [" MAX_LENGTH", " SOCIAL_TEXT_MAX_LENGTH"], + ]); + }); + + it("reports the added import without rewriting the line", () => { + const importLine = diff.diff[0]; + const added = (importLine.diff ?? []).filter( + (token) => token.status === "added", + ); + + expect(importLine.status).toBe("updated"); + expect(added.map((token) => token.value)).toStrictEqual([",", " useMemo"]); + }); +}); + +describe("getCodeDiff - full output", () => { + it("shows every line status in one diff", () => { + expect( + getCodeDiff( + "function total(items) {\n let sum = 0;\n console.log(sum);\n return sum;\n}", + "function total(items, rate) {\n let sum = 0;\n const tax = sum * rate;\n return sum + tax;\n}", + ), + ).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "function total(items, rate) {", + previousValue: "function total(items) {", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "function", status: "equal" }, + { value: " total", status: "equal" }, + { value: "(", status: "equal" }, + { value: "items", status: "equal" }, + { value: ",", status: "added" }, + { value: " rate", status: "added" }, + { value: ")", status: "equal" }, + { value: " {", status: "equal" }, + ], + }, + { + value: " let sum = 0;", + previousValue: " let sum = 0;", + line: 2, + previousLine: 2, + status: "equal", + }, + { + value: " console.log(sum);", + line: null, + previousLine: 3, + status: "deleted", + }, + { + value: " const tax = sum * rate;", + line: 3, + previousLine: null, + status: "added", + }, + { + value: " return sum + tax;", + previousValue: " return sum;", + line: 4, + previousLine: 4, + status: "updated", + diff: [ + { value: " return", status: "equal" }, + { value: " sum", status: "equal" }, + { value: " +", status: "added" }, + { value: " tax", status: "added" }, + { value: ";", status: "equal" }, + ], + }, + { + value: "}", + previousValue: "}", + line: 5, + previousLine: 5, + status: "equal", + }, + ], + }); + }); + + it("javascript", () => { + expect(getCodeDiff("foo(a).bar = 1;", "foo(b).bar = 2;")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "foo(b).bar = 2;", + previousValue: "foo(a).bar = 1;", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "foo", status: "equal" }, + { value: "(", status: "equal" }, + { value: "b", previousValue: "a", status: "updated" }, + { value: ")", status: "equal" }, + { value: ".", status: "equal" }, + { value: "bar", status: "equal" }, + { value: " =", status: "equal" }, + { value: " 2", previousValue: " 1", status: "updated" }, + { value: ";", status: "equal" }, + ], + }, + ], + }); + }); + + it("clojure", () => { + expect(getCodeDiff("(set! foo-bar 1)", "(set! foo-baz 2)")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "(set! foo-baz 2)", + previousValue: "(set! foo-bar 1)", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "(", status: "equal" }, + { value: "set", status: "equal" }, + { value: "!", status: "equal" }, + { value: " foo", status: "equal" }, + { value: "-", status: "equal" }, + { value: "bar", status: "deleted" }, + { value: "baz", previousValue: " 1", status: "updated" }, + { value: " 2", status: "added" }, + { value: ")", status: "equal" }, + ], + }, + ], + }); + }); + + it("ruby", () => { + expect(getCodeDiff("@x.nil? && y!", "@x.present? || y!")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "@x.present? || y!", + previousValue: "@x.nil? && y!", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "@", status: "equal" }, + { value: "x", status: "equal" }, + { value: ".", status: "equal" }, + { value: "present", previousValue: "nil", status: "updated" }, + { value: "?", status: "equal" }, + { value: " &", status: "deleted" }, + { value: " |", previousValue: "&", status: "updated" }, + { value: "|", status: "added" }, + { value: " y", status: "equal" }, + { value: "!", status: "equal" }, + ], + }, + ], + }); + }); + + it("css", () => { + expect(getCodeDiff(".a--b{color:#fff}", ".a--c{color:#000}")).toStrictEqual( + { + type: "code", + status: "updated", + diff: [ + { + value: ".a--c{color:#000}", + previousValue: ".a--b{color:#fff}", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: ".", status: "equal" }, + { value: "a", status: "equal" }, + { value: "-", status: "equal" }, + { value: "-", status: "equal" }, + { value: "c", previousValue: "b", status: "updated" }, + { value: "{", status: "equal" }, + { value: "color", status: "equal" }, + { value: ":", status: "equal" }, + { value: "#", status: "equal" }, + { value: "000", previousValue: "fff", status: "updated" }, + { value: "}", status: "equal" }, + ], + }, + ], + }, + ); + }); + + it("sql", () => { + expect( + getCodeDiff( + "SELECT * FROM t WHERE a <> 'b';", + "SELECT id FROM t WHERE a = 'c';", + ), + ).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "SELECT id FROM t WHERE a = 'c';", + previousValue: "SELECT * FROM t WHERE a <> 'b';", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "SELECT", status: "equal" }, + { value: " id", previousValue: " *", status: "updated" }, + { value: " FROM", status: "equal" }, + { value: " t", status: "equal" }, + { value: " WHERE", status: "equal" }, + { value: " a", status: "equal" }, + { value: " <", status: "deleted" }, + { value: " =", previousValue: ">", status: "updated" }, + { value: " '", status: "equal" }, + { value: "c", previousValue: "b", status: "updated" }, + { value: "'", status: "equal" }, + { value: ";", status: "equal" }, + ], + }, + ], + }); + }); + + it("php", () => { + expect( + getCodeDiff("$x = @$y['k'] ?? 'd';", "$x = @$z['k'] ?? 'e';"), + ).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "$x = @$z['k'] ?? 'e';", + previousValue: "$x = @$y['k'] ?? 'd';", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: "$x", status: "equal" }, + { value: " =", status: "equal" }, + { value: " @", status: "equal" }, + { value: "$z", previousValue: "$y", status: "updated" }, + { value: "[", status: "equal" }, + { value: "'", status: "equal" }, + { value: "k", status: "equal" }, + { value: "'", status: "equal" }, + { value: "]", status: "equal" }, + { value: " ?", status: "equal" }, + { value: "?", status: "equal" }, + { value: " '", status: "equal" }, + { value: "e", previousValue: "d", status: "updated" }, + { value: "'", status: "equal" }, + { value: ";", status: "equal" }, + ], + }, + ], + }); + }); + + it("tabs to spaces", () => { + expect( + getCodeDiff( + "\tif (a) {\n\t\treturn;\n\t}", + " if (b) {\n return;\n }", + ), + ).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: " if (b) {", + previousValue: "\tif (a) {", + line: 1, + previousLine: 1, + status: "updated", + diff: [ + { value: " if", previousValue: "\tif", status: "updated" }, + { value: " (", status: "equal" }, + { value: "b", previousValue: "a", status: "updated" }, + { value: ")", status: "equal" }, + { value: " {", status: "equal" }, + ], + }, + { + value: " return;", + previousValue: "\t\treturn;", + line: 2, + previousLine: 2, + status: "updated", + diff: [ + { + value: " return", + previousValue: "\t\treturn", + status: "updated", + }, + { value: ";", status: "equal" }, + ], + }, + { + value: "\t}", + line: null, + previousLine: 3, + status: "deleted", + }, + { + value: " }", + line: 3, + previousLine: null, + status: "added", + }, + ], + }); + }); + + it("blank lines removed", () => { + expect(getCodeDiff("a\n\n\nb", "a\n\nb")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "a", + previousValue: "a", + line: 1, + previousLine: 1, + status: "equal", + }, + { + value: "", + previousValue: "", + line: 2, + previousLine: 2, + status: "equal", + }, + { + value: "", + line: null, + previousLine: 3, + status: "deleted", + }, + { + value: "b", + previousValue: "b", + line: 3, + previousLine: 4, + status: "equal", + }, + ], + }); + }); + + it("whitespace only", () => { + expect(getCodeDiff(" ", " ")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: " ", + line: null, + previousLine: 1, + status: "deleted", + }, + { + value: " ", + line: 1, + previousLine: null, + status: "added", + }, + ], + }); + }); + + it("trailing whitespace removed", () => { + expect(getCodeDiff("trailing ", "trailing")).toStrictEqual({ + type: "code", + status: "updated", + diff: [ + { + value: "trailing ", + line: null, + previousLine: 1, + status: "deleted", + }, + { + value: "trailing", + line: 1, + previousLine: null, + status: "added", + }, + ], + }); + }); +}); diff --git a/src/lib/code-diff/diff.ts b/src/lib/code-diff/diff.ts new file mode 100644 index 0000000..a5c09e9 --- /dev/null +++ b/src/lib/code-diff/diff.ts @@ -0,0 +1,132 @@ +import { myersDiff } from "@core/myers"; +import { + CodeDiff, + CodeLineDiff, + CodeStatus, + CodeTokenDiff, +} from "@models/code"; +import { LCSStatus, Token } from "@models/lcs"; +import { getLCSTokenDiff } from "./lcs"; +import { tokenizeLines } from "./lines"; +import { getLinePairs } from "./pair"; +import { tokenizeCode } from "./tokenize"; +import { getDiffStatus } from "./utils"; + +function getTokenDiff(previousLine: string, currentLine: string) { + return getLCSTokenDiff(tokenizeCode(previousLine), tokenizeCode(currentLine)); +} + +function getLineDiff( + value: string, + previousValue: string | undefined, + line: number | null, + previousLine: number | null, + status: CodeStatus, + diff?: CodeTokenDiff[], +): CodeLineDiff { + const entry: CodeLineDiff = { value, line, previousLine, status }; + if (previousValue !== undefined) entry.previousValue = previousValue; + if (diff) entry.diff = diff; + return entry; +} + +export function getCodeLinesDiff( + previousCode: string, + currentCode: string, +): CodeDiff { + const previousLines = tokenizeLines(previousCode); + const currentLines = tokenizeLines(currentCode); + const edits = myersDiff(previousLines, currentLines); + + const diff: CodeLineDiff[] = []; + const statusSet = new Set(); + + let i = 0; + while (i < edits.length) { + const edit = edits[i]; + + if (edit.status === LCSStatus.EQUAL) { + diff.push( + getLineDiff( + currentLines[edit.curr].value, + previousLines[edit.prev].value, + edit.curr + 1, + edit.prev + 1, + CodeStatus.EQUAL, + ), + ); + statusSet.add(CodeStatus.EQUAL); + i++; + continue; + } + + const removed: Token[] = []; + const added: Token[] = []; + while (i < edits.length && edits[i].status !== LCSStatus.EQUAL) { + const blockEdit = edits[i]; + if (blockEdit.status === LCSStatus.DELETED) { + removed.push(previousLines[blockEdit.prev]); + } else if (blockEdit.status === LCSStatus.ADDED) { + added.push(currentLines[blockEdit.curr]); + } + i++; + } + + const pairs = getLinePairs(removed, added); + let removedIndex = 0; + let addedIndex = 0; + + const pushDeletedLine = (line: Token) => { + diff.push( + getLineDiff( + line.value, + undefined, + null, + line.index + 1, + CodeStatus.DELETED, + ), + ); + statusSet.add(CodeStatus.DELETED); + }; + const pushAddedLine = (line: Token) => { + diff.push( + getLineDiff( + line.value, + undefined, + line.index + 1, + null, + CodeStatus.ADDED, + ), + ); + statusSet.add(CodeStatus.ADDED); + }; + + for (const pair of pairs) { + while (removedIndex < pair.removed) + pushDeletedLine(removed[removedIndex++]); + while (addedIndex < pair.added) pushAddedLine(added[addedIndex++]); + + const removedLine = removed[pair.removed]; + const addedLine = added[pair.added]; + diff.push( + getLineDiff( + addedLine.value, + removedLine.value, + addedLine.index + 1, + removedLine.index + 1, + CodeStatus.UPDATED, + getTokenDiff(removedLine.value, addedLine.value), + ), + ); + statusSet.add(CodeStatus.UPDATED); + removedIndex = pair.removed + 1; + addedIndex = pair.added + 1; + } + + while (removedIndex < removed.length) + pushDeletedLine(removed[removedIndex++]); + while (addedIndex < added.length) pushAddedLine(added[addedIndex++]); + } + + return { type: "code", status: getDiffStatus(statusSet), diff }; +} diff --git a/src/lib/code-diff/index.ts b/src/lib/code-diff/index.ts new file mode 100644 index 0000000..7d3dacc --- /dev/null +++ b/src/lib/code-diff/index.ts @@ -0,0 +1,41 @@ +import { CodeDiff, CodeStatus } from "@models/code"; +import { getCodeLinesDiff } from "./diff"; +import { tokenizeLines } from "./lines"; + +function getSingleCodeDiff(code: string, status: CodeStatus): CodeDiff { + const isAdded = status === CodeStatus.ADDED; + return { + type: "code", + status, + diff: tokenizeLines(code).map((line) => ({ + value: line.value, + line: isAdded ? line.index + 1 : null, + previousLine: isAdded ? null : line.index + 1, + status, + })), + }; +} + +/** + *Compares two codes and returns a structured diff. + * Lines are diffed first, then the lines that changed are diffed token by token. + * To diff files, read them first: `getCodeDiff(await previous.text(), await current.text())`. + * @param {string | null | undefined} previousCode - The original code. + * @param {string | null | undefined} currentCode - The current code. + * @returns CodeDiff + */ +export function getCodeDiff( + previousCode: string | null | undefined, + currentCode: string | null | undefined, +): CodeDiff { + if (!previousCode && !currentCode) { + return { type: "code", status: CodeStatus.EQUAL, diff: [] }; + } + if (!previousCode) { + return getSingleCodeDiff(currentCode as string, CodeStatus.ADDED); + } + if (!currentCode) { + return getSingleCodeDiff(previousCode as string, CodeStatus.DELETED); + } + return getCodeLinesDiff(previousCode, currentCode); +} diff --git a/src/lib/code-diff/lcs/index.ts b/src/lib/code-diff/lcs/index.ts new file mode 100644 index 0000000..36e5f66 --- /dev/null +++ b/src/lib/code-diff/lcs/index.ts @@ -0,0 +1,48 @@ +import { myersDiff } from "@core/myers"; +import { CodeStatus, CodeTokenDiff } from "@models/code"; +import { LCSStatus, Token } from "@models/lcs"; + +export function getLCSTokenDiff( + previousTokens: Token[], + currentTokens: Token[], +): CodeTokenDiff[] { + const edits = myersDiff(previousTokens, currentTokens); + const diff: CodeTokenDiff[] = []; + + for (let i = 0; i < edits.length; i++) { + const edit = edits[i]; + + if (edit.status === LCSStatus.EQUAL) { + diff.push({ + value: currentTokens[edit.curr].value, + status: CodeStatus.EQUAL, + }); + continue; + } + + if (edit.status === LCSStatus.DELETED) { + const next = edits[i + 1]; + if (next && next.status === LCSStatus.ADDED) { + diff.push({ + value: currentTokens[next.curr].value, + previousValue: previousTokens[edit.prev].value, + status: CodeStatus.UPDATED, + }); + i++; + continue; + } + diff.push({ + value: previousTokens[edit.prev].value, + status: CodeStatus.DELETED, + }); + continue; + } + + diff.push({ + value: currentTokens[edit.curr].value, + status: CodeStatus.ADDED, + }); + } + + return diff; +} diff --git a/src/lib/code-diff/lines.ts b/src/lib/code-diff/lines.ts new file mode 100644 index 0000000..4da2c25 --- /dev/null +++ b/src/lib/code-diff/lines.ts @@ -0,0 +1,8 @@ +import { Token } from "@models/lcs"; + +export const tokenizeLines = (code: string): Token[] => + code.split("\n").map((value, index) => ({ + value, + normalizedValue: value, + index, + })); diff --git a/src/lib/code-diff/pair.ts b/src/lib/code-diff/pair.ts new file mode 100644 index 0000000..ce9028a --- /dev/null +++ b/src/lib/code-diff/pair.ts @@ -0,0 +1,66 @@ +import { Token } from "@models/lcs"; +import { tokenizeCode } from "./tokenize"; + +const MAX_PAIRING_BLOCK = 40; +const MIN_SIMILARITY_SCORE = 0.34; + +export type LinePair = { removed: number; added: number }; + +function getSimilarityScore(previousLine: string, currentLine: string): number { + const previousTokens = tokenizeCode(previousLine); + const currentTokens = tokenizeCode(currentLine); + if (previousTokens.length === 0 && currentTokens.length === 0) return 1; + if (previousTokens.length === 0 || currentTokens.length === 0) return 0; + + const pool = new Map(); + for (const token of previousTokens) { + const key = token.normalizedValue; + pool.set(key, (pool.get(key) || 0) + 1); + } + + let shared = 0; + for (const token of currentTokens) { + const remaining = pool.get(token.normalizedValue); + if (remaining) { + shared++; + pool.set(token.normalizedValue, remaining - 1); + } + } + + return (2 * shared) / (previousTokens.length + currentTokens.length); +} + +export function getLinePairs(removed: Token[], added: Token[]): LinePair[] { + if (removed.length === 0 || added.length === 0) return []; + + if (removed.length * added.length > MAX_PAIRING_BLOCK * MAX_PAIRING_BLOCK) { + const size = Math.min(removed.length, added.length); + return Array.from({ length: size }, (_unused, i) => ({ + removed: i, + added: i, + })); + } + + const scored: { score: number; removed: number; added: number }[] = []; + for (let r = 0; r < removed.length; r++) { + for (let a = 0; a < added.length; a++) { + const score = getSimilarityScore(removed[r].value, added[a].value); + if (score >= MIN_SIMILARITY_SCORE) + scored.push({ score, removed: r, added: a }); + } + } + scored.sort((a, b) => b.score - a.score || a.removed - b.removed); + + const pairs: LinePair[] = []; + for (const candidate of scored) { + const crosses = pairs.some( + (pair) => + (candidate.removed - pair.removed) * (candidate.added - pair.added) <= + 0, + ); + if (crosses) continue; + pairs.push({ removed: candidate.removed, added: candidate.added }); + } + + return pairs.sort((a, b) => a.removed - b.removed); +} diff --git a/src/lib/code-diff/tokenize.ts b/src/lib/code-diff/tokenize.ts new file mode 100644 index 0000000..dd501e9 --- /dev/null +++ b/src/lib/code-diff/tokenize.ts @@ -0,0 +1,46 @@ +import { Token } from "@models/lcs"; + +const CODE_EMOJI = + String.raw`\p{Regional_Indicator}{2}` + + String.raw`|\p{Extended_Pictographic}(?:\p{Emoji_Modifier}|\uFE0F|\u200D\p{Extended_Pictographic})*`; +const CODE_IDENTIFIER = String.raw`[\p{L}\p{N}_$][\p{L}\p{N}\p{M}_$]*`; +const CODE_SYMBOL = String.raw`[^\s\p{L}\p{N}_$\p{M}]\p{M}*`; +const CODE_ORPHAN_MARKS = String.raw`\p{M}+`; +const CODE_TOKEN = new RegExp( + String.raw`\s*(?:${CODE_EMOJI}|${CODE_IDENTIFIER}|${CODE_SYMBOL}|${CODE_ORPHAN_MARKS})`, + "gu", +); + +export const tokenizeCode = (code: string | null | undefined): Token[] => { + const result: Token[] = []; + if (!code) return result; + + const tokens = code.match(CODE_TOKEN) || []; + let matchedLength = 0; + for (let i = 0; i < tokens.length; i++) { + const value = tokens[i]; + matchedLength += value.length; + result.push({ + value, + normalizedValue: value, + index: i, + }); + } + + const trailingWhitespace = code.slice(matchedLength); + if (!trailingWhitespace) return result; + + if (result.length === 0) { + result.push({ + value: trailingWhitespace, + normalizedValue: trailingWhitespace, + index: 0, + }); + return result; + } + + const lastToken = result[result.length - 1]; + lastToken.value += trailingWhitespace; + lastToken.normalizedValue += trailingWhitespace; + return result; +}; diff --git a/src/lib/code-diff/utils/index.ts b/src/lib/code-diff/utils/index.ts new file mode 100644 index 0000000..c20bac2 --- /dev/null +++ b/src/lib/code-diff/utils/index.ts @@ -0,0 +1,18 @@ +import { CodeDiff, CodeStatus } from "@models/code"; + +export function getDiffStatus(statusMap: Set): CodeDiff["status"] { + if (statusMap.has(CodeStatus.UPDATED)) return CodeStatus.UPDATED; + + const isUniqueStatus = (status: CodeStatus) => { + for (const value of statusMap) { + if (value !== status) return false; + } + return true; + }; + + if (statusMap.size === 0) return CodeStatus.EQUAL; + if (isUniqueStatus(CodeStatus.ADDED)) return CodeStatus.ADDED; + if (isUniqueStatus(CodeStatus.DELETED)) return CodeStatus.DELETED; + if (isUniqueStatus(CodeStatus.EQUAL)) return CodeStatus.EQUAL; + return CodeStatus.UPDATED; +} diff --git a/src/lib/text-diff/index.ts b/src/lib/text-diff/index.ts index b7adcbb..dd02c8e 100644 --- a/src/lib/text-diff/index.ts +++ b/src/lib/text-diff/index.ts @@ -5,9 +5,9 @@ import { TextStatus, } from "@models/text"; import { getPositionalTextDiff } from "./positional"; -import { getLCSTextDiff } from "./lcs"; import { tokenizeNormalText } from "./tokenize/normal"; import { tokenizeStrictText } from "./tokenize/strict"; +import { getLCSTextDiff } from "./lcs"; /** *Compares two texts and returns a structured diff at a character, word, or sentence level. diff --git a/src/lib/text-diff/lcs/index.ts b/src/lib/text-diff/lcs/index.ts index 5f3ed11..8aa6775 100644 --- a/src/lib/text-diff/lcs/index.ts +++ b/src/lib/text-diff/lcs/index.ts @@ -1,19 +1,20 @@ -import { TextDiff, TextStatus, TextToken, TextTokenDiff } from "@models/text"; -import { myersDiff } from "./myers"; +import { myersDiff } from "@core/myers"; +import { TextDiff, TextStatus } from "@models/text"; import { getDiffStatus } from "../utils/status"; +import { LCSStatus, Token, TokenDiff } from "@models/lcs"; export function getLCSTextDiff( - previousTokens: TextToken[], - currentTokens: TextToken[], + previousTokens: Token[], + currentTokens: Token[], ): TextDiff { const edits = myersDiff(previousTokens, currentTokens); - const diff: TextTokenDiff[] = []; + const diff: TokenDiff[] = []; const statusSet = new Set(); for (let i = 0; i < edits.length; i++) { const edit = edits[i]; - if (edit.status === TextStatus.EQUAL) { + if (edit.status === LCSStatus.EQUAL) { diff.push({ value: currentTokens[edit.curr].value, index: edit.curr, @@ -23,7 +24,7 @@ export function getLCSTextDiff( statusSet.add(TextStatus.EQUAL); } - if (edit.status === TextStatus.ADDED) { + if (edit.status === LCSStatus.ADDED) { diff.push({ value: currentTokens[edit.curr].value, index: edit.curr, @@ -33,7 +34,7 @@ export function getLCSTextDiff( statusSet.add(TextStatus.ADDED); } - if (edit.status === TextStatus.DELETED) { + if (edit.status === LCSStatus.DELETED) { diff.push({ value: previousTokens[edit.prev].value, index: null, diff --git a/src/lib/text-diff/positional/index.ts b/src/lib/text-diff/positional/index.ts index fe9a4b1..832372f 100644 --- a/src/lib/text-diff/positional/index.ts +++ b/src/lib/text-diff/positional/index.ts @@ -1,14 +1,15 @@ -import { TextDiff, TextToken, TextTokenDiff, TextStatus } from "@models/text"; +import { TextDiff, TextStatus } from "@models/text"; import { getDiffStatus } from "../utils/status"; +import { Token, TokenDiff } from "@models/lcs"; export function getPositionalTextDiff( - previousTokens: TextToken[], - currentTokens: TextToken[], + previousTokens: Token[], + currentTokens: Token[], ): TextDiff { - const previousTokensMap = new Map(); - const addedTokensMap = new Map(); + const previousTokensMap = new Map(); + const addedTokensMap = new Map(); const statusSet = new Set(); - const diff: TextTokenDiff[] = []; + const diff: TokenDiff[] = []; for (let i = 0; i < previousTokens.length; i++) { const token = previousTokens[i]; diff --git a/src/lib/text-diff/tokenize/normal.ts b/src/lib/text-diff/tokenize/normal.ts index 707a84e..55787d0 100644 --- a/src/lib/text-diff/tokenize/normal.ts +++ b/src/lib/text-diff/tokenize/normal.ts @@ -1,9 +1,9 @@ +import { Token } from "@models/lcs"; import { DEFAULT_TEXT_DIFF_OPTIONS, PUNCTUATION_REGEX, TextDiffOptions, TextSeparation, - TextToken, } from "@models/text"; function normalizeToken(token: string, options: TextDiffOptions): string { @@ -33,8 +33,8 @@ function tokenizePreservingWhitespace( text: string, options: TextDiffOptions, separation: TextSeparation, -): TextToken[] { - const result: TextToken[] = []; +): Token[] { + const result: Token[] = []; const tokens = text.match(TOKEN_WITH_LEADING_WHITESPACE[separation]) || []; let matchedLength = 0; @@ -69,9 +69,9 @@ function tokenizePreservingWhitespace( export const tokenizeNormalText = ( text: string | null | undefined, options: TextDiffOptions = DEFAULT_TEXT_DIFF_OPTIONS, -): TextToken[] => { +): Token[] => { const separation = options.separation || DEFAULT_TEXT_DIFF_OPTIONS.separation; - const result: TextToken[] = []; + const result: Token[] = []; if (!text) return result; if (options.preserveWhitespace) { diff --git a/src/lib/text-diff/tokenize/strict.ts b/src/lib/text-diff/tokenize/strict.ts index f4f0904..8367284 100644 --- a/src/lib/text-diff/tokenize/strict.ts +++ b/src/lib/text-diff/tokenize/strict.ts @@ -1,9 +1,9 @@ +import { Token } from "@models/lcs"; import { DEFAULT_TEXT_DIFF_OPTIONS, EMOJI_SPLIT_REGEX, PUNCTUATION_REGEX, TextDiffOptions, - TextToken, } from "@models/text"; const segmenterCache = new Map(); @@ -35,8 +35,8 @@ function normalizeToken(token: string, options: TextDiffOptions): string { export const tokenizeStrictText = ( text: string | null | undefined, options: TextDiffOptions = DEFAULT_TEXT_DIFF_OPTIONS, -): TextToken[] => { - const result: TextToken[] = []; +): Token[] => { + const result: Token[] = []; if (!text || !text.trim()) return result; const separation = options.separation || DEFAULT_TEXT_DIFF_OPTIONS.separation; diff --git a/src/models/code/index.ts b/src/models/code/index.ts new file mode 100644 index 0000000..9cd8b51 --- /dev/null +++ b/src/models/code/index.ts @@ -0,0 +1,27 @@ +export enum CodeStatus { + ADDED = "added", + DELETED = "deleted", + EQUAL = "equal", + UPDATED = "updated", +} + +export type CodeTokenDiff = { + value: string; + previousValue?: string; + status: `${CodeStatus}`; +}; + +export type CodeLineDiff = { + value: string; + previousValue?: string; + line: number | null; + previousLine: number | null; + status: `${CodeStatus}`; + diff?: CodeTokenDiff[]; +}; + +export type CodeDiff = { + type: "code"; + status: `${CodeStatus}`; + diff: CodeLineDiff[]; +}; diff --git a/src/models/lcs/index.ts b/src/models/lcs/index.ts new file mode 100644 index 0000000..718b044 --- /dev/null +++ b/src/models/lcs/index.ts @@ -0,0 +1,33 @@ +import { CodeStatus } from "@models/code"; +import { TextStatus } from "@models/text"; + +export type Token = { + value: string; + normalizedValue: string; + index: number; +}; + +export type TokenDiff = T extends CodeStatus + ? { + value: string; + previousValue?: string; + status: T; + } + : { + value: string; + index: number | null; + previousValue?: string; + previousIndex: number | null; + status: T; + }; + +export enum LCSStatus { + ADDED = "added", + DELETED = "deleted", + EQUAL = "equal", +} + +export type MyersEdit = + | { status: LCSStatus.EQUAL; prev: number; curr: number } + | { status: LCSStatus.ADDED; curr: number } + | { status: LCSStatus.DELETED; prev: number }; diff --git a/src/models/text/index.ts b/src/models/text/index.ts index b935ea6..963c366 100644 --- a/src/models/text/index.ts +++ b/src/models/text/index.ts @@ -13,20 +13,6 @@ export const DEFAULT_TEXT_DIFF_OPTIONS: TextDiffOptions = { locale: undefined, }; -export type TextToken = { - value: string; - normalizedValue: string; - index: number; -}; - -export type TextTokenDiff = { - value: string; - index: number | null; - previousValue?: string; - previousIndex: number | null; - status: TextStatus; -}; - export enum TextStatus { ADDED = "added", EQUAL = "equal", diff --git a/tsconfig.json b/tsconfig.json index 9588839..12a543a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "allowSyntheticDefaultImports": true, "skipLibCheck": true , "paths": { + "@core/*": ["./src/core/*"], "@lib/*": ["./src/lib/*"], "@mocks/*": ["./src/mocks/*"], "@models/*": ["./src/models/*"]