Skip to content

Commit

Permalink
Merge pull request #31 from de-vri-es/show-diff
Browse files Browse the repository at this point in the history
Show diffs when a binary comparison fails.
  • Loading branch information
de-vri-es authored Feb 17, 2024
2 parents c72d8cf + e1958a3 commit 53973f4
Show file tree
Hide file tree
Showing 8 changed files with 570 additions and 66 deletions.
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

0 comments on commit 53973f4

Please sign in to comment.