Skip to main content

assert_str/
lib.rs

1//! Macros for asserting multiline [`String`] and [`&str`] values.
2//!
3//! The API mirrors the standard library's [`assert_eq!`] / [`assert_ne!`], but
4//! before comparing, both operands are normalized so that differences which are
5//! usually noise get ignored. Every macro treats `\n` and `\r\n` as equivalent;
6//! the `trim` and `trim_all` variants additionally normalize whitespace. The
7//! left and right operands may be different types (`&str`, [`String`],
8//! `&String`, …).
9//!
10//! # Macros
11//!
12//! Each `*_eq!` asserts equality and each `*_ne!` asserts inequality after
13//! applying the normalization below. All accept an optional trailing format
14//! message, like [`assert_eq!`].
15//!
16//! | Macros | Normalization applied before comparing |
17//! | ------ | --------------------------------------- |
18//! | [`assert_str_eq!`] / [`assert_str_ne!`] | Line endings only (`\n` ≡ `\r\n`). |
19//! | [`assert_str_trim_eq!`] / [`assert_str_trim_ne!`] | Line endings, plus each line trimmed and blank lines dropped. |
20//! | [`assert_str_trim_all_eq!`] / [`assert_str_trim_all_ne!`] | All whitespace removed, anywhere in the string. |
21//!
22//! The same normalization is available directly via [`normalize`] and [`Mode`]
23//! for reuse outside of assertions.
24//!
25//! This crate has no dependencies and contains no `unsafe` code
26//! (`#![forbid(unsafe_code)]`).
27//!
28//! [`String`]: https://doc.rust-lang.org/std/string/struct.String.html
29//! [`&str`]: https://doc.rust-lang.org/std/primitive.str.html
30#![forbid(unsafe_code)]
31
32/// Normalization strategy shared by all of the assertion macros.
33///
34/// It is exposed so the exact same normalization the macros use can be reused
35/// outside of assertions -- for example to pre-process snapshots, build
36/// whitespace-insensitive hash/sort keys, or write your own comparison via
37/// [`normalize`].
38///
39/// This enum is `#[non_exhaustive]`: additional modes may be added in future
40/// releases without a breaking change.
41#[non_exhaustive]
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum Mode {
44    /// Compare line by line, treating `\n` and `\r\n` as equivalent. A single
45    /// trailing newline is ignored (a consequence of [`str::lines`]).
46    Lines,
47    /// Like [`Mode::Lines`], but additionally trims each line and drops empty
48    /// lines.
49    Trim,
50    /// Removes **all** whitespace anywhere in the string (spaces, tabs, and
51    /// line breaks). Useful for comparing minified and pretty-printed formats.
52    TrimAll,
53}
54
55/// Normalizes `s` according to `mode`, returning the canonical form that the
56/// assertion macros compare.
57///
58/// # Examples
59///
60/// ```
61/// use assert_str::{normalize, Mode};
62///
63/// assert_eq!(normalize("a\r\nb", Mode::Lines), "a\nb");
64/// assert_eq!(normalize("  a \n\n b ", Mode::Trim), "a\nb");
65/// assert_eq!(normalize("a b\nc", Mode::TrimAll), "abc");
66/// ```
67pub fn normalize(s: &str, mode: Mode) -> String {
68    match mode {
69        Mode::Lines => s.lines().collect::<Vec<_>>().join("\n"),
70        Mode::Trim => s
71            .lines()
72            .map(str::trim)
73            .filter(|line| !line.is_empty())
74            .collect::<Vec<_>>()
75            .join("\n"),
76        Mode::TrimAll => s.split_whitespace().collect(),
77    }
78}
79
80/// Implementation detail shared by the assertion macros. Not part of the
81/// public API -- use the macros instead.
82///
83/// It is `pub` only so the exported macros can reach it as
84/// `$crate::assert_str_impl`; `#[track_caller]` keeps panics pointing at the
85/// call site rather than at this crate.
86#[doc(hidden)]
87#[track_caller]
88pub fn assert_str_impl(
89    left: impl AsRef<str>,
90    right: impl AsRef<str>,
91    mode: Mode,
92    negated: bool,
93    args: Option<core::fmt::Arguments<'_>>,
94) {
95    let left = normalize(left.as_ref(), mode);
96    let right = normalize(right.as_ref(), mode);
97
98    // `negated` selects the failing condition: `assert_str_eq!` fails when the
99    // normalized forms differ, `assert_str_ne!` fails when they are equal.
100    if (left == right) == negated {
101        let op = if negated { "!=" } else { "==" };
102        match args {
103            Some(args) => panic!(
104                "assertion failed: `(left {op} right)`\n  left: `{left}`,\n right: `{right}`: {args}"
105            ),
106            None => panic!(
107                "assertion failed: `(left {op} right)`\n  left: `{left}`,\n right: `{right}`"
108            ),
109        }
110    }
111}
112
113/// Asserts that multiline strings([`&str`] or [`String`]) are identical. It
114/// ignores different new line characters for different OSes: `\n` or `\r\n`.
115///
116/// # Examples
117///
118/// Test on equality of two strings generated on different OSes:
119///
120/// ```
121/// use assert_str::assert_str_eq;
122/// assert_str_eq!("This string\nEnd", "This string\r\nEnd", "Responses should be equal");
123/// ```
124///
125/// [`String`]: https://doc.rust-lang.org/std/string/struct.String.html
126/// [`&str`]: https://doc.rust-lang.org/std/primitive.str.html
127#[macro_export]
128macro_rules! assert_str_eq {
129    ($left:expr, $right:expr $(,)?) => {
130        $crate::assert_str_impl(&$left, &$right, $crate::Mode::Lines, false, ::core::option::Option::None)
131    };
132    ($left:expr, $right:expr, $($arg:tt)+) => {
133        $crate::assert_str_impl(
134            &$left,
135            &$right,
136            $crate::Mode::Lines,
137            false,
138            ::core::option::Option::Some(::core::format_args!($($arg)+)),
139        )
140    };
141}
142
143/// Asserts that multiline strings([`&str`] or [`String`]) are not identical. It
144/// ignores different new line characters for different OSes: `\n` or `\r\n`.
145///
146/// # Examples
147///
148/// Test on inequality of two strings generated on different OSes:
149///
150/// ```
151/// use assert_str::assert_str_ne;
152/// assert_str_ne!("This string\nEnd", "This string\r\nFinalEnd", "Responses should not be equal");
153/// ```
154///
155/// [`String`]: https://doc.rust-lang.org/std/string/struct.String.html
156/// [`&str`]: https://doc.rust-lang.org/std/primitive.str.html
157#[macro_export]
158macro_rules! assert_str_ne {
159    ($left:expr, $right:expr $(,)?) => {
160        $crate::assert_str_impl(&$left, &$right, $crate::Mode::Lines, true, ::core::option::Option::None)
161    };
162    ($left:expr, $right:expr, $($arg:tt)+) => {
163        $crate::assert_str_impl(
164            &$left,
165            &$right,
166            $crate::Mode::Lines,
167            true,
168            ::core::option::Option::Some(::core::format_args!($($arg)+)),
169        )
170    };
171}
172
173/// Asserts that multiline strings([`&str`] or [`String`]) are identical when
174/// every line is trimmed and empty lines are removed. It ignores different
175/// new line characters for different OSes: `\n` or `\r\n`.
176///
177/// # Examples
178///
179/// Test on equality of two trimmed strings generated on different OSes:
180///
181/// ```
182/// use assert_str::assert_str_trim_eq;
183/// assert_str_trim_eq!("<html>\t \n\t<head> \n\t</head></html>",
184///     "<html>\r\n<head>\r\n</head></html>", "Responses should be equal");
185/// ```
186///
187/// [`String`]: https://doc.rust-lang.org/std/string/struct.String.html
188/// [`&str`]: https://doc.rust-lang.org/std/primitive.str.html
189#[macro_export]
190macro_rules! assert_str_trim_eq {
191    ($left:expr, $right:expr $(,)?) => {
192        $crate::assert_str_impl(&$left, &$right, $crate::Mode::Trim, false, ::core::option::Option::None)
193    };
194    ($left:expr, $right:expr, $($arg:tt)+) => {
195        $crate::assert_str_impl(
196            &$left,
197            &$right,
198            $crate::Mode::Trim,
199            false,
200            ::core::option::Option::Some(::core::format_args!($($arg)+)),
201        )
202    };
203}
204
205/// Asserts that multiline strings([`&str`] or [`String`]) are not identical
206/// when every line is trimmed and empty lines are removed. It ignores different
207/// new line characters for different OSes: `\n` or `\r\n`.
208///
209/// # Examples
210///
211/// Test on inequality of two trimmed strings:
212///
213/// ```
214/// use assert_str::assert_str_trim_ne;
215/// assert_str_trim_ne!("<html>\t \n\t<head> \n\t</head></html>",
216///     "<HTML><head></head></html>", "Responses should not be equal");
217/// ```
218///
219/// [`String`]: https://doc.rust-lang.org/std/string/struct.String.html
220/// [`&str`]: https://doc.rust-lang.org/std/primitive.str.html
221#[macro_export]
222macro_rules! assert_str_trim_ne {
223    ($left:expr, $right:expr $(,)?) => {
224        $crate::assert_str_impl(&$left, &$right, $crate::Mode::Trim, true, ::core::option::Option::None)
225    };
226    ($left:expr, $right:expr, $($arg:tt)+) => {
227        $crate::assert_str_impl(
228            &$left,
229            &$right,
230            $crate::Mode::Trim,
231            true,
232            ::core::option::Option::Some(::core::format_args!($($arg)+)),
233        )
234    };
235}
236
237/// Asserts that multiline strings([`&str`] or [`String`]) are identical after
238/// removing **all** whitespace (spaces, tabs, and line breaks, anywhere in the
239/// string). Useful for comparing minified and pretty-printed formats.
240///
241/// # Examples
242///
243/// Test on equality of two strings ignoring all whitespace:
244///
245/// ```
246/// use assert_str::assert_str_trim_all_eq;
247/// assert_str_trim_all_eq!("<html>\t \n\t<head> \n\t</head></html>",
248///     "<html><head></head></html>", "Responses should be equal");
249/// ```
250///
251/// [`String`]: https://doc.rust-lang.org/std/string/struct.String.html
252/// [`&str`]: https://doc.rust-lang.org/std/primitive.str.html
253#[macro_export]
254macro_rules! assert_str_trim_all_eq {
255    ($left:expr, $right:expr $(,)?) => {
256        $crate::assert_str_impl(&$left, &$right, $crate::Mode::TrimAll, false, ::core::option::Option::None)
257    };
258    ($left:expr, $right:expr, $($arg:tt)+) => {
259        $crate::assert_str_impl(
260            &$left,
261            &$right,
262            $crate::Mode::TrimAll,
263            false,
264            ::core::option::Option::Some(::core::format_args!($($arg)+)),
265        )
266    };
267}
268
269/// Asserts that multiline strings([`&str`] or [`String`]) are not identical after
270/// removing **all** whitespace (spaces, tabs, and line breaks, anywhere in the
271/// string). Useful for comparing minified and pretty-printed formats.
272///
273/// # Examples
274///
275/// Test on inequality of two strings ignoring all whitespace:
276///
277/// ```
278/// use assert_str::assert_str_trim_all_ne;
279/// assert_str_trim_all_ne!("<html>\t \n\t<head> \n\t</head></html>",
280///     "<HTML><head></head></html>", "Responses should not be equal");
281/// ```
282///
283/// [`String`]: https://doc.rust-lang.org/std/string/struct.String.html
284/// [`&str`]: https://doc.rust-lang.org/std/primitive.str.html
285#[macro_export]
286macro_rules! assert_str_trim_all_ne {
287    ($left:expr, $right:expr $(,)?) => {
288        $crate::assert_str_impl(&$left, &$right, $crate::Mode::TrimAll, true, ::core::option::Option::None)
289    };
290    ($left:expr, $right:expr, $($arg:tt)+) => {
291        $crate::assert_str_impl(
292            &$left,
293            &$right,
294            $crate::Mode::TrimAll,
295            true,
296            ::core::option::Option::Some(::core::format_args!($($arg)+)),
297        )
298    };
299}
300
301#[cfg(test)]
302mod tests {
303    // Several tests pass `.to_owned()` values on purpose: they exercise that the
304    // macros accept owned `String`s as well as `&str`/`&String`. That is part of
305    // the contract, so the conversions are intentional rather than redundant.
306    #![allow(clippy::unnecessary_to_owned)]
307
308    #[test]
309    fn cross_str_equal_simple() {
310        assert_str_eq!("Line\nLine2", "Line\r\nLine2");
311        assert_str_eq!("Line\nLine2".to_owned(), "Line\r\nLine2");
312        assert_str_eq!("Line\nLine2", "Line\r\nLine2".to_owned());
313        assert_str_eq!("Line\nLine2".to_owned(), "Line\r\nLine2".to_owned());
314        assert_str_eq!("Line\nLine2", "Line\r\nLine2",);
315    }
316
317    #[test]
318    fn cross_str_equal_message() {
319        assert_str_eq!("Line\nLine2".to_owned(), "Line\r\nLine2", "Message");
320        assert_str_eq!("Line\nLine2", "Line\r\nLine2".to_owned(), "Message");
321        assert_str_eq!("L\nLine2".to_owned(), "L\r\nLine2".to_owned(), "Message");
322        assert_str_eq!("Line\nLine2", "Line\r\nLine2", "Message");
323    }
324
325    #[test]
326    fn cross_str_not_equal_simple() {
327        assert_str_ne!("Line\nLine2", "Line\r\nLine");
328        assert_str_ne!("Line\nLine2".to_owned(), "Line\r\nLine");
329        assert_str_ne!("Line\nLine2", "Line\r\nLine".to_owned());
330        assert_str_ne!("Line\nLine2", "Line\r\nLine",);
331    }
332
333    #[test]
334    fn cross_str_not_equal_message() {
335        assert_str_ne!("Line\nLine2".to_owned(), "Line\r\nLine", "Message");
336        assert_str_ne!("Line\nLine2", "Line\r\nLine".to_owned(), "Message");
337        assert_str_ne!("L\nLine2".to_owned(), "L\r\nLine".to_owned(), "Message");
338        assert_str_ne!("Line\nLine2", "Line\r\nLine", "Message");
339    }
340
341    #[test]
342    fn cross_str_trim_equal() {
343        let left = "String  \n Line ".to_owned();
344        let right = "String\r\nLine".to_owned();
345        assert_str_trim_eq!(left, right);
346        assert_str_trim_eq!(&left, right);
347        assert_str_trim_eq!(left, &right);
348        assert_str_trim_eq!(&left, &right);
349        assert_str_trim_eq!(left, right,);
350    }
351
352    #[test]
353    fn cross_str_trim_equal_message() {
354        let left = "String  \n Line ".to_owned();
355        let right = "String\r\nLine".to_owned();
356        assert_str_trim_eq!(&left, right, "Message");
357        assert_str_trim_eq!(left, &right, "Message");
358        assert_str_trim_eq!(left, right, "Message");
359        assert_str_trim_eq!(&left, &right, "Message");
360    }
361
362    #[test]
363    fn cross_str_trim_not_equal() {
364        let left = "String  \n Line ".to_owned();
365        let right = "String\r\n12".to_owned();
366        assert_str_trim_ne!(left, right);
367        assert_str_trim_ne!(&left, right);
368        assert_str_trim_ne!(left, &right);
369        assert_str_trim_ne!(&left, &right);
370        assert_str_trim_ne!(left, right,);
371    }
372
373    #[test]
374    fn cross_str_trim_not_equal_message() {
375        let left = "String  \n Line ".to_owned();
376        let right = "String\r\n12".to_owned();
377        assert_str_trim_ne!(left, right, "Message");
378        assert_str_trim_ne!(&left, right, "Message");
379        assert_str_trim_ne!(left, &right, "Message");
380        assert_str_trim_ne!(&left, &right, "Message");
381    }
382
383    #[test]
384    fn cross_str_trim_all_equal() {
385        let left = "String  \n Line ".to_owned();
386        let right = "String\r\nLine".to_owned();
387        assert_str_trim_all_eq!(left, right);
388        assert_str_trim_all_eq!(&left, right);
389        assert_str_trim_all_eq!(left, &right);
390        assert_str_trim_all_eq!(&left, &right);
391        assert_str_trim_all_eq!(left, right,);
392    }
393
394    #[test]
395    fn cross_str_trim_all_equal_message() {
396        let left = "String  \n Line ".to_owned();
397        let right = "StringLine".to_owned();
398        assert_str_trim_all_eq!(&left, right, "Message");
399        assert_str_trim_all_eq!(left, &right, "Message");
400        assert_str_trim_all_eq!(left, right, "Message");
401        assert_str_trim_all_eq!(&left, &right, "Message");
402    }
403
404    #[test]
405    fn cross_str_trim_all_not_equal() {
406        let left = "String  \n Line ".to_owned();
407        let right = "Stringline".to_owned();
408        assert_str_trim_all_ne!(left, right);
409        assert_str_trim_all_ne!(&left, right);
410        assert_str_trim_all_ne!(left, &right);
411        assert_str_trim_all_ne!(&left, &right);
412        assert_str_trim_all_ne!(left, right,);
413    }
414
415    #[test]
416    fn cross_str_trim_all_not_equal_message() {
417        let left = "String  \n Line ".to_owned();
418        let right = "String12".to_owned();
419        assert_str_trim_all_ne!(left, right, "Message");
420        assert_str_trim_all_ne!(&left, right, "Message");
421        assert_str_trim_all_ne!(left, &right, "Message");
422        assert_str_trim_all_ne!(&left, &right, "Message");
423    }
424
425    // Regression tests for the `trim_all` semantics bugs.
426    //
427    // `trim_all` must remove ALL whitespace (its documented contract: "removes all
428    // whitespace including between tags"). Previously the 2-arg and trailing-comma
429    // arms used `trim_eq` semantics (line-based, internal whitespace preserved) and
430    // the trailing-comma arm even delegated to the wrong macro, so these inputs --
431    // which differ only by internal whitespace -- were wrongly reported unequal.
432    #[test]
433    fn trim_all_eq_ignores_internal_whitespace() {
434        assert_str_trim_all_eq!("a b", "ab");
435        assert_str_trim_all_eq!("a b", "ab",);
436        assert_str_trim_all_eq!("a b", "ab", "internal whitespace must be ignored");
437    }
438
439    // `trim_all_ne` must use `trim_all` semantics on every arm too (the trailing-comma
440    // arm previously delegated to `assert_str_trim_ne!`).
441    #[test]
442    fn trim_all_ne_uses_trim_all_semantics() {
443        assert_str_trim_all_ne!("abc", "x y z");
444        assert_str_trim_all_ne!("abc", "x y z",);
445        assert_str_trim_all_ne!("abc", "x y z", "content differs after removing whitespace");
446    }
447
448    // Failure paths: verify each macro panics when the assertion is violated and
449    // that the panic message keeps the documented format on every arm.
450    #[test]
451    #[should_panic(expected = "assertion failed: `(left == right)`")]
452    fn eq_panics_when_different() {
453        assert_str_eq!("a", "b");
454    }
455
456    #[test]
457    #[should_panic(expected = " right: `b`: custom 42")]
458    fn eq_panics_with_custom_message() {
459        assert_str_eq!("a", "b", "custom {}", 42);
460    }
461
462    #[test]
463    #[should_panic(expected = "assertion failed: `(left != right)`")]
464    fn ne_panics_when_equal() {
465        assert_str_ne!("a\nb", "a\r\nb");
466    }
467
468    #[test]
469    #[should_panic(expected = "assertion failed: `(left == right)`")]
470    fn trim_eq_panics_when_different() {
471        assert_str_trim_eq!(" a \n b ", "a\nc");
472    }
473
474    // Regression (needs `should_panic`, so it could not live in the bug-fix PR):
475    // the trailing-comma `trim_all_ne` arm used to delegate to `assert_str_trim_ne!`
476    // and would NOT panic here. With correct `trim_all` semantics `"a b"` and `"ab"`
477    // are equal, so `ne` must panic.
478    #[test]
479    #[should_panic(expected = "assertion failed: `(left != right)`")]
480    fn trim_all_ne_trailing_comma_panics_on_whitespace_only_difference() {
481        assert_str_trim_all_ne!("a b", "ab",);
482    }
483
484    /// Direct unit tests for the public [`normalize`](crate::normalize) function.
485    mod normalize_fn {
486        use crate::{Mode, normalize};
487
488        #[test]
489        fn lines_treats_lf_and_crlf_as_equal() {
490            assert_eq!(normalize("a\r\nb", Mode::Lines), "a\nb");
491            assert_eq!(normalize("a\nb", Mode::Lines), "a\nb");
492        }
493
494        #[test]
495        fn lines_absorbs_a_single_trailing_newline() {
496            assert_eq!(normalize("a\nb\n", Mode::Lines), "a\nb");
497            assert_eq!(normalize("a\nb", Mode::Lines), "a\nb");
498        }
499
500        #[test]
501        fn trim_strips_line_edges_and_drops_blank_lines() {
502            assert_eq!(normalize("  a  \n\n\t b \t", Mode::Trim), "a\nb");
503        }
504
505        #[test]
506        fn trim_all_removes_every_whitespace_run() {
507            assert_eq!(normalize("a b\tc\n d ", Mode::TrimAll), "abcd");
508        }
509
510        #[test]
511        fn empty_input_normalizes_to_empty() {
512            assert_eq!(normalize("", Mode::Lines), "");
513            assert_eq!(normalize("", Mode::Trim), "");
514            assert_eq!(normalize("", Mode::TrimAll), "");
515        }
516    }
517
518    /// Characterization tests documenting current normalization behaviour on
519    /// tricky Unicode / whitespace inputs. These pin down *what the crate does
520    /// today* (not necessarily what it should ideally do) so any future change
521    /// is a deliberate one.
522    mod behaviour_edge_cases {
523        use crate::{Mode, normalize};
524
525        // `str::lines()` only splits on `\n` and `\r\n`. A classic-Mac bare `\r`
526        // is therefore NOT treated as a line break and is preserved.
527        #[test]
528        fn bare_carriage_return_is_not_a_line_break() {
529            assert_eq!(normalize("a\rb", Mode::Lines), "a\rb");
530        }
531
532        // Unicode LINE SEPARATOR (U+2028) is likewise not split by `lines()`.
533        #[test]
534        fn unicode_line_separator_is_not_split_by_lines() {
535            assert_eq!(normalize("a\u{2028}b", Mode::Lines), "a\u{2028}b");
536        }
537
538        // A byte-order mark is not whitespace, so no mode strips it.
539        #[test]
540        fn byte_order_mark_is_preserved() {
541            assert_eq!(normalize("\u{feff}hi", Mode::TrimAll), "\u{feff}hi");
542        }
543
544        // `trim`/`split_whitespace` use the full Unicode `White_Space` set, so a
545        // non-breaking space IS treated as whitespace and removed by `TrimAll`.
546        #[test]
547        fn non_breaking_space_is_whitespace() {
548            assert_eq!(normalize("a\u{a0}b", Mode::TrimAll), "ab");
549        }
550
551        // A zero-width space is NOT `White_Space`, so it survives normalization.
552        #[test]
553        fn zero_width_space_is_preserved() {
554            assert_eq!(normalize("a\u{200b}b", Mode::TrimAll), "a\u{200b}b");
555        }
556    }
557}