Skip to content
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
38 changes: 30 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/snapbox/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ path = ["dep:tempfile", "dep:walkdir", "dep:dunce", "detect-encoding", "dep:file
cmd = ["dep:os_pipe", "dep:wait-timeout", "dep:libc", "dep:windows-sys"]
## Building of examples for snapshotting
examples = ["dep:escargot"]
## Regex text substitutions
regex = ["dep:regex"]

## Snapshotting of json
json = ["structured-data", "dep:serde_json"]
Expand Down Expand Up @@ -94,6 +96,7 @@ document-features = { version = "0.2.6", optional = true }

serde_json = { version = "1.0.85", optional = true}
anstyle-svg = { version = "0.1.3", optional = true }
regex = { version = "1.10.4", optional = true, default-features = false, features = ["std"] }

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.52.0", features = ["Win32_Foundation"], optional = true }
Expand Down
1 change: 1 addition & 0 deletions crates/snapbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ pub use data::Data;
pub use data::ToDebug;
pub use error::Error;
pub use snapbox_macros::debug;
pub use substitutions::SubstitutionValue;
pub use substitutions::Substitutions;

pub type Result<T, E = Error> = std::result::Result<T, E>;
Expand Down
192 changes: 172 additions & 20 deletions crates/snapbox/src/substitutions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ use std::borrow::Cow;
/// Built-in expressions:
/// - `...` on a line of its own: match multiple complete lines
/// - `[..]`: match multiple characters within a line
#[derive(Default, Clone, Debug, PartialEq, Eq)]
#[derive(Default, Clone, Debug)]
pub struct Substitutions {
vars: std::collections::BTreeMap<&'static str, std::collections::BTreeSet<Cow<'static, str>>>,
unused: std::collections::BTreeSet<&'static str>,
vars: std::collections::BTreeMap<
&'static str,
std::collections::BTreeSet<SubstitutionValueInner>,
>,
unused: std::collections::BTreeSet<SubstitutionValueInner>,
}

impl Substitutions {
Expand All @@ -32,20 +35,28 @@ impl Substitutions {
/// let mut subst = snapbox::Substitutions::new();
/// subst.insert("[EXE]", std::env::consts::EXE_SUFFIX);
/// ```
///
/// With the `regex` feature, you can define patterns using regexes.
/// You can choose to replace a subset of the regex by giving it the named capture group
/// `replace`.
///
/// ```rust
/// # #[cfg(feature = "regex")] {
/// let mut subst = snapbox::Substitutions::new();
/// subst.insert("[OBJECT]", regex::Regex::new("(?<replace>(world|moon))").unwrap());
/// # }
/// ```
pub fn insert(
&mut self,
key: &'static str,
value: impl Into<Cow<'static, str>>,
value: impl Into<SubstitutionValue>,
) -> Result<(), crate::Error> {
let key = validate_key(key)?;
let value = value.into();
if value.is_empty() {
self.unused.insert(key);
if let Some(inner) = value.inner {
self.vars.entry(key).or_default().insert(inner);
} else {
self.vars
.entry(key)
.or_default()
.insert(crate::utils::normalize_text(value.as_ref()).into());
self.unused.insert(SubstitutionValueInner::Str(key));
}
Ok(())
}
Expand All @@ -55,7 +66,7 @@ impl Substitutions {
/// keys must be enclosed in `[` and `]`.
pub fn extend(
&mut self,
vars: impl IntoIterator<Item = (&'static str, impl Into<Cow<'static, str>>)>,
vars: impl IntoIterator<Item = (&'static str, impl Into<SubstitutionValue>)>,
) -> Result<(), crate::Error> {
for (key, value) in vars {
self.insert(key, value)?;
Expand Down Expand Up @@ -88,35 +99,151 @@ impl Substitutions {
let mut input = input.to_owned();
replace_many(
&mut input,
self.vars.iter().flat_map(|(var, replaces)| {
replaces.iter().map(|replace| (replace.as_ref(), *var))
}),
self.vars
.iter()
.flat_map(|(var, replaces)| replaces.iter().map(|replace| (replace, *var))),
);
input
}

fn clear<'v>(&self, pattern: &'v str) -> Cow<'v, str> {
if !self.unused.is_empty() && pattern.contains('[') {
let mut pattern = pattern.to_owned();
replace_many(&mut pattern, self.unused.iter().map(|var| (*var, "")));
replace_many(&mut pattern, self.unused.iter().map(|var| (var, "")));
Cow::Owned(pattern)
} else {
Cow::Borrowed(pattern)
}
}
}

#[derive(Clone)]
pub struct SubstitutionValue {
inner: Option<SubstitutionValueInner>,
}

#[derive(Clone, Debug)]
enum SubstitutionValueInner {
Str(&'static str),
String(String),
#[cfg(feature = "regex")]
Regex(regex::Regex),
}

impl SubstitutionValueInner {
fn find_in(&self, buffer: &str) -> Option<std::ops::Range<usize>> {
match self {
Self::Str(s) => buffer.find(s).map(|offset| offset..(offset + s.len())),
Self::String(s) => buffer.find(s).map(|offset| offset..(offset + s.len())),
#[cfg(feature = "regex")]
Self::Regex(r) => {
let captures = r.captures(buffer)?;
let m = captures.name("replace").or_else(|| captures.get(0))?;
Some(m.range())
}
}
}

fn as_cmp(&self) -> &str {
match self {
Self::Str(s) => s,
Self::String(s) => s,
#[cfg(feature = "regex")]
Self::Regex(s) => s.as_str(),
}
}
}

impl From<&'static str> for SubstitutionValue {
fn from(inner: &'static str) -> Self {
if inner.is_empty() {
Self { inner: None }
} else {
Self {
inner: Some(SubstitutionValueInner::String(
crate::utils::normalize_text(inner),
)),
}
}
}
}

impl From<String> for SubstitutionValue {
fn from(inner: String) -> Self {
if inner.is_empty() {
Self { inner: None }
} else {
Self {
inner: Some(SubstitutionValueInner::String(
crate::utils::normalize_text(&inner),
)),
}
}
}
}

impl From<&'_ String> for SubstitutionValue {
fn from(inner: &'_ String) -> Self {
inner.clone().into()
}
}

impl From<Cow<'static, str>> for SubstitutionValue {
fn from(inner: Cow<'static, str>) -> Self {
match inner {
Cow::Borrowed(s) => s.into(),
Cow::Owned(s) => s.into(),
}
}
}

#[cfg(feature = "regex")]
impl From<regex::Regex> for SubstitutionValue {
fn from(inner: regex::Regex) -> Self {
Self {
inner: Some(SubstitutionValueInner::Regex(inner)),
}
}
}

#[cfg(feature = "regex")]
impl From<&'_ regex::Regex> for SubstitutionValue {
fn from(inner: &'_ regex::Regex) -> Self {
inner.clone().into()
}
}

impl PartialOrd for SubstitutionValueInner {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.as_cmp().partial_cmp(other.as_cmp())
}
}

impl Ord for SubstitutionValueInner {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.as_cmp().cmp(other.as_cmp())
}
}

