fastanim_diff/lib.rs
1//! A small, dependency-free, generic sequence diff library (see `docs/SPEC.md` §5).
2//!
3//! It knows nothing about animation: it turns two ordered sequences into an edit script of
4//! [`Op`]s. Items are compared by a caller-supplied **key** rather than `PartialEq`, so the
5//! caller decides what "the same" means.
6//!
7//! The pipeline is:
8//!
9//! 1. Keys are interned to dense integers and the common prefix/suffix is trimmed.
10//! 2. A shortest edit script is computed with Myers' greedy algorithm, its linear-space
11//! variant (chosen automatically for large inputs), or patience diff. For inputs of
12//! moderate size, ties between equally short scripts are broken by [`TieBreak::Stable`].
13//! 3. Hunks are normalized so deletions come before insertions (determinism).
14//! 4. Optional post-passes: move detection (§5.4), semantic cleanup, and replacement
15//! pairing, both adjacent and cross-hunk (§5.5).
16//!
17//! ```
18//! use fastanim_diff::{diff, DiffOptions, Op};
19//!
20//! let a: Vec<char> = "a+b=c".chars().collect();
21//! let b: Vec<char> = "b+a=c".chars().collect();
22//! let ops = diff(&a, &b, |c| *c, &DiffOptions::default());
23//! assert!(ops.iter().any(|op| matches!(op, Op::Move { .. })));
24//! ```
25
26#![forbid(unsafe_code)]
27
28mod assign;
29mod linear;
30mod myers;
31mod patience;
32mod post;
33mod script;
34mod stable;
35
36use std::collections::HashMap;
37use std::hash::Hash;
38use std::ops::Range;
39
40pub use script::{ScriptError, apply, edit_cost, validate};
41
42/// Above this many items (`N + M`, after trimming), [`Algorithm::Myers`] switches to the
43/// linear-space variant.
44pub const LINEAR_SPACE_THRESHOLD: usize = 10_000;
45
46/// [`TieBreak::Stable`] applies while `N · M` (after trimming, if needed) is at most this.
47pub const STABLE_LIMIT: usize = 1 << 20;
48
49/// One step of an edit script.
50///
51/// Scripts list ops in output order: the `b` indices of `Equal`, `Insert`, `Move` and
52/// `Replace` ops appear in increasing order, so walking the script reconstructs `b`.
53/// `Delete` ops sit at their position relative to the surviving `a` items.
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub enum Op {
56 /// `a[a]` is kept and becomes `b[b]`.
57 Equal {
58 /// Index into `a`.
59 a: usize,
60 /// Index into `b`.
61 b: usize,
62 },
63 /// `a[a]` is removed.
64 Delete {
65 /// Index into `a`.
66 a: usize,
67 },
68 /// `b[b]` is new.
69 Insert {
70 /// Index into `b`.
71 b: usize,
72 },
73 /// `a[a]` and `b[b]` have equal keys but were not matched in order: the item travels.
74 Move {
75 /// Index into `a`.
76 a: usize,
77 /// Index into `b`.
78 b: usize,
79 },
80 /// The items `a[a]` are replaced by (morphed into) the items `b[b]`.
81 Replace {
82 /// Range of `a`.
83 a: Range<usize>,
84 /// Range of `b`.
85 b: Range<usize>,
86 },
87}
88
89/// The core shortest-edit-script algorithm.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum Algorithm {
92 /// Myers' greedy algorithm, switching to the linear-space variant above
93 /// [`LINEAR_SPACE_THRESHOLD`] items.
94 #[default]
95 Myers,
96 /// Always use Myers' linear-space (middle snake) variant.
97 MyersLinearSpace,
98 /// Patience diff: anchor on items that are unique in both inputs, Myers in between.
99 /// Not guaranteed minimal, but often reads better for code.
100 Patience,
101}
102
103/// How to choose between edit scripts of equal (minimal) cost.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum TieBreak {
106 /// Prefer the script whose unchanged blocks shift the least, then the one with fewest
107 /// blocks, so that what stayed the same visibly stays put. For example
108 /// `a + b = c → b + a = c` keeps `+ = c` and swaps the letters. Costs an O(N·M) pass,
109 /// so it applies only up to [`STABLE_LIMIT`]; larger inputs keep Myers' choice.
110 #[default]
111 Stable,
112 /// Whatever the algorithm's search order yields first. Fastest.
113 Myers,
114}
115
116/// Folding of short equal runs into the surrounding edits.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum Cleanup {
119 /// Keep the edit script as computed.
120 #[default]
121 None,
122 /// Equal runs shorter than `min_equal_run` with an insertion or deletion on both sides
123 /// are turned into edits, so a lone unchanged item doesn't sit still while everything
124 /// around it changes.
125 Semantic {
126 /// Shortest equal run that is kept.
127 min_equal_run: usize,
128 },
129}
130
131/// Options for [`diff`] and [`Differ`].
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct DiffOptions {
134 /// The core algorithm.
135 pub algorithm: Algorithm,
136 /// Choice between equally short scripts. Ignored by [`Algorithm::Patience`].
137 pub tie_break: TieBreak,
138 /// Pair deletions and insertions with equal keys into [`Op::Move`].
139 pub detect_moves: bool,
140 /// Optional folding of short equal runs.
141 pub cleanup: Cleanup,
142 /// Pair adjacent deletions and insertions into [`Op::Replace`], and, when a class
143 /// function is given to [`Differ::class`], leftover non-adjacent ones of the same class.
144 pub pair_replacements: bool,
145}
146
147impl Default for DiffOptions {
148 fn default() -> Self {
149 Self {
150 algorithm: Algorithm::Myers,
151 tie_break: TieBreak::Stable,
152 detect_moves: true,
153 cleanup: Cleanup::None,
154 pair_replacements: true,
155 }
156 }
157}
158
159impl DiffOptions {
160 /// Only the core algorithm: the result contains just `Equal`, `Delete` and `Insert`.
161 pub fn raw() -> Self {
162 Self {
163 detect_moves: false,
164 pair_replacements: false,
165 ..Self::default()
166 }
167 }
168}
169
170/// Diffs `a` against `b`, comparing items by `key`.
171///
172/// Moves and cross-hunk replacements are paired by index distance; use [`Differ`] to supply
173/// a visual cost or replacement classes.
174pub fn diff<T, K: Eq + Hash>(
175 a: &[T],
176 b: &[T],
177 key: impl Fn(&T) -> K,
178 opts: &DiffOptions,
179) -> Vec<Op> {
180 Differ::new(a, b, key).options(opts.clone()).run()
181}
182
183type CostFn<'c> = Box<dyn Fn(usize, usize) -> f64 + 'c>;
184
185/// Interned replacement classes of `a` and `b`.
186type Classes = (Vec<Option<u32>>, Vec<Option<u32>>);
187
188/// A configurable diff, for when [`diff`] isn't enough.
189///
190/// ```
191/// use fastanim_diff::{Differ, Op};
192///
193/// let a = ["a", "²", "+", "b", "²", "=", "c", "²"];
194/// let b = ["a", "²", "=", "c", "²", "-", "b", "²"];
195/// let ops = Differ::new(&a, &b, |t| *t)
196/// .class(|t| matches!(*t, "+" | "-").then_some("operator"))
197/// .run();
198/// assert!(ops.contains(&Op::Replace { a: 2..3, b: 5..6 }));
199/// ```
200pub struct Differ<'c, T> {
201 a: &'c [T],
202 b: &'c [T],
203 keys_a: Vec<u32>,
204 keys_b: Vec<u32>,
205 classes: Option<Classes>,
206 cost: Option<CostFn<'c>>,
207 opts: DiffOptions,
208}
209
210impl<'c, T> Differ<'c, T> {
211 /// Prepares a diff of `a` against `b`, comparing items by `key`.
212 pub fn new<K: Eq + Hash>(a: &'c [T], b: &'c [T], key: impl Fn(&T) -> K) -> Self {
213 let (keys_a, keys_b) = intern(a, b, key);
214 Self {
215 a,
216 b,
217 keys_a,
218 keys_b,
219 classes: None,
220 cost: None,
221 opts: DiffOptions::default(),
222 }
223 }
224
225 /// Sets the options.
226 pub fn options(mut self, opts: DiffOptions) -> Self {
227 self.opts = opts;
228 self
229 }
230
231 /// Sets the cost of pairing `a[i]` with `b[j]` in move detection and cross-hunk
232 /// replacement pairing, typically the distance between their centroids. Lower is better.
233 /// Defaults to the index distance `|i - j|`.
234 pub fn cost(mut self, cost: impl Fn(usize, usize) -> f64 + 'c) -> Self {
235 self.cost = Some(Box::new(cost));
236 self
237 }
238
239 /// Enables cross-hunk replacement pairing: leftover deletions and insertions whose class
240 /// is `Some` and equal may be paired into a single-item [`Op::Replace`].
241 pub fn class<C: Eq + Hash>(mut self, class: impl Fn(&T) -> Option<C>) -> Self {
242 let mut ids = HashMap::new();
243 let mut id = |item: &T| {
244 class(item).map(|c| {
245 let next = ids.len() as u32;
246 *ids.entry(c).or_insert(next)
247 })
248 };
249 let ca = self.a.iter().map(&mut id).collect();
250 let cb = self.b.iter().map(&mut id).collect();
251 self.classes = Some((ca, cb));
252 self
253 }
254
255 /// Computes the edit script.
256 pub fn run(&self) -> Vec<Op> {
257 let (a, b) = (&self.keys_a[..], &self.keys_b[..]);
258 let mut ops = core_diff(a, b, self.opts.algorithm, self.opts.tie_break);
259 post::normalize(&mut ops);
260
261 let index_cost = |i: usize, j: usize| i.abs_diff(j) as f64;
262 let cost: &dyn Fn(usize, usize) -> f64 = match &self.cost {
263 Some(c) => c,
264 None => &index_cost,
265 };
266
267 if self.opts.detect_moves {
268 ops = post::detect_moves(ops, a, b, cost);
269 }
270 if let Cleanup::Semantic { min_equal_run } = self.opts.cleanup {
271 post::semantic_cleanup(&mut ops, min_equal_run);
272 }
273 if self.opts.pair_replacements {
274 ops = post::pair_adjacent(ops);
275 if let Some((ca, cb)) = &self.classes {
276 ops = post::pair_cross_hunk(ops, ca, cb, cost);
277 }
278 }
279 ops
280 }
281}
282
283/// Expands a script over groups (e.g. lines) into one over their items (e.g. tokens), for
284/// coarse-to-fine diffing (SPEC §5.6). `a` and `b` give each group's contiguous range of items.
285///
286/// Equal and moved groups pair their items in order (leftovers are deleted or inserted);
287/// deleted and inserted groups expand item by item; each replaced run of groups is handed to
288/// `inner` as item ranges, and its script (relative to those ranges) is spliced in.
289///
290/// ```
291/// use fastanim_diff::{Differ, Op, expand};
292///
293/// let (a, b) = (["x", "y", "z"], ["x", "w", "z"]);
294/// let lines_a = [0..2, 2..3]; // "x y", "z"
295/// let lines_b = [0..2, 2..3]; // "x w", "z"
296/// let ka: Vec<_> = lines_a.iter().map(|l| &a[l.clone()]).collect();
297/// let kb: Vec<_> = lines_b.iter().map(|l| &b[l.clone()]).collect();
298/// let outer = Differ::new(&ka, &kb, |l| *l).run();
299/// let ops = expand(&outer, &lines_a, &lines_b, |ra, rb| {
300/// Differ::new(&a[ra], &b[rb], |t| *t).run()
301/// });
302/// assert_eq!(ops[0], Op::Equal { a: 0, b: 0 });
303/// assert!(ops.contains(&Op::Equal { a: 2, b: 2 }));
304/// ```
305pub fn expand(
306 ops: &[Op],
307 a: &[Range<usize>],
308 b: &[Range<usize>],
309 mut inner: impl FnMut(Range<usize>, Range<usize>) -> Vec<Op>,
310) -> Vec<Op> {
311 // Items of a run of groups; groups are contiguous, so this is one range.
312 let items = |g: &[Range<usize>], r: Range<usize>| match (g.get(r.start), r.end.checked_sub(1)) {
313 (Some(first), Some(last)) if r.start < r.end => first.start..g[last].end,
314 _ => 0..0,
315 };
316 let mut out = Vec::new();
317 for op in ops {
318 match op {
319 Op::Equal { a: i, b: j } | Op::Move { a: i, b: j } => {
320 let (ra, rb) = (a[*i].clone(), b[*j].clone());
321 let n = ra.len().min(rb.len());
322 for k in 0..n {
323 let (a, b) = (ra.start + k, rb.start + k);
324 out.push(match op {
325 Op::Equal { .. } => Op::Equal { a, b },
326 _ => Op::Move { a, b },
327 });
328 }
329 out.extend((ra.start + n..ra.end).map(|a| Op::Delete { a }));
330 out.extend((rb.start + n..rb.end).map(|b| Op::Insert { b }));
331 }
332 Op::Delete { a: i } => out.extend(a[*i].clone().map(|a| Op::Delete { a })),
333 Op::Insert { b: j } => out.extend(b[*j].clone().map(|b| Op::Insert { b })),
334 Op::Replace { a: ga, b: gb } => {
335 let (ra, rb) = (items(a, ga.clone()), items(b, gb.clone()));
336 let (ao, bo) = (ra.start, rb.start);
337 out.extend(inner(ra, rb).into_iter().map(|op| match op {
338 Op::Equal { a, b } => Op::Equal {
339 a: a + ao,
340 b: b + bo,
341 },
342 Op::Move { a, b } => Op::Move {
343 a: a + ao,
344 b: b + bo,
345 },
346 Op::Delete { a } => Op::Delete { a: a + ao },
347 Op::Insert { b } => Op::Insert { b: b + bo },
348 Op::Replace { a, b } => Op::Replace {
349 a: a.start + ao..a.end + ao,
350 b: b.start + bo..b.end + bo,
351 },
352 }));
353 }
354 }
355 }
356 out
357}
358
359/// Maps keys to dense ids in order of first appearance. Exact (no hash collisions) and
360/// deterministic across runs and platforms.
361fn intern<T, K: Eq + Hash>(a: &[T], b: &[T], key: impl Fn(&T) -> K) -> (Vec<u32>, Vec<u32>) {
362 let mut ids = HashMap::new();
363 let mut id = |item: &T| {
364 let next = ids.len() as u32;
365 *ids.entry(key(item)).or_insert(next)
366 };
367 let ka = a.iter().map(&mut id).collect();
368 let kb = b.iter().map(&mut id).collect();
369 (ka, kb)
370}
371
372/// Runs the core algorithm on interned keys, producing `Equal`/`Delete`/`Insert` only.
373fn core_diff(a: &[u32], b: &[u32], algorithm: Algorithm, tie_break: TieBreak) -> Vec<Op> {
374 let mut out = Vec::with_capacity(a.len().max(b.len()));
375 let sink = &mut Sink::new(&mut out);
376 let myers = match algorithm {
377 Algorithm::Patience => {
378 patience::diff(a, b, 0, 0, sink);
379 return out;
380 }
381 Algorithm::Myers if a.len() + b.len() <= LINEAR_SPACE_THRESHOLD => myers::diff,
382 Algorithm::Myers | Algorithm::MyersLinearSpace => linear::diff,
383 };
384 let fits = |a: &[u32], b: &[u32]| a.len().saturating_mul(b.len()) <= STABLE_LIMIT;
385 match tie_break {
386 TieBreak::Myers => myers(a, b, 0, 0, sink),
387 // Untrimmed when possible: trimming the suffix forces it to match, which can split
388 // a block that would otherwise stay together.
389 TieBreak::Stable if fits(a, b) => trimmed_none(a, b, sink),
390 TieBreak::Stable => trimmed(a, b, 0, 0, sink, |a, b, ao, bo, sink| {
391 if fits(a, b) {
392 stable::diff(a, b, ao, bo, sink)
393 } else {
394 myers(a, b, ao, bo, sink)
395 }
396 }),
397 }
398 out
399}
400
401/// Runs [`stable::diff`] on whole inputs, handling empty ones.
402fn trimmed_none(a: &[u32], b: &[u32], sink: &mut Sink) {
403 match (a.is_empty(), b.is_empty()) {
404 (true, _) => (0..b.len()).for_each(|j| sink.insert(j)),
405 (false, true) => (0..a.len()).for_each(|i| sink.delete(i)),
406 (false, false) => stable::diff(a, b, 0, 0, sink),
407 }
408}
409
410/// Collects core ops, translating sub-problem indices to global ones.
411struct Sink<'o> {
412 out: &'o mut Vec<Op>,
413}
414
415impl<'o> Sink<'o> {
416 fn new(out: &'o mut Vec<Op>) -> Self {
417 Self { out }
418 }
419
420 fn equal(&mut self, a: usize, b: usize) {
421 self.out.push(Op::Equal { a, b });
422 }
423
424 fn delete(&mut self, a: usize) {
425 self.out.push(Op::Delete { a });
426 }
427
428 fn insert(&mut self, b: usize) {
429 self.out.push(Op::Insert { b });
430 }
431}
432
433/// Emits the common prefix and returns its length.
434fn emit_prefix(a: &[u32], b: &[u32], ao: usize, bo: usize, sink: &mut Sink) -> usize {
435 let n = a.iter().zip(b).take_while(|(x, y)| x == y).count();
436 for i in 0..n {
437 sink.equal(ao + i, bo + i);
438 }
439 n
440}
441
442/// Length of the common suffix (not emitted: callers emit it after the middle).
443fn suffix_len(a: &[u32], b: &[u32]) -> usize {
444 a.iter()
445 .rev()
446 .zip(b.iter().rev())
447 .take_while(|(x, y)| x == y)
448 .count()
449}
450
451/// Trims the common prefix and suffix around `middle`, which diffs what's left.
452fn trimmed(
453 a: &[u32],
454 b: &[u32],
455 ao: usize,
456 bo: usize,
457 sink: &mut Sink,
458 middle: impl FnOnce(&[u32], &[u32], usize, usize, &mut Sink),
459) {
460 let p = emit_prefix(a, b, ao, bo, sink);
461 let (a, b) = (&a[p..], &b[p..]);
462 let s = suffix_len(a, b);
463 let (am, bm) = (&a[..a.len() - s], &b[..b.len() - s]);
464 let (ao, bo) = (ao + p, bo + p);
465 match (am.is_empty(), bm.is_empty()) {
466 (true, true) => {}
467 (true, false) => (0..bm.len()).for_each(|j| sink.insert(bo + j)),
468 (false, true) => (0..am.len()).for_each(|i| sink.delete(ao + i)),
469 (false, false) => middle(am, bm, ao, bo, sink),
470 }
471 for i in 0..s {
472 sink.equal(ao + am.len() + i, bo + bm.len() + i);
473 }
474}