Skip to main content

test_lang/
diff.rs

1//! Line-level difference between two blocks of text.
2//!
3//! A snapshot assertion is only as useful as the report it prints when it
4//! fails. Comparing two multi-line strings and saying "they differ" is not
5//! enough — the reader needs to see *which* lines were expected, which were
6//! produced, and where the two streams line up again. That is what [`Diff`]
7//! provides: a minimal edit script over lines, rendered in the unified `-`/`+`
8//! style every developer already knows from `git diff`.
9//!
10//! The engine is a longest-common-subsequence (LCS) diff with a common
11//! prefix/suffix fast path. Identical leading and trailing lines are matched in
12//! linear time before the quadratic LCS ever runs, so the expensive step only
13//! sees the region that actually changed. For snapshot-sized inputs this is
14//! effectively instant, and it keeps the reported diff tight instead of
15//! re-aligning lines that never moved.
16//!
17//! The LCS table takes memory proportional to the product of the two changed
18//! regions' line counts. Past 4 Mi cells (a changed region of
19//! about 2,000 by 2,000 lines) the region is reported as deleted and then
20//! inserted as a whole, which is still a correct edit script but not a minimal
21//! one.
22
23use alloc::string::String;
24use alloc::vec::Vec;
25use core::fmt;
26
27/// The most cells the LCS table may have: 4 Mi cells, 16 MiB of `u32`.
28const MAX_LCS_CELLS: usize = 1 << 22;
29
30/// The role a line plays in a [`Diff`].
31///
32/// A diff is a sequence of lines, each tagged with how it relates to the two
33/// inputs. "Expected" is the baseline (the left/old side); "actual" is the
34/// value under test (the right/new side).
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum Change {
37    /// The line is present, unchanged, in both the expected and actual text.
38    Equal,
39    /// The line is present in the expected text but missing from the actual
40    /// text. Rendered with a leading `-`.
41    Delete,
42    /// The line is present in the actual text but was not expected. Rendered
43    /// with a leading `+`.
44    Insert,
45}
46
47impl Change {
48    /// The single-character marker used when rendering this change in a unified
49    /// diff: `' '` for [`Equal`](Change::Equal), `'-'` for
50    /// [`Delete`](Change::Delete), `'+'` for [`Insert`](Change::Insert).
51    #[inline]
52    #[must_use]
53    pub const fn marker(self) -> char {
54        match self {
55            Change::Equal => ' ',
56            Change::Delete => '-',
57            Change::Insert => '+',
58        }
59    }
60}
61
62/// A line-level edit script between two blocks of text.
63///
64/// The script is minimal unless the changed region is very large (more than
65/// about 2,000 by 2,000 lines once common leading and trailing lines are
66/// removed). Such a region is shown as deleted and then inserted in full, so
67/// memory stays bounded.
68///
69/// Construct one with [`Diff::lines`]. Iterate the individual changes with
70/// [`changes`](Diff::changes), ask whether the two inputs matched with
71/// [`is_empty`](Diff::is_empty), or format the whole thing as a unified diff
72/// through its [`Display`](fmt::Display) implementation.
73///
74/// # Examples
75///
76/// ```
77/// use test_lang::{Change, Diff};
78///
79/// let diff = Diff::lines("a\nb\nc", "a\nB\nc");
80/// assert!(!diff.is_empty());
81///
82/// // The unchanged `a` and `c` frame the one changed line.
83/// let rendered = diff.to_string();
84/// assert!(rendered.contains("-b"));
85/// assert!(rendered.contains("+B"));
86/// assert!(rendered.contains(" a"));
87/// ```
88#[derive(Debug, Clone, PartialEq, Eq)]
89#[must_use]
90pub struct Diff {
91    changes: Vec<(Change, String)>,
92}
93
94impl Diff {
95    /// Compute the line-level diff between `expected` and `actual`.
96    ///
97    /// Both inputs are split on `\n` into lines (the caller is expected to have
98    /// already normalized line endings — [`Snapshot`](crate::Snapshot) does
99    /// this for you). Common leading and trailing lines are matched directly;
100    /// only the differing middle region is run through the LCS engine.
101    ///
102    /// # Examples
103    ///
104    /// Two identical strings produce an empty diff:
105    ///
106    /// ```
107    /// use test_lang::Diff;
108    ///
109    /// let diff = Diff::lines("same\ntext", "same\ntext");
110    /// assert!(diff.is_empty());
111    /// ```
112    ///
113    /// An added line shows up as an insertion:
114    ///
115    /// ```
116    /// use test_lang::{Change, Diff};
117    ///
118    /// let diff = Diff::lines("one\ntwo", "one\ntwo\nthree");
119    /// let inserted: Vec<_> = diff
120    ///     .changes()
121    ///     .filter(|(c, _)| *c == Change::Insert)
122    ///     .map(|(_, line)| line)
123    ///     .collect();
124    /// assert_eq!(inserted, ["three"]);
125    /// ```
126    pub fn lines(expected: &str, actual: &str) -> Self {
127        let old: Vec<&str> = split_lines(expected);
128        let new: Vec<&str> = split_lines(actual);
129
130        let mut changes = Vec::new();
131
132        // Fast path: peel off the identical prefix without touching the LCS
133        // table. This is the common case for a near-miss snapshot.
134        let mut start = 0;
135        let max_prefix = old.len().min(new.len());
136        while start < max_prefix && old[start] == new[start] {
137            start += 1;
138        }
139
140        // Peel off the identical suffix, stopping before the shared prefix so a
141        // line is never counted on both ends.
142        let mut old_end = old.len();
143        let mut new_end = new.len();
144        while old_end > start && new_end > start && old[old_end - 1] == new[new_end - 1] {
145            old_end -= 1;
146            new_end -= 1;
147        }
148
149        for line in &old[..start] {
150            changes.push((Change::Equal, String::from(*line)));
151        }
152
153        diff_middle(&old[start..old_end], &new[start..new_end], &mut changes);
154
155        for line in &old[old_end..] {
156            changes.push((Change::Equal, String::from(*line)));
157        }
158
159        Diff { changes }
160    }
161
162    /// Returns `true` when the two inputs were line-for-line identical, i.e. the
163    /// diff contains no insertions or deletions.
164    ///
165    /// # Examples
166    ///
167    /// ```
168    /// use test_lang::Diff;
169    ///
170    /// assert!(Diff::lines("x", "x").is_empty());
171    /// assert!(!Diff::lines("x", "y").is_empty());
172    /// ```
173    #[must_use]
174    pub fn is_empty(&self) -> bool {
175        self.changes.iter().all(|(c, _)| *c == Change::Equal)
176    }
177
178    /// Iterate over every line in the diff, in order, as `(change, text)` pairs.
179    ///
180    /// Equal lines are included so the caller can reconstruct the full aligned
181    /// view; filter on the [`Change`] if only edits are of interest.
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// use test_lang::{Change, Diff};
187    ///
188    /// let diff = Diff::lines("keep\ndrop", "keep");
189    /// let deleted = diff
190    ///     .changes()
191    ///     .filter(|(c, _)| *c == Change::Delete)
192    ///     .count();
193    /// assert_eq!(deleted, 1);
194    /// ```
195    pub fn changes(&self) -> impl Iterator<Item = (Change, &str)> {
196        self.changes.iter().map(|(c, s)| (*c, s.as_str()))
197    }
198}
199
200impl fmt::Display for Diff {
201    /// Render the diff in unified style: one line per change, each prefixed with
202    /// its [`marker`](Change::marker) and a space.
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        for (i, (change, text)) in self.changes.iter().enumerate() {
205            if i > 0 {
206                f.write_str("\n")?;
207            }
208            f.write_fmt(format_args!("{}{}", change.marker(), text))?;
209        }
210        Ok(())
211    }
212}
213
214/// Split a block of text into lines without allocating.
215///
216/// An empty string is treated as zero lines (not one empty line) so that an
217/// empty snapshot compares clean against another empty snapshot.
218fn split_lines(text: &str) -> Vec<&str> {
219    if text.is_empty() {
220        Vec::new()
221    } else {
222        text.split('\n').collect()
223    }
224}
225
226/// Diff the non-trivial middle region with an LCS edit script.
227///
228/// `old` and `new` have already had their common prefix and suffix removed, so
229/// the first and last lines here are guaranteed to differ (unless one side is
230/// empty). Deletions are emitted before insertions at each divergence point,
231/// which reads naturally in the unified output.
232fn diff_middle<'a>(old: &[&'a str], new: &[&'a str], out: &mut Vec<(Change, String)>) {
233    if old.is_empty() {
234        out.extend(new.iter().map(|l| (Change::Insert, String::from(*l))));
235        return;
236    }
237    if new.is_empty() {
238        out.extend(old.iter().map(|l| (Change::Delete, String::from(*l))));
239        return;
240    }
241
242    let cells = (old.len() + 1).checked_mul(new.len() + 1);
243    if cells.is_none_or(|cells| cells > MAX_LCS_CELLS) {
244        // Too large for the table: report the region as replaced.
245        out.extend(old.iter().map(|l| (Change::Delete, String::from(*l))));
246        out.extend(new.iter().map(|l| (Change::Insert, String::from(*l))));
247        return;
248    }
249
250    let table = lcs_table(old, new);
251
252    // Walk the LCS table from the top-left, emitting an edit script. At each
253    // cell we either take a matching line (diagonal) or step in the direction
254    // the table says preserves the most shared lines.
255    let mut i = 0;
256    let mut j = 0;
257    while i < old.len() && j < new.len() {
258        if old[i] == new[j] {
259            out.push((Change::Equal, String::from(old[i])));
260            i += 1;
261            j += 1;
262        } else if table[i + 1][j] >= table[i][j + 1] {
263            out.push((Change::Delete, String::from(old[i])));
264            i += 1;
265        } else {
266            out.push((Change::Insert, String::from(new[j])));
267            j += 1;
268        }
269    }
270    out.extend(old[i..].iter().map(|l| (Change::Delete, String::from(*l))));
271    out.extend(new[j..].iter().map(|l| (Change::Insert, String::from(*l))));
272}
273
274/// Build the LCS-length dynamic-programming table for two line slices.
275///
276/// `table[i][j]` holds the length of the longest common subsequence of
277/// `old[i..]` and `new[j..]`, so the forward walk in [`diff_middle`] can pick
278/// the direction that retains the most shared lines. The table is
279/// `(old.len() + 1) x (new.len() + 1)`; the extra row and column are the empty
280/// base cases.
281fn lcs_table(old: &[&str], new: &[&str]) -> Vec<Vec<u32>> {
282    let rows = old.len() + 1;
283    let cols = new.len() + 1;
284    let mut table = alloc::vec![alloc::vec![0u32; cols]; rows];
285
286    for i in (0..old.len()).rev() {
287        for j in (0..new.len()).rev() {
288            table[i][j] = if old[i] == new[j] {
289                table[i + 1][j + 1] + 1
290            } else {
291                table[i + 1][j].max(table[i][j + 1])
292            };
293        }
294    }
295    table
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used, clippy::expect_used)]
300mod tests {
301    use super::*;
302    use alloc::string::ToString;
303    use alloc::vec;
304
305    #[test]
306    fn test_diff_identical_is_empty() {
307        assert!(Diff::lines("a\nb\nc", "a\nb\nc").is_empty());
308    }
309
310    #[test]
311    fn test_diff_empty_inputs_is_empty() {
312        assert!(Diff::lines("", "").is_empty());
313    }
314
315    #[test]
316    fn test_diff_single_line_change_reports_both_sides() {
317        let diff = Diff::lines("a\nb\nc", "a\nB\nc");
318        let changes: Vec<_> = diff.changes().collect();
319        assert_eq!(
320            changes,
321            vec![
322                (Change::Equal, "a"),
323                (Change::Delete, "b"),
324                (Change::Insert, "B"),
325                (Change::Equal, "c"),
326            ]
327        );
328    }
329
330    #[test]
331    fn test_diff_insertion_at_end() {
332        let diff = Diff::lines("a\nb", "a\nb\nc");
333        let inserted: Vec<_> = diff
334            .changes()
335            .filter(|(c, _)| *c == Change::Insert)
336            .map(|(_, l)| l)
337            .collect();
338        assert_eq!(inserted, vec!["c"]);
339    }
340
341    #[test]
342    fn test_diff_deletion_at_start() {
343        let diff = Diff::lines("a\nb\nc", "b\nc");
344        let deleted: Vec<_> = diff
345            .changes()
346            .filter(|(c, _)| *c == Change::Delete)
347            .map(|(_, l)| l)
348            .collect();
349        assert_eq!(deleted, vec!["a"]);
350    }
351
352    #[test]
353    fn test_diff_all_different() {
354        let diff = Diff::lines("a\nb", "x\ny");
355        assert!(!diff.is_empty());
356        assert_eq!(
357            diff.changes().filter(|(c, _)| *c == Change::Delete).count(),
358            2
359        );
360        assert_eq!(
361            diff.changes().filter(|(c, _)| *c == Change::Insert).count(),
362            2
363        );
364    }
365
366    #[test]
367    fn test_diff_expected_empty_all_insert() {
368        let diff = Diff::lines("", "a\nb");
369        let changes: Vec<_> = diff.changes().collect();
370        assert_eq!(changes, vec![(Change::Insert, "a"), (Change::Insert, "b")]);
371    }
372
373    #[test]
374    fn test_diff_actual_empty_all_delete() {
375        let diff = Diff::lines("a\nb", "");
376        let changes: Vec<_> = diff.changes().collect();
377        assert_eq!(changes, vec![(Change::Delete, "a"), (Change::Delete, "b")]);
378    }
379
380    #[test]
381    fn test_diff_display_uses_markers() {
382        let diff = Diff::lines("a\nb", "a\nc");
383        let rendered = diff.to_string();
384        assert_eq!(rendered, " a\n-b\n+c");
385    }
386
387    #[test]
388    fn test_change_marker() {
389        assert_eq!(Change::Equal.marker(), ' ');
390        assert_eq!(Change::Delete.marker(), '-');
391        assert_eq!(Change::Insert.marker(), '+');
392    }
393
394    #[test]
395    fn test_diff_preserves_common_prefix_and_suffix() {
396        let diff = Diff::lines("head\nold\ntail", "head\nnew\ntail");
397        let changes: Vec<_> = diff.changes().collect();
398        assert_eq!(
399            changes,
400            vec![
401                (Change::Equal, "head"),
402                (Change::Delete, "old"),
403                (Change::Insert, "new"),
404                (Change::Equal, "tail"),
405            ]
406        );
407    }
408
409    #[test]
410    fn test_diff_lcs_finds_shared_middle() {
411        // The shared "keep" line should be matched, not deleted-and-reinserted.
412        let diff = Diff::lines("a\nkeep\nb", "x\nkeep\ny");
413        assert_eq!(
414            diff.changes().filter(|(c, _)| *c == Change::Equal).count(),
415            1
416        );
417    }
418}