Compare commits

..

9 commits

Author SHA1 Message Date
fcc856279c Bump versions to 0.12.1
All checks were successful
Check / build (push) Successful in 9m31s
Publish / build (push) Successful in 9m53s
Publish / publish-crate (push) Has been skipped
Publish / publish-npm (push) Has been skipped
Publish / publish-pypi (push) Successful in 6m53s
2026-05-31 22:31:39 +01:00
fd3a374b0f Fix windows build
Some checks failed
Publish / publish-crate (push) Blocked by required conditions
Publish / publish-npm (push) Blocked by required conditions
Publish / publish-pypi (push) Blocked by required conditions
Check / build (push) Has been cancelled
Publish / build (push) Has been cancelled
2026-05-31 22:31:18 +01:00
171045ad66 Bump versions to 0.12.0
Some checks failed
Check / build (push) Successful in 8m51s
Publish / build (push) Successful in 8m16s
Publish / publish-crate (push) Has been skipped
Publish / publish-pypi (push) Failing after 3m39s
Publish / publish-npm (push) Successful in 1m43s
2026-05-31 20:28:49 +01:00
a8fbac6934 Add react-native support (#63)
Some checks failed
Publish / build (push) Waiting to run
Publish / publish-crate (push) Blocked by required conditions
Publish / publish-npm (push) Blocked by required conditions
Publish / publish-pypi (push) Blocked by required conditions
Check / build (push) Has been cancelled
Reviewed-on: https://home.schmelczer.dev/git/git/andras/reconcile/pulls/63
2026-05-31 20:28:20 +01:00
08e7d824f4 Publish
All checks were successful
Check / build (pull_request) Successful in 9m11s
Check / build (push) Successful in 7m53s
Publish / build (push) Successful in 6m31s
Publish / publish-crate (push) Has been skipped
Publish / publish-npm (push) Has been skipped
Publish / publish-pypi (push) Has been skipped
2026-05-31 20:08:23 +01:00
17a96be0fc Install UV
All checks were successful
Check / build (pull_request) Successful in 7m58s
Publish / build (push) Successful in 8m39s
Publish / publish-crate (push) Has been skipped
Publish / publish-npm (push) Has been skipped
Check / build (push) Successful in 9m51s
2026-05-22 08:17:19 +01:00
22723cbcae Remove
Some checks failed
Check / build (pull_request) Failing after 1m27s
2026-05-22 08:07:36 +01:00
8e237bc232 Improve TS docs 2026-05-22 08:05:55 +01:00
c1bc0b8955 Migrate to forgejo
Some checks failed
Check / build (pull_request) Failing after 2m27s
2026-05-21 21:15:22 +01:00
24 changed files with 1509 additions and 696 deletions

View file

@ -0,0 +1,74 @@
name: Check
on:
push:
branches: ['main']
pull_request:
branches: ['main']
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: '-Dwarnings'
jobs:
build:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.x'
check-latest: true
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: |
reconcile-js/node_modules
examples/website/node_modules
~/.npm
key: >-
${{ runner.os }}-npm-${{
hashFiles(
'reconcile-js/package-lock.json',
'examples/website/package-lock.json'
)
}}
restore-keys: |
${{ runner.os }}-npm-
- name: Install Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain none --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install uv
run: |
curl --proto '=https' --tlsv1.2 -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Lint
run: scripts/lint.sh
- name: Test
run: scripts/test.sh
- name: Build website
run: scripts/build-website.sh

View file

@ -0,0 +1,265 @@
name: Publish
on:
push:
branches: ['main']
tags: ['*']
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: '-Dwarnings'
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
build:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.x'
check-latest: true
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: |
reconcile-js/node_modules
examples/website/node_modules
~/.npm
key: >-
${{ runner.os }}-npm-${{
hashFiles(
'reconcile-js/package-lock.json',
'examples/website/package-lock.json'
)
}}
restore-keys: |
${{ runner.os }}-npm-
- name: Install Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain none --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Install uv
run: |
curl --proto '=https' --tlsv1.2 -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Lint
run: scripts/lint.sh
- name: Test
run: scripts/test.sh
- name: Build website
run: scripts/build-website.sh
- name: Deploy to pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
apt-get update && apt-get install -y rsync
rsync -a --delete examples/website/dist/ /pages/reconcile
publish-crate:
needs: build
runs-on: docker
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Install Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain none --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Publish to crates.io
run: cargo publish --token ${{ secrets.CRATES_IO_TOKEN }}
publish-npm:
needs: build
runs-on: docker
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.x'
check-latest: true
registry-url: 'https://registry.npmjs.org'
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: |
reconcile-js/node_modules
~/.npm
key: >-
${{ runner.os }}-npm-${{
hashFiles('reconcile-js/package-lock.json')
}}
restore-keys: |
${{ runner.os }}-npm-
- name: Install Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain none --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Build website
run: scripts/build-website.sh
- name: Publish reconcile-js to NPM
run: |
cd reconcile-js
cp ../README.md .
npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish-pypi:
needs: build
runs-on: docker
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-pypi-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-pypi-
${{ runner.os }}-cargo-
# clang/lld/llvm provide clang-cl, lld-link and llvm-lib, which cargo-xwin
# uses to cross-compile the Windows wheel from this Linux runner.
- name: Install cross-compilation system dependencies
run: |
apt-get update
apt-get install -y clang lld llvm
- name: Install Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain none --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The Linux targets ship in rust-toolchain.toml; add the cross targets.
- name: Add cross-compilation Rust targets
run: |
rustup target add aarch64-unknown-linux-gnu x86_64-pc-windows-msvc
# zig is the C toolchain maturin's `--zig` uses to produce manylinux2014
# wheels with a pinned (old) glibc, independent of the runner's glibc.
- name: Install zig
run: |
ZIG_VERSION=0.13.0
curl --proto '=https' --tlsv1.2 -fLsS \
"https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz" \
| tar -xJ
echo "$PWD/zig-linux-x86_64-${ZIG_VERSION}" >> "$GITHUB_PATH"
- name: Install cargo-xwin
run: command -v cargo-xwin || cargo install --locked cargo-xwin
- name: Install uv
run: |
curl --proto '=https' --tlsv1.2 -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Copy README
run: cp README.md reconcile-python/
- name: Build sdist
working-directory: reconcile-python
run: uv run maturin sdist --out dist
- name: Build Linux x86_64 wheel
working-directory: reconcile-python
run: >-
uv run maturin build --release --out dist
--compatibility manylinux2014
--target x86_64-unknown-linux-gnu --zig
- name: Build Linux aarch64 wheel
working-directory: reconcile-python
run: >-
uv run maturin build --release --out dist
--compatibility manylinux2014
--target aarch64-unknown-linux-gnu --zig
- name: Build Windows x86_64 wheel
working-directory: reconcile-python
run: >-
uv run maturin build --release --out dist
--target x86_64-pc-windows-msvc
# Forgejo cannot use PyPI trusted publishing (OIDC), so authenticate with
# an API token. --skip-existing makes re-runs of a tag idempotent.
- name: Publish to PyPI
working-directory: reconcile-python
env:
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv run maturin upload --skip-existing dist/*

View file

@ -1,26 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: 'cargo'
directories: ['**']
schedule:
interval: 'daily'
- package-ecosystem: 'github-actions'
directories: ['**']
schedule:
interval: 'daily'
- package-ecosystem: 'npm'
directories: ['/reconcile-js', '/examples/website']
schedule:
interval: 'daily'
- package-ecosystem: 'pip'
directories: ['/reconcile-python']
schedule:
interval: 'daily'

View file

@ -1,197 +0,0 @@
name: Check & publish
on:
push:
branches: ['main']
tags: ['*']
pull_request:
branches: ['main']
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: '-Dwarnings'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js environment
uses: actions/setup-node@v6.3.0
with:
node-version: '22.x'
check-latest: true
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Cache Rust dependencies
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Lint
run: scripts/lint.sh
- name: Test
run: scripts/test.sh
publish-crate:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v6
- name: Cache Rust dependencies
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Publish to crates.io
run: cargo publish --token ${{ secrets.CRATES_IO_TOKEN }}
publish-npm:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- name: Setup Node.js environment
uses: actions/setup-node@v6.3.0
with:
node-version: '24.x'
check-latest: true
- name: Cache Rust dependencies
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Cache npm dependencies
uses: actions/cache@v5
with:
path: |
reconcile-js/node_modules
~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('reconcile-js/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Build website
run: scripts/build-website.sh
- name: Publish reconcile-js to NPM
run: |
cd reconcile-js
cp ../README.md .
npm publish --access public
build-python-wheels:
needs: build
if: startsWith(github.ref, 'refs/tags/')
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64
- os: ubuntu-latest
target: aarch64
- os: macos-latest
target: x86_64
- os: macos-latest
target: aarch64
- os: windows-latest
target: x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Copy README
run: cp README.md reconcile-python/
- uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
manylinux: auto
working-directory: reconcile-python
- uses: actions/upload-artifact@v7
with:
name: wheels-${{ matrix.os }}-${{ matrix.target }}
path: reconcile-python/dist/*.whl
build-python-sdist:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Copy README
run: cp README.md reconcile-python/
- uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist
working-directory: reconcile-python
- uses: actions/upload-artifact@v7
with:
name: sdist
path: reconcile-python/dist/*.tar.gz
publish-pypi:
needs: [build-python-wheels, build-python-sdist]
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: '{wheels-*,sdist}'
merge-multiple: true
path: dist
- uses: pypa/gh-action-pypi-publish@release/v1

View file

@ -1,72 +0,0 @@
name: Deploy Website to GitHub Pages
on:
push:
branches:
- main
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Cache Rust dependencies
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Cache npm dependencies
uses: actions/cache@v5
with:
path: |
reconcile-js/node_modules
~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('reconcile-js/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Build wasm
run: |
which wasm-pack || cargo install wasm-pack
scripts/build-website.sh
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: examples/website/dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

3
.gitignore vendored
View file

@ -10,5 +10,8 @@ node_modules
# WebPack build output # WebPack build output
dist dist
# Generated wasm-bindgen bundler + wasm2js output for the React Native build
pkg-rn
# Python virtual environment # Python virtual environment
.venv .venv

2
Cargo.lock generated
View file

@ -428,7 +428,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "reconcile-text" name = "reconcile-text"
version = "0.11.0" version = "0.12.1"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"diff-match-patch-rs", "diff-match-patch-rs",

View file

@ -1,7 +1,7 @@
[package] [package]
name = "reconcile-text" name = "reconcile-text"
description = "Intelligent 3-way text merging with automated conflict resolution" description = "Intelligent 3-way text merging with automated conflict resolution"
version = "0.11.0" version = "0.12.1"
rust-version = "1.94" rust-version = "1.94"
authors = ["Andras Schmelczer <andras@schmelczer.dev>"] authors = ["Andras Schmelczer <andras@schmelczer.dev>"]
edition = "2024" edition = "2024"

View file

@ -80,6 +80,13 @@ console.log(result.text); // "Hi beautiful world"
See the [example website source](examples/website/src/index.ts) for a more complex example, or the [advanced examples document](docs/advanced-ts.md). See the [example website source](examples/website/src/index.ts) for a more complex example, or the [advanced examples document](docs/advanced-ts.md).
#### React Native (Hermes)
React Native's default engine, Hermes, does not expose a runtime `WebAssembly`
global, so the WebAssembly build cannot run there. For React Native, the package
ships a pure-JavaScript build produced by [Binaryen's `wasm2js`](https://github.com/WebAssembly/binaryen)
via its `react-native` entry point.
### Python ### Python
Install via uv or pip: Install via uv or pip:

View file

@ -2,40 +2,65 @@
## Edit Provenance ## Edit Provenance
Track which changes came from where using `reconcileWithHistory`: Track which changes came from where using `reconcileWithHistory`. The result's
`history` field is typed as `SpanWithHistory[]`, and each span's `history` is a
`History` string-literal union.
```javascript ```typescript
const result = reconcileWithHistory( import { reconcileWithHistory, type History, type SpanWithHistory } from 'reconcile-text';
'Hello world',
'Hello beautiful world', const result = reconcileWithHistory('Hello world', 'Hello beautiful world', 'Hi world');
'Hi world'
);
console.log(result.text); // "Hi beautiful world" console.log(result.text); // "Hi beautiful world"
console.log(result.history); /*
[ const history: SpanWithHistory[] = result.history;
{ console.log(history);
"text": "Hello", // [
"history": "RemovedFromRight" // { text: "Hello", history: "RemovedFromRight" },
}, // { text: "Hi", history: "AddedFromRight" },
{ // { text: " beautiful", history: "AddedFromLeft" },
"text": "Hi", // { text: " ", history: "Unchanged" },
"history": "AddedFromRight" // { text: "world", history: "Unchanged" },
}, // ]
{
"text": " beautiful", const classByHistory = {
"history": "AddedFromLeft" Unchanged: 'merge-unchanged',
}, AddedFromLeft: 'merge-added-left',
{ AddedFromRight: 'merge-added-right',
"text": " ", RemovedFromLeft: 'merge-removed-left',
"history": "Unchanged" RemovedFromRight: 'merge-removed-right',
}, } satisfies Record<History, string>;
{ ```
"text": "world",
"history": "Unchanged" Using `satisfies Record<History, string>` keeps the object literal's values
narrow while forcing every history case to be handled. If a future version adds
another `History` value, TypeScript will point at this mapping.
For control flow, use the same union as an exhaustiveness check:
```typescript
import type { History } from 'reconcile-text';
function historyLabel(history: History): string {
switch (history) {
case 'Unchanged':
return 'unchanged';
case 'AddedFromLeft':
return 'added by left';
case 'AddedFromRight':
return 'added by right';
case 'RemovedFromLeft':
return 'removed from left';
case 'RemovedFromRight':
return 'removed from right';
default:
return assertNever(history);
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled history value: ${value}`);
} }
]
*/
``` ```
## Tokenisation Strategies ## Tokenisation Strategies
@ -45,26 +70,162 @@ console.log(result.history); /*
- **Word tokeniser** (`"Word"`) - Splits on word boundaries (recommended for prose) - **Word tokeniser** (`"Word"`) - Splits on word boundaries (recommended for prose)
- **Character tokeniser** (`"Character"`) - Individual characters (fine-grained control) - **Character tokeniser** (`"Character"`) - Individual characters (fine-grained control)
- **Line tokeniser** (`"Line"`) - Line-by-line (similar to `git merge` or more precisely [`git merge-file`](https://git-scm.com/docs/git-merge-file)) - **Line tokeniser** (`"Line"`) - Line-by-line (similar to `git merge` or more precisely [`git merge-file`](https://git-scm.com/docs/git-merge-file))
- **Markdown tokeniser** (`"Markdown"`) - Splits on Markdown structural boundaries (headings, list items, paragraphs)
```typescript
import { reconcile, type BuiltinTokenizer } from 'reconcile-text';
const tokenizers = [
'Word',
'Character',
'Line',
'Markdown',
] as const satisfies readonly BuiltinTokenizer[];
const result = reconcile('abc', 'axc', 'abyc', 'Character');
console.log(result.text); // "axyc"
for (const tokenizer of tokenizers) {
const merged = reconcile(
'# Title\n\n- old item\n',
'# Title\n\n- old item\n- left item\n',
'# New title\n\n- old item\n',
tokenizer
);
console.log(tokenizer, merged.text);
}
```
## Cursor Tracking ## Cursor Tracking
`reconcile-text` automatically tracks cursor positions through merges, which is useful for collaborative editors. Selections can be tracked by providing them as a pair of cursors. `reconcile-text` automatically tracks cursor positions through merges, which is
useful for collaborative editors. Selections can be tracked by providing them as
a pair of cursors.
```javascript ```typescript
const result = reconcile( import { reconcile, type TextWithOptionalCursors } from 'reconcile-text';
'Hello world',
{ const left = {
text: 'Hello beautiful world', text: 'Hello beautiful world',
cursors: [{ id: 1, position: 6 }], // After "Hello " cursors: [{ id: 1, position: 6 }], // After "Hello "
}, } satisfies TextWithOptionalCursors;
{
const right = {
text: 'Hi world', text: 'Hi world',
cursors: [{ id: 2, position: 0 }], // At the beginning cursors: [{ id: 2, position: 0 }], // At the beginning
} } satisfies TextWithOptionalCursors;
);
const result = reconcile('Hello world', left, right);
// Result: "Hi beautiful world" with repositioned cursors // Result: "Hi beautiful world" with repositioned cursors
console.log(result.text); // "Hi beautiful world" console.log(result.text); // "Hi beautiful world"
console.log(result.cursors); // [{ id: 2, position: 0 }, { id: 1, position: 3 }] console.log(result.cursors); // [{ id: 2, position: 0 }, { id: 1, position: 3 }]
``` ```
> The `cursors` list is sorted by character position (not IDs). > The `cursors` list is sorted by character position (not IDs).
## Generic Helpers and Inference
The exported merge functions are intentionally small: they merge strings, or
strings plus cursor metadata. In TypeScript applications, keep domain-specific
metadata in your own typed wrappers and let inference preserve the surrounding
shape.
```typescript
import { reconcile, type BuiltinTokenizer } from 'reconcile-text';
type ReconciledText<T extends { text: string }> = Omit<T, 'text'> & {
text: string;
};
function reconcileDraft<TDraft extends { text: string }>(
parent: TDraft,
left: TDraft,
right: TDraft,
tokenizer?: BuiltinTokenizer
): ReconciledText<TDraft> {
return {
...right,
text: reconcile(parent.text, left.text, right.text, tokenizer).text,
};
}
interface MarkdownDraft {
id: string;
text: string;
updatedAt: Date;
}
const parent: MarkdownDraft = {
id: 'intro',
text: '# Title\n\nOld text\n',
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const left: MarkdownDraft = {
...parent,
text: '# Title\n\nOld text\n\n- left note\n',
};
const right: MarkdownDraft = {
...parent,
text: '# New title\n\nOld text\n',
};
const merged = reconcileDraft(parent, left, right, 'Markdown');
// merged is inferred as { id: string; updatedAt: Date; text: string }
```
Use `satisfies` for configuration objects and cursor payloads when you want
compile-time checking without widening everything to the library interface.
```typescript
import type { BuiltinTokenizer, TextWithOptionalCursors } from 'reconcile-text';
const mergeOptions = {
tokenizer: 'Markdown',
renderDeletedSpans: true,
} satisfies {
tokenizer: BuiltinTokenizer;
renderDeletedSpans: boolean;
};
const documentWithSelection = {
text: 'Hello beautiful world',
cursors: [
{ id: 1, position: 6 },
{ id: 2, position: 15 },
],
} satisfies TextWithOptionalCursors;
```
## Compact Diffs
Generate and apply compact diff representations. The TypeScript type is
`Array<number | string>` for `diff()` and `Array<number | bigint | string>` for
`undiff()`, because the underlying WebAssembly layer may represent integer
entries as `bigint`.
```typescript
import { diff, undiff } from 'reconcile-text';
const original = 'Hello world';
const changed = 'Hello beautiful world';
// Generate a compact diff
const changes = diff(original, changed);
console.log(changes); // [5, " beautiful world"]
// Reconstruct the changed text from the diff
const reconstructed = undiff(original, changes);
console.assert(reconstructed === changed);
```
Diff entries are positive integers (retain N characters), negative integers
(delete N characters), and strings (insert text).
## Complete Example
For a complete browser example that renders `SpanWithHistory` values and cursor
selections, see the [example website source](../examples/website/src/index.ts).

View file

@ -28,11 +28,12 @@
}, },
"../../reconcile-js": { "../../reconcile-js": {
"name": "reconcile-text", "name": "reconcile-text",
"version": "0.11.0", "version": "0.12.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"binaryen": "^123.0.0",
"jest": "^30.3.0", "jest": "^30.3.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"reconcile-text": "file:../pkg", "reconcile-text": "file:../pkg",
@ -1135,6 +1136,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -1175,6 +1177,7 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@ -1411,6 +1414,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -3976,6 +3980,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -4484,6 +4489,7 @@
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"immutable": "^5.1.5", "immutable": "^5.1.5",
@ -5303,6 +5309,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5386,7 +5393,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD",
"peer": true
}, },
"node_modules/tsyringe": { "node_modules/tsyringe": {
"version": "4.10.0", "version": "4.10.0",
@ -5428,6 +5436,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5568,6 +5577,7 @@
"integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8", "@types/estree": "^1.0.8",
@ -5617,6 +5627,7 @@
"integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@discoveryjs/json-ext": "^0.6.1", "@discoveryjs/json-ext": "^0.6.1",
"@webpack-cli/configtest": "^3.0.1", "@webpack-cli/configtest": "^3.0.1",

View file

@ -1,15 +1,16 @@
{ {
"name": "reconcile-text", "name": "reconcile-text",
"version": "0.11.0", "version": "0.12.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "reconcile-text", "name": "reconcile-text",
"version": "0.11.0", "version": "0.12.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"binaryen": "^123.0.0",
"jest": "^30.3.0", "jest": "^30.3.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"reconcile-text": "file:../pkg", "reconcile-text": "file:../pkg",
@ -24,7 +25,7 @@
}, },
"../pkg": { "../pkg": {
"name": "reconcile-text", "name": "reconcile-text",
"version": "0.11.0", "version": "0.12.1",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -65,6 +66,7 @@
"version": "7.28.0", "version": "7.28.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
@ -1656,6 +1658,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -1682,6 +1685,7 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@ -1908,6 +1912,24 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/binaryen": {
"version": "123.0.0",
"resolved": "https://registry.npmjs.org/binaryen/-/binaryen-123.0.0.tgz",
"integrity": "sha512-/hls/a309aZCc0itqP6uhoR+5DsKSlJVfB8Opd2BY9Ndghs84IScTunlyidyF4r2Xe3lQttnfBNIDjaNpj6mTw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"wasm-as": "bin/wasm-as",
"wasm-ctor-eval": "bin/wasm-ctor-eval",
"wasm-dis": "bin/wasm-dis",
"wasm-merge": "bin/wasm-merge",
"wasm-metadce": "bin/wasm-metadce",
"wasm-opt": "bin/wasm-opt",
"wasm-reduce": "bin/wasm-reduce",
"wasm-shell": "bin/wasm-shell",
"wasm2js": "bin/wasm2js"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@ -1950,6 +1972,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -3053,6 +3076,7 @@
"integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "30.3.0", "@jest/core": "30.3.0",
"@jest/types": "30.3.0", "@jest/types": "30.3.0",
@ -4936,6 +4960,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5072,6 +5097,7 @@
"integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8", "@types/estree": "^1.0.8",
@ -5119,6 +5145,7 @@
"version": "6.0.1", "version": "6.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@discoveryjs/json-ext": "^0.6.1", "@discoveryjs/json-ext": "^0.6.1",
"@webpack-cli/configtest": "^3.0.1", "@webpack-cli/configtest": "^3.0.1",

View file

@ -1,9 +1,10 @@
{ {
"name": "reconcile-text", "name": "reconcile-text",
"version": "0.11.0", "version": "0.12.1",
"description": "Intelligent 3-way text merging with automated conflict resolution", "description": "Intelligent 3-way text merging with automated conflict resolution",
"main": "dist/reconcile.node.js", "main": "dist/reconcile.node.js",
"browser": "dist/reconcile.web.js", "browser": "dist/reconcile.web.js",
"react-native": "dist/reconcile.rn.js",
"keywords": [ "keywords": [
"text editing", "text editing",
"sync", "sync",
@ -31,12 +32,13 @@
"dist/**/*" "dist/**/*"
], ],
"scripts": { "scripts": {
"build": "webpack --mode production", "build": "node scripts/build-rn.mjs && webpack --mode production",
"format": "prettier --write \"./**/*.(ts|scss|json|html)\"", "format": "prettier --write \"./**/*.(ts|mjs|scss|json|html)\"",
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest" "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"binaryen": "^123.0.0",
"jest": "^30.3.0", "jest": "^30.3.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"reconcile-text": "file:../pkg", "reconcile-text": "file:../pkg",

View file

@ -0,0 +1,307 @@
// Generates `pkg-rn/`: a React Native / Hermes-compatible build of the
// wasm-bindgen bindings in which the WebAssembly module is replaced by its
// wasm2js (pure-JS) translation.
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
const here = dirname(fileURLToPath(import.meta.url));
const reconcileJsDir = resolve(here, '..');
const repoRoot = resolve(reconcileJsDir, '..');
const releaseWasm = resolve(
repoRoot,
'target/wasm32-unknown-unknown/release/reconcile_text.wasm'
);
const outDir = resolve(reconcileJsDir, 'pkg-rn');
const bgWasm = resolve(outDir, 'reconcile_text_bg.wasm');
const bgWasmJs = resolve(outDir, 'reconcile_text_bg.wasm.js');
const loweredWasm = resolve(outDir, '_lowered.wasm');
const entryJs = resolve(outDir, 'reconcile_text.js');
const wasmOpt = resolve(reconcileJsDir, 'node_modules/.bin/wasm-opt');
const wasm2js = resolve(reconcileJsDir, 'node_modules/.bin/wasm2js');
function run(cmd, args) {
execFileSync(cmd, args, { stdio: 'inherit' });
}
// Locate the wasm-bindgen CLI. It MUST match the `wasm-bindgen` crate version pinned
// in Cargo.toml: a mismatched CLI emits bindings the runtime can't use. So we resolve
// the required version first and verify every candidate against it, failing loudly
// rather than silently falling back to whatever other version happens to be around.
function findWasmBindgen() {
const cargoToml = readFileSync(resolve(repoRoot, 'Cargo.toml'), 'utf8');
const wanted = cargoToml.match(
/wasm-bindgen\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"/
)?.[1];
if (!wanted) {
throw new Error(
'[build-rn] Could not parse the pinned wasm-bindgen version from Cargo.toml, so ' +
'the required CLI version is unknown. Has the dependency declaration changed?'
);
}
// 1. On PATH: accept it only if its version matches the pin.
let onPath = null;
try {
onPath = execFileSync('which', ['wasm-bindgen'], { encoding: 'utf8' }).trim();
} catch {
/* not on PATH; try the wasm-pack cache next */
}
if (onPath) {
const version = execFileSync(onPath, ['--version'], { encoding: 'utf8' }).match(
/\d+\.\d+\.\d+/
)?.[0];
if (version !== wanted) {
throw new Error(
`[build-rn] wasm-bindgen on PATH (${onPath}) is ${version ?? 'an unknown version'}, ` +
`but Cargo.toml pins ${wanted}. Install the matching CLI ` +
`(\`cargo install wasm-bindgen-cli --version ${wanted}\`) or remove the mismatched one.`
);
}
return onPath;
}
const cacheRoots = [
resolve(homedir(), 'Library/Caches/.wasm-pack'),
resolve(homedir(), '.cache/.wasm-pack'),
];
for (const root of cacheRoots) {
if (!existsSync(root)) {
continue;
}
for (const entry of readdirSync(root)) {
const candidate = resolve(root, entry, 'wasm-bindgen');
if (!existsSync(candidate)) {
continue;
}
let version;
try {
version = execFileSync(candidate, ['--version'], { encoding: 'utf8' }).match(
/\d+\.\d+\.\d+/
)?.[0];
} catch {
continue; // not an invokable wasm-bindgen; ignore
}
if (version === wanted) {
return candidate;
}
}
}
throw new Error(
`[build-rn] No wasm-bindgen ${wanted} found on PATH or in the wasm-pack cache. ` +
'Run `wasm-pack build --target web --features wasm` first (it caches the matching ' +
`wasm-bindgen), or \`cargo install wasm-bindgen-cli --version ${wanted}\`.`
);
}
if (!existsSync(releaseWasm)) {
throw new Error(
`Missing ${releaseWasm}.\nRun \`wasm-pack build --target web --features wasm\` from the repo root first.`
);
}
console.log('[build-rn] generating bundler-target bindings with wasm-bindgen');
rmSync(outDir, { recursive: true, force: true });
const wasmBindgen = findWasmBindgen();
run(wasmBindgen, ['--target', 'bundler', '--out-dir', outDir, releaseWasm]);
// --- Patch wasm-bindgen's cached-memory getters for wasm2js -----------------
//
// wasm-bindgen caches typed-array / DataView views over `wasm.memory.buffer` and
// only re-creates them when it detects the heap grew. It detects a grow by looking
// for ArrayBuffer *detachment*: a real `WebAssembly.Memory.grow()` detaches the old
// buffer (its `byteLength` becomes 0 and `.detached` becomes true), and those are the
// only signals the generated getters check:
// - getUint8ArrayMemory0(): refreshes when `byteLength === 0` (detach only)
// - getDataViewMemory0(): refreshes when `.detached === true`, OR when the buffer
// identity changed but only `if (.detached === undefined)` — i.e. that identity
// fallback runs solely on engines lacking `ArrayBuffer.prototype.detached`.
//
// wasm2js grows differently: `__wasm_memory_grow` (in reconcile_text_bg.wasm.js)
// allocates a NEW ArrayBuffer, copies the old heap into it, and reassigns
// `memory.buffer` WITHOUT ever detaching the old buffer. So the old buffer keeps
// `byteLength > 0` and `.detached === false`, and on modern engines that DO expose
// `ArrayBuffer.prototype.detached` (Node 25+, current Hermes) the identity fallback is
// gated off. Net effect: after a grow the getters keep returning views over the stale
// pre-grow buffer, silently corrupting any operation large enough to grow the heap.
// Small inputs never grow, so this escapes naive testing.
//
// WHY WE PATCH INSTEAD OF CONFIGURING.
// This is not fixed or configurable upstream: wasm-bindgen has no wasm2js / asm.js /
// React Native / "no-WebAssembly" target (every target assumes real WebAssembly
// detach-on-grow semantics), there is no flag to force buffer-identity comparison, and
// the getter-generation logic (crates/cli-support/src/js/mod.rs `memview`) is
// byte-for-byte identical from the pinned 0.2.114 through the latest release and
// `main`. The non-detaching-grow case is not even a tracked upstream issue. Rewriting
// the generated glue is therefore the only available fix: the two replacements below
// make BOTH getters also refresh on a buffer-identity change
// (`buffer !== wasm.memory.buffer`), which is the one signal wasm2js does give.
//
// Each replacement is asserted independently. If a future wasm-bindgen reshapes one
// getter but not the other, we MUST fail the build rather than ship a half-patched
// module whose un-patched getter corrupts large inputs. The post-build self-test at
// the bottom of this file is the backstop that proves the result survives a real grow.
const bgJsPath = resolve(outDir, 'reconcile_text_bg.js');
let bgJs = readFileSync(bgJsPath, 'utf8');
// (1) Uint8Array getter: append an unconditional buffer-identity check to the
// `byteLength === 0` detach guard (upstream has no identity check here at all).
const byteLengthGuard = /(cached\w*Memory0)\.byteLength === 0/g;
const byteLengthHits = bgJs.match(byteLengthGuard)?.length ?? 0;
if (byteLengthHits === 0) {
throw new Error(
`[build-rn] Could not find the Uint8Array \`byteLength === 0\` growth guard in ` +
`${bgJsPath} to patch for wasm2js. The wasm-bindgen output shape changed; update ` +
'this patch (see crates/cli-support/src/js/mod.rs `memview`) — do NOT ship an ' +
'unpatched getter, it will corrupt large inputs under wasm2js.'
);
}
bgJs = bgJs.replace(
byteLengthGuard,
'$1.byteLength === 0 || $1.buffer !== wasm.memory.buffer'
);
// (2) DataView getter: drop the `detached === undefined &&` prefix so the existing
// buffer-identity check runs on every runtime, not only legacy ones.
const gatedGuard =
/(cached\w*Memory0)\.buffer\.detached === undefined && \1\.buffer !== wasm\.memory\.buffer/g;
const gatedHits = bgJs.match(gatedGuard)?.length ?? 0;
if (gatedHits === 0) {
throw new Error(
`[build-rn] Could not find the DataView \`detached === undefined\`-gated buffer-identity ` +
`check in ${bgJsPath} to un-gate for wasm2js. The wasm-bindgen output shape changed; ` +
'update this patch (see crates/cli-support/src/js/mod.rs `memview`) — do NOT ship an ' +
'unpatched getter, it will corrupt large inputs under wasm2js.'
);
}
bgJs = bgJs.replace(gatedGuard, '$1.buffer !== wasm.memory.buffer');
writeFileSync(bgJsPath, bgJs);
// Post-MVP features that wasm2js cannot translate must be lowered to MVP first.
// reference-types stays enabled: it only covers the funcref table here, which
// wasm2js handles via call_indirect.
const featureFlags = [
'--enable-bulk-memory',
'--enable-sign-ext',
'--enable-nontrapping-float-to-int',
'--enable-mutable-globals',
'--enable-reference-types',
];
console.log('[build-rn] optimising and lowering to MVP with wasm-opt');
run(wasmOpt, [
...featureFlags,
'-O3',
'--signext-lowering',
'--llvm-memory-copy-fill-lowering',
'--llvm-nontrapping-fptoint-lowering',
bgWasm,
'-o',
loweredWasm,
]);
console.log('[build-rn] translating wasm -> JS with wasm2js');
run(wasm2js, ['--enable-reference-types', loweredWasm, '-o', bgWasmJs]);
console.log('[build-rn] wiring the JS translation into reconcile_text.js');
const entry = readFileSync(entryJs, 'utf8');
const rewired = entry.replace(
/from\s+(['"])\.\/reconcile_text_bg\.wasm\1/,
'from $1./reconcile_text_bg.wasm.js$1'
);
if (rewired === entry) {
throw new Error(
`Could not find the \`./reconcile_text_bg.wasm\` import in ${entryJs}; ` +
'the wasm-bindgen bundler output layout may have changed.'
);
}
writeFileSync(entryJs, rewired);
// The binary and the intermediate are no longer referenced; remove them so no
// bundler attempts to instantiate WebAssembly from this directory.
rmSync(bgWasm, { force: true });
rmSync(loweredWasm, { force: true });
// Mark the directory as ESM (matching the web `pkg/`) so Node and Jest treat
// these `.js` files as modules. `sideEffects` stays true because importing the
// entry runs `__wbg_set_wasm(...)`, which must not be tree-shaken away.
writeFileSync(
resolve(outDir, 'package.json'),
JSON.stringify({ type: 'module', sideEffects: true }, null, 2) + '\n'
);
// Backstop: import the freshly generated module and prove it survives a heap grow.
// The patches above are matched by regex against wasm-bindgen output; a silently
// mis-applied patch (or a wasm-bindgen change we matched too loosely) would leave a
// getter reading the stale pre-grow buffer and corrupt large inputs only. Rather than
// trust the regexes, we force a grow here and assert a byte-exact round-trip, so a
// broken bundle fails the build instead of reaching a React Native consumer.
async function selfTest() {
// Importing the entry runs `__wbg_set_wasm(...)`, initialising the wasm2js module.
const api = await import(pathToFileURL(entryJs).href);
// Same module instance (Node caches by resolved path), so this `memory` is the heap
// the API operates on; its `.buffer` getter reflects the current (post-grow) buffer.
const { memory } = await import(pathToFileURL(bgWasmJs).href);
// ~100 KB of distinct tokens. The diff working set amplifies the input many-fold
// (a ~50 KB input already forces dozens of grows), so this reliably grows the heap
// well past wasm2js's ~1 MB initial allocation while staying fast. A tiny parent
// keeps the edit distance — and therefore the runtime — small.
const tokens = [];
for (let i = 0; i < 10000; i++) {
tokens.push(`token-${i}`);
}
const target = tokens.join(' ');
const parent = 'reconcile self-test';
const heapBefore = memory.buffer.byteLength;
// Stale post-grow reads surface either as an out-of-bounds throw or as silently
// wrong bytes, so handle both: a throw here is itself the failure signal.
let roundTripped;
try {
const changed = new api.TextWithCursors(target, []);
const compact = api
.diff(parent, changed, 'Word')
// This build's `undiff` rejects BigInt; normalise exactly as src/core.ts does.
.map((item) => (typeof item === 'bigint' ? Number(item) : item));
changed.free();
roundTripped = api.undiff(parent, compact, 'Word');
} catch (cause) {
throw new Error(
'[build-rn] self-test crashed during a large diff/undiff round-trip (after the heap ' +
'grew). This is the signature of unpatched wasm2js cached-memory getters reading the ' +
'stale pre-grow buffer. The growth patch is not taking effect. Refusing to ship this ' +
'React Native bundle.',
{ cause }
);
}
const heapAfter = memory.buffer.byteLength;
if (heapAfter <= heapBefore) {
throw new Error(
`[build-rn] self-test did not grow the wasm heap (stayed at ${heapBefore} bytes), ` +
'so it cannot validate the memory-growth patch. Enlarge the self-test input.'
);
}
if (roundTripped !== target) {
throw new Error(
'[build-rn] self-test FAILED: diff/undiff round-trip did not match after a heap grow. ' +
'The patched wasm2js cached-memory getters are returning stale/corrupt data — the ' +
'growth patch is not taking effect. Refusing to ship this React Native bundle.'
);
}
}
console.log('[build-rn] self-testing the patched module (forces a heap grow)');
await selfTest();
console.log('[build-rn] done -> pkg-rn/');

400
reconcile-js/src/core.ts Normal file
View file

@ -0,0 +1,400 @@
// Shared, platform-agnostic wrapper around the generated wasm-bindgen surface.
//
// The actual wasm bindings are injected by a platform-specific entrypoint:
// - `index.ts` (web/node) instantiates the real WebAssembly module lazily
// on first use via `initSync`.
// - `index.rn.ts` (React Native / Hermes) links a wasm2js (pure-JS)
// implementation, since Hermes does not expose a runtime
// `WebAssembly` global. See `scripts/build-rn.mjs`.
type WasmModule = typeof import('reconcile-text');
/**
* The generated wasm-bindgen surface this library wraps, plus a hook to make
* sure the underlying module is ready. Supplied by a platform entrypoint.
*/
export interface WasmBackend {
CursorPosition: WasmModule['CursorPosition'];
TextWithCursors: WasmModule['TextWithCursors'];
reconcile: WasmModule['reconcile'];
reconcileWithHistory: WasmModule['reconcileWithHistory'];
diff: WasmModule['diff'];
undiff: WasmModule['undiff'];
/**
* Make the wasm module ready for use. Invoked before every operation, so it
* must be cheap and idempotent (a no-op once initialised).
*/
ensureReady(): void;
}
// Define the enum values as a const array to avoid duplication
const BUILTIN_TOKENIZERS = ['Character', 'Line', 'Markdown', 'Word'] as const;
/**
* Tokenisation strategies for text merging.
*
* These correspond to the built-in tokenizers available in the underlying WASM module.
*/
export type BuiltinTokenizer = (typeof BUILTIN_TOKENIZERS)[number];
/**
* History classification for text spans in merge results.
*
* Indicates the origin of each text span in the merged document.
*/
export type History =
| 'Unchanged'
| 'AddedFromLeft'
| 'AddedFromRight'
| 'RemovedFromLeft'
| 'RemovedFromRight';
/**
* Represents a text document with associated cursor positions.
*
* This interface is used both as input to reconcile functions (to specify where
* cursors are positioned in the original documents) and as output (with cursors
* automatically repositioned after merging).
*/
export interface TextWithCursors {
/** The document's entire content as a string */
text: string;
/**
* Array of cursor positions within the text. Can be empty if there are no cursors to track.
* Each cursor has a unique ID and position.
*/
cursors: CursorPosition[];
}
/**
* Like `TextWithCursors`, but cursors may be null or undefined (treated as empty).
* Used as input where cursor tracking is optional.
*/
export interface TextWithOptionalCursors {
/** The document's entire content as a string */
text: string;
/**
* Array of cursor positions within the text. Can be null, undefined, or empty
* if there are no cursors to track. Each cursor has a unique ID and position.
*/
cursors: null | undefined | CursorPosition[];
}
/**
* Represents a cursor position within a text document.
*
* Cursors are automatically repositioned during text merging to maintain their
* relative positions as text is inserted, deleted, or modified around them.
*/
export interface CursorPosition {
/** Unique identifier for the cursor (can be any number, must be unique within the document) */
id: number;
/** Character position in the text, 0-based index from the beginning of the document */
position: number;
}
/**
* Represents a merged text document with cursor positions and detailed change history.
*
* This is the return type of `reconcileWithHistory()` and provides complete information
* about how the merge was performed, including which parts of the final text came from
* which source documents.
*/
export interface TextWithCursorsAndHistory {
/** The merged document's entire content */
text: string;
/**
* Array of cursor positions within the merged text. Can be empty if there are no cursors to track.
* All cursors are automatically repositioned from the left and right documents.
*/
cursors: CursorPosition[];
/**
* Detailed provenance information showing the origin of each text span in the result.
* Each span indicates whether it was unchanged, added from left, added from right, etc.
*/
history: SpanWithHistory[];
}
/**
* Represents a span of text in the merged result with its change history.
*
* This shows exactly which source document contributed each piece of text to the
* final merged result. Useful for understanding merge decisions and creating
* visualisations of how documents were combined.
*/
export interface SpanWithHistory {
/** The text content of this span */
text: string;
/** The origin of this text span in the merge result */
history: History;
}
/** The public, synchronous API surface, identical across platforms. */
export interface ReconcileApi {
/**
* Merges three versions of text using intelligent conflict resolution.
*
* This is the primary function for 3-way text merging. Unlike traditional merge tools
* that produce conflict markers, this function automatically resolves conflicts by
* applying both sets of changes where possible.
*
* @param original - The original/base version of the text that both sides diverged from
* @param left - The left version of the text (either string or TextWithCursors with cursor positions)
* @param right - The right version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy: "Word" (default, recommended for prose),
* "Character" (fine-grained), "Line" (similar to git merge), or
* "Markdown" (splits on Markdown structure)
* @returns The reconciled text with automatically repositioned cursor positions
*
* @example
* ```typescript
* const original = "Hello world";
* const left = "Hello beautiful world"; // Added "beautiful"
* const right = "Hi world"; // Changed "Hello" to "Hi"
*
* const result = reconcile(original, left, right);
* console.log(result.text); // "Hi beautiful world"
* ```
*/
reconcile(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer?: BuiltinTokenizer
): TextWithCursors;
/**
* Generates a compact diff representation between an original and changed text.
*
* These can be parsed and unpacked using the `undiff` function or the Rust crate's EditedText::from_diff.
* Cursor positions are omitted from the diff result.
*
* This function computes the differences between two versions of text and returns
* a compact representation of those changes.
*
* @param original - The original/base version of the text
* @param changed - The modified version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy, which is the same as used in `reconcile`.
* @returns An array of inserts (strings), deletes (negative integers), and retained spans (positive integers).
*/
diff(
original: string,
changed: string | TextWithOptionalCursors,
tokenizer?: BuiltinTokenizer
): Array<number | string>;
/**
* Applies a compact diff to an original text to reconstruct the changed version.
*
* This function takes an original text and a compact diff representation (as produced
* by the `diff` function) and reconstructs the modified text.
*
* @param original - The original/base version of the text
* @param diff - The compact diff array (inserts as strings, deletes as negative integers, retained spans as positive integers)
* @param tokenizer - The tokenisation strategy, which is the same as used in `reconcile`.
* @returns The reconstructed changed text as a string.
*/
undiff(
original: string,
diff: Array<number | bigint | string>,
tokenizer?: BuiltinTokenizer
): string;
/**
* Merges three versions of text and returns detailed provenance information.
*
* This function behaves like `reconcile()` but also provides
* detailed historical information about the origin of each text span in the result.
* This is valuable for understanding how the merge was performed and which changes
* came from which source.
*
* Note: Computing the history is computationally more expensive than the basic merge.
*
* @param original - The original/base version of the text that both sides diverged from
* @param left - The left version of the text (either string or TextWithCursors with cursor positions)
* @param right - The right version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy: "Word" (default, recommended for prose),
* "Character" (fine-grained), "Line" (similar to git merge), or
* "Markdown" (splits on Markdown structure)
* @returns The reconciled text with cursor positions and detailed change history
*
* @example
* ```typescript
* const original = "Hello world";
* const left = "Hello beautiful world";
* const right = "Hi world";
*
* const result = reconcileWithHistory(original, left, right);
* console.log(result.text); // "Hi beautiful world"
* console.log(result.history); // Array of SpanWithHistory objects showing change origins
* ```
*/
reconcileWithHistory(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer?: BuiltinTokenizer
): TextWithCursorsAndHistory;
}
const UNSUPPORTED_TOKENIZER_ERROR = `Unsupported tokenizer, only ${BUILTIN_TOKENIZERS.join(
', '
)} are supported`;
/**
* Build the public {@link ReconcileApi} on top of a {@link WasmBackend}.
*
* Each operation calls `backend.ensureReady()` first, then marshals JS values
* into the wasm representation, invokes the binding, and frees the wasm-side
* objects. The behaviour is identical regardless of whether the backend is a
* real WebAssembly module or its wasm2js translation.
*/
export function makeReconcileApi(backend: WasmBackend): ReconcileApi {
function assertTokenizer(tokenizer: BuiltinTokenizer): void {
if (!BUILTIN_TOKENIZERS.includes(tokenizer)) {
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
}
}
function toWasmTextWithCursors(text: string | TextWithOptionalCursors) {
const isInputString = typeof text === 'string';
const innerText = isInputString ? text : text.text;
const innerCursors = isInputString ? [] : (text.cursors ?? []);
return new backend.TextWithCursors(
innerText,
innerCursors.map(({ id, position }) => new backend.CursorPosition(id, position))
);
}
function toTextWithCursors(textWithCursor: {
text(): string;
cursors(): Array<{ id(): number; characterIndex(): number; free(): void }>;
}): TextWithCursors {
const wasmCursors = textWithCursor.cursors();
const cursors = wasmCursors.map((cursor) => ({
id: cursor.id(),
position: cursor.characterIndex(),
}));
for (const cursor of wasmCursors) {
cursor.free();
}
return {
text: textWithCursor.text(),
cursors,
};
}
function toSpanWithHistory(span: {
text(): string;
history(): History;
free(): void;
}): SpanWithHistory {
const result = {
text: span.text(),
history: span.history(),
};
span.free();
return result;
}
function reconcile(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): TextWithCursors {
backend.ensureReady();
assertTokenizer(tokenizer);
const leftCursor = toWasmTextWithCursors(left);
const rightCursor = toWasmTextWithCursors(right);
const result = backend.reconcile(original, leftCursor, rightCursor, tokenizer);
leftCursor.free();
rightCursor.free();
const jsResult = toTextWithCursors(result);
result.free();
return jsResult;
}
function diff(
original: string,
changed: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): Array<number | string> {
backend.ensureReady();
assertTokenizer(tokenizer);
const changedWasm = toWasmTextWithCursors(changed);
const result = backend.diff(original, changedWasm, tokenizer);
changedWasm.free();
return result.map((item) => (typeof item === 'bigint' ? Number(item) : item));
}
function undiff(
original: string,
diffValue: Array<number | bigint | string>,
tokenizer: BuiltinTokenizer = 'Word'
): string {
backend.ensureReady();
assertTokenizer(tokenizer);
// The real-WebAssembly backend's `diff` emits BigInt spans, whereas the
// wasm2js (React Native) backend rejects BigInt outright. Normalise to
// plain numbers - exactly as `diff` does on the way out - so a `diff`
// result round-trips through `undiff` identically on every platform.
return backend.undiff(
original,
diffValue.map((item) => (typeof item === 'bigint' ? Number(item) : item)),
tokenizer
);
}
function reconcileWithHistory(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): TextWithCursorsAndHistory {
backend.ensureReady();
assertTokenizer(tokenizer);
const leftCursor = toWasmTextWithCursors(left);
const rightCursor = toWasmTextWithCursors(right);
const result = backend.reconcileWithHistory(
original,
leftCursor,
rightCursor,
tokenizer
);
leftCursor.free();
rightCursor.free();
const jsResult = toTextWithCursors(result);
const history = result.history().map(toSpanWithHistory);
result.free();
return {
...jsResult,
history,
};
}
return { reconcile, diff, undiff, reconcileWithHistory };
}

View file

@ -0,0 +1,47 @@
// React Native entrypoint (resolved via the `react-native` package field).
//
// Hermes — the default React Native engine since RN 0.84 / Expo SDK 56 — does
// not expose a runtime `WebAssembly` global, so the normal `new
// WebAssembly.Module(...)` path used by `index.ts` throws
// `ReferenceError: Property 'WebAssembly' doesn't exist`.
//
// Instead we link a wasm2js translation of the module: pure JavaScript that
// needs no `WebAssembly` global and is instantiated synchronously at import
// time. The public API and its synchronous signatures are unchanged, so
// callers need no modification. The `pkg-rn` directory is generated by
// `scripts/build-rn.mjs`.
import {
CursorPosition as wasmCursorPosition,
TextWithCursors as wasmTextWithCursors,
reconcile as wasmReconcile,
reconcileWithHistory as wasmReconcileWithHistory,
diff as wasmDiff,
undiff as wasmUndiff,
} from '../pkg-rn/reconcile_text.js';
import { makeReconcileApi, type WasmBackend } from './core';
const backend: WasmBackend = {
CursorPosition: wasmCursorPosition,
TextWithCursors: wasmTextWithCursors,
reconcile: wasmReconcile,
reconcileWithHistory: wasmReconcileWithHistory,
diff: wasmDiff,
undiff: wasmUndiff,
// The wasm2js module initialises itself at import time, so this is a no-op.
ensureReady() {},
};
export const { reconcile, diff, undiff, reconcileWithHistory } =
makeReconcileApi(backend);
export type {
BuiltinTokenizer,
History,
CursorPosition,
TextWithCursors,
TextWithOptionalCursors,
TextWithCursorsAndHistory,
SpanWithHistory,
} from './core';

View file

@ -1,4 +1,5 @@
import { reconcile, reconcileWithHistory, diff, undiff } from './index'; import * as webApi from './index';
import * as rnApi from './index.rn';
import { installWasmLeakDetector, checkForWasmLeaks } from './wasm-leak-detector'; import { installWasmLeakDetector, checkForWasmLeaks } from './wasm-leak-detector';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -17,7 +18,18 @@ afterEach(() => {
} }
}); });
describe('reconcile', () => { // `./index` is the web/node build (real WebAssembly); `./index.rn` is the React
// Native build (the wasm2js pure-JS translation). Both are thin backends over the
// same `src/core.ts` wrapper and expose an identical public API, so the behavioural
// suite below runs against both to guarantee they stay in lock-step.
const backends = [
{ name: 'web/node (WebAssembly)', api: webApi },
{ name: 'React Native (wasm2js)', api: rnApi },
];
describe.each(backends)('reconcile [$name]', ({ api }) => {
const { reconcile, reconcileWithHistory, diff, undiff } = api;
it('call reconcile without cursors', () => { it('call reconcile without cursors', () => {
expect(reconcile('Hello', 'Hello world', 'Hi world').text).toEqual('Hi world'); expect(reconcile('Hello', 'Hello world', 'Hi world').text).toEqual('Hi world');
}); });
@ -60,9 +72,26 @@ describe('reconcile', () => {
expect(result.text).toEqual('Hi world'); expect(result.text).toEqual('Hi world');
expect(result.history.length).toBeGreaterThan(0); expect(result.history.length).toBeGreaterThan(0);
}); });
it('undiff accepts bigint entries (per the Array<number | bigint | string> type)', () => {
const original = 'Hello world';
const changed = 'Hello cruel world';
// `diff` returns plain numbers; emulate a caller that supplies BigInt, which the
// public signature permits. The wasm2js build rejects raw BigInt, so the shared
// wrapper must normalise it — running this on both backends asserts the contract.
const withBigints = diff(original, changed).map((item) =>
typeof item === 'number' ? BigInt(item) : item
);
expect(withBigints.some((item) => typeof item === 'bigint')).toBe(true);
expect(undiff(original, withBigints)).toEqual(changed);
});
}); });
describe('test_diff_and_undiff_are_inverse', () => { describe.each(backends)('diff and undiff are inverse [$name]', ({ api }) => {
const { diff, undiff } = api;
const resourcesPath = path.join(__dirname, '../../tests/resources'); const resourcesPath = path.join(__dirname, '../../tests/resources');
const readFileSlice = (fileName: string, start: number, end: number): string => { const readFileSlice = (fileName: string, start: number, end: number): string => {
@ -93,3 +122,31 @@ describe('test_diff_and_undiff_are_inverse', () => {
}); });
}); });
}); });
// React-Native-only: Hermes exposes no `WebAssembly` global, which is the whole reason
// the RN entry point links a wasm2js build. Only the wasm2js backend can satisfy this.
describe('React Native (wasm2js) Hermes parity', () => {
const { reconcile, reconcileWithHistory, diff, undiff } = rnApi;
it('runs every operation with no WebAssembly global', () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'WebAssembly');
delete (globalThis as { WebAssembly?: unknown }).WebAssembly;
try {
expect((globalThis as { WebAssembly?: unknown }).WebAssembly).toBeUndefined();
expect(reconcile('Hello', 'Hello world', 'Hi world').text).toEqual('Hi world');
const changes = diff('Hello world', 'Hello cruel world');
expect(undiff('Hello world', changes)).toEqual('Hello cruel world');
expect(
reconcileWithHistory('Hello', 'Hello world', 'Hi world').history.length
).toBeGreaterThan(0);
} finally {
// Restore the global so the leak check and later suites are unaffected.
if (descriptor) {
Object.defineProperty(globalThis, 'WebAssembly', descriptor);
}
}
});
});

