@jsonjoy.com/diff
Fast diff algorithms for strings, lines, and binary data. Zero dependencies.
str— character-level string diffing (Myers-style, with common prefix/suffix and overlap optimizations), patch normalization, inversion, and caret-awarediffEditfor text inputs.line— line-level diffing built on top ofstr, with per-line patch aggregation and in-place modification (MIX) detection.lines— token-based line diff: a faster,MIX-free alternative toline, built ontok; returns aline-compatibleLinePatch.bin—Uint8Arraydiffing via a binary-to-string mapping overstr.optimize— optional patch post-processing:coarsen(cost models for fewer or byte-cheaper operations),align(slide edits onto word/line boundaries, losslessly),semantic(coarsen toward human-readable hunks), andshiftDown(place hunk boundaries the way GNUdiffplaces them). Loaded only when imported, sostr-only users don't pay for it.tok— Myers diff over any token sequenceT[](interned to integers), returning run-length operations.word— word-level string diff built ontok; operation boundaries fall on word boundaries. Returns astr-compatible patch.format— the wire formats: turns aLinePatchplus its two line arrays into diff text in all eight styles —normal,unified(-u/-U n),context(-c/-C n),ed(-e),forwardEd(-f),rcs(-n),sideBySide(-y) andifdef(-Dand the--*-formatlittle languages) — each a generator of chunks, so no diff text is ever assembled into one string.limits— opt-in bounds forstr,linesandtok: a deterministicmaxCost, an optional wall-clockdeadline, and ahitLimitflag when the result is no longer guaranteed minimal. Diffing is unbounded unless you pass them.defaultLineCost(n, m)is the calibrated bound for line-sized elements; there is no calibrated bound for character input, and using that one on a largestr.diffcan be slower than passing nothing.
import {str} from '@jsonjoy.com/diff';
const src = 'hello world';
const patch = str.diff(src, 'hello there world');
str.apply(patch, src.length, onInsert, onDelete);
str.src(patch); // 'hello world'
str.dst(patch); // 'hello there world'
Or deep-import a single module:
import * as str from '@jsonjoy.com/diff/lib/str';
Post-improve a patch (opt-in, kept out of str so it is only loaded on demand):
import * as str from '@jsonjoy.com/diff/lib/str';
import {coarsen, byteSizeModel} from '@jsonjoy.com/diff/lib/optimize';
const patch = str.diff(src, dst); // minimal, most operations
coarsen(patch); // fewer operations, larger edit volume
coarsen(patch, byteSizeModel()); // smaller serialized size
import * as str from '@jsonjoy.com/diff/lib/str';
import {align} from '@jsonjoy.com/diff/lib/optimize';
// Move edits onto word/line boundaries without changing what is inserted or deleted.
align(str.diff('The cat.', 'The cow and the cat.'));
// [[0, 'The '], [1, 'cow and the '], [0, 'cat.']]
import * as word from '@jsonjoy.com/diff/lib/word';
word.diff('the cat runs', 'the dog runs');
// [[0, 'the '], [-1, 'cat'], [1, 'dog'], [0, ' runs']]
Write a diff. Lines carry no terminators, so a file that does not end in one is
a flag: diffKeys encodes it for the diff — without it two files differing
only in their final newline compare equal and you get no diff at all — and
srcNoEol/dstNoEol place the \ No newline at end of file marker in the
output. Diff the keys, format the lines:
import * as lines from '@jsonjoy.com/diff/lib/lines';
import {diffKeys, unified} from '@jsonjoy.com/diff/lib/format';
/** 'a\nb' is two lines, the second unterminated. */
const split = (text: string): [lines: string[], noEol: boolean] => {
if (text === '') return [[], false];
const noEol = !text.endsWith('\n');
return [(noEol ? text : text.slice(0, -1)).split('\n'), noEol];
};
const [src, srcNoEol] = split(await readFile('a.txt', 'utf8'));
const [dst, dstNoEol] = split(await readFile('b.txt', 'utf8'));
const patch = lines.diff(diffKeys(src, srcNoEol), diffKeys(dst, dstNoEol));
for (const chunk of unified(src, dst, patch, {oldName: 'a.txt', newName: 'b.txt', srcNoEol, dstNoEol}))
process.stdout.write(chunk);
The other seven styles take the same three arguments; context also takes labels
and a width, ed and rcs carry neither:
import {context, ed, forwardEd, ifdef, normal, rcs, sideBySide} from '@jsonjoy.com/diff/lib/format';
normal(src, dst, patch); // 3a4 / 5d4 / 7,8c6,9
context(src, dst, patch, {context: 1, oldName: 'a.txt', newName: 'b.txt'});
ed(src, dst, patch); // an ed script, bottom-up
forwardEd(src, dst, patch); // the same commands top-down: c2 / d2 3
rcs(src, dst, patch); // d3 1 / a3 2
sideBySide(src, dst, patch, {width: 80}); // two columns with a | / < / > gutter
ifdef(src, dst, patch, {
groupFormat: ['%=', '#ifndef X\n%<#endif /* ! X */\n', '#ifdef X\n%>#endif /* X */\n', '%<%>'],
lineFormat: ['%l\n', '%l\n', '%l\n'],
});
The last two print both files, so unlike every other style they write
something when nothing changed — a caller wanting GNU's exit-code semantics
reads hunks() for the verdict and these for the output.
Read a diff. The style is detected, prose and mail headers around the hunks are
skipped, a multi-file patch splits into one FilePatch each, and a malformed
hunk is reported rather than thrown — so a caller can reject that hunk and
still apply the rest, which is what a .rej file is:
import {parse} from '@jsonjoy.com/diff/lib/format';
for (const file of parse(await readFile('fix.patch', 'utf8'))) {
console.log(file.oldName, '->', file.newName, file.meta?.renameFrom ? '(renamed)' : '');
for (const {code, line, message} of file.errors) console.warn(`line ${line}: ${code}: ${message}`);
for (const hunk of file.hunks) console.log(hunk.oldStart, hunk.oldCount, hunk.lines.length);
}
Names come back exactly as written, a/…b/ prefixes and /dev/null included,
so a -p strip applies to what was on the wire. Two limits are the formats', not
the parser's: an ed script records neither the deleted text (its hunks carry
oldCount and no DEL lines — a blind delete) nor a missing final newline, and
rcs is not read back at all, since a d3 1 names lines whose text the format
never wrote down.