Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

rapier-diff

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.

Exact edits and readable changes

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.

Recorded operations

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.

Restore chosen concerns

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.

Structure: documents and code

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.

Formatting requires evidence

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.

Merge concurrent edits

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.

HTML and unified patches

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.

Runtime

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.

Measurements

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.

Complete comparison time

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.

Exact source marks

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.

Package size

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.

Structural merge

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.

Larger inputs and work limits

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.

Historical correctness scope

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.

Check a repository checkout

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.

Licence

MIT.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages