Skip to main content

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}