test_lang/snapshot.rs
1//! Capturing and asserting textual snapshots of compiler output.
2//!
3//! A [`Snapshot`] is a normalized block of text — the rendered form of some
4//! stage's output, such as a token stream, a syntax tree, or a set of
5//! diagnostics. The point of normalizing is that a test can be written once and
6//! pass on every platform: a snapshot captured on Windows (CRLF line endings,
7//! stray trailing whitespace from a formatter) compares equal to the same
8//! snapshot written by hand in a test on Linux.
9//!
10//! The typical flow is: run the source through the stage under test, wrap the
11//! result in a `Snapshot`, and call [`Snapshot::check`] against the expected
12//! text. On a match you get `Ok(())`; on a mismatch you get a [`Mismatch`]
13//! whose [`Display`](fmt::Display) is a ready-to-read unified diff.
14
15use crate::diff::Diff;
16use alloc::string::String;
17use core::fmt;
18
19/// A normalized, comparable rendering of some compiler output.
20///
21/// Snapshots normalize three things so that byte-for-byte equality is not
22/// required for a test to pass:
23///
24/// - **Line endings.** `\r\n` and lone `\r` both become `\n`.
25/// - **Trailing whitespace.** Spaces and tabs at the end of each line are
26/// stripped, so an editor that trims (or fails to trim) lines does not break
27/// a test.
28/// - **Trailing blank lines.** A trailing newline — or several — is removed, so
29/// the expected text can be written with or without one.
30///
31/// Interior blank lines and leading whitespace are preserved: they are usually
32/// significant (indentation in a pretty-printed tree, for example).
33///
34/// # Examples
35///
36/// Capture a value that implements [`Display`](fmt::Display):
37///
38/// ```
39/// use test_lang::Snapshot;
40///
41/// let snap = Snapshot::display(&42);
42/// assert_eq!(snap.as_str(), "42");
43/// ```
44///
45/// Capture a token stream, one item per line:
46///
47/// ```
48/// use test_lang::Snapshot;
49///
50/// let tokens = ["let", "x", "=", "1"];
51/// let snap = Snapshot::per_line(tokens);
52/// assert_eq!(snap.as_str(), "let\nx\n=\n1");
53/// ```
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct Snapshot {
56 text: String,
57}
58
59impl Snapshot {
60 /// Build a snapshot from an already-rendered block of text.
61 ///
62 /// The input is normalized (see the type-level docs). Use this when the
63 /// stage under test already hands you a `String`.
64 ///
65 /// # Examples
66 ///
67 /// ```
68 /// use test_lang::Snapshot;
69 ///
70 /// // Trailing whitespace and CRLF endings are normalized away.
71 /// let snap = Snapshot::new("a \r\nb\n");
72 /// assert_eq!(snap.as_str(), "a\nb");
73 /// ```
74 pub fn new(text: impl AsRef<str>) -> Self {
75 Snapshot {
76 text: normalize(text.as_ref()),
77 }
78 }
79
80 /// Build a snapshot by rendering a value through its
81 /// [`Display`](fmt::Display) implementation.
82 ///
83 /// This is the natural entry point for a value that already knows how to
84 /// print itself the way a test should read it — a formatted diagnostic, a
85 /// single token, a pretty-printed tree.
86 ///
87 /// # Examples
88 ///
89 /// ```
90 /// use core::fmt;
91 /// use test_lang::Snapshot;
92 ///
93 /// struct Diagnostic;
94 /// impl fmt::Display for Diagnostic {
95 /// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 /// write!(f, "error: unexpected token")
97 /// }
98 /// }
99 ///
100 /// let snap = Snapshot::display(&Diagnostic);
101 /// assert_eq!(snap.as_str(), "error: unexpected token");
102 /// ```
103 pub fn display(value: &impl fmt::Display) -> Self {
104 Snapshot::new(alloc::format!("{value}"))
105 }
106
107 /// Build a snapshot by rendering a value through its
108 /// [`Debug`](fmt::Debug) implementation, using the alternate (`{:#?}`)
109 /// pretty form.
110 ///
111 /// Most syntax-tree node types derive `Debug` but not `Display`; this
112 /// captures the multi-line pretty-printed tree without requiring the node to
113 /// implement a display format of its own.
114 ///
115 /// # Examples
116 ///
117 /// ```
118 /// use test_lang::Snapshot;
119 ///
120 /// #[derive(Debug)]
121 /// struct Binary { op: char }
122 ///
123 /// let snap = Snapshot::debug(&Binary { op: '+' });
124 /// assert!(snap.as_str().contains("op: '+'"));
125 /// ```
126 pub fn debug(value: &impl fmt::Debug) -> Self {
127 Snapshot::new(alloc::format!("{value:#?}"))
128 }
129
130 /// Build a snapshot from a sequence of values, rendering each on its own
131 /// line through [`Display`](fmt::Display).
132 ///
133 /// This is the idiomatic way to snapshot a token stream: one token per
134 /// line makes the diff on failure point at the exact token that changed.
135 ///
136 /// The text is normalized like any snapshot, so an item that renders as
137 /// only spaces or tabs becomes a blank line, and blank lines at the end
138 /// are dropped. If whitespace tokens matter, render them visibly, for
139 /// example through [`Debug`](fmt::Debug) (`" "` instead of ` `).
140 ///
141 /// # Examples
142 ///
143 /// ```
144 /// use test_lang::Snapshot;
145 ///
146 /// let kinds = ["Ident(x)", "Eq", "Int(1)"];
147 /// let snap = Snapshot::per_line(kinds);
148 /// assert_eq!(snap.as_str(), "Ident(x)\nEq\nInt(1)");
149 /// ```
150 ///
151 /// An empty sequence yields an empty snapshot:
152 ///
153 /// ```
154 /// use test_lang::Snapshot;
155 ///
156 /// let empty: [&str; 0] = [];
157 /// assert_eq!(Snapshot::per_line(empty).as_str(), "");
158 /// ```
159 pub fn per_line<I>(items: I) -> Self
160 where
161 I: IntoIterator,
162 I::Item: fmt::Display,
163 {
164 let mut text = String::new();
165 for (i, item) in items.into_iter().enumerate() {
166 if i > 0 {
167 text.push('\n');
168 }
169 // `write!` into a `String` never fails; the result is discarded on
170 // the infallible path deliberately.
171 let _ = fmt::write(&mut text, format_args!("{item}"));
172 }
173 Snapshot::new(text)
174 }
175
176 /// The normalized snapshot text.
177 ///
178 /// This is what [`check`](Snapshot::check) compares, and what you paste back
179 /// into a test as the expected value when accepting a new snapshot.
180 #[inline]
181 #[must_use]
182 pub fn as_str(&self) -> &str {
183 &self.text
184 }
185
186 /// Compare the snapshot against the `expected` text.
187 ///
188 /// The expected text is normalized the same way the snapshot was, so it can
189 /// be written inline in a test as a plain string literal without worrying
190 /// about trailing newlines or platform line endings. Returns `Ok(())` on a
191 /// match, or a [`Mismatch`] carrying the diff otherwise.
192 ///
193 /// # Errors
194 ///
195 /// Returns [`Mismatch`] when the normalized snapshot differs from the
196 /// normalized `expected` text. The error's [`Display`](fmt::Display) renders
197 /// a unified diff; [`Mismatch::diff`] exposes it programmatically.
198 ///
199 /// # Examples
200 ///
201 /// A matching snapshot:
202 ///
203 /// ```
204 /// use test_lang::Snapshot;
205 ///
206 /// let snap = Snapshot::per_line(["a", "b"]);
207 /// assert!(snap.check("a\nb").is_ok());
208 /// ```
209 ///
210 /// A mismatch carries a diff you can print or inspect:
211 ///
212 /// ```
213 /// use test_lang::Snapshot;
214 ///
215 /// let snap = Snapshot::per_line(["a", "b"]);
216 /// let err = snap.check("a\nc").unwrap_err();
217 /// assert!(err.to_string().contains("-c")); // expected `c`, was missing
218 /// assert!(err.to_string().contains("+b")); // `b` was produced instead
219 /// ```
220 pub fn check(&self, expected: impl AsRef<str>) -> Result<(), Mismatch> {
221 let expected = normalize(expected.as_ref());
222 // The common case is a match: compare the text first and build the
223 // line diff only when there is something to report.
224 if expected == self.text {
225 return Ok(());
226 }
227 let diff = Diff::lines(&expected, &self.text);
228 if diff.is_empty() {
229 Ok(())
230 } else {
231 Err(Mismatch { diff })
232 }
233 }
234}
235
236impl fmt::Display for Snapshot {
237 #[inline]
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 f.write_str(&self.text)
240 }
241}
242
243/// The error returned by [`Snapshot::check`] when the snapshot does not match
244/// the expected text.
245///
246/// A `Mismatch` owns the computed [`Diff`]. Its [`Display`](fmt::Display) prints
247/// a unified diff — `-` lines were expected, `+` lines were produced — suitable
248/// for surfacing straight through a failing test's panic message.
249///
250/// # Examples
251///
252/// ```
253/// use test_lang::Snapshot;
254///
255/// let err = Snapshot::new("actual").check("expected").unwrap_err();
256/// // `-expected` was wanted; `+actual` is what the stage produced.
257/// assert!(err.to_string().contains("-expected"));
258/// assert!(err.to_string().contains("+actual"));
259/// ```
260#[derive(Debug, Clone, PartialEq, Eq)]
261#[must_use]
262pub struct Mismatch {
263 diff: Diff,
264}
265
266impl Mismatch {
267 /// The line-level diff between the expected text and the snapshot.
268 ///
269 /// Use this to inspect the mismatch programmatically instead of parsing the
270 /// rendered string — for example, to count how many lines changed.
271 ///
272 /// # Examples
273 ///
274 /// ```
275 /// use test_lang::{Change, Snapshot};
276 ///
277 /// let err = Snapshot::new("a\nb").check("a\nB").unwrap_err();
278 /// let edits = err
279 /// .diff()
280 /// .changes()
281 /// .filter(|(c, _)| *c != Change::Equal)
282 /// .count();
283 /// assert_eq!(edits, 2); // one deletion, one insertion
284 /// ```
285 #[inline]
286 pub fn diff(&self) -> &Diff {
287 &self.diff
288 }
289}
290
291impl fmt::Display for Mismatch {
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 f.write_str("snapshot mismatch (-expected +actual):\n")?;
294 fmt::Display::fmt(&self.diff, f)
295 }
296}
297
298impl core::error::Error for Mismatch {}
299
300/// Normalize a block of text for comparison.
301///
302/// Converts CRLF and lone CR to LF, strips trailing horizontal whitespace from
303/// every line, and drops any trailing blank lines. This is what makes a
304/// snapshot stable across platforms and editors.
305fn normalize(text: &str) -> String {
306 let mut out = String::with_capacity(text.len());
307
308 // Normalize line endings while splitting: treat `\r\n` and `\r` as `\n`.
309 let unified = text.replace("\r\n", "\n");
310 let unified = unified.replace('\r', "\n");
311
312 for line in unified.split('\n') {
313 out.push_str(line.trim_end_matches([' ', '\t']));
314 out.push('\n');
315 }
316
317 // Drop the trailing blank lines produced above (and any the caller wrote).
318 let trimmed = out.trim_end_matches('\n');
319 out.truncate(trimmed.len());
320 out
321}
322
323#[cfg(test)]
324#[allow(clippy::unwrap_used, clippy::expect_used)]
325mod tests {
326 use super::*;
327 use alloc::string::ToString;
328
329 #[test]
330 fn test_new_normalizes_crlf_to_lf() {
331 assert_eq!(Snapshot::new("a\r\nb").as_str(), "a\nb");
332 }
333
334 #[test]
335 fn test_new_normalizes_lone_cr() {
336 assert_eq!(Snapshot::new("a\rb").as_str(), "a\nb");
337 }
338
339 #[test]
340 fn test_new_strips_trailing_whitespace_per_line() {
341 assert_eq!(Snapshot::new("a \t\nb ").as_str(), "a\nb");
342 }
343
344 #[test]
345 fn test_new_strips_trailing_newlines() {
346 assert_eq!(Snapshot::new("a\nb\n\n\n").as_str(), "a\nb");
347 }
348
349 #[test]
350 fn test_new_preserves_interior_blank_lines() {
351 assert_eq!(Snapshot::new("a\n\nb").as_str(), "a\n\nb");
352 }
353
354 #[test]
355 fn test_new_preserves_leading_indentation() {
356 assert_eq!(Snapshot::new(" indented").as_str(), " indented");
357 }
358
359 #[test]
360 fn test_new_empty_input_is_empty() {
361 assert_eq!(Snapshot::new("").as_str(), "");
362 assert_eq!(Snapshot::new("\n\n").as_str(), "");
363 }
364
365 #[test]
366 fn test_display_renders_value() {
367 assert_eq!(Snapshot::display(&123).as_str(), "123");
368 }
369
370 #[test]
371 fn test_debug_uses_pretty_form() {
372 // The field is read only through the derived `Debug`, which the
373 // dead-code lint does not count; the test asserts on that rendering.
374 #[derive(Debug)]
375 #[allow(dead_code)]
376 struct Node {
377 kind: u8,
378 }
379 let snap = Snapshot::debug(&Node { kind: 7 });
380 assert!(snap.as_str().contains("kind: 7"));
381 }
382
383 #[test]
384 fn test_per_line_joins_with_newlines() {
385 assert_eq!(Snapshot::per_line(["a", "b", "c"]).as_str(), "a\nb\nc");
386 }
387
388 #[test]
389 fn test_per_line_empty_is_empty() {
390 let empty: [&str; 0] = [];
391 assert_eq!(Snapshot::per_line(empty).as_str(), "");
392 }
393
394 #[test]
395 fn test_check_matching_returns_ok() {
396 assert!(Snapshot::new("a\nb").check("a\nb").is_ok());
397 }
398
399 #[test]
400 fn test_check_matching_ignores_trailing_newline_difference() {
401 assert!(Snapshot::new("a\nb").check("a\nb\n").is_ok());
402 }
403
404 #[test]
405 fn test_check_matching_ignores_line_ending_difference() {
406 assert!(Snapshot::new("a\nb").check("a\r\nb").is_ok());
407 }
408
409 #[test]
410 fn test_check_mismatch_returns_err() {
411 let err = Snapshot::new("a\nb").check("a\nc").unwrap_err();
412 assert!(err.to_string().contains("-c"));
413 assert!(err.to_string().contains("+b"));
414 }
415
416 #[test]
417 fn test_check_mismatch_header_present() {
418 let err = Snapshot::new("x").check("y").unwrap_err();
419 assert!(err.to_string().starts_with("snapshot mismatch"));
420 }
421
422 #[test]
423 fn test_mismatch_diff_accessor() {
424 let err = Snapshot::new("a").check("b").unwrap_err();
425 assert!(!err.diff().is_empty());
426 }
427
428 #[test]
429 fn test_display_impl_matches_as_str() {
430 let snap = Snapshot::new("a\nb");
431 assert_eq!(snap.to_string(), snap.as_str());
432 }
433}