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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ All notable changes to this project are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]
## [1.1.0] - 2026-08-31

### Added
- `diffRanges(a, b, options?)`: the diff as `[aStart, aEnd, bStart, bEnd]`
code-unit offset tuples instead of text entries, for editors and
highlighters that slice the originals themselves.
- Hosted demo at <https://krkarma777.github.io/string-difference-finder/>,
deployed from `master` by CI, with controls for the new options.
- `heuristic` option: caps the search cost per subproblem (the git xdiff
strategy) so pathological inputs stay fast — the 8 KB completely-different
worst case drops from ~217 ms to ~8 ms for an edit script ~8% above
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,18 @@ Returns `DiffEntry[]` — the shortest edit script between `a` and `b`.
- `char` — individual code points (surrogate-pair safe)
- `line` — lines with their terminators attached

### `diffTokens(aTokens, bTokens)`
### `diffRanges(a, b, options?)`

Lower-level API: diff two pre-tokenized `string[]` sequences with any tokenization you like.
The same diff as offset tuples instead of text entries — for editors, highlighters, and anyone who wants to slice the originals themselves. Each `[aStart, aEnd, bStart, bEnd]` says `a[aStart, aEnd)` was replaced by `b[bStart, bEnd)` (either side may be empty for pure insertions/deletions; offsets are UTF-16 code units).

```ts
diffRanges('the quick fox', 'the slow fox');
// [[4, 9, 4, 8]] — "quick" → "slow"
```

### `diffTokens(aTokens, bTokens, options?)`

Lower-level API: diff two pre-tokenized `string[]` sequences with any tokenization you like (`options.heuristic` supported).

### `tokenize(text, mode?)`

Expand Down
42 changes: 42 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,48 @@ export function diff(a: string, b: string, options: DiffOptions = {}): DiffEntry
return entries;
}

/**
* A changed region as code-unit offsets into the inputs:
* a[aStart, aEnd) was replaced by b[bStart, bEnd). Either side (but never
* both) may be empty, representing a pure insertion or deletion.
*/
export type DiffRange = [aStart: number, aEnd: number, bStart: number, bEnd: number];

/**
* Computes the changed regions between two strings as offset ranges instead
* of text entries — convenient for editors, highlighters, and any consumer
* that wants to slice the originals itself. Equivalent to projecting the
* entries of diff(a, b, options) onto string offsets.
*/
export function diffRanges(a: string, b: string, options: DiffOptions = {}): DiffRange[] {
const entries = diff(a, b, options);
const ranges: DiffRange[] = [];
let aPos = 0;
let bPos = 0;
let i = 0;
while (i < entries.length) {
const entry = entries[i];
if (entry.operation === 'equal') {
aPos += entry.text.length;
bPos += entry.text.length;
i++;
continue;
}
const aStart = aPos;
const bStart = bPos;
if (entries[i] !== undefined && entries[i].operation === 'delete') {
aPos += entries[i].text.length;
i++;
}
if (entries[i] !== undefined && entries[i].operation === 'insert') {
bPos += entries[i].text.length;
i++;
}
ranges.push([aStart, aPos, bStart, bPos]);
}
return ranges;
}

/** Re-diffs adjacent delete/insert pairs at a finer granularity. */
function refineEntries(entries: DiffEntry[], finerMode: DiffMode, heuristic: boolean): DiffEntry[] {
const out: DiffEntry[] = [];
Expand Down
67 changes: 67 additions & 0 deletions test/ranges.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { diffRanges } from '../src/index.ts';

/** Rebuilds b from a by applying the changed ranges left to right. */
function applyRanges(a: string, b: string, ranges: Array<[number, number, number, number]>): string {
let result = '';
let cursor = 0;
for (const [aStart, aEnd, bStart, bEnd] of ranges) {
result += a.slice(cursor, aStart) + b.slice(bStart, bEnd);
cursor = aEnd;
}
return result + a.slice(cursor);
}

test('diffRanges: known example in code-unit offsets', () => {
assert.deepEqual(diffRanges('the quick fox', 'the slow fox'), [
[4, 9, 4, 8], // "quick" -> "slow"
]);
});

test('diffRanges: identical and empty inputs produce no ranges', () => {
assert.deepEqual(diffRanges('same', 'same'), []);
assert.deepEqual(diffRanges('', ''), []);
});

test('diffRanges: pure insertion and pure deletion', () => {
assert.deepEqual(diffRanges('', 'abc'), [[0, 0, 0, 3]]);
assert.deepEqual(diffRanges('abc', ''), [[0, 3, 0, 0]]);
});

test('diffRanges: ranges are ascending, non-overlapping, and rebuild b', () => {
let state = 20261201 >>> 0;
const rng = () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0x100000000;
};
const pieces = ['a', 'bb', '가나', '😀', ' ', '\n', '.', 'word'];
const make = () => {
const len = Math.floor(rng() * 40);
let s = '';
for (let i = 0; i < len; i++) s += pieces[Math.floor(rng() * pieces.length)];
return s;
};
for (let iter = 0; iter < 200; iter++) {
const a = make();
const b = make();
for (const mode of ['word', 'char', 'line'] as const) {
for (const refine of [false, true]) {
const ranges = diffRanges(a, b, { mode, refine });
let prevA = 0;
let prevB = 0;
for (const [aStart, aEnd, bStart, bEnd] of ranges) {
assert.ok(aStart >= prevA && aEnd >= aStart, `a-range order: ${JSON.stringify(ranges)}`);
assert.ok(bStart >= prevB && bEnd >= bStart, `b-range order: ${JSON.stringify(ranges)}`);
assert.ok(aEnd > aStart || bEnd > bStart, 'no empty-empty ranges');
prevA = aEnd;
prevB = bEnd;
}
assert.equal(
applyRanges(a, b, ranges), b,
`rebuild failed mode=${mode} refine=${refine} a=${JSON.stringify(a)} b=${JSON.stringify(b)}`,
);
}
}
}
});
Loading