204 lines
9.7 KiB
Markdown
204 lines
9.7 KiB
Markdown
# `reconcile-text`: conflict-free 3-way text merging
|
|
|
|
`reconcile-text` merges two edited versions of a document using their common parent, without leaving conflict markers. It combines changes from both versions and adjusts any cursor positions you pass in. You can use it from Rust, JavaScript/TypeScript, or Python.
|
|
|
|
It's designed for syncing text files when all you have are snapshots: the original document and two edited copies. You don't need to record editing operations or keep extra document metadata between merges.
|
|
|
|
## Try it
|
|
|
|
✨ **[Try the interactive demo][demo]** to see it in action!
|
|
|
|
For the motivation and algorithm behind the library, read [Conflict-Free Three-Way Text Merging][article].
|
|
|
|
## Quick start
|
|
|
|
In each example, one edit adds "beautiful" and the other changes "Hello" to "Hi". The merge keeps both changes.
|
|
|
|
### Rust
|
|
|
|
Install from [crates.io][crates]:
|
|
|
|
```sh
|
|
cargo add reconcile-text
|
|
```
|
|
|
|
```rust
|
|
use reconcile_text::{reconcile, BuiltinTokenizer};
|
|
|
|
let parent = "Hello world";
|
|
let left = "Hello beautiful world"; // Added "beautiful"
|
|
let right = "Hi world"; // Changed "Hello" to "Hi"
|
|
|
|
let result = reconcile(parent, &left.into(), &right.into(), &*BuiltinTokenizer::Word);
|
|
assert_eq!(result.apply().text(), "Hi beautiful world");
|
|
```
|
|
|
|
The [merge-file example][rust-merge-file] shows how to merge files from the command line. See the [Rust API documentation][rust-docs] for the full API.
|
|
|
|
### JavaScript/TypeScript
|
|
|
|
Install from [npm][npm]:
|
|
|
|
```sh
|
|
npm install reconcile-text
|
|
```
|
|
|
|
```javascript
|
|
import { reconcile } from 'reconcile-text';
|
|
|
|
const parent = 'Hello world';
|
|
const left = 'Hello beautiful world';
|
|
const right = 'Hi world';
|
|
|
|
const result = reconcile(parent, left, right);
|
|
console.log(result.text); // "Hi beautiful world"
|
|
```
|
|
|
|
The [TypeScript guide][typescript-guide] shows how to track cursors, see where changes came from, and work with compact diffs. For an example of using the library in an editor, see the [demo website source][demo-source].
|
|
|
|
The Node.js and browser builds use the Rust library compiled to WebAssembly. React Native uses a JavaScript build generated by [Binaryen's `wasm2js`][binaryen]. It's available through the package's `react-native` entry point and runs in Hermes without a `WebAssembly` global.
|
|
|
|
### Python
|
|
|
|
Install from [PyPI][pypi] with uv or pip:
|
|
|
|
```sh
|
|
uv add reconcile-text
|
|
# or: pip install reconcile-text
|
|
```
|
|
|
|
```python
|
|
from reconcile_text import reconcile
|
|
|
|
parent = "Hello world"
|
|
left = "Hello beautiful world"
|
|
right = "Hi world"
|
|
|
|
result = reconcile(parent, left, right)
|
|
print(result["text"]) # "Hi beautiful world"
|
|
```
|
|
|
|
The Python package uses native bindings to the same Rust library. For merging files from the command line, see the [merge-file example][python-merge-file]. The [Python guide][python-guide] shows how to track cursors, see where changes came from, and work with compact diffs.
|
|
|
|
## Why merge snapshots?
|
|
|
|
Suppose you edit a note in Obsidian on one device and Vim on another while they're offline. When the devices reconnect, your file sync service has two edited copies to combine. It can read the files, but it can't ask either editor which operations produced them.
|
|
|
|
Keeping the original version gives it a common parent to compare both copies against. Git works with the same inputs and leaves conflicts it can't resolve for a person to handle. That's a useful default for source code. For notes and prose, an automatic merge can be more useful, even if the wording occasionally needs tidying up.
|
|
|
|
## How it works
|
|
|
|
Like [`diff3`][diff3], `reconcile-text` starts with a **parent** document and two edited versions, `left` and `right`. It then:
|
|
|
|
1. **Splits the text into tokens**, such as words, characters, or lines.
|
|
2. **Finds each side's changes** using Myers' diff algorithm to compare the parent with `left` and with `right`.
|
|
3. **Cleans up the diffs** by reordering and grouping operations to keep related changes together.
|
|
4. **Combines the edits** using operational transformation (OT) principles to account for changes from both sides and update cursor positions.
|
|
|
|
Whilst the primary goal of `reconcile-text` isn't to implement OT, it provides an elegant way to merge Myers' diff outputs. (For a dedicated Rust OT implementation, see [operational-transform-rs][operational-transform-rs].) The same could be achieved with CRDTs, which many libraries implement well for text (see [Loro][loro], [cola][cola], and [automerge][automerge]).
|
|
|
|
### Choosing a tokeniser
|
|
|
|
The tokeniser decides which pieces of text stay together during a merge:
|
|
|
|
- **Word** keeps words together. This is the default in JavaScript/TypeScript and Python.
|
|
- **Character** works with individual Unicode code points, so edits can be combined within a word.
|
|
- **Line** treats each line as a unit.
|
|
- **Markdown** builds on word tokenisation. It preserves newlines and keeps block prefixes, such as list markers, attached to the first word.
|
|
|
|
In Rust, you choose a tokeniser explicitly and can supply your own function. The merge keeps tokens intact, though the Markdown tokeniser doesn't check whether the merged document is valid Markdown. Try the options in the [demo][demo] to see how they handle your text.
|
|
|
|
## Comparison with other approaches
|
|
|
|
### diff3 and Git
|
|
|
|
`diff3` and Git's default text merge leave conflict markers where they can't combine changes automatically. Git also has a [`union` merge driver][git-merge-drivers] that keeps lines from both sides of a conflict. Because it works with whole lines, edits to different words in the same sentence can leave you with two versions of that sentence. With word tokenisation, `reconcile-text` can combine those edits within the line.
|
|
|
|
### diff-match-patch
|
|
|
|
[diff-match-patch][diff-match-patch] compares two texts and applies patches using fuzzy matching. This is useful when the text around a change may have shifted. Patches can fail, so you need to check the success flags returned when applying them.
|
|
|
|
`reconcile-text` compares each edited version with the common parent before combining the changes. The [comparison example][comparison-example] shows how both approaches handle overlapping replacements and a sentence inserted into a rewritten paragraph.
|
|
|
|
### CRDTs and OT
|
|
|
|
Libraries such as [Yjs][yjs], [Automerge][automerge], [Loro][loro], and [cola][cola] use conflict-free replicated data types (CRDTs) to merge updates across replicas. They keep document structures and metadata between edits, so your application needs to store and work with that state. You can also turn differences between snapshots into CRDT updates; using a CRDT doesn't require recording every keystroke.
|
|
|
|
With OT, applications exchange editing operations and use a protocol to coordinate them. Libraries such as [ot.js][ot-js] and Rust's [operational-transform-rs][operational-transform-rs] provide tools for transforming those operations. `reconcile-text` uses OT ideas internally, deriving its operations from the snapshots when you call it.
|
|
|
|
The main difference is what your application keeps between merges. `reconcile-text` takes a parent and two edited copies, and merges those two copies in one call. A collaborative editing system built around CRDTs or OT also manages the state and coordination needed to keep replicas in sync over time.
|
|
|
|
## Development
|
|
|
|
Contributions are welcome!
|
|
|
|
### Python setup
|
|
|
|
Install [uv][uv] and build the extension for development:
|
|
|
|
```sh
|
|
cd reconcile-python
|
|
uv run maturin develop
|
|
```
|
|
|
|
### Node.js setup
|
|
|
|
1. Install [nvm][nvm]:
|
|
```sh
|
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
|
|
```
|
|
2. Install and use Node 22:
|
|
```sh
|
|
nvm install 22 && nvm use 22
|
|
```
|
|
3. Optionally, make Node 22 your default:
|
|
```sh
|
|
nvm alias default 22
|
|
```
|
|
|
|
### Rust toolchain
|
|
|
|
Install [rustup][rustup]:
|
|
|
|
```sh
|
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
|
```
|
|
|
|
### Scripts
|
|
|
|
- **Run tests**: `scripts/test.sh`
|
|
- **Check lint and formatting**: `scripts/lint.sh`
|
|
- **Run the demo locally**: `scripts/dev-website.sh`
|
|
- **Build the demo**: `scripts/build-website.sh`
|
|
- **Publish a new version**: `scripts/bump-version.sh patch`
|
|
|
|
## License
|
|
|
|
[MIT][license]
|
|
|
|
[demo]: https://reconcile.schmelczer.dev/
|
|
[article]: https://schmelczer.dev/articles/reconcile-text-3-way-merge/
|
|
[crates]: https://crates.io/crates/reconcile-text
|
|
[npm]: https://www.npmjs.com/package/reconcile-text
|
|
[pypi]: https://pypi.org/project/reconcile-text/
|
|
[rust-merge-file]: https://git.schmelczer.dev/andras/reconcile/src/branch/main/examples/merge-file.rs
|
|
[rust-docs]: https://docs.rs/reconcile-text/latest/reconcile_text/
|
|
[typescript-guide]: https://git.schmelczer.dev/andras/reconcile/src/branch/main/docs/advanced-ts.md
|
|
[demo-source]: https://git.schmelczer.dev/andras/reconcile/src/branch/main/examples/website/src/index.ts
|
|
[binaryen]: https://github.com/WebAssembly/binaryen
|
|
[python-merge-file]: https://git.schmelczer.dev/andras/reconcile/src/branch/main/examples/merge_file.py
|
|
[python-guide]: https://git.schmelczer.dev/andras/reconcile/src/branch/main/docs/advanced-python.md
|
|
[diff3]: https://blog.jcoglan.com/2017/05/08/merging-with-diff3/
|
|
[operational-transform-rs]: https://github.com/spebern/operational-transform-rs
|
|
[loro]: https://github.com/loro-dev/loro
|
|
[cola]: https://github.com/noib3/cola
|
|
[automerge]: https://github.com/automerge/automerge
|
|
[git-merge-drivers]: https://git-scm.com/docs/gitattributes#_built_in_merge_drivers
|
|
[diff-match-patch]: https://github.com/google/diff-match-patch
|
|
[comparison-example]: https://git.schmelczer.dev/andras/reconcile/src/branch/main/examples/compare-with-diff-match-patch.rs
|
|
[yjs]: https://github.com/yjs/yjs
|
|
[ot-js]: https://github.com/Operational-Transformation/ot.js
|
|
[uv]: https://docs.astral.sh/uv/getting-started/installation/
|
|
[nvm]: https://github.com/nvm-sh/nvm
|
|
[rustup]: https://rustup.rs
|
|
[license]: https://git.schmelczer.dev/andras/reconcile/src/branch/main/LICENSE
|