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}