Compare commits

...

23 commits
0.9.3 ... main

Author SHA1 Message Date
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
8d14510b1c Bump versions to 0.11.0 2026-03-14 12:03:35 +00:00
6d63d0ee8f Refactor & improve diffing 2026-03-14 11:59:41 +00:00
fc0d17837d Implement hash 2026-03-14 11:59:41 +00:00
1c94f771b2 Bump versions to 0.10.0 2026-03-12 22:12:22 +00:00
bd3c454941 Fix publishing for real 2026-03-12 22:12:13 +00:00
656f3a91df Bump versions to 0.9.7 2026-03-12 21:47:04 +00:00
b611ac813e Try again 2026-03-12 21:46:52 +00:00
4f8abc9ce2 Bump versions to 0.9.6 2026-03-12 21:15:51 +00:00
77e5fc07d3 Fix publishing 2026-03-12 21:15:40 +00:00
f661e1d6f9 Bump versions to 0.9.5 2026-03-12 20:25:43 +00:00
4cc0444b5b Fix intellisense 2026-03-12 20:25:32 +00:00
7ad029924e Fix package json 2026-03-12 20:22:20 +00:00
32d338d496 Bump versions to 0.9.4 2026-03-12 07:50:55 +00:00
e08ef27d6a Open on new page 2026-03-12 07:50:42 +00:00
149ff8fd95 Fix missing readme 2026-03-12 07:50:42 +00:00
dependabot[bot]
5d588b1bac Bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-12 07:47:03 +00:00
dependabot[bot]
7759275a53 Bump astral-sh/setup-uv from 6 to 7
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 6 to 7.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v6...v7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-12 07:46:48 +00:00
dependabot[bot]
386535497b Bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-12 07:46:40 +00:00
6d280112fd Fix vulnerabilities 2026-03-12 07:46:24 +00:00
22 changed files with 723 additions and 516 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,172 @@
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 }}

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,198 +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@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: 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: '22.x'
check-latest: true
registry-url: 'https://registry.npmjs.org'
- 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 --provenance --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@v4
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@v4
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@v4
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@v4

View file

@ -8,5 +8,8 @@
}, },
"rust-analyzer.cargo.features": [ "rust-analyzer.cargo.features": [
"all" "all"
],
"python.analysis.extraPaths": [
"./reconcile-python/python"
] ]
} }

2
Cargo.lock generated
View file

@ -428,7 +428,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "reconcile-text" name = "reconcile-text"
version = "0.9.3" version = "0.11.0"
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.9.3" version = "0.11.0"
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

