From a943535229d06116595dd50fa3867d0540768697 Mon Sep 17 00:00:00 2001 From: krkarma777 Date: Mon, 31 Aug 2026 11:00:53 +0900 Subject: [PATCH] feat: Add diffRanges offset-tuple API --- CHANGELOG.md | 7 ++++- README.md | 13 +++++++-- src/index.ts | 42 ++++++++++++++++++++++++++++ test/ranges.test.ts | 67 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 test/ranges.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 600550e..cb0a3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 , + 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 diff --git a/README.md b/README.md index e7cabe7..480b119 100644 --- a/README.md +++ b/README.md @@ -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?)` diff --git a/src/index.ts b/src/index.ts index 4ea2438..cd92ef4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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[] = []; diff --git a/test/ranges.test.ts b/test/ranges.test.ts new file mode 100644 index 0000000..52a6f64 --- /dev/null +++ b/test/ranges.test.ts @@ -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)}`, + ); + } + } + } +});