Show people what changed in prose or code, then restore one concern without losing the rest. The core exports
diff and merge: one ES module, plain JSON, no dependencies or top-level state. Optional modules add parser
adapters, HTML, unified patches and selective restore.
import {diff, merge} from 'rapier-diff';
const before = 'We will chek the email.';
const after = 'We will check the email address.';
const {edits, changes} = diff(before, after);
// edits: [{before: [11, 11], after: [11, 12]},
// {before: [22, 22], after: [23, 31]}]
// The two concerns own [0] and [1]: a letter correction and an added word.edits hold the exact source ranges. changes group those edits into concerns a person can read and restore.
A renderer can show a surrounding sentence or section without making that context part of an edit.
diff(before, after, options) returns exactly {edits, changes}. Identical strings return two empty arrays.
All offsets are half-open UTF-16 indices, as used by JavaScript slice; they are not byte offsets.
Each edit is {before: [start, end], after: [start, end]}, with coarse: true when the work limit stopped refinement.
An insert has an empty before; a delete has an empty after. Edits are ordered and disjoint on both sides. Every
unchanged gap, including the prefix and suffix, is equal in both strings. Interleave those gaps with either side's
edit slices to rebuild that original string exactly. CRLF, combined characters and unpaired surrogates are preserved.
A coarse edit remains exact; its range can include unchanged text and need not be a minimal edit.
Every edit index belongs to exactly one change. Changes are ordered by their first owned edit and contain:
| Field | Meaning |
|---|---|
id |
A positive integer, deterministic for the same source, options, evidence, parser configuration and Unicode rules. Keep it with that comparison. |
kind |
insert, delete, replace, move, rename or format. |
unit |
char, word, sentence, line, block, or a parser's part kind, such as section, task, cell or declaration. |
edits |
Indices into the exact edit array. A rename owns its distant occurrences. A move owns equal transported fragments; edits to its wording belong to separate changes. |
name |
Optional part name supplied by structure, such as ## Tasks or save. |
context |
Optional {before: [start, end], after: [start, end]} for display. Context can overlap and never supplies source for reconstruction or restore. |
breaks |
Optional [{at: [start, end], to: name}] identifying formerly known targets absent from the new reference graph. |
affects |
Optional ranges in after whose referenced part changed while the range itself stayed unchanged. |
A typo can be a letter; a word replacement can own several exact fragments; a dense rewrite can be one sentence
concern. Each emitted concern is separately selectable; sentence grouping is a reading heuristic. Read the changed text with
before.slice(...edits[index].before) and after.slice(...edits[index].after).
| Option | Default | Meaning |
|---|---|---|
as |
'auto' |
'prose', 'code', or automatic detection. Prose uses word and sentence presentation; code uses lines and changed words. |
structure |
none | text => {parts, links, meaning?}. Named nested parts and reference links supply structural alignment, rename groups and consequences. A parser failure falls back to text comparison. |
limit |
1e7 |
Counted search/refinement steps. Finite negative values become zero; nonfinite values use the default. Exhaustion keeps exact edits; it can coarsen refinement or stop semantic annotations. |
from |
none | Sequential recorded operations, oldest first: [{pos, removed, inserted}]. Verified evidence preserves the occurrence the host observed. |
The work limit bounds search and refinement. Native string/Unicode operations, allocation and caller parser work still depend on input size; it is not a wall-clock deadline or an out-of-memory guarantee. Use a worker to protect page responsiveness. Completed move and rename groups survive later budget exhaustion; additional grouping or reference annotations may remain incomplete. Nested structural alignment caps depth at 128; flattened part and search collections cap at 131,072.
pos is measured in the string immediately before that operation. Every removed substring must match; replay must
finish at after. The engine condenses cancellations and overwrites into surviving source pieces, then refines only
the touched spans. It does not search the untouched document for another equal occurrence.
const before = 'Same paragraph.\n\nSame paragraph.\n';
const pos = before.lastIndexOf('.');
const from = [{pos, removed: '', inserted: ' revised'}];
const after = before.slice(0, pos) + ' revised' + before.slice(pos);
const result = diff(before, after, {from});A zero search budget still retains valid evidence, with touched edits marked coarse when necessary. Evidence verification has a separate fixed step bound. If that bound is exhausted, the result is a coarse exact comparison; malformed evidence falls back to snapshot comparison. Offsets that cross a combined character expand to safe boundaries. Keep the source strings and the original operation order; an undo is another operation in that sequence.
import {restore, restoreEdits} from 'rapier-diff/restore';
const result = diff(before, after, options);
const restored = restore(before, after, result, [2]);
// Change 2 is put back. Other concerns keep their after values.An empty selection returns after; selecting every id returns before. A grouped rename restores every owned
occurrence. For a recognized move with inner edits, restoring position keeps the new wording at the old position;
restoring wording keeps the section at its new position. Selecting both restores both. Display context is ignored,
even if omitted or widened.
Move recognition is heuristic and uses equal transported fragments to bind both ends of the source extent. Repeated text may remain ordinary edits; if several insertions match one deletion, source order can choose the first eligible match. Wording changes without equal boundary anchors remain ordinary exact edits. Every emitted move retains the exact source fragments needed for independent position and wording restoration.
restoreEdits(before, after, result, ids) returns the same restoration as sequential {pos, removed, inserted}
operations. Hosts can commit those directly without a second diff that might select a different repeated occurrence.
Both functions validate ranges, unchanged gaps, edit ownership and ids, throwing TypeError for malformed input.
A result does not authenticate its source: retain the exact original before and after, bind them to the current
document/revision, and refuse stale restoration before committing operations.
The adapters use a parser you supply. The core has no parser dependency.
import MarkdownIt from 'markdown-it';
import * as acorn from 'acorn';
import {markdown} from 'rapier-diff/markdown';
import {js} from 'rapier-diff/js';
const structure = markdown(new MarkdownIt(), {code: js(acorn)});
const result = diff(before, after, {structure});Configure Markdown plugins before creating the adapter. Ordinary list tokens and checkbox source supply tasks;
footnotes require a parser plugin. Markdown provides sections, paragraphs, lists, items, tasks, tables, rows, cells,
quotes, footnotes, links and images when the configured parser emits them. Its reference graph joins heading links,
footnotes and reference-style links to their definitions. The optional code reader describes fenced code it can
parse; an unsupported fence remains an ordinary block.
The JavaScript adapter describes declarations, scopes, calls, imports, exports and inline JavaScript scripts in HTML.
It connects identifier uses to known bindings. Import/export targets can use module#export; the adapter does not
fetch another file or resolve an entire project. Parts match by name where correspondence is unambiguous; stable
binding keys connect renamed declarations to their uses.
A custom structure supplies source-ordered parts:
{start, end, kind, name?, key?, nameAt?, children?} and links:
{at: [start, end], to, key?, labelAt?}. Child spans must stay inside their parent and refer to the exact source.
nameAt is the definition-name span; key identifies a binding across a rename. Without those fields, some renames
cannot be identified. breaks and affects describe this supplied graph, not an execution trace or whole-program proof.
Names and binding keys can contain ordinal labels. Keep them with the comparison; the engine does not establish
persistent occurrence identity from them.
format is a parser-relative classification: the adapter supplied equal meaning fingerprints for both complete
inputs. It preserves the observations represented by that fingerprint. Plain text and code without equal fingerprints
stay substantive, including Python indentation that moves a statement into another block. A link destination change
remains visible even when its label stays the same.
The Markdown fingerprint retains token structure, destinations, hard breaks, code content and parser environment.
The JavaScript fingerprint retains the parsed tree and comments, excluding source positions and ordinary Literal.raw
spelling. It retains template-element raw text, including untagged templates: tags such as String.raw can observe it
even when cooked values agree. A raw-template escape change therefore stays substantive. HTML outside parsed scripts
stays exact. Ordinary literal spelling is still normalized, so source introspection is outside any promise of equal
behavior. A custom meaning fingerprint must retain the distinctions its host needs. A mixed substantive/formatting
revision stays substantive. Formatting keeps its exact edits and remains restorable; hosts decide how to present it.
const {text, conflicts} = merge(base, personText, agentText, {structure});
// conflicts: [{at: [start, end], theirs: 'the agent’s version'}]Independent source edits combine. Interacting edits form conflict groups, with mine kept for the conflicting
content in text and the alternative in theirs. Each at is a UTF-16 range in the returned text. Apply selected
alternatives from the end backward to keep offsets valid. Parser structure can separate adjacent section, table-cell
or function edits within the same search budget.
With consistent valid structure, a unique named sibling reorder can carry the other branch's internal edits to the
new position. Matching uses kind and name within the corresponding container; all three inputs must retain the
same unique sibling membership. The untouched branch's gap bytes must agree with the base, or both reordered
branches must agree on their order and gaps. Named nested reorders use the same rule. The merge uses exact original
source slices, then combines their internal edits with the remaining shared budget.
Changed repeated names or definition spellings, reorders involving repeated occurrences, competing positions,
deletion versus a changed definition, incompatible membership or gaps, partially unavailable structure, and exhausted transport work retain the complete original
mine and theirs alternatives. A changed unnamed container hiding named descendants also stays a complete choice.
Possible moves between containers remain conservative conflicts; this can include independent label changes in
different containers. The merge never uses display context or ordinal binding keys as transport authority.
Without usable structural correspondence, ordinary source-coordinate merging remains available. It cannot establish move identity from text alone, and this API does not promise a general refactoring merge or a valid parsed program.
import {toHTML} from 'rapier-diff/html';
import {toPatch} from 'rapier-diff/patch';
element.innerHTML = toHTML(before, after, diff(before, after));
const patch = toPatch(before, after, {
beforeName: 'a/example.txt', afterName: 'b/example.txt', context: 3
});HTML uses exact edits for <del> and <ins>, with classes for concern kind/unit and data-change for its id.
It escapes text and attribute metadata and adds no styles. Context cannot widen a source mark. A rename's distant
marks share one id; transported fragments retain the move id and inner wording uses its own id.
The patch helper writes unified diffs, including missing-final-newline markers. It accepts the comparison options and expands exact edits to source lines for the patch format. It does not apply patches.
The core needs ES2022 and Intl.Segmenter. Import it in pages, workers or Node, or inline the module in an HTML file.
Deno and Bun can provide those APIs but were not exercised in this proof. Grapheme boundaries are requested lazily;
the core does not allocate a whole-input boundary table.
The current source was checked in Node 22.23.3. Results are deterministic for the same source, options, evidence, parser configuration and runtime Unicode rules. Cross-browser JSON parity for this source revision remains unverified; prior-version receipts do not establish parity for a changed module.
The complete-comparison, source-mark and larger-input tables below are historical measurements from 2026-10-10, before the template-fingerprint and moved-content merge repairs. They have not been rerun for the current source. Package sizes and the separate structural-merge comparison were measured on 2026-10-11. The historical runs used Node 22.23.3, ICU 78.3 and Unicode 17.0, with other builds and proof runs paused. These are observations on a shared host, not latency guarantees. The corpus uses pinned, hash-verified public snapshots: 43 document pairs and 16 code pairs spanning C, Go, Java, Python, Rust and TypeScript. Three additional JavaScript pairs exercise the parser adapter outside the 59-pair timing table. This run reused the verified corpus snapshots; it did not fetch a new revision set.
Each cell sums the per-pair median milliseconds. Seven samples rotate engine order after warmup; each sample batches
up to 32 complete comparisons. Timing includes the full public diff call and excludes reconstruction and readability
scoring. Rapier uses prose/code mode without optional structure. The previous Rapier engine and jsdiff use
diffWordsWithSpace; diff-match-patch uses diff_main with Diff_Timeout = 0, followed by diff_cleanupSemantic.
| Corpus | Pairs | rapier-diff | Previous Rapier | jsdiff 9.0.0 | diff-match-patch 1.0.5 |
|---|---|---|---|---|---|
| Documents | 43 | 58.088 ms | 556.053 ms | 845.253 ms | 31.651 ms |
| Code | 16 | 30.847 ms | 208.744 ms | 159.861 ms | 19.372 ms |
Rapier was faster than jsdiff and the previous Rapier engine on all 59 pairs. Its slowest relative result was 0.549 times jsdiff's time and 0.694 times the previous engine's time. Diff-match-patch was faster on 54 of the 59 pairs. Every engine's output rebuilt both originals. Rapier emitted no coarse edits in this corpus.
Counts below cover both originals. A fragment is a maximal changed run within a paragraph separated by blank lines. These measurements use exact source edits, not expanded display context or the number of selectable concerns. A rename or word concern can own several fragments. The mean uses each engine's own changed-paragraph count.
| Corpus / engine | Fragments | Changed paragraphs | Fragments / paragraph | Word-boundary splits | Scattered words |
|---|---|---|---|---|---|
| Documents / rapier-diff | 357 | 159 | 2.245 | 85 | 11 |
| Documents / Previous Rapier engine | 402 | 166 | 2.422 | 37 | 9 |
| Documents / jsdiff 9.0.0 | 395 | 167 | 2.365 | 37 | 7 |
| Documents / diff-match-patch 1.0.5 | 331 | 160 | 2.069 | 82 | 6 |
| Code / rapier-diff | 117 | 46 | 2.543 | 24 | 5 |
| Code / Previous Rapier engine | 193 | 49 | 3.939 | 6 | 1 |
| Code / jsdiff 9.0.0 | 198 | 49 | 4.041 | 4 | 0 |
| Code / diff-match-patch 1.0.5 | 94 | 46 | 2.043 | 20 | 1 |
A word-boundary split is an exact changed endpoint inside a word; a scattered word has multiple changed fragments. Rapier's character-sized concerns account for 31 intra-word fragments in documents and four in code. Other exact fragments can also lie inside words while their owning concern supplies whole-word display context. Fewer fragments or fewer splits alone does not establish better reading. None of these counts substitutes for visual inspection.
Rapier split no grapheme or CRLF boundaries in either corpus. In the document corpus, the previous engine and jsdiff each split one grapheme boundary; diff-match-patch split one grapheme and one CRLF boundary. All four recorded zero such boundary splits in the code corpus. No engine reported formatting runs in these plain-text corpus calls.
Complete files, with no tree shaking. Terser 5.51.2 uses ECMA 2022, two compression passes, mangling and no comments; ES modules use module mode and competitor distributions use script mode. Gzip uses level 9. Optional modules are measured separately and are not included in the core's row. The previous engine is its complete original module; jsdiff and diff-match-patch are their complete published distributions, not selected functions.
| File | Source bytes | Minified bytes | Minified + gzip bytes |
|---|---|---|---|
index.mjs |
32,504 | 16,504 | 6,787 |
html.mjs |
1,033 | 603 | 370 |
patch.mjs |
2,916 | 1,468 | 811 |
restore.mjs |
3,972 | 2,034 | 943 |
js.mjs |
19,363 | 10,912 | 3,980 |
markdown.mjs |
16,468 | 8,551 | 3,692 |
| Previous Rapier engine | 24,254 | 10,536 | 4,531 |
jsdiff-9.0.0 |
111,301 | 23,145 | 7,981 |
diff-match-patch-1.0.5 |
78,052 | 21,258 | 6,259 |
The complete core exceeds the original 5,000-byte gzip target by 1,787 bytes. Structural-merge consolidation, including a malformed-reader exception guard, reduced it from 6,817 to 6,787 bytes; the size gain is small. Both public core exports and every capability remain in this measurement. Optional parsers are caller-supplied and excluded from the adapter sizes. The three comparison-distribution rows retain their historical measurements.
The engine now admits structural alignment before computing edit ranges that transport would discard. It snapshots each reader result, retains the second base observation to detect inconsistent evidence, indexes sibling names once, and assembles source only for containers with transported descendants. Display-name and definition-spelling ambiguities share the same occurrence check. All state belongs to one call; the API is unchanged.
The following comparison uses the preceding complete core and the optimized core before the later malformed-reader exception guard, the same inputs and Node 22.23.3. The guard is included in the complete size above; these timing and allocation samples were not repeated for it. Each case contains 1,000 unique JavaScript declarations with either disjoint internal edits or a last-to-first sibling rotation plus an internal edit. Eleven paired samples rotate engine order after eight mixed-fixture warmup rounds and four case-specific warmup rounds. Each merge still calls the actual Acorn adapter four times.
| Case | Previous total | Current total | Previous engine work | Current engine work |
|---|---|---|---|---|
| Internal edits | 43.250 ms | 40.431 ms | 9.860 ms | 8.280 ms |
| Rotation plus edit | 41.721 ms | 36.085 ms | 10.624 ms | 5.662 ms |
Engine work is each call's total time minus its timed reader work. Columns are independent medians and need not add to a median total. With frozen, precomputed reader results, V8 allocation sampling observed 31% less core allocation for internal edits and 56% less for a rotation plus edit: approximately 10.19 to 7.01 MB and 11.13 to 4.93 MB per call. Sampling used a 256-byte interval, included allocations collected by minor and major GC, and covered three rotating rounds of twelve calls. These are sampled allocated bytes, not retained heap, peak memory or memory bounds.
Every measured result matched the exact expected source. Ordinary text, recorded operations, coordinate merging and
caller-provided edit arrays were also compared; no speedup is claimed for those paths. The public diff implementation
is unchanged. These shared-host observations do not qualify a browser, phone, live editor or Undo path.
Authored ASCII Markdown, five rotating-order samples after two warmups. Values are median milliseconds for the
complete comparison and exclude reconstruction. The unrelated 10 MiB case measures only Rapier at limit: 0;
there is no competitor timing claim for that row.
| Input | rapier-diff | Previous Rapier | jsdiff 9.0.0 | diff-match-patch 1.0.5 |
|---|---|---|---|---|
| 1 MiB, identical | 0.012028 ms | 26.553129 ms | 169.089500 ms | 0.032980 ms |
| 1 MiB, one edit | 10.763817 ms | 43.438837 ms | 101.838255 ms | 1.152089 ms |
| 1 MiB, three edits | 28.920934 ms | 206.952325 ms | 156.797240 ms | 19.887828 ms |
| 10 MiB, unrelated, limit zero | 0.043656 ms | Not measured | Not measured | Not measured |
All large-input results reconstructed exactly. The zero-limit result explicitly marked its coarse edit. Native string/Unicode operations and caller parsing remain outside the search counter.
The earlier measured core passed 59 raw corpus pairs, 62 configured-parser pairs, 766 official Unicode 17 grapheme joins and 766 exact public diffs, 10,000 expanded Unicode cases and 10,000 general fuzz cases. The restore corpus exercises 124 raw/structured comparisons: 755 single concerns, 9,577 pairs, 10,240 independent ordinary-edit oracle selections and 10,580 sequential splice projections. Authored examples additionally check all four move/wording and moved-binding rename choices, including recorded operations. Node-only packed-page worker checks match the standalone core in both built profiles. Current Chrome/Firefox JSON parity and physical-phone inspection remain unverified.
The template/move repair previously passed the focused adapter, semantic, structured-merge, operation, restoration, combination and exactness probes in Node 22.23.3. The current structural-merge consolidation passed 34 semantic cases, 31 adapter cases, 53 moved-content cases, 1,026 low-budget branch-order cases, 12,000 authored merge pairs and 2,400 public comparisons across six limits. Additional source checks cover overwritten/frozen reader objects and partial or inconsistent base observations. The supplied diagnostic passed 1,000 seeded Unicode/exactness cases and recorded second-occurrence restoration. The historical full corpus, packed-page worker and official Unicode results above were not rerun for this source.
Run node test/check.mjs in Node 22.23.3. The standalone check imports all six package entry points and checks
exact source reconstruction, selective restoration, Unicode and CRLF preservation, conflict alternatives,
structural move/edit transport, malformed-reader fallback, HTML escaping and unified patches. It uses authored
cases with no downloaded parsers or corpus and makes no performance claim. The test file is kept in the repository,
not the npm payload.
MIT.