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}