impl PartialEq for SubstitutionValueInner {
fn eq(&self, other: &Self) -> bool {
self.as_cmp().eq(other.as_cmp())
}
}

impl Eq for SubstitutionValueInner {}

/// Replacements is `(from, to)`
fn replace_many<'a>(
buffer: &mut String,
replacements: impl IntoIterator<Item = (&'a str, &'a str)>,
replacements: impl IntoIterator<Item = (&'a SubstitutionValueInner, &'a str)>,
) {
for (var, replace) in replacements {
let mut index = 0;
while let Some(offset) = buffer[index..].find(var) {
let old_range = (index + offset)..(index + offset + var.len());
while let Some(offset) = var.find_in(&buffer[index..]) {
let old_range = (index + offset.start)..(index + offset.end);
buffer.replace_range(old_range, replace);
index += offset + replace.len();
index += offset.start + replace.len();
}
}
}
Expand Down Expand Up @@ -413,7 +540,32 @@ mod test {
fn substitute_disabled() {
let input = "cargo";
let pattern = "cargo[EXE]";
let sub = Substitutions::with_exe();
let mut sub = Substitutions::new();
sub.insert("[EXE]", "").unwrap();
let actual = normalize(input, pattern, &sub);
assert_eq!(actual, pattern);
}

#[test]
#[cfg(feature = "regex")]
fn substitute_regex_unnamed() {
let input = "Hello world!";
let pattern = "Hello [OBJECT]!";
let mut sub = Substitutions::new();
sub.insert("[OBJECT]", regex::Regex::new("world").unwrap())
.unwrap();
let actual = normalize(input, pattern, &sub);
assert_eq!(actual, pattern);
}

#[test]
#[cfg(feature = "regex")]
fn substitute_regex_named() {
let input = "Hello world!";
let pattern = "Hello [OBJECT]!";
let mut sub = Substitutions::new();
sub.insert("[OBJECT]", regex::Regex::new("(?<replace>world)!").unwrap())
.unwrap();
let actual = normalize(input, pattern, &sub);
assert_eq!(actual, pattern);
}
Expand Down
6 changes: 4 additions & 2 deletions src/cases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ impl TestCases {
var: &'static str,
value: impl Into<Cow<'static, str>>,
) -> Result<&Self, crate::Error> {
self.substitutions.borrow_mut().insert(var, value)?;
self.substitutions.borrow_mut().insert(var, value.into())?;
Ok(self)
}

Expand All @@ -148,7 +148,9 @@ impl TestCases {
&self,
vars: impl IntoIterator<Item = (&'static str, impl Into<Cow<'static, str>>)>,
) -> Result<&Self, crate::Error> {
self.substitutions.borrow_mut().extend(vars)?;
self.substitutions
.borrow_mut()
.extend(vars.into_iter().map(|(v, r)| (v, r.into())))?;
Ok(self)
}

Expand Down