View file

@ -1,8 +1,7 @@
import { import {
CursorPosition as wasmCursorPosition, CursorPosition as wasmCursorPosition,
reconcile as wasmReconcile,
TextWithCursors as wasmTextWithCursors, TextWithCursors as wasmTextWithCursors,
SpanWithHistory as wasmSpanWithHistory, reconcile as wasmReconcile,
reconcileWithHistory as wasmReconcileWithHistory, reconcileWithHistory as wasmReconcileWithHistory,
diff as wasmDiff, diff as wasmDiff,
undiff as wasmUndiff, undiff as wasmUndiff,
@ -11,290 +10,18 @@ import {
import wasmBytes from 'reconcile-text/reconcile_text_bg.wasm'; import wasmBytes from 'reconcile-text/reconcile_text_bg.wasm';
// Define the enum values as const arrays to avoid duplication import { makeReconcileApi, type WasmBackend } from './core';
const BUILTIN_TOKENIZERS = ['Character', 'Line', 'Markdown', 'Word'] as const;
const HISTORY_VALUES = [
'Unchanged',
'AddedFromLeft',
'AddedFromRight',
'RemovedFromLeft',
'RemovedFromRight',
] as const;
/**
* Tokenisation strategies for text merging.
*
* These correspond to the built-in tokenizers available in the underlying WASM module.
*/
export type BuiltinTokenizer = (typeof BUILTIN_TOKENIZERS)[number];
/**
* History classification for text spans in merge results.
*
* Indicates the origin of each text span in the merged document.
*/
export type History = (typeof HISTORY_VALUES)[number];
/**
* Represents a text document with associated cursor positions.
*
* This interface is used both as input to reconcile functions (to specify where
* cursors are positioned in the original documents) and as output (with cursors
* automatically repositioned after merging).
*/
export interface TextWithCursors {
/** The document's entire content as a string */
text: string;
/**
* Array of cursor positions within the text. Can be empty if there are no cursors to track.
* Each cursor has a unique ID and position.
*/
cursors: CursorPosition[];
}
/**
* Like `TextWithCursors`, but cursors may be null or undefined (treated as empty).
* Used as input where cursor tracking is optional.
*/
export interface TextWithOptionalCursors {
/** The document's entire content as a string */
text: string;
/**
* Array of cursor positions within the text. Can be null, undefined, or empty
* if there are no cursors to track. Each cursor has a unique ID and position.
*/
cursors: null | undefined | CursorPosition[];
}
/**
* Represents a cursor position within a text document.
*
* Cursors are automatically repositioned during text merging to maintain their
* relative positions as text is inserted, deleted, or modified around them.
*/
export interface CursorPosition {
/** Unique identifier for the cursor (can be any number, must be unique within the document) */
id: number;
/** Character position in the text, 0-based index from the beginning of the document */
position: number;
}
/**
* Represents a merged text document with cursor positions and detailed change history.
*
* This is the return type of `reconcileWithHistory()` and provides complete information
* about how the merge was performed, including which parts of the final text came from
* which source documents.
*/
export interface TextWithCursorsAndHistory {
/** The merged document's entire content */
text: string;
/**
* Array of cursor positions within the merged text. Can be empty if there are no cursors to track.
* All cursors are automatically repositioned from the left and right documents.
*/
cursors: CursorPosition[];
/**
* Detailed provenance information showing the origin of each text span in the result.
* Each span indicates whether it was unchanged, added from left, added from right, etc.
*/
history: SpanWithHistory[];
}
/**
* Represents a span of text in the merged result with its change history.
*
* This shows exactly which source document contributed each piece of text to the
* final merged result. Useful for understanding merge decisions and creating
* visualisations of how documents were combined.
*/
export interface SpanWithHistory {
/** The text content of this span */
text: string;
/** The origin of this text span in the merge result */
history: History;
}
const UNSUPPORTED_TOKENIZER_ERROR = `Unsupported tokenizer, only ${BUILTIN_TOKENIZERS.join(
', '
)} are supported`;
let isInitialised = false; let isInitialised = false;
/** const backend: WasmBackend = {
* Merges three versions of text using intelligent conflict resolution. CursorPosition: wasmCursorPosition,
* TextWithCursors: wasmTextWithCursors,
* This is the primary function for 3-way text merging. Unlike traditional merge tools reconcile: wasmReconcile,
* that produce conflict markers, this function automatically resolves conflicts by reconcileWithHistory: wasmReconcileWithHistory,
* applying both sets of changes where possible. diff: wasmDiff,
* undiff: wasmUndiff,
* @param original - The original/base version of the text that both sides diverged from ensureReady() {
* @param left - The left version of the text (either string or TextWithCursors with cursor positions)
* @param right - The right version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy: "Word" (default, recommended for prose),
* "Character" (fine-grained), or "Line" (similar to git merge)
* @returns The reconciled text with automatically repositioned cursor positions
*
* @example
* ```typescript
* const original = "Hello world";
* const left = "Hello beautiful world"; // Added "beautiful"
* const right = "Hi world"; // Changed "Hello" to "Hi"
*
* const result = reconcile(original, left, right);
* console.log(result.text); // "Hi beautiful world"
* ```
*/
export function reconcile(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): TextWithCursors {
init();
if (!BUILTIN_TOKENIZERS.includes(tokenizer)) {
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
}
const leftCursor = toWasmTextWithCursors(left);
const rightCursor = toWasmTextWithCursors(right);
const result = wasmReconcile(original, leftCursor, rightCursor, tokenizer);
leftCursor.free();
rightCursor.free();
const jsResult = toTextWithCursors(result);
result.free();
return jsResult;
}
/**
* Generates a compact diff representation between an original and changed text.
*
* These can be parsed and unpacked using the `undiff` function or the Rust crate's EditedText::from_diff.
* Cursor positions are omitted from the diff result.
*
* This function computes the differences between two versions of text and returns
* a compact representation of those changes.
*
* @param original - The original/base version of the text
* @param changed - The modified version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy, which is the same as used in `reconcile`.
* @returns An array of inserts (strings), deletes (negative integers), and retained spans (positive integers).
*/
export function diff(
original: string,
changed: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): Array<number | string> {
init();
if (!BUILTIN_TOKENIZERS.includes(tokenizer)) {
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
}
const changedWasm = toWasmTextWithCursors(changed);
const result = wasmDiff(original, changedWasm, tokenizer);
changedWasm.free();
return result.map((item) => (typeof item === 'bigint' ? Number(item) : item));
}
/**
* Applies a compact diff to an original text to reconstruct the changed version.
*
* This function takes an original text and a compact diff representation (as produced
* by the `diff` function) and reconstructs the modified text.
*
* @param original - The original/base version of the text
* @param diff - The compact diff array (inserts as strings, deletes as negative integers, retained spans as positive integers)
* @param tokenizer - The tokenisation strategy, which is the same as used in `reconcile`.
* @returns The reconstructed changed text as a string.
*/
export function undiff(
original: string,
diff: Array<number | bigint | string>,
tokenizer: BuiltinTokenizer = 'Word'
): string {
init();
if (!BUILTIN_TOKENIZERS.includes(tokenizer)) {
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
}
return wasmUndiff(original, diff, tokenizer);
}
/**
* Merges three versions of text and returns detailed provenance information.
*
* This function behaves like `reconcile()` but also provides
* detailed historical information about the origin of each text span in the result.
* This is valuable for understanding how the merge was performed and which changes
* came from which source.
*
* Note: Computing the history is computationally more expensive than the basic merge.
*
* @param original - The original/base version of the text that both sides diverged from
* @param left - The left version of the text (either string or TextWithCursors with cursor positions)
* @param right - The right version of the text (either string or TextWithCursors with cursor positions)
* @param tokenizer - The tokenisation strategy: "Word" (default, recommended for prose),
* "Character" (fine-grained), or "Line" (similar to git merge)
* @returns The reconciled text with cursor positions and detailed change history
*
* @example
* ```typescript
* const original = "Hello world";
* const left = "Hello beautiful world";
* const right = "Hi world";
*
* const result = reconcileWithHistory(original, left, right);
* console.log(result.text); // "Hi beautiful world"
* console.log(result.history); // Array of SpanWithHistory objects showing change origins
* ```
*/
export function reconcileWithHistory(
original: string,
left: string | TextWithOptionalCursors,
right: string | TextWithOptionalCursors,
tokenizer: BuiltinTokenizer = 'Word'
): TextWithCursorsAndHistory {
init();
if (!BUILTIN_TOKENIZERS.includes(tokenizer)) {
throw new Error(UNSUPPORTED_TOKENIZER_ERROR);
}
const leftCursor = toWasmTextWithCursors(left);
const rightCursor = toWasmTextWithCursors(right);
const result = wasmReconcileWithHistory(original, leftCursor, rightCursor, tokenizer);
leftCursor.free();
rightCursor.free();
const jsResult = toTextWithCursors(result);
const history = result.history().map(toSpanWithHistory);
result.free();
return {
...jsResult,
history,
};
}
function init() {
if (isInitialised) { if (isInitialised) {
return; return;
} }
@ -305,47 +32,18 @@ function init() {
initSync({ module: wasmBinary }); initSync({ module: wasmBinary });
isInitialised = true; isInitialised = true;
} },
function toWasmTextWithCursors(
text: string | TextWithOptionalCursors
): wasmTextWithCursors {
const isInputString = typeof text === 'string';
const leftText = isInputString ? text : text.text;
const leftCursors = isInputString ? [] : (text.cursors ?? []);
return new wasmTextWithCursors(leftText, leftCursors.map(toWasmCursorPosition));
}
function toWasmCursorPosition({ id, position }: CursorPosition): wasmCursorPosition {
return new wasmCursorPosition(id, position);
}
function toTextWithCursors(textWithCursor: wasmTextWithCursors): TextWithCursors {
const wasmCursors = textWithCursor.cursors();
const cursors = wasmCursors.map(toCursorPosition);
for (const cursor of wasmCursors) {
cursor.free();
}
return {
text: textWithCursor.text(),
cursors,
}; };
}
function toCursorPosition(cursor: wasmCursorPosition): CursorPosition { export const { reconcile, diff, undiff, reconcileWithHistory } =
return { makeReconcileApi(backend);
id: cursor.id(),
position: cursor.characterIndex(),
};
}
function toSpanWithHistory(span: wasmSpanWithHistory): SpanWithHistory { export type {
const result = { BuiltinTokenizer,
text: span.text(), History,
history: span.history(), CursorPosition,
}; TextWithCursors,
span.free(); TextWithOptionalCursors,
return result; TextWithCursorsAndHistory,
} SpanWithHistory,
} from './core';

View file

@ -2,7 +2,6 @@ const path = require('path');
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const common = { const common = {
entry: './src/index.ts',
optimization: { optimization: {
// the consuming project should take care of minification // the consuming project should take care of minification
minimize: false, minimize: false,
@ -38,8 +37,10 @@ const common = {
}; };
module.exports = [ module.exports = [
// Web build: real WebAssembly, instantiated synchronously from inlined base64.
merge(common, { merge(common, {
target: 'web', target: 'web',
entry: './src/index.ts',
output: { output: {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: 'reconcile.web.js', filename: 'reconcile.web.js',
@ -50,12 +51,31 @@ module.exports = [
globalObject: 'this', globalObject: 'this',
}, },
}), }),
// Node build: real WebAssembly.
merge(common, { merge(common, {
target: 'node', target: 'node',
entry: './src/index.ts',
output: { output: {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: 'reconcile.node.js', filename: 'reconcile.node.js',
libraryTarget: 'commonjs2', libraryTarget: 'commonjs2',
}, },
}), }),
// React Native build: wasm2js (pure JS), for Hermes which has no
// `WebAssembly` global. Sources come from `pkg-rn/`
merge(common, {
target: 'web',
entry: './src/index.rn.ts',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'reconcile.rn.js',
library: {
name: 'reconcile',
type: 'umd',
},
globalObject: 'this',
},
}),
]; ];

View file

@ -2,6 +2,22 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "cc"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@ -55,6 +71,7 @@ version = "0.28.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf94ee265674bf76c09fa430b0e99c26e319c945d96ca0d5a8215f31bf81cf7" checksum = "8bf94ee265674bf76c09fa430b0e99c26e319c945d96ca0d5a8215f31bf81cf7"
dependencies = [ dependencies = [
"python3-dll-a",
"target-lexicon", "target-lexicon",
] ]
@ -93,6 +110,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "python3-dll-a"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d80ba7540edb18890d444c5aa8e1f1f99b1bdf26fb26ae383135325f4a36042b"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@ -104,19 +130,25 @@ dependencies = [
[[package]] [[package]]
name = "reconcile-text" name = "reconcile-text"
version = "0.11.0" version = "0.12.1"
dependencies = [ dependencies = [
"thiserror", "thiserror",
] ]
[[package]] [[package]]
name = "reconcile-text-python" name = "reconcile-text-python"
version = "0.11.0" version = "0.12.1"
dependencies = [ dependencies = [
"pyo3", "pyo3",
"reconcile-text", "reconcile-text",
] ]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "reconcile-text-python" name = "reconcile-text-python"
version = "0.11.0" version = "0.12.1"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
authors = ["Andras Schmelczer <andras@schmelczer.dev>"] authors = ["Andras Schmelczer <andras@schmelczer.dev>"]
@ -13,4 +13,4 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
reconcile-text = { path = ".." } reconcile-text = { path = ".." }
pyo3 = { version = "0.28.2", features = ["extension-module"] } pyo3 = { version = "0.28.2", features = ["extension-module", "abi3-py39", "generate-import-lib"] }

View file

@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "reconcile-text" name = "reconcile-text"
version = "0.11.0" version = "0.12.1"
description = "Intelligent 3-way text merging with automated conflict resolution" description = "Intelligent 3-way text merging with automated conflict resolution"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }

View file

@ -168,7 +168,7 @@ wheels = [
[[package]] [[package]]
name = "reconcile-text" name = "reconcile-text"
version = "0.11.0" version = "0.12.1"
source = { editable = "." } source = { editable = "." }
[package.dev-dependencies] [package.dev-dependencies]

View file

@ -5,9 +5,6 @@ set -e
which cargo-machete || cargo install cargo-machete which cargo-machete || cargo install cargo-machete
cargo machete cargo machete
which lychee || cargo install lychee
lychee --verbose --exclude npmjs.com README.md
cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged
cargo fmt --all cargo fmt --all