Fable clean up

This commit is contained in:
Andras Schmelczer 2026-06-11 21:35:45 +01:00
parent 3441a7e4af
commit 4ce8a4f41d
46 changed files with 642 additions and 911 deletions

View file

@ -49,25 +49,25 @@ Every existing tool got close and missed:
So the library does exactly one thing: pure function from three strings to one. No async, no networking, no concurrency, no plugins. Anything outside that boundary is somebody else's library.
## The decisions worth naming
## The decisions that mattered
**Myers diff per side, then weave the diffs.** Each child is diffed against the parent, the two edit scripts are optimised so adjacent changes group cleanly, then a single weaving pass interleaves them into one ordered op sequence that produces the merged text. The weave borrows the shape of operational transformation, but the inputs are batched complete diffs, not live keystrokes, so it only runs once per merge.
**Tokeniser is the user knob.** This is the choice I'd defend hardest. Most of what people want when they say "merge differently" isn't a new algorithm; it's a different unit. Word-level tokenisation turns most "conflicts" in prose into two adjacent edits that coexist. Line-level makes it behave like `git merge-file`. Markdown-level merges on headings and list items. Same engine, four different products depending on what you call a token.
**Tokeniser is the user knob.** Of everything in the library, this is the choice I'd argue for longest. Most of what people want when they say "merge differently" isn't a new algorithm; it's a different unit. Word-level tokenisation turns most "conflicts" in prose into two adjacent edits that coexist. Line-level makes it behave like `git merge-file`. Markdown-level merges on headings and list items. Same engine, four different products depending on what you call a token.
**Cursors are first-class merge inputs.** Each cursor has a stable ID and rides through the merge so a collaborative editor can ask "where did this cursor go?" without reconstructing it from the output text. This is the bit that made it useful to anything that wasn't just [the Obsidian sync plugin I wrote alongside it](/articles/vault-link-obsidian-sync/).
**Cursors are first-class merge inputs.** Each cursor has a stable ID and rides through the merge, so a collaborative editor can ask "where did this cursor go?" without reconstructing it from the output text. The test fixtures mark cursor positions with a `|` straight in the YAML, which keeps these cases readable enough that I actually write them. Cursor support is the bit that made the library useful to anything that wasn't just [the Obsidian sync plugin I wrote alongside it](/articles/vault-link-obsidian-sync/).
**The Rust core is generic; the FFI surface is not.** Inside Rust, the tokeniser is a `dyn Fn(&str) -> Vec<Token<T>>`. That dies the moment you try to pass it through wasm-bindgen or pyo3. The fix was a closed enum of built-in tokenisers for non-Rust callers, with the generic version reserved for Rust users. Not elegant, but the alternative was per-binding glue forever.
**WASM size mattered enough to tune for it.** The release profile is aggressive about size, and the JS package ships a small leak detector that warns if you forget to free wasm-bindgen objects. I lost an afternoon to that the first time and didn't want anyone else to.
**WASM size mattered enough to tune for it.** The release profile is aggressive about size, and the JS package ships a small leak detector that warns if you forget to free wasm-bindgen objects. I lost an afternoon to that the first time and didn't want anyone else to. The strangest target is React Native: Hermes doesn't run WASM, so the package transpiles the WASM back into JavaScript with Binaryen: slower, but the same Rust core gets to follow the library anywhere JavaScript runs.
## What's held up, what I'd change
- **Kept:** the never-emits-markers, never-drops-edits guarantee. It's the only reason a sync engine can call this library without an escape hatch.
- **Kept:** the comparison example against `diff-match-patch`. It's a runnable program in the repo showing exact inputs where the alternative is wrong. Way more convincing than a benchmark table.
- **Cut:** the snapshot tests do well on regressions and badly on unknown edge cases. Three-way merging is exactly what proptest was made for, and I should have written generators on day one.
- **Regret:** the snapshot and fixture tests do well on regressions and badly on edge cases nobody has imagined yet. Three-way merging is exactly the shape of problem property-based testing was made for, and the generators still aren't written. Saying it here is partly a way of making myself do it.
- **Next:** I want to be more explicit about the boundary. reconcile-text is a merge primitive, not a live collab engine. If you have a keystroke stream and a real-time channel, use Yjs or Automerge. This library is for when you don't.
## If you take one idea from this
Prose deserves a merger that prefers a slightly clumsy sentence over a marker. Code doesn't. That one asymmetry is the whole reason the library exists in the shape it does; everything else fell out of taking it seriously.
Prose deserves a merger that prefers a slightly clumsy sentence over a conflict marker. Code doesn't. That one asymmetry is the whole reason the library exists in the shape it does; every other decision is downstream of taking it seriously.