Skip to main content

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}