From 03c5a6690d943e48ac5b5464c2ac2fd054ea6251 Mon Sep 17 00:00:00 2001 From: Aleksey Kladov Date: Sat, 27 Jun 2020 17:53:50 +0200 Subject: Add light-weight snapshot testing library with editor integration --- crates/expect/src/lib.rs | 308 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 crates/expect/src/lib.rs (limited to 'crates/expect/src/lib.rs') diff --git a/crates/expect/src/lib.rs b/crates/expect/src/lib.rs new file mode 100644 index 000000000..08d0eafdf --- /dev/null +++ b/crates/expect/src/lib.rs @@ -0,0 +1,308 @@ +//! Snapshot testing library, see +//! https://github.com/rust-analyzer/rust-analyzer/pull/5101 +use std::{ + collections::HashMap, + env, fmt, fs, + ops::Range, + path::{Path, PathBuf}, + sync::Mutex, +}; + +use once_cell::sync::Lazy; +use stdx::{lines_with_ends, trim_indent}; + +const HELP: &str = " +You can update all `expect![[]]` tests by: + + env UPDATE_EXPECT=1 cargo test + +To update a single test, place the cursor on `expect` token and use `run` feature of rust-analyzer. +"; + +fn update_expect() -> bool { + env::var("UPDATE_EXPECT").is_ok() +} + +/// expect![[""]] +#[macro_export] +macro_rules! expect { + [[$lit:literal]] => {$crate::Expect { + file: file!(), + line: line!(), + column: column!(), + data: $lit, + }}; + [[]] => { $crate::expect![[""]] }; +} + +#[derive(Debug)] +pub struct Expect { + pub file: &'static str, + pub line: u32, + pub column: u32, + pub data: &'static str, +} + +impl Expect { + pub fn assert_eq(&self, actual: &str) { + let trimmed = self.trimmed(); + if &trimmed == actual { + return; + } + Runtime::fail(self, &trimmed, actual); + } + pub fn assert_debug_eq(&self, actual: &impl fmt::Debug) { + let actual = format!("{:#?}\n", actual); + self.assert_eq(&actual) + } + + fn trimmed(&self) -> String { + if !self.data.contains('\n') { + return self.data.to_string(); + } + trim_indent(self.data) + } + + fn locate(&self, file: &str) -> Location { + let mut target_line = None; + let mut line_start = 0; + for (i, line) in lines_with_ends(file).enumerate() { + if i == self.line as usize - 1 { + let pat = "expect![["; + let offset = line.find(pat).unwrap(); + let literal_start = line_start + offset + pat.len(); + let indent = line.chars().take_while(|&it| it == ' ').count(); + target_line = Some((literal_start, indent)); + break; + } + line_start += line.len(); + } + let (literal_start, line_indent) = target_line.unwrap(); + let literal_length = file[literal_start..].find("]]").unwrap(); + let literal_range = literal_start..literal_start + literal_length; + Location { line_indent, literal_range } + } +} + +#[derive(Default)] +struct Runtime { + help_printed: bool, + per_file: HashMap<&'static str, FileRuntime>, +} +static RT: Lazy> = Lazy::new(Default::default); + +impl Runtime { + fn fail(expect: &Expect, expected: &str, actual: &str) { + let mut rt = RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut updated = ""; + if update_expect() { + updated = " (updated)"; + rt.per_file + .entry(expect.file) + .or_insert_with(|| FileRuntime::new(expect)) + .update(expect, actual); + } + let print_help = !rt.help_printed && !update_expect(); + rt.help_printed = true; + + let help = if print_help { HELP } else { "" }; + panic!( + "\n +error: expect test failed{} + --> {}:{}:{} +{} +Expect: +---- +{} +---- + +Actual: +---- +{} +---- +", + updated, expect.file, expect.line, expect.column, help, expected, actual + ) + } +} + +struct FileRuntime { + path: PathBuf, + original_text: String, + patchwork: Patchwork, +} + +impl FileRuntime { + fn new(expect: &Expect) -> FileRuntime { + let path = workspace_root().join(expect.file); + let original_text = fs::read_to_string(&path).unwrap(); + let patchwork = Patchwork::new(original_text.clone()); + FileRuntime { path, original_text, patchwork } + } + fn update(&mut self, expect: &Expect, actual: &str) { + let loc = expect.locate(&self.original_text); + let patch = format_patch(loc.line_indent.clone(), actual); + self.patchwork.patch(loc.literal_range, &patch); + fs::write(&self.path, &self.patchwork.text).unwrap() + } +} + +#[derive(Debug)] +struct Location { + line_indent: usize, + literal_range: Range, +} + +#[derive(Debug)] +struct Patchwork { + text: String, + indels: Vec<(Range, usize)>, +} + +impl Patchwork { + fn new(text: String) -> Patchwork { + Patchwork { text, indels: Vec::new() } + } + fn patch(&mut self, mut range: Range, patch: &str) { + self.indels.push((range.clone(), patch.len())); + self.indels.sort_by_key(|(delete, _insert)| delete.start); + + let (delete, insert) = self + .indels + .iter() + .take_while(|(delete, _)| delete.start < range.start) + .map(|(delete, insert)| (delete.end - delete.start, insert)) + .fold((0usize, 0usize), |(x1, y1), (x2, y2)| (x1 + x2, y1 + y2)); + + for pos in &mut [&mut range.start, &mut range.end] { + **pos += insert; + **pos -= delete + } + + self.text.replace_range(range, &patch); + } +} + +fn format_patch(line_indent: usize, patch: &str) -> String { + let mut max_hashes = 0; + let mut cur_hashes = 0; + for byte in patch.bytes() { + if byte != b'#' { + cur_hashes = 0; + continue; + } + cur_hashes += 1; + max_hashes = max_hashes.max(cur_hashes); + } + let hashes = &"#".repeat(max_hashes + 1); + let indent = &" ".repeat(line_indent); + let is_multiline = patch.contains('\n'); + + let mut buf = String::new(); + buf.push('r'); + buf.push_str(hashes); + buf.push('"'); + if is_multiline { + buf.push('\n'); + } + let mut final_newline = false; + for line in lines_with_ends(patch) { + if is_multiline { + buf.push_str(indent); + buf.push_str(" "); + } + buf.push_str(line); + final_newline = line.ends_with('\n'); + } + if final_newline { + buf.push_str(indent); + } + buf.push('"'); + buf.push_str(hashes); + buf +} + +fn workspace_root() -> PathBuf { + Path::new( + &env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| env!("CARGO_MANIFEST_DIR").to_owned()), + ) + .ancestors() + .nth(2) + .unwrap() + .to_path_buf() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expect_macro() { + let empty = expect![[]]; + expect![[r#" + Expect { + file: "crates/expect/src/lib.rs", + line: 241, + column: 21, + data: "", + } + "#]] + .assert_debug_eq(&empty); + + let expect = expect![[" + hello + world + "]]; + expect![[r#" + Expect { + file: "crates/expect/src/lib.rs", + line: 252, + column: 22, + data: "\n hello\n world\n ", + } + "#]] + .assert_debug_eq(&expect); + } + + #[test] + fn test_format_patch() { + let patch = format_patch(0, "hello\nworld\n"); + expect![[r##" + r#" + hello + world + "#"##]] + .assert_eq(&patch); + + let patch = format_patch(4, "single line"); + expect![[r##"r#"single line"#"##]].assert_eq(&patch); + } + + #[test] + fn test_patchwork() { + let mut patchwork = Patchwork::new("one two three".to_string()); + patchwork.patch(4..7, "zwei"); + patchwork.patch(0..3, "один"); + patchwork.patch(8..13, "3"); + expect![[r#" + Patchwork { + text: "один zwei 3", + indels: [ + ( + 0..3, + 8, + ), + ( + 4..7, + 4, + ), + ( + 8..13, + 1, + ), + ], + } + "#]] + .assert_debug_eq(&patchwork); + } +} -- cgit v1.2.3 From d21dae738b4440f699356865c71e4235f2911de6 Mon Sep 17 00:00:00 2001 From: Aleksey Kladov Date: Sat, 27 Jun 2020 19:55:54 +0200 Subject: Update crates/expect/src/lib.rs Co-authored-by: bjorn3 --- crates/expect/src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'crates/expect/src/lib.rs') diff --git a/crates/expect/src/lib.rs b/crates/expect/src/lib.rs index 08d0eafdf..1a1302cec 100644 --- a/crates/expect/src/lib.rs +++ b/crates/expect/src/lib.rs @@ -106,7 +106,7 @@ impl Runtime { rt.help_printed = true; let help = if print_help { HELP } else { "" }; - panic!( + println!( "\n error: expect test failed{} --> {}:{}:{} @@ -122,7 +122,9 @@ Actual: ---- ", updated, expect.file, expect.line, expect.column, help, expected, actual - ) + ); + // Use resume_unwind instead of panic!() to prevent a backtrace, which is unnecessary noise. + std::panic::resume_unwind(Box::new(())); } } -- cgit v1.2.3 From 18e4e9fb0ba2fa4e1f018b499d9b3c025cb2a51c Mon Sep 17 00:00:00 2001 From: Aleksey Kladov Date: Sat, 27 Jun 2020 20:45:04 +0200 Subject: Update crates/expect/src/lib.rs Co-authored-by: bjorn3 --- crates/expect/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'crates/expect/src/lib.rs') diff --git a/crates/expect/src/lib.rs b/crates/expect/src/lib.rs index 1a1302cec..92364bfa7 100644 --- a/crates/expect/src/lib.rs +++ b/crates/expect/src/lib.rs @@ -78,7 +78,7 @@ impl Expect { line_start += line.len(); } let (literal_start, line_indent) = target_line.unwrap(); - let literal_length = file[literal_start..].find("]]").unwrap(); + let literal_length = file[literal_start..].find("]]").expect("Couldn't find matching `]]` for `expect![[`."); let literal_range = literal_start..literal_start + literal_length; Location { line_indent, literal_range } } -- cgit v1.2.3 From 175e48e5be46faf6338d36907c6caf10c2d056f1 Mon Sep 17 00:00:00 2001 From: Aleksey Kladov Date: Sat, 27 Jun 2020 20:45:59 +0200 Subject: Remove fragile test This test needs to be updated after every change (it contains line number), which is annoying. It also fails on windows due to \, so it's easier to remove it. --- crates/expect/src/lib.rs | 31 ++----------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) (limited to 'crates/expect/src/lib.rs') diff --git a/crates/expect/src/lib.rs b/crates/expect/src/lib.rs index 92364bfa7..dc4a4223e 100644 --- a/crates/expect/src/lib.rs +++ b/crates/expect/src/lib.rs @@ -78,7 +78,8 @@ impl Expect { line_start += line.len(); } let (literal_start, line_indent) = target_line.unwrap(); - let literal_length = file[literal_start..].find("]]").expect("Couldn't find matching `]]` for `expect![[`."); + let literal_length = + file[literal_start..].find("]]").expect("Couldn't find matching `]]` for `expect![[`."); let literal_range = literal_start..literal_start + literal_length; Location { line_indent, literal_range } } @@ -238,34 +239,6 @@ fn workspace_root() -> PathBuf { mod tests { use super::*; - #[test] - fn test_expect_macro() { - let empty = expect![[]]; - expect![[r#" - Expect { - file: "crates/expect/src/lib.rs", - line: 241, - column: 21, - data: "", - } - "#]] - .assert_debug_eq(&empty); - - let expect = expect![[" - hello - world - "]]; - expect![[r#" - Expect { - file: "crates/expect/src/lib.rs", - line: 252, - column: 22, - data: "\n hello\n world\n ", - } - "#]] - .assert_debug_eq(&expect); - } - #[test] fn test_format_patch() { let patch = format_patch(0, "hello\nworld\n"); -- cgit v1.2.3 From a9b4fb034bab194bef80c75f146288e55ae8aa2d Mon Sep 17 00:00:00 2001 From: Aleksey Kladov Date: Sat, 27 Jun 2020 21:13:49 +0200 Subject: Add colors --- crates/expect/src/lib.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) (limited to 'crates/expect/src/lib.rs') diff --git a/crates/expect/src/lib.rs b/crates/expect/src/lib.rs index dc4a4223e..18f361ec2 100644 --- a/crates/expect/src/lib.rs +++ b/crates/expect/src/lib.rs @@ -107,22 +107,30 @@ impl Runtime { rt.help_printed = true; let help = if print_help { HELP } else { "" }; + + let diff = difference::Changeset::new(actual, expected, "\n"); + println!( "\n -error: expect test failed{} - --> {}:{}:{} +\x1b[1m\x1b[91merror\x1b[97m: expect test failed\x1b[0m{} + \x1b[1m\x1b[34m-->\x1b[0m {}:{}:{} +{} +\x1b[1mExpect\x1b[0m: +---- {} -Expect: +---- + +\x1b[1mActual\x1b[0m: ---- {} ---- -Actual: +\x1b[1mDiff\x1b[0m: ---- {} ---- ", - updated, expect.file, expect.line, expect.column, help, expected, actual + updated, expect.file, expect.line, expect.column, help, expected, actual, diff ); // Use resume_unwind instead of panic!() to prevent a backtrace, which is unnecessary noise. std::panic::resume_unwind(Box::new(())); -- cgit v1.2.3 From 53787c7eba71d91811c6519a1186755787dcd204 Mon Sep 17 00:00:00 2001 From: Aleksey Kladov Date: Sat, 27 Jun 2020 21:33:14 +0200 Subject: style --- crates/expect/src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'crates/expect/src/lib.rs') diff --git a/crates/expect/src/lib.rs b/crates/expect/src/lib.rs index 18f361ec2..aa95a88c5 100644 --- a/crates/expect/src/lib.rs +++ b/crates/expect/src/lib.rs @@ -4,10 +4,12 @@ use std::{ collections::HashMap, env, fmt, fs, ops::Range, + panic, path::{Path, PathBuf}, sync::Mutex, }; +use difference::Changeset; use once_cell::sync::Lazy; use stdx::{lines_with_ends, trim_indent}; @@ -108,7 +110,7 @@ impl Runtime { let help = if print_help { HELP } else { "" }; - let diff = difference::Changeset::new(actual, expected, "\n"); + let diff = Changeset::new(actual, expected, "\n"); println!( "\n @@ -133,7 +135,7 @@ impl Runtime { updated, expect.file, expect.line, expect.column, help, expected, actual, diff ); // Use resume_unwind instead of panic!() to prevent a backtrace, which is unnecessary noise. - std::panic::resume_unwind(Box::new(())); + panic::resume_unwind(Box::new(())); } } -- cgit v1.2.3 From 3c1714d76d01b513a2e31fbeae14feca438515fa Mon Sep 17 00:00:00 2001 From: Aleksey Kladov Date: Sat, 27 Jun 2020 21:35:52 +0200 Subject: Fix potential overflow --- crates/expect/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'crates/expect/src/lib.rs') diff --git a/crates/expect/src/lib.rs b/crates/expect/src/lib.rs index aa95a88c5..dd7b96aab 100644 --- a/crates/expect/src/lib.rs +++ b/crates/expect/src/lib.rs @@ -188,8 +188,8 @@ impl Patchwork { .fold((0usize, 0usize), |(x1, y1), (x2, y2)| (x1 + x2, y1 + y2)); for pos in &mut [&mut range.start, &mut range.end] { + **pos -= delete; **pos += insert; - **pos -= delete } self.text.replace_range(range, &patch); -- cgit v1.2.3