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    /// # Examples
137    ///
138    /// ```
139    /// use test_lang::Snapshot;
140    ///
141    /// let kinds = ["Ident(x)", "Eq", "Int(1)"];
142    /// let snap = Snapshot::per_line(kinds);
143    /// assert_eq!(snap.as_str(), "Ident(x)\nEq\nInt(1)");
144    /// ```
145    ///
146    /// An empty sequence yields an empty snapshot:
147    ///
148    /// ```
149    /// use test_lang::Snapshot;
150    ///
151    /// let empty: [&str; 0] = [];
152    /// assert_eq!(Snapshot::per_line(empty).as_str(), "");
153    /// ```
154    pub fn per_line<I>(items: I) -> Self
155    where
156        I: IntoIterator,
157        I::Item: fmt::Display,
158    {
159        let mut text = String::new();
160        for (i, item) in items.into_iter().enumerate() {
161            if i > 0 {
162                text.push('\n');
163            }
164            // `write!` into a `String` never fails; the result is discarded on
165            // the infallible path deliberately.
166            let _ = fmt::write(&mut text, format_args!("{item}"));
167        }
168        Snapshot::new(text)
169    }
170
171    /// The normalized snapshot text.
172    ///
173    /// This is what [`check`](Snapshot::check) compares, and what you paste back
174    /// into a test as the expected value when accepting a new snapshot.
175    #[inline]
176    #[must_use]
177    pub fn as_str(&self) -> &str {
178        &self.text
179    }
180
181    /// Compare the snapshot against the `expected` text.
182    ///
183    /// The expected text is normalized the same way the snapshot was, so it can
184    /// be written inline in a test as a plain string literal without worrying
185    /// about trailing newlines or platform line endings. Returns `Ok(())` on a
186    /// match, or a [`Mismatch`] carrying the diff otherwise.
187    ///
188    /// # Errors
189    ///
190    /// Returns [`Mismatch`] when the normalized snapshot differs from the
191    /// normalized `expected` text. The error's [`Display`](fmt::Display) renders
192    /// a unified diff; [`Mismatch::diff`] exposes it programmatically.
193    ///
194    /// # Examples
195    ///
196    /// A matching snapshot:
197    ///
198    /// ```
199    /// use test_lang::Snapshot;
200    ///
201    /// let snap = Snapshot::per_line(["a", "b"]);
202    /// assert!(snap.check("a\nb").is_ok());
203    /// ```
204    ///
205    /// A mismatch carries a diff you can print or inspect:
206    ///
207    /// ```
208    /// use test_lang::Snapshot;
209    ///
210    /// let snap = Snapshot::per_line(["a", "b"]);
211    /// let err = snap.check("a\nc").unwrap_err();
212    /// assert!(err.to_string().contains("-c"));  // expected `c`, was missing
213    /// assert!(err.to_string().contains("+b"));  // `b` was produced instead
214    /// ```
215    pub fn check(&self, expected: impl AsRef<str>) -> Result<(), Mismatch> {
216        let expected = normalize(expected.as_ref());
217        let diff = Diff::lines(&expected, &self.text);
218        if diff.is_empty() {
219            Ok(())
220        } else {
221            Err(Mismatch { diff })
222        }
223    }
224}
225
226impl fmt::Display for Snapshot {
227    #[inline]
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        f.write_str(&self.text)
230    }
231}
232
233/// The error returned by [`Snapshot::check`] when the snapshot does not match
234/// the expected text.
235///
236/// A `Mismatch` owns the computed [`Diff`]. Its [`Display`](fmt::Display) prints
237/// a unified diff — `-` lines were expected, `+` lines were produced — suitable
238/// for surfacing straight through a failing test's panic message.
239///
240/// # Examples
241///
242/// ```
243/// use test_lang::Snapshot;
244///
245/// let err = Snapshot::new("actual").check("expected").unwrap_err();
246/// // `-expected` was wanted; `+actual` is what the stage produced.
247/// assert!(err.to_string().contains("-expected"));
248/// assert!(err.to_string().contains("+actual"));
249/// ```
250#[derive(Debug, Clone, PartialEq, Eq)]
251#[must_use]
252pub struct Mismatch {
253    diff: Diff,
254}
255
256impl Mismatch {
257    /// The line-level diff between the expected text and the snapshot.
258    ///
259    /// Use this to inspect the mismatch programmatically instead of parsing the
260    /// rendered string — for example, to count how many lines changed.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use test_lang::{Change, Snapshot};
266    ///
267    /// let err = Snapshot::new("a\nb").check("a\nB").unwrap_err();
268    /// let edits = err
269    ///     .diff()
270    ///     .changes()
271    ///     .filter(|(c, _)| *c != Change::Equal)
272    ///     .count();
273    /// assert_eq!(edits, 2); // one deletion, one insertion
274    /// ```
275    #[inline]
276    pub fn diff(&self) -> &Diff {
277        &self.diff
278    }
279}
280
281impl fmt::Display for Mismatch {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        f.write_str("snapshot mismatch (-expected +actual):\n")?;
284        fmt::Display::fmt(&self.diff, f)
285    }
286}
287
288impl core::error::Error for Mismatch {}
289
290/// Normalize a block of text for comparison.
291///
292/// Converts CRLF and lone CR to LF, strips trailing horizontal whitespace from
293/// every line, and drops any trailing blank lines. This is what makes a
294/// snapshot stable across platforms and editors.
295fn normalize(text: &str) -> String {
296    let mut out = String::with_capacity(text.len());
297
298    // Normalize line endings while splitting: treat `\r\n` and `\r` as `\n`.
299    let unified = text.replace("\r\n", "\n");
300    let unified = unified.replace('\r', "\n");
301
302    for line in unified.split('\n') {
303        out.push_str(line.trim_end_matches([' ', '\t']));
304        out.push('\n');
305    }
306
307    // Drop the trailing blank lines produced above (and any the caller wrote).
308    let trimmed = out.trim_end_matches('\n');
309    out.truncate(trimmed.len());
310    out
311}
312
313#[cfg(test)]
314#[allow(clippy::unwrap_used, clippy::expect_used)]
315mod tests {
316    use super::*;
317    use alloc::string::ToString;
318
319    #[test]
320    fn test_new_normalizes_crlf_to_lf() {
321        assert_eq!(Snapshot::new("a\r\nb").as_str(), "a\nb");
322    }
323
324    #[test]
325    fn test_new_normalizes_lone_cr() {
326        assert_eq!(Snapshot::new("a\rb").as_str(), "a\nb");
327    }
328
329    #[test]
330    fn test_new_strips_trailing_whitespace_per_line() {
331        assert_eq!(Snapshot::new("a \t\nb  ").as_str(), "a\nb");
332    }
333
334    #[test]
335    fn test_new_strips_trailing_newlines() {
336        assert_eq!(Snapshot::new("a\nb\n\n\n").as_str(), "a\nb");
337    }
338
339    #[test]
340    fn test_new_preserves_interior_blank_lines() {
341        assert_eq!(Snapshot::new("a\n\nb").as_str(), "a\n\nb");
342    }
343
344    #[test]
345    fn test_new_preserves_leading_indentation() {
346        assert_eq!(Snapshot::new("    indented").as_str(), "    indented");
347    }
348
349    #[test]
350    fn test_new_empty_input_is_empty() {
351        assert_eq!(Snapshot::new("").as_str(), "");
352        assert_eq!(Snapshot::new("\n\n").as_str(), "");
353    }
354
355    #[test]
356    fn test_display_renders_value() {
357        assert_eq!(Snapshot::display(&123).as_str(), "123");
358    }
359
360    #[test]
361    fn test_debug_uses_pretty_form() {
362        // The field is read only through the derived `Debug`, which the
363        // dead-code lint does not count; the test asserts on that rendering.
364        #[derive(Debug)]
365        #[allow(dead_code)]
366        struct Node {
367            kind: u8,
368        }
369        let snap = Snapshot::debug(&Node { kind: 7 });
370        assert!(snap.as_str().contains("kind: 7"));
371    }
372
373    #[test]
374    fn test_per_line_joins_with_newlines() {
375        assert_eq!(Snapshot::per_line(["a", "b", "c"]).as_str(), "a\nb\nc");
376    }
377
378    #[test]
379    fn test_per_line_empty_is_empty() {
380        let empty: [&str; 0] = [];
381        assert_eq!(Snapshot::per_line(empty).as_str(), "");
382    }
383
384    #[test]
385    fn test_check_matching_returns_ok() {
386        assert!(Snapshot::new("a\nb").check("a\nb").is_ok());
387    }
388
389    #[test]
390    fn test_check_matching_ignores_trailing_newline_difference() {
391        assert!(Snapshot::new("a\nb").check("a\nb\n").is_ok());
392    }
393
394    #[test]
395    fn test_check_matching_ignores_line_ending_difference() {
396        assert!(Snapshot::new("a\nb").check("a\r\nb").is_ok());
397    }
398
399    #[test]
400    fn test_check_mismatch_returns_err() {
401        let err = Snapshot::new("a\nb").check("a\nc").unwrap_err();
402        assert!(err.to_string().contains("-c"));
403        assert!(err.to_string().contains("+b"));
404    }
405
406    #[test]
407    fn test_check_mismatch_header_present() {
408        let err = Snapshot::new("x").check("y").unwrap_err();
409        assert!(err.to_string().starts_with("snapshot mismatch"));
410    }
411
412    #[test]
413    fn test_mismatch_diff_accessor() {
414        let err = Snapshot::new("a").check("b").unwrap_err();
415        assert!(!err.diff().is_empty());
416    }
417
418    #[test]
419    fn test_display_impl_matches_as_str() {
420        let snap = Snapshot::new("a\nb");
421        assert_eq!(snap.to_string(), snap.as_str());
422    }
423}