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}