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}