similar_asserts/
lib.rs

1//! `similar-asserts` is a crate that enhances the default assertion
2//! experience by using [similar](https://crates.io/crates/similar) for diffing.
3//! On failed assertions it renders out a colorized diff to the terminal.
4//!
5//! It comes with a handful of macros to replace [`std::assert_eq!`] with:
6//!
7//! - [`assert_eq!`]: diffs `Debug` on assertion failure.
8#![cfg_attr(
9    feature = "serde",
10    doc = r#"
11- [`assert_serde_eq!`]: diffs `Serialize` on assertion failure.
12"#
13)]
14//!
15//! ![](https://raw.githubusercontent.com/mitsuhiko/similar-asserts/main/assets/screenshot.png)
16//!
17//! # Usage
18//!
19//! ```rust
20//! use similar_asserts::assert_eq;
21//! assert_eq!((1..3).collect::<Vec<_>>(), vec![1, 2]);
22//! ```
23//!
24//! Optionally the assertion macros also let you "name" the left and right
25//! side which will produce slightly more explicit output:
26//!
27//! ```rust
28//! use similar_asserts::assert_eq;
29//! assert_eq!(expected: vec![1, 2], actual: (1..3).collect::<Vec<_>>());
30//! ```
31//!
32//! # Feature Flags
33//!
34//! * `unicode` enable improved character matching (enabled by default)
35//! * `serde` turns on support for serde.
36//!
37//! # Faster Builds
38//!
39//! This crate works best if you add it as `dev-dependency` only.  To make your code
40//! still compile you can use conditional uses that override the default uses for the
41//! `assert_eq!` macro from the stdlib:
42//!
43//! ```
44//! #[cfg(test)]
45//! use similar_asserts::assert_eq;
46//! ```
47//!
48//! Since `similar_asserts` uses the `similar` library for diffing you can also
49//! enable optimziation for them in all build types for quicker diffing.  Add
50//! this to your `Cargo.toml`:
51//!
52//! ```toml
53//! [profile.dev.package.similar]
54//! opt-level = 3
55//! ```
56//!
57//! # String Truncation
58//!
59//! By default the assertion only shows 200 characters.  This can be changed with the
60//! `SIMILAR_ASSERTS_MAX_STRING_LENGTH` environment variable.  Setting it to `0` disables
61//! all truncation, otherwise it sets the maximum number of characters before truncation
62//! kicks in.
63//!
64//! # Manual Diff Printing
65//!
66//! If you want to build your own comparison macros and you need a quick and simple
67//! way to render diffs, you can use the [`SimpleDiff`] type and display it:
68//!
69//! ```should_panic
70//! use similar_asserts::SimpleDiff;
71//! panic!("Not equal\n\n{}", SimpleDiff::from_str("a\nb\n", "b\nb\n", "left", "right"));
72//! ```
73use std::borrow::Cow;
74use std::fmt::{self, Display};
75use std::time::Duration;
76
77use console::{style, Style};
78use similar::{Algorithm, ChangeTag, TextDiff};
79
80#[cfg(feature = "serde")]
81#[doc(hidden)]
82pub mod serde_impl;
83
84// This needs to be public as we are using it internally in a macro.
85#[doc(hidden)]
86pub mod print;
87
88/// The maximum number of characters a string can be long before truncating.
89fn get_max_string_length() -> usize {
90    use std::sync::atomic::{AtomicUsize, Ordering};
91    static TRUNCATE: AtomicUsize = AtomicUsize::new(!0);
92    let rv = TRUNCATE.load(Ordering::Relaxed);
93    if rv != !0 {
94        return rv;
95    }
96    let rv: usize = std::env::var("SIMILAR_ASSERTS_MAX_STRING_LENGTH")
97        .ok()
98        .and_then(|x| x.parse().ok())
99        .unwrap_or(200);
100    TRUNCATE.store(rv, Ordering::Relaxed);
101    rv
102}
103
104/// A console printable diff.
105///
106/// The [`Display`](std::fmt::Display) implementation of this type renders out a
107/// diff with ANSI markers so it creates a nice colored diff. This can be used to
108/// build your own custom assertions in addition to the ones from this crate.
109///
110/// It does not provide much customization beyond what's possible done by default.
111pub struct SimpleDiff<'a> {
112    pub(crate) left_short: Cow<'a, str>,
113    pub(crate) right_short: Cow<'a, str>,
114    pub(crate) left_expanded: Option<Cow<'a, str>>,
115    pub(crate) right_expanded: Option<Cow<'a, str>>,
116    pub(crate) left_label: &'a str,
117    pub(crate) right_label: &'a str,
118}
119
120impl<'a> SimpleDiff<'a> {
121    /// Creates a diff from two strings.
122    ///
123    /// `left_label` and `right_label` are the labels used for the two sides.
124    /// `"left"` and `"right"` are sensible defaults if you don't know what
125    /// to pick.
126    pub fn from_str(
127        left: &'a str,
128        right: &'a str,
129        left_label: &'a str,
130        right_label: &'a str,
131    ) -> SimpleDiff<'a> {
132        SimpleDiff {
133            left_short: left.into(),
134            right_short: right.into(),
135            left_expanded: None,
136            right_expanded: None,
137            left_label,
138            right_label,
139        }
140    }
141
142    #[doc(hidden)]
143    pub fn __from_macro(
144        left_short: Option<Cow<'a, str>>,
145        right_short: Option<Cow<'a, str>>,
146        left_expanded: Option<Cow<'a, str>>,
147        right_expanded: Option<Cow<'a, str>>,
148        left_label: &'a str,
149        right_label: &'a str,
150    ) -> SimpleDiff<'a> {
151        SimpleDiff {
152            left_short: left_short.unwrap_or_else(|| "<unprintable object>".into()),
153            right_short: right_short.unwrap_or_else(|| "<unprintable object>".into()),
154            left_expanded,
155            right_expanded,
156            left_label,
157            right_label,
158        }
159    }
160
161    /// Returns the left side as string.
162    fn left(&self) -> &str {
163        self.left_expanded.as_deref().unwrap_or(&self.left_short)
164    }
165
166    /// Returns the right side as string.
167    fn right(&self) -> &str {
168        self.right_expanded.as_deref().unwrap_or(&self.right_short)
169    }
170
171    /// Returns the label padding
172    fn label_padding(&self) -> usize {
173        self.left_label
174            .chars()
175            .count()
176            .max(self.right_label.chars().count())
177    }
178
179    #[doc(hidden)]
180    #[track_caller]
181    pub fn fail_assertion(&self, hint: &dyn Display) {
182        // prefer the shortened version here.
183        let len = get_max_string_length();
184        let (left, left_truncated) = truncate_str(&self.left_short, len);
185        let (right, right_truncated) = truncate_str(&self.right_short, len);
186
187        panic!(
188            "assertion failed: `({} == {})`{}'\
189               \n {:>label_padding$}: `{:?}`{}\
190               \n {:>label_padding$}: `{:?}`{}\
191               \n\n{}\n",
192            self.left_label,
193            self.right_label,
194            hint,
195            self.left_label,
196            DebugStrTruncated(left, left_truncated),
197            if left_truncated { " (truncated)" } else { "" },
198            self.right_label,
199            DebugStrTruncated(right, right_truncated),
200            if right_truncated { " (truncated)" } else { "" },
201            &self,
202            label_padding = self.label_padding(),
203        );
204    }
205}
206
207fn truncate_str(s: &str, chars: usize) -> (&str, bool) {
208    if chars == 0 {
209        return (s, false);
210    }
211    s.char_indices()
212        .enumerate()
213        .find_map(|(idx, (offset, _))| {
214            if idx == chars {
215                Some((&s[..offset], true))
216            } else {
217                None
218            }
219        })
220        .unwrap_or((s, false))
221}
222
223struct DebugStrTruncated<'s>(&'s str, bool);
224
225impl<'s> fmt::Debug for DebugStrTruncated<'s> {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        if self.1 {
228            let s = format!("{}...", self.0);
229            fmt::Debug::fmt(&s, f)
230        } else {
231            fmt::Debug::fmt(&self.0, f)
232        }
233    }
234}
235
236fn trailing_newline(s: &str) -> &str {
237    if s.ends_with("\r\n") {
238        "\r\n"
239    } else if s.ends_with("\r") {
240        "\r"
241    } else if s.ends_with("\n") {
242        "\n"
243    } else {
244        ""
245    }
246}
247
248fn detect_newlines(s: &str) -> (bool, bool, bool) {
249    let mut last_char = None;
250    let mut detected_crlf = false;
251    let mut detected_cr = false;
252    let mut detected_lf = false;
253
254    for c in s.chars() {
255        if c == '\n' {
256            if last_char.take() == Some('\r') {
257                detected_crlf = true;
258            } else {
259                detected_lf = true;
260            }
261        }
262        if last_char == Some('\r') {
263            detected_cr = true;
264        }
265        last_char = Some(c);
266    }
267    if last_char == Some('\r') {
268        detected_cr = true;
269    }
270
271    (detected_cr, detected_crlf, detected_lf)
272}
273
274#[allow(clippy::match_like_matches_macro)]
275fn newlines_matter(left: &str, right: &str) -> bool {
276    if trailing_newline(left) != trailing_newline(right) {
277        return true;
278    }
279
280    let (cr1, crlf1, lf1) = detect_newlines(left);
281    let (cr2, crlf2, lf2) = detect_newlines(right);
282
283    match (cr1 || cr2, crlf1 || crlf2, lf1 || lf2) {
284        (false, false, false) => false,
285        (true, false, false) => false,
286        (false, true, false) => false,
287        (false, false, true) => false,
288        _ => true,
289    }
290}
291
292impl<'a> fmt::Display for SimpleDiff<'a> {
293    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
294        let left = self.left();
295        let right = self.right();
296        let newlines_matter = newlines_matter(left, right);
297
298        if left == right {
299            writeln!(
300                f,
301                "{}: the two values are the same in string form.",
302                style("Invisible differences").bold(),
303            )?;
304            return Ok(());
305        }
306
307        let diff = TextDiff::configure()
308            .timeout(Duration::from_millis(200))
309            .algorithm(Algorithm::Patience)
310            .diff_lines(left, right);
311
312        writeln!(
313            f,
314            "{} ({}{}|{}{}):",
315            style("Differences").bold(),
316            style("-").red().dim(),
317            style(self.left_label).red(),
318            style("+").green().dim(),
319            style(self.right_label).green(),
320        )?;
321        for (idx, group) in diff.grouped_ops(4).into_iter().enumerate() {
322            if idx > 0 {
323                writeln!(f, "@ {}", style("~~~").dim())?;
324            }
325            for op in group {
326                for change in diff.iter_inline_changes(&op) {
327                    let (marker, style) = match change.tag() {
328                        ChangeTag::Delete => ('-', Style::new().red()),
329                        ChangeTag::Insert => ('+', Style::new().green()),
330                        ChangeTag::Equal => (' ', Style::new().dim()),
331                    };
332                    write!(f, "{}", style.apply_to(marker).dim().bold())?;
333                    for &(emphasized, value) in change.values() {
334                        let value = if newlines_matter {
335                            Cow::Owned(
336                                value
337                                    .replace("\r", "␍\r")
338                                    .replace("\n", "␊\n")
339                                    .replace("␍\r␊\n", "␍␊\r\n"),
340                            )
341                        } else {
342                            Cow::Borrowed(value)
343                        };
344                        if emphasized {
345                            write!(f, "{}", style.clone().underlined().bold().apply_to(value))?;
346                        } else {
347                            write!(f, "{}", style.apply_to(value))?;
348                        }
349                    }
350                    if change.missing_newline() {
351                        writeln!(f)?;
352                    }
353                }
354            }
355        }
356
357        Ok(())
358    }
359}
360
361#[doc(hidden)]
362#[macro_export]
363macro_rules! __assert_eq {
364    (
365        $method:ident,
366        $left_label:ident,
367        $left:expr,
368        $right_label:ident,
369        $right:expr,
370        $hint_suffix:expr
371    ) => {{
372        match (&($left), &($right)) {
373            (left_val, right_val) =>
374            {
375                #[allow(unused_mut)]
376                if !(*left_val == *right_val) {
377                    use $crate::print::{PrintMode, PrintObject};
378                    let left_label = stringify!($left_label);
379                    let right_label = stringify!($right_label);
380                    let mut left_val_tup1 = (&left_val,);
381                    let mut right_val_tup1 = (&right_val,);
382                    let mut left_val_tup2 = (&left_val,);
383                    let mut right_val_tup2 = (&right_val,);
384                    let left_short = left_val_tup1.print_object(PrintMode::Default);
385                    let right_short = right_val_tup1.print_object(PrintMode::Default);
386                    let left_expanded = left_val_tup2.print_object(PrintMode::Expanded);
387                    let right_expanded = right_val_tup2.print_object(PrintMode::Expanded);
388                    let diff = $crate::SimpleDiff::__from_macro(
389                        left_short,
390                        right_short,
391                        left_expanded,
392                        right_expanded,
393                        left_label,
394                        right_label,
395                    );
396                    diff.fail_assertion(&$hint_suffix);
397                }
398            }
399        }
400    }};
401}
402
403/// Asserts that two expressions are equal to each other (using [`PartialEq`]).
404///
405/// On panic, this macro will print the values of the expressions with their
406/// [`Debug`] or [`ToString`] representations with a colorized diff of the
407/// changes in the debug output.  It picks [`Debug`] for all types that are
408/// not strings themselves and [`ToString`] for [`str`] and [`String`].
409///
410/// Like [`assert!`], this macro has a second form, where a custom panic
411/// message can be provided.
412///
413/// ```rust
414/// use similar_asserts::assert_eq;
415/// assert_eq!((1..3).collect::<Vec<_>>(), vec![1, 2]);
416/// ```
417#[macro_export]
418macro_rules! assert_eq {
419    ($left_label:ident: $left:expr, $right_label:ident: $right:expr $(,)?) => ({
420        $crate::__assert_eq!(make_diff, $left_label, $left, $right_label, $right, "");
421    });
422    ($left_label:ident: $left:expr, $right_label:ident: $right:expr, $($arg:tt)*) => ({
423        $crate::__assert_eq!(make_diff, $left_label, $left, $right_label, $right, format_args!(": {}", format_args!($($arg)*)));
424    });
425    ($left:expr, $right:expr $(,)?) => ({
426        $crate::assert_eq!(left: $left, right: $right);
427    });
428    ($left:expr, $right:expr, $($arg:tt)*) => ({
429        $crate::assert_eq!(left: $left, right: $right, $($arg)*);
430    });
431}
432
433/// Deprecated macro.  Use [`assert_eq!`] instead.
434#[macro_export]
435#[doc(hidden)]
436#[deprecated(since = "1.4.0", note = "use assert_eq! instead")]
437macro_rules! assert_str_eq {
438    ($left_label:ident: $left:expr, $right_label:ident: $right:expr $(,)?) => ({
439        $crate::assert_eq!($left_label: $left, $right_label: $right);
440    });
441    ($left_label:ident: $left:expr, $right_label:ident: $right:expr, $($arg:tt)*) => ({
442        $crate::assert_eq!($left_label: $left, $right_label: $right, $($arg)*);
443    });
444    ($left:expr, $right:expr $(,)?) => ({
445        $crate::assert_eq!($left, $right);
446    });
447    ($left:expr, $right:expr, $($arg:tt)*) => ({
448        $crate::assert_eq!($left, $right, $($arg)*);
449    });
450}
451
452#[test]
453fn test_newlines_matter() {
454    assert!(newlines_matter("\r\n", "\n"));
455    assert!(newlines_matter("foo\n", "foo"));
456    assert!(newlines_matter("foo\r\nbar", "foo\rbar"));
457    assert!(newlines_matter("foo\r\nbar", "foo\nbar"));
458    assert!(newlines_matter("foo\r\nbar\n", "foobar"));
459    assert!(newlines_matter("foo\nbar\r\n", "foo\nbar\r\n"));
460    assert!(newlines_matter("foo\nbar\n", "foo\nbar"));
461
462    assert!(!newlines_matter("foo\nbar", "foo\nbar"));
463    assert!(!newlines_matter("foo\nbar\n", "foo\nbar\n"));
464    assert!(!newlines_matter("foo\r\nbar", "foo\r\nbar"));
465    assert!(!newlines_matter("foo\r\nbar\r\n", "foo\r\nbar\r\n"));
466    assert!(!newlines_matter("foo\r\nbar", "foo\r\nbar"));
467}
468
469#[test]
470fn test_truncate_str() {
471    assert_eq!(truncate_str("foobar", 20), ("foobar", false));
472    assert_eq!(truncate_str("foobar", 2), ("fo", true));
473    assert_eq!(truncate_str("🔥🔥🔥🔥🔥", 2), ("🔥🔥", true));
474}