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