Skip to main content

test_better_matchers/
strings.rs

1//! String matchers: [`contains_str`], [`starts_with`], [`ends_with`], and
2//! (behind the `regex` feature) [`matches_regex`].
3//!
4//! Each is generic over `T: AsRef<str>`, so it matches `&str`, `String`,
5//! `&String`, and `str` alike. When a mismatch involves a multi-line string,
6//! the failure carries a line-oriented diff.
7
8use std::fmt;
9
10use crate::description::Description;
11use crate::matcher::{MatchResult, Matcher, Mismatch};
12
13/// A line-oriented diff of `expected` against `actual`, but only when at least
14/// one of them spans multiple lines: a diff of two single-line strings is just
15/// noise. With the `diff` feature off this is always `None`.
16#[cfg(feature = "diff")]
17fn multi_line_str_diff(expected: &str, actual: &str) -> Option<String> {
18    if expected.contains('\n') || actual.contains('\n') {
19        Some(crate::diff::diff_lines(expected, actual))
20    } else {
21        None
22    }
23}
24
25#[cfg(not(feature = "diff"))]
26fn multi_line_str_diff(_expected: &str, _actual: &str) -> Option<String> {
27    None
28}
29
30/// Generates a string matcher backed by a `str` predicate method (`contains`,
31/// `starts_with`, `ends_with`). The `str_description` inherent method keeps the
32/// description reachable from `check` without an ambiguous `self.description()`
33/// (the matcher implements `Matcher<T>` for a family of `T`).
34macro_rules! str_predicate_matcher {
35    ($matcher:ident, $method:ident, $describe:literal) => {
36        struct $matcher {
37            needle: String,
38        }
39
40        impl $matcher {
41            fn str_description(&self) -> Description {
42                Description::text(format!(concat!($describe, " {:?}"), self.needle))
43            }
44        }
45
46        impl<T> Matcher<T> for $matcher
47        where
48            T: AsRef<str> + fmt::Debug + ?Sized,
49        {
50            fn check(&self, actual: &T) -> MatchResult {
51                let haystack = actual.as_ref();
52                if haystack.$method(self.needle.as_str()) {
53                    MatchResult::pass()
54                } else {
55                    let mut mismatch = Mismatch::new(self.str_description(), format!("{actual:?}"));
56                    if let Some(diff) = multi_line_str_diff(&self.needle, haystack) {
57                        mismatch = mismatch.with_diff(diff);
58                    }
59                    MatchResult::fail(mismatch)
60                }
61            }
62
63            fn description(&self) -> Description {
64                self.str_description()
65            }
66        }
67    };
68}
69
70str_predicate_matcher!(ContainsStrMatcher, contains, "a string containing");
71str_predicate_matcher!(StartsWithMatcher, starts_with, "a string starting with");
72str_predicate_matcher!(EndsWithMatcher, ends_with, "a string ending with");
73
74/// Matches a string that contains `needle` as a substring.
75///
76/// ```
77/// use test_better_core::TestResult;
78/// use test_better_matchers::{contains_str, check};
79///
80/// fn main() -> TestResult {
81///     check!("hello, world").satisfies(contains_str("o, w"))?;
82///     check!(String::from("hello")).violates(contains_str("bye"))?;
83///     Ok(())
84/// }
85/// ```
86#[must_use]
87pub fn contains_str<T>(needle: impl Into<String>) -> impl Matcher<T>
88where
89    T: AsRef<str> + fmt::Debug + ?Sized,
90{
91    ContainsStrMatcher {
92        needle: needle.into(),
93    }
94}
95
96/// Matches a string that starts with `prefix`.
97///
98/// ```
99/// use test_better_core::TestResult;
100/// use test_better_matchers::{check, starts_with};
101///
102/// fn main() -> TestResult {
103///     check!("hello, world").satisfies(starts_with("hello"))?;
104///     Ok(())
105/// }
106/// ```
107#[must_use]
108pub fn starts_with<T>(prefix: impl Into<String>) -> impl Matcher<T>
109where
110    T: AsRef<str> + fmt::Debug + ?Sized,
111{
112    StartsWithMatcher {
113        needle: prefix.into(),
114    }
115}
116
117/// Matches a string that ends with `suffix`.
118///
119/// ```
120/// use test_better_core::TestResult;
121/// use test_better_matchers::{ends_with, check};
122///
123/// fn main() -> TestResult {
124///     check!("hello, world").satisfies(ends_with("world"))?;
125///     Ok(())
126/// }
127/// ```
128#[must_use]
129pub fn ends_with<T>(suffix: impl Into<String>) -> impl Matcher<T>
130where
131    T: AsRef<str> + fmt::Debug + ?Sized,
132{
133    EndsWithMatcher {
134        needle: suffix.into(),
135    }
136}
137
138/// The matcher behind [`matches_regex`]. The pattern is compiled eagerly in
139/// the constructor; a compilation error is held and surfaced as an ordinary
140/// match failure, so the constructor needs no `Result` and the call site needs
141/// no `?` on it.
142#[cfg(feature = "regex")]
143struct RegexMatcher {
144    pattern: String,
145    compiled: Result<regex::Regex, regex::Error>,
146}
147
148#[cfg(feature = "regex")]
149impl RegexMatcher {
150    fn regex_description(&self) -> Description {
151        Description::text(format!("a string matching the regex {:?}", self.pattern))
152    }
153}
154
155#[cfg(feature = "regex")]
156impl<T> Matcher<T> for RegexMatcher
157where
158    T: AsRef<str> + fmt::Debug + ?Sized,
159{
160    fn check(&self, actual: &T) -> MatchResult {
161        match &self.compiled {
162            Err(error) => MatchResult::fail(Mismatch::new(
163                self.regex_description(),
164                format!("<invalid regex {:?}: {error}>", self.pattern),
165            )),
166            Ok(regex) => {
167                if regex.is_match(actual.as_ref()) {
168                    MatchResult::pass()
169                } else {
170                    MatchResult::fail(Mismatch::new(
171                        self.regex_description(),
172                        format!("{actual:?}"),
173                    ))
174                }
175            }
176        }
177    }
178
179    fn description(&self) -> Description {
180        self.regex_description()
181    }
182}
183
184/// Matches a string that the regular expression `pattern` finds a match in.
185///
186/// An invalid `pattern` is not a panic: it is held and reported as a match
187/// failure when the matcher runs, so the constructor returns a plain matcher.
188///
189/// Behind the `regex` feature, which is off by default.
190///
191/// ```
192/// use test_better_core::TestResult;
193/// use test_better_matchers::{check, matches_regex};
194///
195/// fn main() -> TestResult {
196///     check!("order #1234").satisfies(matches_regex(r"#\d+"))?;
197///     Ok(())
198/// }
199/// ```
200#[cfg(feature = "regex")]
201#[must_use]
202pub fn matches_regex<T>(pattern: impl Into<String>) -> impl Matcher<T>
203where
204    T: AsRef<str> + fmt::Debug + ?Sized,
205{
206    let pattern = pattern.into();
207    let compiled = regex::Regex::new(&pattern);
208    RegexMatcher { pattern, compiled }
209}
210
211#[cfg(test)]
212mod tests {
213    use test_better_core::{OrFail, TestResult};
214
215    use super::*;
216    use crate::{check, eq, is_false, is_true};
217
218    #[test]
219    fn contains_str_matches_a_substring() -> TestResult {
220        check!(contains_str("ell").check("hello").matched).satisfies(is_true())?;
221        check!(contains_str("xyz").check("hello").matched).satisfies(is_false())?;
222        // Works for `String` as well as `&str`.
223        check!(contains_str("ell").check(&String::from("hello")).matched).satisfies(is_true())?;
224        Ok(())
225    }
226
227    #[test]
228    fn starts_with_and_ends_with_check_the_ends() -> TestResult {
229        check!(starts_with("he").check("hello").matched).satisfies(is_true())?;
230        check!(starts_with("lo").check("hello").matched).satisfies(is_false())?;
231        check!(ends_with("lo").check("hello").matched).satisfies(is_true())?;
232        check!(ends_with("he").check("hello").matched).satisfies(is_false())?;
233        Ok(())
234    }
235
236    #[test]
237    fn contains_str_failure_describes_the_needle_and_renders_the_actual() -> TestResult {
238        let failure = contains_str("xyz")
239            .check("hello")
240            .failure
241            .or_fail_with("hello does not contain xyz")?;
242        check!(failure.expected.to_string())
243            .satisfies(eq("a string containing \"xyz\"".to_string()))?;
244        check!(failure.actual).satisfies(eq("\"hello\"".to_string()))?;
245        Ok(())
246    }
247
248    #[cfg(feature = "diff")]
249    #[test]
250    fn multi_line_string_mismatch_carries_a_diff() -> TestResult {
251        let actual = "line one\nline two\nline three";
252        let failure = starts_with("line one\nline 2")
253            .check(actual)
254            .failure
255            .or_fail_with("the multi-line prefix does not match")?;
256        let diff = failure
257            .diff
258            .or_fail_with("a multi-line string mismatch should carry a diff")?;
259        check!(diff.contains("line 2")).satisfies(is_true())?;
260        check!(diff.contains("line two")).satisfies(is_true())?;
261        Ok(())
262    }
263
264    #[cfg(feature = "regex")]
265    #[test]
266    fn matches_regex_matches_and_reports() -> TestResult {
267        check!(matches_regex(r"\d+").check("abc123").matched).satisfies(is_true())?;
268        check!(matches_regex(r"^\d+$").check("abc123").matched).satisfies(is_false())?;
269        Ok(())
270    }
271
272    #[cfg(feature = "regex")]
273    #[test]
274    fn matches_regex_reports_an_invalid_pattern_as_a_failure() -> TestResult {
275        let failure = matches_regex(r"(unclosed")
276            .check("anything")
277            .failure
278            .or_fail_with("an invalid pattern fails the match")?;
279        check!(failure.actual.contains("invalid regex")).satisfies(is_true())?;
280        Ok(())
281    }
282}