Skip to main content

cratefield_text_diff/
lib.rs

1//! `cratefield-text-diff`: a word-level diff for showing what a rewrite
2//! changed (issue #836).
3//!
4//! [`diff_words`] compares two texts word by word and answers with
5//! [`Segment`]s that are [`Op::Removed`], [`Op::Added`] or [`Op::Same`].
6//! [`diff_words_locked`] also takes the byte ranges of each text that are
7//! **protected** — a figure, a name, a quotation, a line of code, as
8//! `cratefield-text-guard` finds them, or ranges from anywhere else. A
9//! protected range is one atomic token: it is never split into words, and
10//! when it is unchanged it comes back as [`Op::Locked`] so a UI can box it.
11//!
12//! ```
13//! use cratefield_text_diff::{Op, diff_words};
14//!
15//! let segments = diff_words("revenue grew by 18% to $4.2M", "revenue rose 18% to $4.2M");
16//! let ops: Vec<_> = segments.iter().map(|s| (s.op, s.text.as_str())).collect();
17//! assert_eq!(ops, [
18//!     (Op::Same, "revenue "),
19//!     (Op::Removed, "grew by "),
20//!     (Op::Added, "rose "),
21//!     (Op::Same, "18% to $4.2M"),
22//! ]);
23//! ```
24//!
25//! # Tokens and whitespace
26//!
27//! A token is a protected range, a word (letters and digits, with `'` `’`
28//! `-` `.` `,` `%` allowed between or after them, so `don't`, `4.2M` and
29//! `18%` stay whole), or a single other character. Whitespace is not a
30//! token: it rides on the token before it and is not compared, so
31//! re-wrapping a paragraph is not a change. Each segment's text carries its
32//! trailing whitespace from the side it came from — the rewrite's for
33//! `Same`, `Locked` and `Added`, the original's for `Removed` — so the
34//! texts of every segment that is not `Removed`, in order, are exactly the
35//! rewrite.
36//!
37//! # Algorithm
38//!
39//! Myers' O(ND) difference algorithm in its linear-space form (the
40//! "middle snake"), after stripping the common prefix and suffix: a full
41//! rewrite of a long text costs memory proportional to its length, not its
42//! square. The result is a shortest edit script, so a minimal diff, and
43//! within each change everything removed is listed before everything added.
44
45#![forbid(unsafe_code)]
46
47use std::ops::Range;
48
49use serde::{Deserialize, Serialize};
50
51/// What happened to a piece of text between the two versions.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum Op {
55    /// In both texts.
56    Same,
57    /// Only in the original.
58    Removed,
59    /// Only in the rewrite.
60    Added,
61    /// A protected range that is in both texts unchanged.
62    Locked,
63}
64
65/// A run of text with one [`Op`]. Adjacent segments never share an op,
66/// except [`Op::Locked`], which is one segment per protected range.
67#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
68pub struct Segment {
69    pub op: Op,
70    pub text: String,
71}
72
73/// The word diff of `before` and `after`, with nothing protected.
74pub fn diff_words(before: &str, after: &str) -> Vec<Segment> {
75    diff_words_locked(before, after, &[], &[])
76}
77
78/// The word diff of `before` and `after`, where `before_locked` and
79/// `after_locked` are protected byte ranges of each. A range that is out of
80/// bounds, not on a `char` boundary, or overlaps an earlier one is ignored.
81pub fn diff_words_locked(
82    before: &str,
83    after: &str,
84    before_locked: &[Range<usize>],
85    after_locked: &[Range<usize>],
86) -> Vec<Segment> {
87    let a = tokenize(before, before_locked);
88    let b = tokenize(after, after_locked);
89    let a_keys: Vec<&str> = a.iter().map(|t| t.core).collect();
90    let b_keys: Vec<&str> = b.iter().map(|t| t.core).collect();
91
92    let mut edits = Vec::with_capacity(a.len().max(b.len()));
93    Myers::new(&a_keys, &b_keys).run(&mut edits);
94
95    // Between two unchanged tokens, everything removed comes before
96    // everything added, so a replacement reads as "this, then that".
97    let mut ordered = Vec::with_capacity(edits.len());
98    let mut added = Vec::new();
99    for edit in edits {
100        match edit {
101            Edit::Added(_) => added.push(edit),
102            Edit::Removed(_) => ordered.push(edit),
103            Edit::Same(..) => {
104                ordered.append(&mut added);
105                ordered.push(edit);
106            }
107        }
108    }
109    ordered.append(&mut added);
110
111    let mut out: Vec<Segment> = Vec::new();
112    for edit in ordered {
113        let (op, text) = match edit {
114            Edit::Same(i, j) => {
115                let op = if a[i].locked && b[j].locked {
116                    Op::Locked
117                } else {
118                    Op::Same
119                };
120                (op, b[j].text())
121            }
122            Edit::Removed(i) => (Op::Removed, a[i].text()),
123            Edit::Added(j) => (Op::Added, b[j].text()),
124        };
125        match out.last_mut() {
126            Some(last) if last.op == op && op != Op::Locked => last.text.push_str(text),
127            _ => out.push(Segment {
128                op,
129                text: text.to_owned(),
130            }),
131        }
132    }
133    out
134}
135
136/// One token: what is compared (`core`) and the whitespace after it.
137#[derive(Debug)]
138struct Token<'a> {
139    /// `core` and its trailing whitespace, contiguous in the source.
140    full: &'a str,
141    core: &'a str,
142    locked: bool,
143}
144
145impl<'a> Token<'a> {
146    fn text(&self) -> &'a str {
147        self.full
148    }
149}
150
151fn is_joiner(c: char) -> bool {
152    matches!(c, '\'' | '’' | '-' | '.' | ',' | '%')
153}
154
155fn tokenize<'a>(text: &'a str, locked: &[Range<usize>]) -> Vec<Token<'a>> {
156    let mut ranges: Vec<Range<usize>> = locked
157        .iter()
158        .filter(|r| {
159            r.start < r.end
160                && r.end <= text.len()
161                && text.is_char_boundary(r.start)
162                && text.is_char_boundary(r.end)
163        })
164        .cloned()
165        .collect();
166    ranges.sort_by_key(|r| r.start);
167    let mut kept: Vec<Range<usize>> = Vec::with_capacity(ranges.len());
168    for r in ranges {
169        if kept.last().is_none_or(|k| k.end <= r.start) {
170            kept.push(r);
171        }
172    }
173    let mut locked = kept.into_iter().peekable();
174
175    let mut tokens = Vec::new();
176    let mut i = 0;
177    // Leading whitespace is an empty token, so it is not lost.
178    let lead = text.len() - text.trim_start().len();
179    if lead > 0 {
180        tokens.push(Token {
181            full: &text[..lead],
182            core: "",
183            locked: false,
184        });
185        i = lead;
186    }
187    while i < text.len() {
188        let (end, is_locked) = if let Some(r) = locked.next_if(|r| r.start <= i) {
189            // A range that starts inside whitespace already consumed is
190            // clipped to here.
191            (r.end.max(i + 1).min(text.len()), true)
192        } else {
193            let next_lock = locked.peek().map_or(text.len(), |r| r.start);
194            (word_end(text, i, next_lock), false)
195        };
196        let end = end.max(i + text[i..].chars().next().map_or(1, char::len_utf8));
197        let ws = text[end..].len() - text[end..].trim_start().len();
198        let ws_end = locked
199            .peek()
200            .map_or(end + ws, |r| (end + ws).min(r.start.max(end)));
201        tokens.push(Token {
202            full: &text[i..ws_end],
203            core: &text[i..end],
204            locked: is_locked,
205        });
206        i = ws_end;
207    }
208    tokens
209}
210
211/// The end of the word (or single character) starting at `start`, never
212/// past `limit`.
213fn word_end(text: &str, start: usize, limit: usize) -> usize {
214    let mut chars = text[start..limit].char_indices().peekable();
215    let Some((_, first)) = chars.next() else {
216        return start;
217    };
218    if !first.is_alphanumeric() {
219        return start + first.len_utf8();
220    }
221    let mut end = start + first.len_utf8();
222    while let Some((at, c)) = chars.next() {
223        if c.is_alphanumeric() {
224            end = start + at + c.len_utf8();
225        } else if is_joiner(c) && end == start + at {
226            // A joiner stays in the word when a letter or digit follows it,
227            // or when it is a trailing % (18%).
228            let followed = chars.peek().is_some_and(|(_, n)| n.is_alphanumeric());
229            if followed || c == '%' {
230                end = start + at + c.len_utf8();
231            } else {
232                break;
233            }
234        } else {
235            break;
236        }
237    }
238    end
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242enum Edit {
243    Same(usize, usize),
244    Removed(usize),
245    Added(usize),
246}
247
248/// Myers' linear-space diff over two key sequences.
249struct Myers<'k> {
250    a: &'k [&'k str],
251    b: &'k [&'k str],
252    forward: Vec<usize>,
253    backward: Vec<usize>,
254}
255
256/// A snake of the middle: equal tokens from `(x, y)` to `(u, v)`, in
257/// coordinates of the current sub-problem.
258struct Snake {
259    x: usize,
260    y: usize,
261    u: usize,
262    v: usize,
263}
264
265impl<'k> Myers<'k> {
266    fn new(a: &'k [&'k str], b: &'k [&'k str]) -> Self {
267        let size = 2 * (a.len() + b.len()) + 4;
268        Self {
269            a,
270            b,
271            forward: vec![0; size],
272            backward: vec![0; size],
273        }
274    }
275
276    fn run(mut self, out: &mut Vec<Edit>) {
277        self.compare(0..self.a.len(), 0..self.b.len(), out);
278    }
279
280    fn compare(&mut self, mut a: Range<usize>, mut b: Range<usize>, out: &mut Vec<Edit>) {
281        while a.start < a.end && b.start < b.end && self.a[a.start] == self.b[b.start] {
282            out.push(Edit::Same(a.start, b.start));
283            a.start += 1;
284            b.start += 1;
285        }
286        let mut suffix = 0;
287        while a.start < a.end - suffix
288            && b.start < b.end - suffix
289            && self.a[a.end - suffix - 1] == self.b[b.end - suffix - 1]
290        {
291            suffix += 1;
292        }
293        a.end -= suffix;
294        b.end -= suffix;
295
296        if a.is_empty() {
297            out.extend(b.clone().map(Edit::Added));
298        } else if b.is_empty() {
299            out.extend(a.clone().map(Edit::Removed));
300        } else {
301            let snake = self.middle_snake(a.clone(), b.clone());
302            self.compare(a.start..a.start + snake.x, b.start..b.start + snake.y, out);
303            for k in 0..snake.u - snake.x {
304                out.push(Edit::Same(a.start + snake.x + k, b.start + snake.y + k));
305            }
306            self.compare(a.start + snake.u..a.end, b.start + snake.v..b.end, out);
307        }
308
309        for k in (1..=suffix).rev() {
310            out.push(Edit::Same(a.end + suffix - k, b.end + suffix - k));
311        }
312    }
313
314    /// The middle snake of a sub-problem whose sides are both non-empty and
315    /// share no prefix or suffix (Myers 1986, section 4b).
316    // The paper's names (a, b, n, m, d, k, x, y) make it checkable against
317    // the paper; the casts are within `isize` for any text that fits memory.
318    #[allow(
319        clippy::cast_possible_wrap,
320        clippy::cast_sign_loss,
321        clippy::many_single_char_names
322    )]
323    fn middle_snake(&mut self, a: Range<usize>, b: Range<usize>) -> Snake {
324        let n = a.len() as isize;
325        let m = b.len() as isize;
326        let delta = n - m;
327        let odd = delta % 2 != 0;
328        let max = (n + m + 1) / 2;
329        let offset = max + 1;
330        let idx = |k: isize| (k + offset) as usize;
331
332        self.forward[idx(1)] = 0;
333        self.backward[idx(1)] = 0;
334        let fwd_eq =
335            |s: &Self, x: isize, y: isize| s.a[a.start + x as usize] == s.b[b.start + y as usize];
336        let bwd_eq = |s: &Self, x: isize, y: isize| {
337            s.a[a.end - 1 - x as usize] == s.b[b.end - 1 - y as usize]
338        };
339
340        for d in 0..=max {
341            let mut k = -d;
342            while k <= d {
343                let mut x =
344                    if k == -d || (k != d && self.forward[idx(k - 1)] < self.forward[idx(k + 1)]) {
345                        self.forward[idx(k + 1)] as isize
346                    } else {
347                        self.forward[idx(k - 1)] as isize + 1
348                    };
349                let mut y = x - k;
350                let (x0, y0) = (x, y);
351                while x < n && y < m && fwd_eq(self, x, y) {
352                    x += 1;
353                    y += 1;
354                }
355                self.forward[idx(k)] = x as usize;
356                let c = delta - k;
357                if odd && c > -d && c < d && x + self.backward[idx(c)] as isize >= n {
358                    return Snake {
359                        x: x0 as usize,
360                        y: y0 as usize,
361                        u: x as usize,
362                        v: y as usize,
363                    };
364                }
365                k += 2;
366            }
367
368            let mut k = -d;
369            while k <= d {
370                let mut x = if k == -d
371                    || (k != d && self.backward[idx(k - 1)] < self.backward[idx(k + 1)])
372                {
373                    self.backward[idx(k + 1)] as isize
374                } else {
375                    self.backward[idx(k - 1)] as isize + 1
376                };
377                let mut y = x - k;
378                let (x0, y0) = (x, y);
379                while x < n && y < m && bwd_eq(self, x, y) {
380                    x += 1;
381                    y += 1;
382                }
383                self.backward[idx(k)] = x as usize;
384                let c = delta - k;
385                if !odd && c >= -d && c <= d && x + self.forward[idx(c)] as isize >= n {
386                    return Snake {
387                        x: (n - x) as usize,
388                        y: (m - y) as usize,
389                        u: (n - x0) as usize,
390                        v: (m - y0) as usize,
391                    };
392                }
393                k += 2;
394            }
395        }
396        unreachable!("two non-empty sequences always have a middle snake within (n + m + 1) / 2")
397    }
398}
399
400#[cfg(test)]
401mod tests;