diff --git a/crates/snapbox/src/assert.rs b/crates/snapbox/src/assert.rs index 2736ed8f..d9c583d9 100644 --- a/crates/snapbox/src/assert.rs +++ b/crates/snapbox/src/assert.rs @@ -62,6 +62,9 @@ impl Assert { #[track_caller] fn eq_inner(&self, expected: crate::Data, actual: crate::Data) { + if expected.source().is_none() && actual.source().is_some() { + panic!("received `(actual, expected)`, expected `(expected, actual)`"); + } match self.action { Action::Skip => { return; @@ -108,6 +111,9 @@ impl Assert { #[track_caller] fn matches_inner(&self, pattern: crate::Data, actual: crate::Data) { + if pattern.source().is_none() && actual.source().is_some() { + panic!("received `(actual, expected)`, expected `(expected, actual)`"); + } match self.action { Action::Skip => { return; diff --git a/crates/snapbox/src/data/mod.rs b/crates/snapbox/src/data/mod.rs index 0644fba9..2cfcad81 100644 --- a/crates/snapbox/src/data/mod.rs +++ b/crates/snapbox/src/data/mod.rs @@ -1,5 +1,6 @@ mod format; mod normalize; +mod runtime; mod source; #[cfg(test)] mod tests; @@ -10,6 +11,18 @@ pub use normalize::NormalizeMatches; pub use normalize::NormalizeNewlines; pub use normalize::NormalizePaths; pub use source::DataSource; +pub use source::Inline; +pub use source::Position; + +pub trait ToDebug { + fn to_debug(&self) -> Data; +} + +impl ToDebug for D { + fn to_debug(&self) -> Data { + Data::text(format!("{:#?}\n", self)) + } +} /// Declare an expected value for an assert from a file /// @@ -53,6 +66,37 @@ macro_rules! file { }}; } +/// Declare an expected value from within Rust source +/// +/// ``` +/// # use snapbox::str; +/// str![[" +/// Foo { value: 92 } +/// "]]; +/// str![r#"{"Foo": 92}"#]; +/// ``` +/// +/// Leading indentation is stripped. +#[macro_export] +macro_rules! str { + [$data:literal] => { $crate::str![[$data]] }; + [[$data:literal]] => {{ + let position = $crate::data::Position { + file: $crate::path::current_rs!(), + line: line!(), + column: column!(), + }; + let inline = $crate::data::Inline { + position, + data: $data, + indent: true, + }; + inline + }}; + [] => { $crate::str![[""]] }; + [[]] => { $crate::str![[""]] }; +} + /// Test fixture, actual output, or expected result /// /// This provides conveniences for tracking the intended format (binary vs text). @@ -165,6 +209,9 @@ impl Data { pub fn write_to(&self, source: &DataSource) -> Result<(), crate::Error> { match &source.inner { source::DataSourceInner::Path(p) => self.write_to_path(p), + source::DataSourceInner::Inline(p) => runtime::get() + .write(self, p) + .map_err(|err| err.to_string().into()), } } diff --git a/crates/snapbox/src/data/runtime.rs b/crates/snapbox/src/data/runtime.rs new file mode 100644 index 00000000..43758eea --- /dev/null +++ b/crates/snapbox/src/data/runtime.rs @@ -0,0 +1,440 @@ +use super::Data; +use super::Inline; +use super::Position; + +pub(crate) fn get() -> std::sync::MutexGuard<'static, Runtime> { + static RT: std::sync::Mutex = std::sync::Mutex::new(Runtime::new()); + RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[derive(Default)] +pub(crate) struct Runtime { + per_file: Vec, +} + +impl Runtime { + const fn new() -> Self { + Self { + per_file: Vec::new(), + } + } + + pub(crate) fn write(&mut self, actual: &Data, inline: &Inline) -> std::io::Result<()> { + let actual = actual.render().expect("`actual` must be UTF-8"); + if let Some(entry) = self + .per_file + .iter_mut() + .find(|f| f.path == inline.position.file) + { + entry.update(&actual, inline)?; + } else { + let mut entry = FileRuntime::new(inline)?; + entry.update(&actual, inline)?; + self.per_file.push(entry); + } + + Ok(()) + } +} + +struct FileRuntime { + path: std::path::PathBuf, + original_text: String, + patchwork: Patchwork, +} + +impl FileRuntime { + fn new(inline: &Inline) -> std::io::Result { + let path = inline.position.file.clone(); + let original_text = std::fs::read_to_string(&path)?; + let patchwork = Patchwork::new(original_text.clone()); + Ok(FileRuntime { + path, + original_text, + patchwork, + }) + } + fn update(&mut self, actual: &str, inline: &Inline) -> std::io::Result<()> { + let span = Span::from_pos(&inline.position, &self.original_text); + let desired_indent = if inline.indent { + Some(span.line_indent) + } else { + None + }; + let patch = format_patch(desired_indent, actual); + self.patchwork.patch(span.literal_range, &patch); + std::fs::write(&inline.position.file, &self.patchwork.text) + } +} + +#[derive(Debug)] +struct Patchwork { + text: String, + indels: Vec<(std::ops::Range, usize)>, +} + +impl Patchwork { + fn new(text: String) -> Patchwork { + Patchwork { + text, + indels: Vec::new(), + } + } + fn patch(&mut self, mut range: std::ops::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 -= delete; + **pos += insert; + } + + self.text.replace_range(range, patch); + } +} + +fn lit_kind_for_patch(patch: &str) -> StrLitKind { + let has_dquote = patch.chars().any(|c| c == '"'); + if !has_dquote { + let has_bslash_or_newline = patch.chars().any(|c| matches!(c, '\\' | '\n')); + return if has_bslash_or_newline { + StrLitKind::Raw(1) + } else { + StrLitKind::Normal + }; + } + + // Find the maximum number of hashes that follow a double quote in the string. + // We need to use one more than that to delimit the string. + let leading_hashes = |s: &str| s.chars().take_while(|&c| c == '#').count(); + let max_hashes = patch.split('"').map(leading_hashes).max().unwrap(); + StrLitKind::Raw(max_hashes + 1) +} + +fn format_patch(desired_indent: Option, patch: &str) -> String { + let lit_kind = lit_kind_for_patch(patch); + let indent = desired_indent.map(|it| " ".repeat(it)); + let is_multiline = patch.contains('\n'); + + let mut buf = String::new(); + if matches!(lit_kind, StrLitKind::Raw(_)) { + buf.push('['); + } + lit_kind.write_start(&mut buf).unwrap(); + if is_multiline { + buf.push('\n'); + } + let mut final_newline = false; + for line in crate::utils::LinesWithTerminator::new(patch) { + if is_multiline && !line.trim().is_empty() { + if let Some(indent) = &indent { + buf.push_str(indent); + buf.push_str(" "); + } + } + buf.push_str(line); + final_newline = line.ends_with('\n'); + } + if final_newline { + if let Some(indent) = &indent { + buf.push_str(indent); + } + } + lit_kind.write_end(&mut buf).unwrap(); + if matches!(lit_kind, StrLitKind::Raw(_)) { + buf.push(']'); + } + buf +} + +#[derive(Clone, Debug)] +struct Span { + line_indent: usize, + + /// The byte range of the argument to `expect!`, including the inner `[]` if it exists. + literal_range: std::ops::Range, +} + +impl Span { + fn from_pos(pos: &Position, file: &str) -> Span { + let mut target_line = None; + let mut line_start = 0; + for (i, line) in crate::utils::LinesWithTerminator::new(file).enumerate() { + if i == pos.line as usize - 1 { + // `column` points to the first character of the macro invocation: + // + // expect![[r#""#]] expect![""] + // ^ ^ ^ ^ + // column offset offset + // + // Seek past the exclam, then skip any whitespace and + // the macro delimiter to get to our argument. + #[allow(clippy::skip_while_next)] + let byte_offset = line + .char_indices() + .skip((pos.column - 1).try_into().unwrap()) + .skip_while(|&(_, c)| c != '!') + .skip(1) // ! + .skip_while(|&(_, c)| c.is_whitespace()) + .skip(1) // [({ + .skip_while(|&(_, c)| c.is_whitespace()) + .next() + .expect("Failed to parse macro invocation") + .0; + + let literal_start = line_start + byte_offset; + 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 lit_to_eof = &file[literal_start..]; + let lit_to_eof_trimmed = lit_to_eof.trim_start(); + + let literal_start = literal_start + (lit_to_eof.len() - lit_to_eof_trimmed.len()); + + let literal_len = + locate_end(lit_to_eof_trimmed).expect("Couldn't find closing delimiter for `expect!`."); + let literal_range = literal_start..literal_start + literal_len; + Span { + line_indent, + literal_range, + } + } +} + +fn locate_end(arg_start_to_eof: &str) -> Option { + match arg_start_to_eof.chars().next()? { + c if c.is_whitespace() => panic!("skip whitespace before calling `locate_end`"), + + // expect![[]] + '[' => { + let str_start_to_eof = arg_start_to_eof[1..].trim_start(); + let str_len = find_str_lit_len(str_start_to_eof)?; + let str_end_to_eof = &str_start_to_eof[str_len..]; + let closing_brace_offset = str_end_to_eof.find(']')?; + Some((arg_start_to_eof.len() - str_end_to_eof.len()) + closing_brace_offset + 1) + } + + // expect![] | expect!{} | expect!() + ']' | '}' | ')' => Some(0), + + // expect!["..."] | expect![r#"..."#] + _ => find_str_lit_len(arg_start_to_eof), + } +} + +/// Parses a string literal, returning the byte index of its last character +/// (either a quote or a hash). +fn find_str_lit_len(str_lit_to_eof: &str) -> Option { + fn try_find_n_hashes( + s: &mut impl Iterator, + desired_hashes: usize, + ) -> Option<(usize, Option)> { + let mut n = 0; + loop { + match s.next()? { + '#' => n += 1, + c => return Some((n, Some(c))), + } + + if n == desired_hashes { + return Some((n, None)); + } + } + } + + let mut s = str_lit_to_eof.chars(); + let kind = match s.next()? { + '"' => StrLitKind::Normal, + 'r' => { + let (n, c) = try_find_n_hashes(&mut s, usize::MAX)?; + if c != Some('"') { + return None; + } + StrLitKind::Raw(n) + } + _ => return None, + }; + + let mut oldc = None; + loop { + let c = oldc.take().or_else(|| s.next())?; + match (c, kind) { + ('\\', StrLitKind::Normal) => { + let _escaped = s.next()?; + } + ('"', StrLitKind::Normal) => break, + ('"', StrLitKind::Raw(0)) => break, + ('"', StrLitKind::Raw(n)) => { + let (seen, c) = try_find_n_hashes(&mut s, n)?; + if seen == n { + break; + } + oldc = c; + } + _ => {} + } + } + + Some(str_lit_to_eof.len() - s.as_str().len()) +} + +#[derive(Copy, Clone)] +enum StrLitKind { + Normal, + Raw(usize), +} + +impl StrLitKind { + fn write_start(self, w: &mut impl std::fmt::Write) -> std::fmt::Result { + match self { + Self::Normal => write!(w, "\""), + Self::Raw(n) => { + write!(w, "r")?; + for _ in 0..n { + write!(w, "#")?; + } + write!(w, "\"") + } + } + } + + fn write_end(self, w: &mut impl std::fmt::Write) -> std::fmt::Result { + match self { + Self::Normal => write!(w, "\""), + Self::Raw(n) => { + write!(w, "\"")?; + for _ in 0..n { + write!(w, "#")?; + } + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assert_eq; + use crate::str; + use crate::ToDebug as _; + + #[test] + fn test_format_patch() { + let patch = format_patch(None, "hello\nworld\n"); + + assert_eq( + str![[r##" + [r#" + hello + world + "#]"##]], + patch, + ); + + let patch = format_patch(None, r"hello\tworld"); + assert_eq(str![[r##"[r#"hello\tworld"#]"##]], patch); + + let patch = format_patch(None, "{\"foo\": 42}"); + assert_eq(str![[r##"[r#"{"foo": 42}"#]"##]], patch); + + let patch = format_patch(Some(0), "hello\nworld\n"); + assert_eq( + str![[r##" + [r#" + hello + world + "#]"##]], + patch, + ); + + let patch = format_patch(Some(4), "single line"); + assert_eq(str![[r#""single line""#]], 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"); + assert_eq( + str![[r#" + Patchwork { + text: "один zwei 3", + indels: [ + ( + 0..3, + 8, + ), + ( + 4..7, + 4, + ), + ( + 8..13, + 1, + ), + ], + } + "#]], + patchwork.to_debug(), + ); + } + + #[test] + fn test_locate() { + macro_rules! check_locate { + ($( [[$s:literal]] ),* $(,)?) => {$({ + let lit = stringify!($s); + let with_trailer = format!("{} \t]]\n", lit); + assert_eq!(locate_end(&with_trailer), Some(lit.len())); + })*}; + } + + // Check that we handle string literals containing "]]" correctly. + check_locate!( + [[r#"{ arr: [[1, 2], [3, 4]], other: "foo" } "#]], + [["]]"]], + [["\"]]"]], + [[r#""]]"#]], + ); + + // Check `str![[ ]]` as well. + assert_eq!(locate_end("]]"), Some(0)); + } + + #[test] + fn test_find_str_lit_len() { + macro_rules! check_str_lit_len { + ($( $s:literal ),* $(,)?) => {$({ + let lit = stringify!($s); + assert_eq!(find_str_lit_len(lit), Some(lit.len())); + })*} + } + + check_str_lit_len![ + r##"foa\""#"##, + r##" + + asdf][]]""""# + "##, + "", + "\"", + "\"\"", + "#\"#\"#", + ]; + } +} diff --git a/crates/snapbox/src/data/source.rs b/crates/snapbox/src/data/source.rs index 8d02e02a..c1d83009 100644 --- a/crates/snapbox/src/data/source.rs +++ b/crates/snapbox/src/data/source.rs @@ -6,6 +6,7 @@ pub struct DataSource { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum DataSourceInner { Path(std::path::PathBuf), + Inline(Inline), } impl DataSource { @@ -21,7 +22,19 @@ impl DataSource { pub fn as_path(&self) -> Option<&std::path::Path> { match &self.inner { - DataSourceInner::Path(path) => Some(path.as_ref()), + DataSourceInner::Path(value) => Some(value.as_ref()), + _ => None, + } + } + + pub fn is_inline(&self) -> bool { + self.as_inline().is_some() + } + + pub fn as_inline(&self) -> Option<&Inline> { + match &self.inner { + DataSourceInner::Inline(value) => Some(value), + _ => None, } } } @@ -38,10 +51,102 @@ impl From for DataSource { } } +impl From for DataSource { + fn from(inline: Inline) -> Self { + Self { + inner: DataSourceInner::Inline(inline), + } + } +} + impl std::fmt::Display for DataSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.inner { - DataSourceInner::Path(path) => crate::path::display_relpath(path).fmt(f), + DataSourceInner::Path(value) => crate::path::display_relpath(value).fmt(f), + DataSourceInner::Inline(value) => value.fmt(f), + } + } +} + +/// Data from within Rust source code +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Inline { + #[doc(hidden)] + pub position: Position, + #[doc(hidden)] + pub data: &'static str, + #[doc(hidden)] + pub indent: bool, +} + +impl Inline { + /// Indent to quote-level when overwriting the string literal (default) + pub fn indent(mut self, yes: bool) -> Self { + self.indent = yes; + self + } + + pub fn coerce_to(self, format: super::DataFormat) -> super::Data { + let data: super::Data = self.into(); + data.coerce_to(format) + } + + fn trimmed(&self) -> String { + if !self.data.contains('\n') { + return self.data.to_string(); } + trim_indent(self.data) + } +} + +fn trim_indent(mut text: &str) -> String { + if text.starts_with('\n') { + text = &text[1..]; + } + let indent = text + .lines() + .filter(|it| !it.trim().is_empty()) + .map(|it| it.len() - it.trim_start().len()) + .min() + .unwrap_or(0); + + crate::utils::LinesWithTerminator::new(text) + .map(|line| { + if line.len() <= indent { + line.trim_start_matches(' ') + } else { + &line[indent..] + } + }) + .collect() +} + +impl std::fmt::Display for Inline { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.position.fmt(f) + } +} + +impl From for super::Data { + fn from(inline: Inline) -> Self { + let trimmed = inline.trimmed(); + super::Data::text(trimmed).with_source(inline) + } +} + +/// Position within Rust source code, see [`Inline`] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Position { + #[doc(hidden)] + pub file: std::path::PathBuf, + #[doc(hidden)] + pub line: u32, + #[doc(hidden)] + pub column: u32, +} + +impl std::fmt::Display for Position { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}:{}", self.file.display(), self.line, self.column) } } diff --git a/crates/snapbox/src/data/tests.rs b/crates/snapbox/src/data/tests.rs index e05879cf..044448b2 100644 --- a/crates/snapbox/src/data/tests.rs +++ b/crates/snapbox/src/data/tests.rs @@ -1,7 +1,8 @@ -use super::*; #[cfg(feature = "json")] use serde_json::json; +use super::*; + // Tests for checking to_bytes and render produce the same results #[test] fn text_to_bytes_render() { diff --git a/crates/snapbox/src/lib.rs b/crates/snapbox/src/lib.rs index 5c25422e..561d2a0a 100644 --- a/crates/snapbox/src/lib.rs +++ b/crates/snapbox/src/lib.rs @@ -112,6 +112,7 @@ pub use action::Action; pub use action::DEFAULT_ACTION_ENV; pub use assert::Assert; pub use data::Data; +pub use data::ToDebug; pub use error::Error; pub use snapbox_macros::debug; pub use substitutions::Substitutions; @@ -138,7 +139,9 @@ pub type Result = std::result::Result; /// ``` #[track_caller] pub fn assert_eq(expected: impl Into, actual: impl Into) { - Assert::new().eq(expected, actual); + Assert::new() + .action_env(DEFAULT_ACTION_ENV) + .eq(expected, actual); } /// Check if a value matches a pattern @@ -168,7 +171,9 @@ pub fn assert_eq(expected: impl Into, actual: impl Into, actual: impl Into) { - Assert::new().matches(pattern, actual); + Assert::new() + .action_env(DEFAULT_ACTION_ENV) + .matches(pattern, actual); } /// Check if a path matches the content of another path, recursively diff --git a/crates/snapbox/src/macros.rs b/crates/snapbox/src/macros.rs index d61fa763..3baf82ba 100644 --- a/crates/snapbox/src/macros.rs +++ b/crates/snapbox/src/macros.rs @@ -10,6 +10,18 @@ macro_rules! current_dir { }}; } +/// Find the directory for your source file +#[doc(hidden)] // forced to be visible in intended location +#[macro_export] +macro_rules! current_rs { + () => {{ + let root = $crate::path::cargo_rustc_current_dir!(); + let file = ::std::file!(); + let rel_path = ::std::path::Path::new(file); + root.join(rel_path) + }}; +} + /// Find the base directory for [`std::file!`] #[doc(hidden)] // forced to be visible in intended location #[macro_export] diff --git a/crates/snapbox/src/path.rs b/crates/snapbox/src/path.rs index 13c804c0..99efb098 100644 --- a/crates/snapbox/src/path.rs +++ b/crates/snapbox/src/path.rs @@ -4,6 +4,8 @@ pub use crate::cargo_rustc_current_dir; #[doc(inline)] pub use crate::current_dir; +#[doc(inline)] +pub use crate::current_rs; #[cfg(feature = "path")] use crate::data::{NormalizeMatches, NormalizeNewlines, NormalizePaths}; diff --git a/crates/snapbox/tests/assert.rs b/crates/snapbox/tests/assert.rs new file mode 100644 index 00000000..82dcc6c1 --- /dev/null +++ b/crates/snapbox/tests/assert.rs @@ -0,0 +1,40 @@ +use snapbox::assert_eq; +use snapbox::file; +use snapbox::str; + +#[test] +fn test_trivial_assert() { + assert_eq(str!["5"], "5"); +} + +#[test] +fn smoke_test_indent() { + assert_eq( + str![[r#" + line1 + line2 + "#]] + .indent(true), + "\ +line1 + line2 +", + ); + + assert_eq( + str![[r#" +line1 + line2 +"#]] + .indent(false), + "\ +line1 + line2 +", + ); +} + +#[test] +fn test_expect_file() { + assert_eq(file!["../README.md"], include_str!("../README.md")) +}