Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show diffs when a binary comparison fails. #31

Merged
merged 4 commits into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
main:
* Automatically choose between pretty or compact `Debug` output, unless overridden.
* Print a diff for failed binary comparisons.
* Allow end-users to change the output of `assert2` with the `ASSERT2` environment variable.
* Support the `NO_COLOR` environment variable in addition to `CLICOLOR`.

v0.3.11 - 2023-05-24:
* Remove use of `source_text()` on stable since current it gives the source text of only one token tree.
* Remove use of `source_text()` on stable since currently it gives the source text of only one token tree.

v0.3.10 - 2023-02-14:
* Replace unmaintained `atty` dependency with `is-terminal`.
Expand Down
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "assert2"
description = "assert!(...) and check!(...) macros inspired by Catch2"
description = "assert!(...) and check!(...) macros inspired by Catch2, now with diffs!"
version = "0.3.11"
license = "BSD-2-Clause"
authors = [
Expand All @@ -21,6 +21,7 @@ categories = ["development-tools::debugging", "development-tools::testing"]
assert2-macros = { version = "=0.3.11", path = "assert2-macros" }
yansi = "0.5.0"
is-terminal = "0.4.3"
diff = "0.1.13"

[workspace]
resolver = "2"
Expand Down
63 changes: 44 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,66 @@
# assert2

All-purpose [`assert!(...)`](https://docs.rs/assert2/latest/assert2/macro.assert.html) and [`check!(...)`](https://docs.rs/assert2/latest/assert2/macro.check.html) macros, inspired by [Catch2](https://github.com/catchorg/Catch2).
There is also a [`debug_assert!(...)`](https://docs.rs/assert2/latest/assert2/macro.debug_assert.html) macro that is disabled on optimized builds by default.
As cherry on top there is a [`let_assert!(...)`](https://docs.rs/assert2/latest/assert2/macro.let_assert.html) macro that lets you test a pattern while capturing parts of it.
All-purpose [`assert!(...)`](macro.assert.html) and [`check!(...)`](macro.check.html) macros, inspired by [Catch2](https://github.com/catchorg/Catch2).
There is also a [`debug_assert!(...)`](macro.debug_assert.html) macro that is disabled on optimized builds by default.
As cherry on top there is a [`let_assert!(...)`](macro.let_assert.html) macro that lets you test a pattern while capturing parts of it.

## Why these macros?

These macros offer some benefits over the assertions from the standard library:
* The macros parse your expression to detect comparisons and adjust the error message accordingly.
No more `assert_eq` or `assert_ne`, just write `assert!(1 + 1 == 2)`, or even `assert!(1 + 1 > 1)`!
No more `assert_eq!(a, b)` or `assert_ne!(c, d)`, just write `assert!(1 + 1 == 2)`, or even `assert!(1 + 1 > 1)`!
* You can test for pattern matches: `assert!(let Err(_) = File::open("/non/existing/file"))`.
* You can capture parts of the pattern for further testing by using the `let_assert!(...)` macro.
* The `check` macro can be used to perform multiple checks before panicking.
* The macros provide more information when the assertion fails.
* Colored failure messages!
* The macros provide more information than the standard `std::assert!()` when the assertion fails.
* Colored failure messages with diffs!

The macros also accept additional arguments for a custom message, so it is fully compatible with `std::assert`.
That means you don't have to worry about overwriting the standard `assert` with `use assert2::assert`.
This means that you can import the macro as a drop in replacement:
```rust
use assert2::assert;
```

## Examples

```rust
check!(6 + 1 <= 2 * 3);
```

![Assertion error](https://github.com/de-vri-es/assert2-rs/raw/2db44c46e4580ec87d2881a698815e1ec5fcdf3f/binary-operator.png)
![Output](https://raw.githubusercontent.com/de-vri-es/assert2-rs/ba98984a32d6381e6710e34eb1fb83e65e851236/binary-operator.png)

----------

```rust
check!(true && false);
check!(scrappy == coco);
```

![Assertion error](https://github.com/de-vri-es/assert2-rs/raw/2db44c46e4580ec87d2881a698815e1ec5fcdf3f/boolean-expression.png)
![Output](https://raw.githubusercontent.com/de-vri-es/assert2-rs/54ee3141e9b23a0d9038697d34f29f25ef7fe810/multiline-diff.png)

----------

```rust
check!((3, Some(4)) == [1, 2, 3].iter().size_hint());
```

![Output](https://raw.githubusercontent.com/de-vri-es/assert2-rs/54ee3141e9b23a0d9038697d34f29f25ef7fe810/single-line-diff.png)

----------

```rust
check!(let Ok(_) = File::open("/non/existing/file"));
```

![Assertion error](https://github.com/de-vri-es/assert2-rs/raw/2db44c46e4580ec87d2881a698815e1ec5fcdf3f/pattern-match.png)
![Output](https://raw.githubusercontent.com/de-vri-es/assert2-rs/54ee3141e9b23a0d9038697d34f29f25ef7fe810/pattern-match.png)

----------

```rust
let_assert!(Err(e) = File::open("/non/existing/file"));
check!(e.kind() == ErrorKind::PermissionDenied);
```
![Assertion error](https://github.com/de-vri-es/assert2-rs/raw/573a686d1f19e0513cb235df38d157defdadbec0/let-assert.png)

![Output](https://github.com/de-vri-es/assert2-rs/blob/54ee3141e9b23a0d9038697d34f29f25ef7fe810/let-assert.png?raw=true)

## `assert` vs `check`
The crate provides two macros: `check!(...)` and `assert!(...)`.
Expand All @@ -68,7 +80,7 @@ On stable and beta, it falls back to stringifying the expression.
This makes the output a bit more readable on nightly.

## The `let_assert!()` macro
You can also use the [`let_assert!(...)`](https://docs.rs/assert2/latest/assert2/macro.let_assert.html).
You can also use the [`let_assert!(...)`](macro.let_assert.html).
It is very similar to `assert!(let ...)`,
but all placeholders will be made available as variables in the calling scope.

Expand All @@ -85,12 +97,25 @@ check!(e.name() == "bogus name");
check!(e.to_string() == "invalid name: bogus name");
```

## Controlling the output format.

## Controlling colored output.
As an end-user, you can influence the way that `assert2` formats failed assertions by changing the `ASSERT2` environment variable.
You can specify any combination of options, separated by a comma.
The supported options are:
* `auto`: Automatically select the compact or pretty `Debug` format for an assertion based on the length (default).
* `pretty`: Always use the pretty `Debug` format for assertion messages (`{:#?}`).
* `compact`: Always use the compact `Debug` format for assertion messages (`{:?}`).
* `no-color`: Disable colored output, even when the output is going to a terminal.
* `color`: Enable colored output, even when the output is not going to a terminal.

For example, you can run the following command to force the use of the compact `Debug` format with colored output:
```shell
ASSERT2=compact,color cargo test
```

Colored output can be controlled using environment variables,
as per the [clicolors spec](https://bixense.com/clicolors/):
If neither the `color` or the `no-color` options are set,
then `assert2` follows the [clicolors specification](https://bixense.com/clicolors/):

* `CLICOLOR != 0`: ANSI colors are supported and should be used when the program isn't piped.
* `CLICOLOR == 0`: Don't output ANSI color escape codes.
* `CLICOLOR_FORCE != 0`: ANSI colors should be enabled no matter what.
* `NO_COLOR != 0` or `CLICOLOR == 0`: Write plain output without color codes.
* `CLICOLOR != 0`: Write colored output when the output is going to a terminal.
* `CLICOLOR_FORCE != 0`: Write colored output even when it is not going to a terminal.
25 changes: 25 additions & 0 deletions examples/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,29 @@ fn main() {

let_assert!(Err(e) = File::open("/non/existing/file"));
check!(e.kind() == ErrorKind::PermissionDenied);

#[derive(Debug, Eq, PartialEq)]
struct Pet {
name: String,
age: u32,
kind: String,
shaved: bool,
}

let scrappy = Pet {
name: "Scrappy".into(),
age: 7,
kind: "Bearded Collie".into(),
shaved: false,
};

let coco = Pet {
name: "Coco".into(),
age: 7,
kind: "Bearded Collie".into(),
shaved: true,
};
check!(scrappy == coco);

check!((3, Some(4)) == [1, 2, 3].iter().size_hint());
}
208 changes: 208 additions & 0 deletions src/__assert2_impl/print/diff.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
use std::fmt::Write;
use yansi::Paint;

/// A line diff between two inputs.
pub struct MultiLineDiff<'a> {
/// The actual diff results from the [`diff`] crate.
line_diffs: Vec<diff::Result<&'a str>>,
}

impl<'a> MultiLineDiff<'a> {
/// Create a new diff between a left and right input.
pub fn new(left: &'a str, right: &'a str) -> Self {
Self {
line_diffs: diff::lines(left, right),
}
}

/// Write the left and right input interleaved with eachother, highlighting the differences between the two.
pub fn write_interleaved(&self, buffer: &mut String) {
let mut removed = None;
for diff in &self.line_diffs {
match *diff {
diff::Result::Left(left) => {
if let Some(prev) = removed.take() {
writeln!(buffer, "{}", Paint::cyan(format_args!("< {prev}"))).unwrap();
}
removed = Some(left);
},
diff::Result::Right(right) => {
if let Some(left) = removed.take() {
let diff = SingleLineDiff::new(left, right);
write!(buffer, "{} ", diff.left_highlights.normal.paint("<")).unwrap();
diff.write_left(buffer);
write!(buffer, "\n{} ", diff.right_highlights.normal.paint(">")).unwrap();
diff.write_right(buffer);
buffer.push('\n');
} else {
writeln!(buffer, "{}", Paint::yellow(format_args!("> {right}"))).unwrap();
}
},
diff::Result::Both(text, _) => {
if let Some(prev) = removed.take() {
writeln!(buffer, "{}", Paint::cyan(format_args!("< {prev}"))).unwrap();
}
writeln!(buffer, " {}", Paint::default(text).dimmed()).unwrap();
},
}
}
// Remove last newline.
buffer.pop();
}
}

/// A character/word based diff between two single-line inputs.
pub struct SingleLineDiff<'a> {
/// The left line.
left: &'a str,

/// The right line.
right: &'a str,

/// The highlighting for the left line.
left_highlights: Highlighter,

/// The highlighting for the right line.
right_highlights: Highlighter,
}

impl<'a> SingleLineDiff<'a> {
/// Create a new word diff between two input lines.
pub fn new(left: &'a str, right: &'a str) -> Self {
let left_words = Self::split_words(left);
let right_words = Self::split_words(right);
let diffs = diff::slice(&left_words, &right_words);

let mut left_highlights = Highlighter::new(yansi::Color::Cyan);
let mut right_highlights = Highlighter::new(yansi::Color::Yellow);
for diff in &diffs {
match diff {
diff::Result::Left(left) => {
left_highlights.push(left.len(), true);
},
diff::Result::Right(right) => {
right_highlights.push(right.len(), true);
},
diff::Result::Both(left, right) => {
left_highlights.push(left.len(), false);
right_highlights.push(right.len(), false);
}
}
}

Self {
left,
right,
left_highlights,
right_highlights,
}
}

/// Write the left line with highlighting.
///
/// This does not write a line break to the buffer.
pub fn write_left(&self, buffer: &mut String) {
self.left_highlights.write_highlighted(buffer, self.left);
}

/// Write the right line with highlighting.
///
/// This does not write a line break to the buffer.
pub fn write_right(&self, buffer: &mut String) {
self.right_highlights.write_highlighted(buffer, self.right);
}

/// Split an input line into individual words.
fn split_words(mut input: &str) -> Vec<&str> {
/// Check if there should be a word break between character `a` and `b`.
fn is_break_point(a: char, b: char) -> bool {
if a.is_alphabetic() {
!b.is_alphabetic() || (a.is_lowercase() && !b.is_lowercase())
} else if a.is_ascii_digit() {
!b.is_ascii_digit()
} else if a.is_whitespace() {
!b.is_whitespace()
} else {
true
}
}

let mut output = Vec::new();
while !input.is_empty() {
let split = input.chars()
.zip(input.char_indices().skip(1))
.find_map(|(a, (pos, b))| Some(pos).filter(|_| is_break_point(a, b)))
.unwrap_or(input.len());
let (head, tail) = input.split_at(split);
output.push(head);
input = tail;
}
output
}
}

/// Highlighter that incrementaly builds a range of alternating styles.
struct Highlighter {
/// The ranges of alternating highlighting.
///
/// If the boolean is true, the range should be printed with the `highlight` style.
/// If the boolean is false, the range should be printed with the `normal` style.
ranges: Vec<(bool, std::ops::Range<usize>)>,

/// The total length of the highlighted ranges (in bytes, not characters or terminal cells).
total_highlighted: usize,

/// The style for non-highlighted words.
normal: yansi::Style,

/// The style for highlighted words.
highlight: yansi::Style,
}

impl Highlighter {
/// Create a new highlighter with the given color.
fn new(color: yansi::Color) -> Self {
let normal = yansi::Style::new(color);
let highlight = yansi::Style::new(yansi::Color::Black).bg(color).bold();
Self {
ranges: Vec::new(),
total_highlighted: 0,
normal,
highlight,
}
}

/// Push a range to the end of the highlighter.
fn push(&mut self, len: usize, highlight: bool) {
if highlight {
self.total_highlighted += len;
}
if let Some(last) = self.ranges.last_mut() {
if last.0 == highlight {
last.1.end += len;
} else {
let start = last.1.end;
self.ranges.push((highlight, start..start + len));
}
} else {
self.ranges.push((highlight, 0..len))
}
}

/// Write the data using the highlight ranges.
fn write_highlighted(&self, buffer: &mut String, data: &str) {
let not_highlighted = data.len() - self.total_highlighted;
if not_highlighted < self.total_highlighted + self.total_highlighted / 2 {
write!(buffer, "{}", self.normal.paint(data)).unwrap();
} else {
for (highlight, range) in self.ranges.iter().cloned() {
let piece = if highlight {
self.highlight.paint(&data[range])
} else {
self.normal.paint(&data[range])
};
write!(buffer, "{}", piece).unwrap();
}
}
}
}
Loading
Loading