@ -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',
'Hi world'
);
console.log(result.text); // "Hi beautiful world" const result = reconcileWithHistory('Hello world', 'Hello beautiful world', 'Hi world');
console.log(result.history); /*
[ console.log(result.text); // "Hi beautiful world"
{
"text": "Hello", const history: SpanWithHistory[] = result.history;
"history": "RemovedFromRight" console.log(history);
}, // [
{ // { text: "Hello", history: "RemovedFromRight" },
"text": "Hi", // { text: "Hi", history: "AddedFromRight" },
"history": "AddedFromRight" // { text: " beautiful", history: "AddedFromLeft" },
}, // { text: " ", history: "Unchanged" },
{ // { text: "world", history: "Unchanged" },
"text": " beautiful", // ]
"history": "AddedFromLeft"
}, const classByHistory = {
{ Unchanged: 'merge-unchanged',
"text": " ", AddedFromLeft: 'merge-added-left',
"history": "Unchanged" AddedFromRight: 'merge-added-right',
}, RemovedFromLeft: 'merge-removed-left',
{ RemovedFromRight: 'merge-removed-right',
"text": "world", } satisfies Record<History, string>;
"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;
{
text: 'Hi world', const right = {
cursors: [{ id: 2, position: 0 }], // At the beginning text: 'Hi world',
} 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,7 +28,7 @@
}, },
"../../reconcile-js": { "../../reconcile-js": {
"name": "reconcile-text", "name": "reconcile-text",
"version": "0.9.3", "version": "0.11.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
@ -651,16 +651,6 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@ -1180,9 +1170,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.17.1", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3396,9 +3386,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -4550,6 +4540,16 @@
} }
} }
}, },
"node_modules/sax": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/schema-utils": { "node_modules/schema-utils": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
@ -5098,19 +5098,19 @@
} }
}, },
"node_modules/svgo": { "node_modules/svgo": {
"version": "3.3.2", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz",
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@trysound/sax": "0.2.0",
"commander": "^7.2.0", "commander": "^7.2.0",
"css-select": "^5.1.0", "css-select": "^5.1.0",
"css-tree": "^2.3.1", "css-tree": "^2.3.1",
"css-what": "^6.1.0", "css-what": "^6.1.0",
"csso": "^5.0.5", "csso": "^5.0.5",
"picocolors": "^1.0.0" "picocolors": "^1.0.0",
"sax": "^1.5.0"
}, },
"bin": { "bin": {
"svgo": "bin/svgo" "svgo": "bin/svgo"

View file

@ -198,6 +198,8 @@
<div class="footer-links"> <div class="footer-links">
<a <a
href="https://www.npmjs.com/package/reconcile-text" href="https://www.npmjs.com/package/reconcile-text"
target="_blank"
rel="noopener noreferrer"
aria-label="npm package" aria-label="npm package"
> >
<svg <svg
@ -212,6 +214,8 @@
</a> </a>
<a <a
href="https://pypi.org/project/reconcile-text/" href="https://pypi.org/project/reconcile-text/"
target="_blank"
rel="noopener noreferrer"
aria-label="PyPI package" aria-label="PyPI package"
> >
<svg <svg
@ -226,6 +230,8 @@
</a> </a>
<a <a
href="https://crates.io/crates/reconcile-text" href="https://crates.io/crates/reconcile-text"
target="_blank"
rel="noopener noreferrer"
aria-label="crates.io crate" aria-label="crates.io crate"
> >
<svg <svg
@ -240,6 +246,8 @@
</a> </a>
<a <a
href="https://github.com/schmelczer/reconcile" href="https://github.com/schmelczer/reconcile"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository" aria-label="GitHub repository"
> >
<svg <svg

View file

@ -479,27 +479,29 @@ $DOT_RADIUS: 4;
} }
footer { footer {
padding: 16px; padding: 32px 16px;
width: 100%; width: 100%;
position: relative;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 24px;
color: $text-secondary; color: $text-secondary;
} }
.github-link > svg { .footer-links {
position: absolute; display: flex;
align-items: center;
gap: 16px;
}
.footer-links > a > svg {
color: $text-secondary; color: $text-secondary;
top: 50%; width: 28px;
right: 36px; height: 28px;
transform: translateY(-50%);
width: 32px;
height: 32px;
transition: transform 0.2s; transition: transform 0.2s;
} }
.github-link > svg:hover { .footer-links > a > svg:hover {
cursor: pointer; cursor: pointer;
transform: translateY(-50%) scale(1.15); transform: scale(1.15);
} }

View file

@ -1,12 +1,12 @@
{ {
"name": "reconcile-text", "name": "reconcile-text",
"version": "0.9.3", "version": "0.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "reconcile-text", "name": "reconcile-text",
"version": "0.9.3", "version": "0.11.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
@ -24,7 +24,7 @@
}, },
"../pkg": { "../pkg": {
"name": "reconcile-text", "name": "reconcile-text",
"version": "0.9.3", "version": "0.11.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },

View file

@ -1,6 +1,6 @@
{ {
"name": "reconcile-text", "name": "reconcile-text",
"version": "0.9.3", "version": "0.11.0",
"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",
@ -18,7 +18,7 @@
"homepage": "https://schmelczer.dev/reconcile/", "homepage": "https://schmelczer.dev/reconcile/",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/schmelczer/reconcile.git" "url": "git+https://github.com/schmelczer/reconcile.git"
}, },
"bugs": { "bugs": {
"url": "https://github.com/schmelczer/reconcile/issues", "url": "https://github.com/schmelczer/reconcile/issues",

View file

@ -104,14 +104,14 @@ dependencies = [
[[package]] [[package]]
name = "reconcile-text" name = "reconcile-text"
version = "0.9.3" version = "0.11.0"
dependencies = [ dependencies = [
"thiserror", "thiserror",
] ]
[[package]] [[package]]
name = "reconcile-text-python" name = "reconcile-text-python"
version = "0.9.3" version = "0.11.0"
dependencies = [ dependencies = [
"pyo3", "pyo3",
"reconcile-text", "reconcile-text",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "reconcile-text-python" name = "reconcile-text-python"
version = "0.9.3" version = "0.11.0"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
authors = ["Andras Schmelczer <andras@schmelczer.dev>"] authors = ["Andras Schmelczer <andras@schmelczer.dev>"]

View file

@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "reconcile-text" name = "reconcile-text"
version = "0.9.3" version = "0.11.0"
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.9.3" version = "0.11.0"
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
@ -20,6 +17,7 @@ npm ci
npm run format npm run format
cd ../../reconcile-python cd ../../reconcile-python
cp ../README.md .
uv run maturin develop -q uv run maturin develop -q
uv run ruff check python/ tests/ uv run ruff check python/ tests/
uv run ruff format python/ tests/ uv run ruff format python/ tests/

View file

@ -28,6 +28,7 @@ npm run test
cd - cd -
cd reconcile-python cd reconcile-python
cp ../README.md .
uv run maturin develop uv run maturin develop
uv run pytest -v uv run pytest -v
cd - cd -

View file

@ -1,4 +1,7 @@
use std::fmt::Debug; use std::{
fmt::Debug,
hash::{Hash, Hasher},
};
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -78,3 +81,14 @@ where
self.normalized == other.normalized self.normalized == other.normalized
} }
} }
/// Hashes based on the `normalized` field only, consistent with the
/// [`PartialEq`] implementation.
impl<T> Hash for Token<T>
where
T: PartialEq + Clone + Debug + Hash,
{
fn hash<H: Hasher>(&self, state: &mut H) {
self.normalized.hash(state);
}
}

View file

@ -11,13 +11,11 @@
//! The implementation of this algorithm is based on the implementation by //! The implementation of this algorithm is based on the implementation by
//! Brandon Williams. //! Brandon Williams.
//! //!
//! # Heuristics //! # Complexity
//! //!
//! At present this implementation of Myers' does not implement any more //! The worst case (completely dissimilar inputs) is `O((N+M)²)` time. In
//! advanced heuristics that would solve some pathological cases. For instance //! practice the divide-and-conquer strategy with prefix/suffix stripping keeps
//! passing two large and completely distinct sequences to the algorithm will //! subproblems small for typical text.
//! make it spin without making reasonable progress.
//! For potential improvements here see [similar#15](https://github.com/mitsuhiko/similar/issues/15).
use std::{ use std::{
fmt::Debug, fmt::Debug,
@ -41,26 +39,21 @@ pub fn myers_diff<T>(old: &[Token<T>], new: &[Token<T>]) -> Vec<RawOperation<T>>
where where
T: PartialEq + Clone + Debug, T: PartialEq + Clone + Debug,
{ {
let max_d = (old.len() + new.len()).div_ceil(2) + 1; let max_edit_distance = (old.len() + new.len()).div_ceil(2) + 1;
let mut vb = V::new(max_d); let mut backward_endpoints = FurthestEndpoints::new(max_edit_distance);
let mut vf = V::new(max_d); let mut forward_endpoints = FurthestEndpoints::new(max_edit_distance);
let mut result = Vec::new(); let mut result = Vec::with_capacity(old.len() + new.len());
conquer( conquer(
old, old,
0..old.len(), 0..old.len(),
new, new,
0..new.len(), 0..new.len(),
&mut vf, &mut forward_endpoints,
&mut vb, &mut backward_endpoints,
&mut result, &mut result,
); );
debug_assert!(
result.iter().all(|op| op.tokens().len() == 1),
"All operations must be of length 1"
);
result result
} }
@ -68,50 +61,52 @@ where
// edges. All D-paths consist of a (D - 1)-path followed by a non-diagonal edge // edges. All D-paths consist of a (D - 1)-path followed by a non-diagonal edge
// and then a possibly empty sequence of diagonal edges called a snake. // and then a possibly empty sequence of diagonal edges called a snake.
/// `V` contains the endpoints of the furthest reaching `D-paths`. For each /// Contains the endpoints of the furthest reaching `D-paths`. For each
/// recorded endpoint `(x,y)` in diagonal `k`, we only need to retain `x` /// recorded endpoint `(x, y)` on diagonal `k`, we only need to retain `x`
/// because `y` can be computed from `x - k`. In other words, `V` is an array of /// because `y` can be computed from `x - k`. In other words, this is an array
/// integers where `V[k]` contains the row index of the endpoint of the furthest /// of integers where `endpoints[k]` contains the row index of the endpoint of
/// reaching path in diagonal `k`. /// the furthest reaching path on diagonal `k`.
/// ///
/// We can't use a traditional Vec to represent `V` since we use `k` as an index /// We can't use a traditional Vec since we use `k` as an index and it can take
/// and it can take on negative values. So instead `V` is represented as a /// on negative values. So instead this is a light-weight wrapper around a Vec
/// light-weight wrapper around a Vec plus an `offset` which is the maximum /// plus an `offset` which is the maximum value `k` can take on, used to map
/// value `k` can take on to map negative `k`'s back to a value >= 0. /// negative `k`'s back to a value >= 0.
#[derive(Debug)] #[derive(Debug)]
struct V { struct FurthestEndpoints {
offset: isize, offset: isize,
v: Vec<usize>, endpoints: Vec<usize>,
} }
impl V { impl FurthestEndpoints {
fn new(max_d: usize) -> Self { fn new(max_edit_distance: usize) -> Self {
// max_d should fit in isize for the algorithm to work correctly let offset =
let offset = isize::try_from(max_d).expect("max_d must fit in isize"); isize::try_from(max_edit_distance).expect("max_edit_distance must fit in isize");
Self { Self {
offset, offset,
v: vec![0; 2 * max_d + 1], endpoints: vec![0; 2 * max_edit_distance + 1],
} }
} }
fn len(&self) -> usize { fn len(&self) -> usize {
self.v.len() self.endpoints.len()
} }
} }
impl Index<isize> for V { impl Index<isize> for FurthestEndpoints {
type Output = usize; type Output = usize;
fn index(&self, index: isize) -> &Self::Output { fn index(&self, diagonal: isize) -> &Self::Output {
let idx = usize::try_from(index + self.offset).expect("index + offset must fit in usize"); let idx =
&self.v[idx] usize::try_from(diagonal + self.offset).expect("diagonal + offset must fit in usize");
&self.endpoints[idx]
} }
} }
impl IndexMut<isize> for V { impl IndexMut<isize> for FurthestEndpoints {
fn index_mut(&mut self, index: isize) -> &mut Self::Output { fn index_mut(&mut self, diagonal: isize) -> &mut Self::Output {
let idx = usize::try_from(index + self.offset).expect("index + offset must fit in usize"); let idx =
&mut self.v[idx] usize::try_from(diagonal + self.offset).expect("diagonal + offset must fit in usize");
&mut self.endpoints[idx]
} }
} }
@ -119,6 +114,26 @@ fn split_at(range: Range<usize>, at: usize) -> (Range<usize>, Range<usize>) {
(range.start..at, at..range.end) (range.start..at, at..range.end)
} }
/// Adjust a lower diagonal bound so it has the same parity as `edit_distance`.
/// Diagonals are visited in steps of 2, so `lower` must share `edit_distance`'s
/// parity.
fn align_lower_bound(lower: isize, edit_distance: isize) -> isize {
if (lower & 1) == (edit_distance & 1) {
lower
} else {
lower + 1
}
}
/// Adjust an upper diagonal bound so it has the same parity as `edit_distance`.
fn align_upper_bound(upper: isize, edit_distance: isize) -> isize {
if (upper & 1) == (edit_distance & 1) {
upper
} else {
upper - 1
}
}
/// A `Snake` is a sequence of diagonal edges in the edit graph. Normally /// A `Snake` is a sequence of diagonal edges in the edit graph. Normally
/// a snake has a start end end point (and it is possible for a snake to have /// a snake has a start end end point (and it is possible for a snake to have
/// a length of zero, meaning the start and end points are the same) however /// a length of zero, meaning the start and end points are the same) however
@ -135,106 +150,143 @@ fn find_middle_snake<T>(
old_range: Range<usize>, old_range: Range<usize>,
new: &[Token<T>], new: &[Token<T>],
new_range: Range<usize>, new_range: Range<usize>,
vf: &mut V, forward_endpoints: &mut FurthestEndpoints,
vb: &mut V, backward_endpoints: &mut FurthestEndpoints,
) -> Option<(usize, usize)> ) -> Option<(usize, usize)>
where where
T: PartialEq + Clone + Debug, T: PartialEq + Clone + Debug,
{ {
let n = old_range.len(); let old_len = old_range.len();
let m = new_range.len(); let new_len = new_range.len();
let old_len_signed = isize::try_from(old_len).expect("old_len must fit in isize");
let new_len_signed = isize::try_from(new_len).expect("new_len must fit in isize");
// By Lemma 1 in the paper, the optimal edit script length is odd or even as // By Lemma 1 in the paper, the optimal edit script length is odd or even as
// `delta` is odd or even. // `delta` is odd or even.
let delta = isize::try_from(n).expect("n must fit in isize") let delta = old_len_signed - new_len_signed;
- isize::try_from(m).expect("m must fit in isize"); let delta_is_odd = delta & 1 == 1;
let odd = delta & 1 == 1;
// The initial point at (0, -1) // The initial point at (0, -1)
vf[1] = 0; forward_endpoints[1] = 0;
// The initial point at (N, M+1) // The initial point at (N, M+1)
vb[1] = 0; backward_endpoints[1] = 0;
let d_max = (n + m).div_ceil(2) + 1; let max_edit_distance = (old_len + new_len).div_ceil(2) + 1;
assert!(vf.len() >= d_max); assert!(forward_endpoints.len() >= max_edit_distance);
assert!(vb.len() >= d_max); assert!(backward_endpoints.len() >= max_edit_distance);
let max_edit_distance_signed =
isize::try_from(max_edit_distance).expect("max_edit_distance must fit in isize");
for edit_distance in 0..max_edit_distance_signed {
// Tighter diagonal bounds: on diagonal k = x - y the constraints
// 0 <= x <= old_len and 0 <= y <= new_len give k in [-new_len, old_len].
// Intersect with the algorithm's [-edit_distance, edit_distance]
// range and snap to the correct parity (k advances in steps of 2).
let forward_diagonal_lo =
align_lower_bound((-edit_distance).max(-new_len_signed), edit_distance);
let forward_diagonal_hi =
align_upper_bound(edit_distance.min(old_len_signed), edit_distance);
let d_max_isize = isize::try_from(d_max).expect("d_max must fit in isize");
for d in 0..d_max_isize {
// Forward path // Forward path
for k in (-d..=d).rev().step_by(2) { for diagonal in (forward_diagonal_lo..=forward_diagonal_hi).rev().step_by(2) {
let mut x = if k == -d || (k != d && vf[k - 1] < vf[k + 1]) { let mut old_idx = if diagonal == -edit_distance
vf[k + 1] || (diagonal != edit_distance
&& forward_endpoints[diagonal - 1] < forward_endpoints[diagonal + 1])
{
forward_endpoints[diagonal + 1]
} else { } else {
vf[k - 1] + 1 forward_endpoints[diagonal - 1] + 1
}; };
let y = usize::try_from(isize::try_from(x).expect("x must fit in isize") - k) let new_idx = usize::try_from(
.expect("x - k must be non-negative and fit in usize"); isize::try_from(old_idx).expect("old_idx must fit in isize") - diagonal,
)
.expect("old_idx - diagonal must be non-negative and fit in usize");
// The coordinate of the start of a snake // The coordinate of the start of a snake
let (x0, y0) = (x, y); let (snake_start_old, snake_start_new) = (old_idx, new_idx);
// While these sequences are identical, keep moving through the
// graph with no cost // While these sequences are identical, keep moving through the
if x < old_range.len() && y < new_range.len() { // graph with no cost
if old_idx < old_range.len() && new_idx < new_range.len() {
let advance = common_prefix_len( let advance = common_prefix_len(
old, old,
old_range.start + x..old_range.end, old_range.start + old_idx..old_range.end,
new, new,
new_range.start + y..new_range.end, new_range.start + new_idx..new_range.end,
); );
x += advance; old_idx += advance;
} }
// This is the new best x value // This is the new best x value
vf[k] = x; forward_endpoints[diagonal] = old_idx;
// Only check for connections from the forward search when N - M is // Only check for connections from the forward search when N - M is
// odd and when there is a reciprocal k line coming from the other // odd and when there is a reciprocal k line coming from the other
// direction. // direction. Forward diagonal k maps to backward diagonal
if odd && (k - delta).abs() <= (d - 1) { // (delta - k). Overlap occurs when the combined forward + backward
// TODO optimise this so we don't have to compare against n // reach covers the full width:
if vf[k] + vb[-(k - delta)] >= n { // forward_endpoints[k] + backward_endpoints[delta - k] >= old_len.
// Return the snake if delta_is_odd
return Some((x0 + old_range.start, y0 + new_range.start)); && (diagonal - delta).abs() <= (edit_distance - 1)
} && forward_endpoints[diagonal] + backward_endpoints[-(diagonal - delta)] >= old_len
{
return Some((
snake_start_old + old_range.start,
snake_start_new + new_range.start,
));
} }
} }
// Backward path let backward_diagonal_lo =
for k in (-d..=d).rev().step_by(2) { align_lower_bound((-edit_distance).max(-new_len_signed), edit_distance);
let mut x = if k == -d || (k != d && vb[k - 1] < vb[k + 1]) { let backward_diagonal_hi =
vb[k + 1] align_upper_bound(edit_distance.min(old_len_signed), edit_distance);
} else {
vb[k - 1] + 1
};
let mut y = usize::try_from(isize::try_from(x).expect("x must fit in isize") - k)
.expect("x - k must be non-negative and fit in usize");
// The coordinate of the start of a snake // Backward path
if x < n && y < m { for diagonal in (backward_diagonal_lo..=backward_diagonal_hi)
.rev()
.step_by(2)
{
let mut old_idx = if diagonal == -edit_distance
|| (diagonal != edit_distance
&& backward_endpoints[diagonal - 1] < backward_endpoints[diagonal + 1])
{
backward_endpoints[diagonal + 1]
} else {
backward_endpoints[diagonal - 1] + 1
};
let mut new_idx = usize::try_from(
isize::try_from(old_idx).expect("old_idx must fit in isize") - diagonal,
)
.expect("old_idx - diagonal must be non-negative and fit in usize");
// Extend the snake backward (matching suffix)
if old_idx < old_len && new_idx < new_len {
let advance = common_suffix_len( let advance = common_suffix_len(
old, old,
old_range.start..old_range.start + n - x, old_range.start..old_range.start + old_len - old_idx,
new, new,
new_range.start..new_range.start + m - y, new_range.start..new_range.start + new_len - new_idx,
); );
x += advance; old_idx += advance;
y += advance; new_idx += advance;
} }
// This is the new best x value // This is the new best x value
vb[k] = x; backward_endpoints[diagonal] = old_idx;
if !odd && (k - delta).abs() <= d { if !delta_is_odd
// TODO optimise this so we don't have to compare against n && (diagonal - delta).abs() <= edit_distance
if vb[k] + vf[-(k - delta)] >= n { && backward_endpoints[diagonal] + forward_endpoints[-(diagonal - delta)] >= old_len
// Return the snake {
return Some((n - x + old_range.start, m - y + new_range.start)); return Some((
} old_len - old_idx + old_range.start,
new_len - new_idx + new_range.start,
));
} }
} }
// TODO: Maybe there's an opportunity to optimise and bail early?
} }
None None
@ -245,54 +297,72 @@ fn conquer<T>(
mut old_range: Range<usize>, mut old_range: Range<usize>,
new: &[Token<T>], new: &[Token<T>],
mut new_range: Range<usize>, mut new_range: Range<usize>,
vf: &mut V, forward_endpoints: &mut FurthestEndpoints,
vb: &mut V, backward_endpoints: &mut FurthestEndpoints,
result: &mut Vec<RawOperation<T>>, result: &mut Vec<RawOperation<T>>,
) where ) where
T: PartialEq + Clone + Debug, T: PartialEq + Clone + Debug,
{ {
// Check for common prefix // Check for common prefix
let common_prefix_len = common_prefix_len(old, old_range.clone(), new, new_range.clone()); let prefix_len = common_prefix_len(old, old_range.clone(), new, new_range.clone());
if common_prefix_len > 0 { if prefix_len > 0 {
result.extend( result.extend(
old[old_range.start..old_range.start + common_prefix_len] old[old_range.start..old_range.start + prefix_len]
.iter() .iter()
.map(|token| RawOperation::Equal(vec![token.clone()])), .map(|token| RawOperation::Equal(vec![token.clone()])),
); );
} }
old_range.start += common_prefix_len; old_range.start += prefix_len;
new_range.start += common_prefix_len; new_range.start += prefix_len;
// Check for common suffix // Check for common suffix
let common_suffix_len = common_suffix_len(old, old_range.clone(), new, new_range.clone()); let suffix_len = common_suffix_len(old, old_range.clone(), new, new_range.clone());
let common_suffix = ( let suffix_start = old_range.end - suffix_len;
old_range.end - common_suffix_len, old_range.end -= suffix_len;
new_range.end - common_suffix_len, new_range.end -= suffix_len;
);
old_range.end -= common_suffix_len;
new_range.end -= common_suffix_len;
if old_range.is_empty() && new_range.is_empty() { if old_range.is_empty() && new_range.is_empty() {
// do nothing // do nothing
} else if new_range.is_empty() { } else if new_range.is_empty() {
result.extend( result.extend(
old[old_range.start..old_range.start + old_range.len()] old[old_range.start..old_range.end]
.iter() .iter()
.map(|token| RawOperation::Delete(vec![token.clone()])), .map(|token| RawOperation::Delete(vec![token.clone()])),
); );
} else if old_range.is_empty() { } else if old_range.is_empty() {
result.extend( result.extend(
new[new_range.start..new_range.start + new_range.len()] new[new_range.start..new_range.end]
.iter() .iter()
.map(|token| RawOperation::Insert(vec![token.clone()])), .map(|token| RawOperation::Insert(vec![token.clone()])),
); );
} else if let Some((x_start, y_start)) = } else if let Some((split_old, split_new)) = find_middle_snake(
find_middle_snake(old, old_range.clone(), new, new_range.clone(), vf, vb) old,
{ old_range.clone(),
let (old_a, old_b) = split_at(old_range, x_start); new,
let (new_a, new_b) = split_at(new_range, y_start); new_range.clone(),
conquer(old, old_a, new, new_a, vf, vb, result); forward_endpoints,
conquer(old, old_b, new, new_b, vf, vb, result); backward_endpoints,
) {
let (old_before, old_after) = split_at(old_range, split_old);
let (new_before, new_after) = split_at(new_range, split_new);
conquer(
old,
old_before,
new,
new_before,
forward_endpoints,
backward_endpoints,
result,
);
conquer(
old,
old_after,
new,
new_after,
forward_endpoints,
backward_endpoints,
result,
);
} else { } else {
result.extend( result.extend(
old[old_range.start..old_range.end] old[old_range.start..old_range.end]
@ -306,9 +376,9 @@ fn conquer<T>(
); );
} }
if common_suffix_len > 0 { if suffix_len > 0 {
result.extend( result.extend(
old[common_suffix.0..common_suffix.0 + common_suffix_len] old[suffix_start..suffix_start + suffix_len]
.iter() .iter()
.map(|token| RawOperation::Equal(vec![token.clone()])), .map(|token| RawOperation::Equal(vec![token.clone()])),
); );