Skip to main content

rich_ext/diff/
engine.rs

1//! The diff engine: Myers' O(ND) algorithm in linear space, hunk grouping,
2//! token-level diffs for intra-line emphasis and the `diff -u` text format.
3//!
4//! Elements are interned to integers first, a common prefix and suffix are
5//! trimmed, and regions with no element in common are resolved without a
6//! search, so large inputs with few changes stay fast. Past an edit cost of
7//! [`COST_LIMIT`] the search splits at its furthest-reaching point instead of
8//! the exact middle snake (git's heuristic): the script stays valid, it just
9//! may not be minimal.
10
11use std::collections::HashMap;
12use std::hash::Hash;
13use std::ops::Range;
14
15/// The edit cost past which the middle-snake search takes a heuristic split.
16pub const COST_LIMIT: usize = 1024;
17
18/// One run of an edit script. Ranges index the compared sequences: lines for
19/// [`diff_lines`], bytes of the input strings for [`diff_words`] and
20/// [`diff_chars`]. The side a run does not touch has an empty range at the
21/// position where the run applies.
22#[derive(Clone, Debug, PartialEq, Eq, Hash)]
23pub enum Op {
24    /// `old` and `new` hold equal elements.
25    Equal {
26        old: Range<usize>,
27        new: Range<usize>,
28    },
29    /// `old` is removed; `new` is empty.
30    Delete {
31        old: Range<usize>,
32        new: Range<usize>,
33    },
34    /// `new` is inserted; `old` is empty.
35    Insert {
36        old: Range<usize>,
37        new: Range<usize>,
38    },
39}
40
41impl Op {
42    /// The range on the old side.
43    pub fn old(&self) -> Range<usize> {
44        match self {
45            Op::Equal { old, .. } | Op::Delete { old, .. } | Op::Insert { old, .. } => old.clone(),
46        }
47    }
48    /// The range on the new side.
49    pub fn new_range(&self) -> Range<usize> {
50        match self {
51            Op::Equal { new, .. } | Op::Delete { new, .. } | Op::Insert { new, .. } => new.clone(),
52        }
53    }
54    /// Whether this run is unchanged.
55    pub fn is_equal(&self) -> bool {
56        matches!(self, Op::Equal { .. })
57    }
58}
59
60/// Diff two sequences of any hashable elements.
61pub fn diff_slices<T: Hash + Eq>(old: &[T], new: &[T]) -> Vec<Op> {
62    let mut ids: HashMap<&T, u32> = HashMap::new();
63    let mut intern = |item| {
64        let next = ids.len() as u32;
65        *ids.entry(item).or_insert(next)
66    };
67    let a: Vec<u32> = old.iter().map(&mut intern).collect();
68    let b: Vec<u32> = new.iter().map(&mut intern).collect();
69    let mut raw = Vec::new();
70    let max = (a.len() + b.len()).div_ceil(2) + 1;
71    let mut vf = vec![0usize; 2 * max + 2];
72    let mut vb = vec![0usize; 2 * max + 2];
73    conquer(&a, 0..a.len(), &b, 0..b.len(), &mut vf, &mut vb, &mut raw);
74    normalize(raw)
75}
76
77/// Diff two sequences of lines. Include line terminators in the elements when
78/// a missing final newline should count as a change, as `diff` does.
79pub fn diff_lines(old: &[&str], new: &[&str]) -> Vec<Op> {
80    diff_slices(old, new)
81}
82
83/// Split `text` into word, whitespace and punctuation tokens, as byte ranges.
84/// A word is a run of alphanumerics and `_`; whitespace runs are one token;
85/// every other character is its own token.
86pub fn tokenize(text: &str) -> Vec<Range<usize>> {
87    #[derive(PartialEq)]
88    enum Class {
89        Word,
90        Space,
91        Other,
92    }
93    let class = |c: char| {
94        if c.is_alphanumeric() || c == '_' {
95            Class::Word
96        } else if c.is_whitespace() {
97            Class::Space
98        } else {
99            Class::Other
100        }
101    };
102    let mut out: Vec<Range<usize>> = Vec::new();
103    let mut last: Option<Class> = None;
104    for (i, c) in text.char_indices() {
105        let k = class(c);
106        let joins = k != Class::Other && last.as_ref() == Some(&k);
107        match out.last_mut() {
108            Some(range) if joins => range.end = i + c.len_utf8(),
109            _ => out.push(i..i + c.len_utf8()),
110        }
111        last = Some(k);
112    }
113    out
114}
115
116fn diff_tokens(old: &str, new: &str, a: Vec<Range<usize>>, b: Vec<Range<usize>>) -> Vec<Op> {
117    let at = |tokens: &[Range<usize>], text: &str, i: usize| {
118        tokens.get(i).map_or(text.len(), |range| range.start)
119    };
120    let old_items: Vec<&str> = a.iter().map(|r| &old[r.clone()]).collect();
121    let new_items: Vec<&str> = b.iter().map(|r| &new[r.clone()]).collect();
122    diff_slices(&old_items, &new_items)
123        .into_iter()
124        .map(|op| {
125            let (o, n) = (op.old(), op.new_range());
126            let old = at(&a, old, o.start)..at(&a, old, o.end);
127            let new = at(&b, new, n.start)..at(&b, new, n.end);
128            match op {
129                Op::Equal { .. } => Op::Equal { old, new },
130                Op::Delete { .. } => Op::Delete { old, new },
131                Op::Insert { .. } => Op::Insert { old, new },
132            }
133        })
134        .collect()
135}
136
137/// Diff two strings by [`tokenize`]d words; ranges are byte offsets.
138pub fn diff_words(old: &str, new: &str) -> Vec<Op> {
139    diff_tokens(old, new, tokenize(old), tokenize(new))
140}
141
142/// Diff two strings character by character; ranges are byte offsets.
143pub fn diff_chars(old: &str, new: &str) -> Vec<Op> {
144    let chars = |s: &str| {
145        s.char_indices()
146            .map(|(i, c)| i..i + c.len_utf8())
147            .collect::<Vec<_>>()
148    };
149    diff_tokens(old, new, chars(old), chars(new))
150}
151
152#[derive(Clone, Copy, PartialEq, Eq, Debug)]
153pub(crate) enum Tag {
154    Equal,
155    Delete,
156    Insert,
157}
158
159type Raw = (Tag, Range<usize>, Range<usize>);
160
161fn push(out: &mut Vec<Raw>, tag: Tag, old: Range<usize>, new: Range<usize>) {
162    if old.is_empty() && new.is_empty() {
163        return;
164    }
165    out.push((tag, old, new));
166}
167
168fn conquer(
169    a: &[u32],
170    mut ar: Range<usize>,
171    b: &[u32],
172    mut br: Range<usize>,
173    vf: &mut [usize],
174    vb: &mut [usize],
175    out: &mut Vec<Raw>,
176) {
177    // Common prefix.
178    let prefix = a[ar.clone()]
179        .iter()
180        .zip(&b[br.clone()])
181        .take_while(|(x, y)| x == y)
182        .count();
183    push(
184        out,
185        Tag::Equal,
186        ar.start..ar.start + prefix,
187        br.start..br.start + prefix,
188    );
189    ar.start += prefix;
190    br.start += prefix;
191    // Common suffix, emitted after the middle.
192    let suffix = a[ar.clone()]
193        .iter()
194        .rev()
195        .zip(b[br.clone()].iter().rev())
196        .take_while(|(x, y)| x == y)
197        .count();
198    let tail = (ar.end - suffix..ar.end, br.end - suffix..br.end);
199    ar.end -= suffix;
200    br.end -= suffix;
201
202    // An empty side, or no element in common: delete all, insert all.
203    if ar.is_empty() || br.is_empty() || disjoint(&a[ar.clone()], &b[br.clone()]) {
204        push(out, Tag::Delete, ar.clone(), br.start..br.start);
205        push(out, Tag::Insert, ar.end..ar.end, br.clone());
206    } else {
207        match middle_snake(a, ar.clone(), b, br.clone(), vf, vb) {
208            Some((x, y)) if (x, y) != (ar.start, br.start) && (x, y) != (ar.end, br.end) => {
209                conquer(a, ar.start..x, b, br.start..y, vf, vb, out);
210                conquer(a, x..ar.end, b, y..br.end, vf, vb, out);
211            }
212            _ => {
213                push(out, Tag::Delete, ar.clone(), br.start..br.start);
214                push(out, Tag::Insert, ar.end..ar.end, br.clone());
215            }
216        }
217    }
218    push(out, Tag::Equal, tail.0, tail.1);
219}
220
221/// True when no element of `a` occurs in `b`.
222fn disjoint(a: &[u32], b: &[u32]) -> bool {
223    if a.len() * b.len() <= 64 {
224        return !a.iter().any(|x| b.contains(x));
225    }
226    let set: std::collections::HashSet<u32> = b.iter().copied().collect();
227    !a.iter().any(|x| set.contains(x))
228}
229
230/// The start of the middle snake of the shortest edit script between
231/// `a[ar]` and `b[br]`, in absolute indices. Both ranges are non-empty and
232/// differ at both ends.
233fn middle_snake(
234    a: &[u32],
235    ar: Range<usize>,
236    b: &[u32],
237    br: Range<usize>,
238    vf: &mut [usize],
239    vb: &mut [usize],
240) -> Option<(usize, usize)> {
241    let (n, m) = (ar.len() as isize, br.len() as isize);
242    let a = &a[ar.clone()];
243    let b = &b[br.clone()];
244    let delta = n - m;
245    let odd = delta & 1 == 1;
246    let max = (n + m + 1) / 2;
247    let offset = max + 1;
248    let idx = |k: isize| (k + offset) as usize;
249    vf[idx(1)] = 0;
250    vb[idx(1)] = 0;
251    for d in 0..=max {
252        // Forward: furthest x on each diagonal k = x - y.
253        let mut k = -d;
254        while k <= d {
255            let mut x = if k == -d || (k != d && vf[idx(k - 1)] < vf[idx(k + 1)]) {
256                vf[idx(k + 1)] as isize
257            } else {
258                vf[idx(k - 1)] as isize + 1
259            };
260            let mut y = x - k;
261            let (x0, y0) = (x, y);
262            while x < n && y < m && a[x as usize] == b[y as usize] {
263                x += 1;
264                y += 1;
265            }
266            vf[idx(k)] = x as usize;
267            let c = delta - k;
268            if odd && c >= -(d - 1) && c < d && x + vb[idx(c)] as isize >= n {
269                return Some((ar.start + x0 as usize, br.start + y0 as usize));
270            }
271            k += 2;
272        }
273        // Backward, in reversed coordinates.
274        let mut k = -d;
275        while k <= d {
276            let mut x = if k == -d || (k != d && vb[idx(k - 1)] < vb[idx(k + 1)]) {
277                vb[idx(k + 1)] as isize
278            } else {
279                vb[idx(k - 1)] as isize + 1
280            };
281            let mut y = x - k;
282            while x < n && y < m && a[(n - x - 1) as usize] == b[(m - y - 1) as usize] {
283                x += 1;
284                y += 1;
285            }
286            vb[idx(k)] = x as usize;
287            let c = delta - k;
288            if !odd && c >= -d && c <= d && x + vf[idx(c)] as isize >= n {
289                return Some((ar.start + (n - x) as usize, br.start + (m - y) as usize));
290            }
291            k += 2;
292        }
293        if d as usize >= COST_LIMIT {
294            // Too expensive: split at the furthest forward point reached.
295            let mut best: Option<(isize, isize)> = None;
296            let mut k = -d;
297            while k <= d {
298                let x = (vf[idx(k)] as isize).min(n);
299                let y = x - k;
300                if (0..=m).contains(&y)
301                    && x + y > 0
302                    && x + y < n + m
303                    && best.is_none_or(|(bx, by)| x + y > bx + by)
304                {
305                    best = Some((x, y));
306                }
307                k += 2;
308            }
309            return best.map(|(x, y)| (ar.start + x as usize, br.start + y as usize));
310        }
311    }
312    None
313}
314
315/// Merge adjacent runs of one kind and put deletions before insertions within
316/// every change block.
317fn normalize(raw: Vec<Raw>) -> Vec<Op> {
318    let mut merged: Vec<Raw> = Vec::new();
319    let mut pending_delete: Option<Raw> = None;
320    let mut pending_insert: Option<Raw> = None;
321    let flush = |merged: &mut Vec<Raw>, d: &mut Option<Raw>, i: &mut Option<Raw>| {
322        if let Some(d) = d.take() {
323            merged.push(d);
324        }
325        if let Some(mut i) = i.take() {
326            // After the deletions, inserts apply at the end of the deleted run.
327            if let Some(last) = merged.last() {
328                if last.0 == Tag::Delete {
329                    i.1 = last.1.end..last.1.end;
330                }
331            }
332            merged.push(i);
333        }
334    };
335    for (tag, old, new) in raw {
336        match tag {
337            Tag::Equal => {
338                flush(&mut merged, &mut pending_delete, &mut pending_insert);
339                match merged.last_mut() {
340                    Some(last) if last.0 == Tag::Equal => {
341                        last.1.end = old.end;
342                        last.2.end = new.end;
343                    }
344                    _ => merged.push((tag, old, new)),
345                }
346            }
347            Tag::Delete => match &mut pending_delete {
348                Some(d) => d.1.end = old.end,
349                None => {
350                    // A delete that follows inserts applies at the new side's
351                    // start of the block.
352                    let new_at = pending_insert.as_ref().map_or(new.start, |i| i.2.start);
353                    pending_delete = Some((tag, old, new_at..new_at));
354                }
355            },
356            Tag::Insert => match &mut pending_insert {
357                Some(i) => i.2.end = new.end,
358                None => pending_insert = Some((tag, old, new)),
359            },
360        }
361    }
362    flush(&mut merged, &mut pending_delete, &mut pending_insert);
363    merged
364        .into_iter()
365        .map(|(tag, old, new)| match tag {
366            Tag::Equal => Op::Equal { old, new },
367            Tag::Delete => Op::Delete { old, new },
368            Tag::Insert => Op::Insert { old, new },
369        })
370        .collect()
371}
372
373/// A group of changes with surrounding context, as `diff -u` prints them.
374#[derive(Clone, Debug, PartialEq, Eq)]
375pub struct Hunk {
376    /// The runs in this hunk, context included.
377    pub ops: Vec<Op>,
378}
379
380impl Hunk {
381    /// The old-side line range (0-based).
382    pub fn old_range(&self) -> Range<usize> {
383        let first = self.ops.first().map_or(0, |op| op.old().start);
384        let last = self.ops.last().map_or(first, |op| op.old().end);
385        first..last
386    }
387    /// The new-side line range (0-based).
388    pub fn new_range(&self) -> Range<usize> {
389        let first = self.ops.first().map_or(0, |op| op.new_range().start);
390        let last = self.ops.last().map_or(first, |op| op.new_range().end);
391        first..last
392    }
393    /// The `@@ -a,b +c,d @@` header, in `diff -u`'s shape.
394    pub fn header(&self) -> String {
395        hunk_header(self.old_range(), self.new_range())
396    }
397}
398
399/// `@@ -a,b +c,d @@` for 0-based line ranges, with `diff -u`'s conventions:
400/// a single line omits `,1`, and an empty range names the line before it.
401pub fn hunk_header(old: Range<usize>, new: Range<usize>) -> String {
402    fn part(r: Range<usize>) -> String {
403        match r.len() {
404            0 => format!("{},0", r.start),
405            1 => format!("{}", r.start + 1),
406            n => format!("{},{n}", r.start + 1),
407        }
408    }
409    format!("@@ -{} +{} @@", part(old), part(new))
410}
411
412/// Group an edit script into hunks with `context` unchanged elements around
413/// every change; changes closer than twice the context share a hunk.
414pub fn group_hunks(ops: &[Op], context: usize) -> Vec<Hunk> {
415    let spans: Vec<(bool, Range<usize>, Range<usize>)> = ops
416        .iter()
417        .map(|op| (op.is_equal(), op.old(), op.new_range()))
418        .collect();
419    group_ranges(&spans, context)
420        .into_iter()
421        .map(|group| Hunk {
422            ops: group
423                .into_iter()
424                .map(|(index, old, new)| match &ops[index] {
425                    Op::Equal { .. } => Op::Equal { old, new },
426                    Op::Delete { .. } => Op::Delete { old, new },
427                    Op::Insert { .. } => Op::Insert { old, new },
428                })
429                .collect(),
430        })
431        .collect()
432}
433
434/// A run in a hunk: its source index and trimmed old and new ranges.
435pub(crate) type Grouped = (usize, Range<usize>, Range<usize>);
436
437/// Hunk grouping over `(is_equal, old, new)` runs; returns, per hunk, the
438/// index of each source run and its trimmed ranges.
439pub(crate) fn group_ranges(
440    runs: &[(bool, Range<usize>, Range<usize>)],
441    context: usize,
442) -> Vec<Vec<Grouped>> {
443    // difflib's `get_grouped_opcodes`.
444    if runs.iter().all(|r| r.0) {
445        return Vec::new();
446    }
447    let mut codes: Vec<Grouped> = runs
448        .iter()
449        .enumerate()
450        .map(|(i, (_, a, b))| (i, a.clone(), b.clone()))
451        .collect();
452    if let Some(first) = codes.first_mut() {
453        if runs[first.0].0 {
454            first.1.start = first.1.start.max(first.1.end.saturating_sub(context));
455            first.2.start = first.2.start.max(first.2.end.saturating_sub(context));
456        }
457    }
458    if let Some(last) = codes.last_mut() {
459        if runs[last.0].0 {
460            last.1.end = last.1.end.min(last.1.start + context);
461            last.2.end = last.2.end.min(last.2.start + context);
462        }
463    }
464    let mut hunks = Vec::new();
465    let mut group: Vec<Grouped> = Vec::new();
466    for (i, mut a, mut b) in codes {
467        if runs[i].0 && a.len() > context * 2 {
468            group.push((i, a.start..a.start + context, b.start..b.start + context));
469            hunks.push(std::mem::take(&mut group));
470            a.start = a.end - context;
471            b.start = b.end - context;
472        }
473        group.push((i, a, b));
474    }
475    if group.iter().any(|(i, _, _)| !runs[*i].0) {
476        hunks.push(group);
477    }
478    for hunk in &mut hunks {
479        hunk.retain(|(i, a, b)| !(runs[*i].0 && a.is_empty() && b.is_empty()));
480    }
481    hunks.retain(|hunk| hunk.iter().any(|(i, _, _)| !runs[*i].0));
482    hunks
483}
484
485/// Split text into lines that keep their `\n`; the last line may lack one.
486pub(crate) fn split_lines_inclusive(text: &str) -> Vec<&str> {
487    text.split_inclusive('\n').collect()
488}
489
490/// A line without its terminator (`\n` or `\r\n`).
491pub(crate) fn strip_eol(line: &str) -> &str {
492    let line = line.strip_suffix('\n').unwrap_or(line);
493    line.strip_suffix('\r').unwrap_or(line)
494}
495
496/// A line-level diff of two texts.
497///
498/// ```
499/// use rich_ext::diff::TextDiff;
500///
501/// let diff = TextDiff::new("a\nb\nc\n", "a\nB\nc\n");
502/// assert_eq!(diff.stats(), (1, 1));
503/// assert_eq!(
504///     diff.unified("old", "new"),
505///     "--- old\n+++ new\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n"
506/// );
507/// ```
508#[derive(Clone, Debug)]
509pub struct TextDiff {
510    old: Vec<String>,
511    new: Vec<String>,
512    ops: Vec<Op>,
513    context: usize,
514}
515
516impl TextDiff {
517    /// Diff `old` against `new` line by line. A line's terminator is part of
518    /// it, so a missing final newline is a change.
519    pub fn new(old: &str, new: &str) -> Self {
520        let a = split_lines_inclusive(old);
521        let b = split_lines_inclusive(new);
522        let ops = diff_lines(&a, &b);
523        TextDiff {
524            old: a.into_iter().map(str::to_owned).collect(),
525            new: b.into_iter().map(str::to_owned).collect(),
526            ops,
527            context: 3,
528        }
529    }
530    /// Unchanged lines shown around each change (default 3).
531    pub fn context(mut self, lines: usize) -> Self {
532        self.context = lines;
533        self
534    }
535    /// The configured context.
536    pub fn context_lines(&self) -> usize {
537        self.context
538    }
539    /// The edit script over lines.
540    pub fn ops(&self) -> &[Op] {
541        &self.ops
542    }
543    /// The old side's lines, terminators included.
544    pub fn old_lines(&self) -> &[String] {
545        &self.old
546    }
547    /// The new side's lines, terminators included.
548    pub fn new_lines(&self) -> &[String] {
549        &self.new
550    }
551    /// The hunks at the configured context.
552    pub fn hunks(&self) -> Vec<Hunk> {
553        group_hunks(&self.ops, self.context)
554    }
555    /// Whether both sides are identical.
556    pub fn is_equal(&self) -> bool {
557        self.ops.iter().all(Op::is_equal)
558    }
559    /// `(added, removed)` line counts.
560    pub fn stats(&self) -> (usize, usize) {
561        self.ops.iter().fold((0, 0), |(a, r), op| match op {
562            Op::Insert { new, .. } => (a + new.len(), r),
563            Op::Delete { old, .. } => (a, r + old.len()),
564            Op::Equal { .. } => (a, r),
565        })
566    }
567    /// The diff in `diff -u` format with `old_name`/`new_name` headers (no
568    /// timestamps). Empty when the texts are equal, as `diff` prints nothing.
569    pub fn unified(&self, old_name: &str, new_name: &str) -> String {
570        let hunks = self.hunks();
571        if hunks.is_empty() {
572            return String::new();
573        }
574        let mut out = format!("--- {old_name}\n+++ {new_name}\n");
575        let line = |out: &mut String, prefix: char, text: &str| {
576            out.push(prefix);
577            out.push_str(text);
578            if !text.ends_with('\n') {
579                out.push_str("\n\\ No newline at end of file\n");
580            }
581        };
582        for hunk in &hunks {
583            out.push_str(&hunk.header());
584            out.push('\n');
585            for op in &hunk.ops {
586                match op {
587                    Op::Equal { old, .. } => {
588                        for text in &self.old[old.clone()] {
589                            line(&mut out, ' ', text);
590                        }
591                    }
592                    Op::Delete { old, .. } => {
593                        for text in &self.old[old.clone()] {
594                            line(&mut out, '-', text);
595                        }
596                    }
597                    Op::Insert { new, .. } => {
598                        for text in &self.new[new.clone()] {
599                            line(&mut out, '+', text);
600                        }
601                    }
602                }
603            }
604        }
605        out
606    }
607}