Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
dist
.DS_Store
*.tsbuildinfo
*.tgz
# Ignore generated worker files
src/lib/stream-list-diff/server/worker/node-worker.cjs
155 changes: 149 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,21 @@

# 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)!

<hr/>

## 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

<hr/>
Expand Down Expand Up @@ -62,6 +63,14 @@

<br/>

![superdiff-get-code-diff-demo](https://raw.githubusercontent.com/DoneDeal0/superdiff/main/assets/get-code-diff-demo.png)

<p align="center">
<sub>Track code changes with <code>getCodeDiff</code></sub>
</p>

<br/>

<hr/>

## ⚔ COMPETITORS
Expand All @@ -72,6 +81,7 @@
| List diff | ✅ | ❌ | ⚠️ | ❌ | ⚠️ |
| Text diff | ✅ | ❌ | ✅ | ✅ | ❌ |
| Coordinates diff | ✅ | ❌ | ❌ | ❌ | ❌ |
| Code diff | ✅ | ❌ | ❌ | ✅ | ❌ |
| Streaming for huge datasets | ✅ | ❌ | ❌ | ❌ | ❌ |
| Move detection | ✅ | ❌ | ❌ | ❌ | ❌ |
| Output refinement | ✅ | ❌ | ❌ | ❌ | ❌ |
Expand Down Expand Up @@ -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 |

<sub>(Superdiff uses its `normal` accuracy settings to match diff's behavior)</sub>

### 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 |

<sub>(diff has no line + token API, so both passes are run; its line pass alone is 66 ms at 10k lines)</sub>

> 👉 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.

<hr/>
Expand Down Expand Up @@ -752,6 +772,129 @@ getTextDiff(

<hr/>

### 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",
},
],
}
```

<hr/>

### getGeoDiff

```js
Expand Down
Binary file added assets/get-code-diff-demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 75 additions & 0 deletions benchmark/code.ts
Original file line number Diff line number Diff line change
@@ -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<string, Item${i}>();`);
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);
}
15 changes: 14 additions & 1 deletion benchmark/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,6 +29,13 @@ runListBench100K();

// Text
runTextBench10KWords();
runTextBench100KWords();
runTextBench10KSentences();
runTextBench100KSentences();

// Code
runCodeBench1K();
runCodeBench10K();
runCodeBench100K();

console.log("\n- BENCHMARK COMPLETE -");
40 changes: 28 additions & 12 deletions benchmark/texts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
3 changes: 2 additions & 1 deletion jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading