Skip to content

Commit 1c8b92d

Browse files
hovinenbcopybara-github
authored andcommitted
Add to StrMatcher support for matching a string containing a substring a given number of times.
This is analogous to the `times` method of the `contains` matcher. The tester can now assert, for example, that a given substring appears exactly once and no more. Previously, it was only possible to assert that it appears at least once. This works in a manor analogous to `times` in `contains`: it adds a field of type `Option<Box<dyn Matcher<usize>>>` to the configuration. If that field is None, then the behaviour is as before. If it is filled, then the matcher is respected and output in the description and match explanation. The use of a trait object avoids having to add more generics to `StrMatcher` just to support this one case, while the use of an `Option` ensures that the description and match explanation aren't affected when the test doesn't specify anything. PiperOrigin-RevId: 517418605
1 parent e897130 commit 1c8b92d

File tree

1 file changed

+102
-23
lines changed

1 file changed

+102
-23
lines changed

googletest/src/matchers/str_matcher.rs

Lines changed: 102 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use eq_matcher::EqMatcher;
1818
use googletest::matcher::{Matcher, MatcherResult};
1919
#[cfg(not(google3))]
2020
use googletest::matchers::eq_matcher;
21+
use std::borrow::Cow;
2122
use std::fmt::Debug;
2223
use std::ops::Deref;
2324

@@ -88,7 +89,7 @@ pub fn ends_with<T>(expected: T) -> StrMatcher<T> {
8889
/// Matchers which match against string values and, through configuration,
8990
/// specialise to [StrMatcher] implement this trait. Currently that only
9091
/// includes [EqMatcher] and [StrMatcher].
91-
pub trait StrMatcherConfigurator<T> {
92+
pub trait StrMatcherConfigurator<ExpectedT> {
9293
/// Configures the matcher to ignore any leading whitespace in either the
9394
/// actual or the expected value.
9495
///
@@ -104,7 +105,7 @@ pub trait StrMatcherConfigurator<T> {
104105
/// actual value.
105106
///
106107
/// [`str::trim_start`]: https://doc.rust-lang.org/std/primitive.str.html#method.trim_start
107-
fn ignoring_leading_whitespace(self) -> StrMatcher<T>;
108+
fn ignoring_leading_whitespace(self) -> StrMatcher<ExpectedT>;
108109

109110
/// Configures the matcher to ignore any trailing whitespace in either the
110111
/// actual or the expected value.
@@ -121,7 +122,7 @@ pub trait StrMatcherConfigurator<T> {
121122
/// actual value.
122123
///
123124
/// [`str::trim_end`]: https://doc.rust-lang.org/std/primitive.str.html#method.trim_end
124-
fn ignoring_trailing_whitespace(self) -> StrMatcher<T>;
125+
fn ignoring_trailing_whitespace(self) -> StrMatcher<ExpectedT>;
125126

126127
/// Configures the matcher to ignore both leading and trailing whitespace in
127128
/// either the actual or the expected value.
@@ -142,7 +143,7 @@ pub trait StrMatcherConfigurator<T> {
142143
/// value.
143144
///
144145
/// [`str::trim`]: https://doc.rust-lang.org/std/primitive.str.html#method.trim
145-
fn ignoring_outer_whitespace(self) -> StrMatcher<T>;
146+
fn ignoring_outer_whitespace(self) -> StrMatcher<ExpectedT>;
146147

147148
/// Configures the matcher to ignore ASCII case when comparing values.
148149
///
@@ -157,7 +158,28 @@ pub trait StrMatcherConfigurator<T> {
157158
/// case characters outside of the codepoints 0-127 covered by ASCII.
158159
///
159160
/// [`str::eq_ignore_ascii_case`]: https://doc.rust-lang.org/std/primitive.str.html#method.eq_ignore_ascii_case
160-
fn ignoring_ascii_case(self) -> StrMatcher<T>;
161+
fn ignoring_ascii_case(self) -> StrMatcher<ExpectedT>;
162+
163+
/// Configures the matcher to match only strings which otherwise satisfy the
164+
/// conditions a number times matched by the matcher `times`.
165+
///
166+
/// ```
167+
/// verify_that!("Some value\nSome value", contains_substring("value").times(eq(2)))?; // Passes
168+
/// verify_that!("Some value", contains_substring("value").times(eq(2)))?; // Fails
169+
/// ```
170+
///
171+
/// The matched substrings must be disjoint from one another to be counted.
172+
/// For example:
173+
///
174+
/// ```
175+
/// // Fails: substrings distinct but not disjoint!
176+
/// verify_that!("ababab", contains_substring("abab").times(eq(2)))?;
177+
/// ```
178+
///
179+
/// This is only meaningful when the matcher was constructed with
180+
/// [`contains_substring`]. This method will panic when it is used with any
181+
/// other matcher construction.
182+
fn times(self, times: impl Matcher<usize> + 'static) -> StrMatcher<ExpectedT>;
161183
}
162184

163185
/// A matcher which matches equality or containment of a string-like value in a
@@ -169,8 +191,8 @@ pub trait StrMatcherConfigurator<T> {
169191
/// * [`contains_substring`],
170192
/// * [`starts_with`],
171193
/// * [`ends_with`].
172-
pub struct StrMatcher<T> {
173-
expected: T,
194+
pub struct StrMatcher<ExpectedT> {
195+
expected: ExpectedT,
174196
configuration: Configuration,
175197
}
176198

@@ -192,32 +214,42 @@ where
192214
}
193215
}
194216

195-
impl<T, MatcherT: Into<StrMatcher<T>>> StrMatcherConfigurator<T> for MatcherT {
196-
fn ignoring_leading_whitespace(self) -> StrMatcher<T> {
217+
impl<ExpectedT, MatcherT: Into<StrMatcher<ExpectedT>>> StrMatcherConfigurator<ExpectedT>
218+
for MatcherT
219+
{
220+
fn ignoring_leading_whitespace(self) -> StrMatcher<ExpectedT> {
197221
let existing = self.into();
198222
StrMatcher {
199223
configuration: existing.configuration.ignoring_leading_whitespace(),
200224
..existing
201225
}
202226
}
203227

204-
fn ignoring_trailing_whitespace(self) -> StrMatcher<T> {
228+
fn ignoring_trailing_whitespace(self) -> StrMatcher<ExpectedT> {
205229
let existing = self.into();
206230
StrMatcher {
207231
configuration: existing.configuration.ignoring_trailing_whitespace(),
208232
..existing
209233
}
210234
}
211235

212-
fn ignoring_outer_whitespace(self) -> StrMatcher<T> {
236+
fn ignoring_outer_whitespace(self) -> StrMatcher<ExpectedT> {
213237
let existing = self.into();
214238
StrMatcher { configuration: existing.configuration.ignoring_outer_whitespace(), ..existing }
215239
}
216240

217-
fn ignoring_ascii_case(self) -> StrMatcher<T> {
241+
fn ignoring_ascii_case(self) -> StrMatcher<ExpectedT> {
218242
let existing = self.into();
219243
StrMatcher { configuration: existing.configuration.ignoring_ascii_case(), ..existing }
220244
}
245+
246+
fn times(self, times: impl Matcher<usize> + 'static) -> StrMatcher<ExpectedT> {
247+
let existing = self.into();
248+
if !matches!(existing.configuration.mode, MatchMode::Contains) {
249+
panic!("The times() configurator is only meaningful with contains_substring().");
250+
}
251+
StrMatcher { configuration: existing.configuration.times(times), ..existing }
252+
}
221253
}
222254

223255
impl<T: Deref<Target = str>> From<EqMatcher<T>> for StrMatcher<T> {
@@ -242,12 +274,13 @@ impl<T> StrMatcher<T> {
242274
// parameterised, saving compilation time and binary size on monomorphisation.
243275
//
244276
// The default value represents exact equality of the strings.
245-
#[derive(Default, Clone)]
277+
#[derive(Default)]
246278
struct Configuration {
247279
mode: MatchMode,
248280
ignore_leading_whitespace: bool,
249281
ignore_trailing_whitespace: bool,
250282
case_policy: CasePolicy,
283+
times: Option<Box<dyn Matcher<usize>>>,
251284
}
252285

253286
#[derive(Default, Clone)]
@@ -283,10 +316,11 @@ impl Configuration {
283316
CasePolicy::IgnoreAscii => expected.eq_ignore_ascii_case(actual),
284317
},
285318
MatchMode::Contains => match self.case_policy {
286-
CasePolicy::Respect => actual.contains(expected),
287-
CasePolicy::IgnoreAscii => {
288-
actual.to_ascii_lowercase().contains(&expected.to_ascii_lowercase())
289-
}
319+
CasePolicy::Respect => self.does_containment_match(actual, expected),
320+
CasePolicy::IgnoreAscii => self.does_containment_match(
321+
actual.to_ascii_lowercase().as_str(),
322+
expected.to_ascii_lowercase().as_str(),
323+
),
290324
},
291325
MatchMode::StartsWith => match self.case_policy {
292326
CasePolicy::Respect => actual.starts_with(expected),
@@ -304,18 +338,35 @@ impl Configuration {
304338
},
305339
}
306340
}
341+
342+
// Returns whether actual contains expected a number of times matched by the
343+
// matcher self.times. Does not take other configuration into account.
344+
fn does_containment_match(&self, actual: &str, expected: &str) -> bool {
345+
if let Some(times) = self.times.as_ref() {
346+
// Split returns an iterator over the "boundaries" left and right of the
347+
// substring to be matched, of which there is one more than the number of
348+
// substrings.
349+
matches!(times.matches(&(actual.split(expected).count() - 1)), MatcherResult::Matches)
350+
} else {
351+
actual.contains(expected)
352+
}
353+
}
354+
307355
// StrMatcher::describe redirects immediately to this function.
308356
fn describe(&self, matcher_result: MatcherResult, expected: &str) -> String {
309-
let mut addenda = Vec::with_capacity(3);
357+
let mut addenda: Vec<Cow<'static, str>> = Vec::with_capacity(3);
310358
match (self.ignore_leading_whitespace, self.ignore_trailing_whitespace) {
311-
(true, true) => addenda.push("ignoring leading and trailing whitespace"),
312-
(true, false) => addenda.push("ignoring leading whitespace"),
313-
(false, true) => addenda.push("ignoring trailing whitespace"),
359+
(true, true) => addenda.push("ignoring leading and trailing whitespace".into()),
360+
(true, false) => addenda.push("ignoring leading whitespace".into()),
361+
(false, true) => addenda.push("ignoring trailing whitespace".into()),
314362
(false, false) => {}
315363
}
316364
match self.case_policy {
317365
CasePolicy::Respect => {}
318-
CasePolicy::IgnoreAscii => addenda.push("ignoring ASCII case"),
366+
CasePolicy::IgnoreAscii => addenda.push("ignoring ASCII case".into()),
367+
}
368+
if let Some(times) = self.times.as_ref() {
369+
addenda.push(format!("count {}", times.describe(matcher_result)).into());
319370
}
320371
let extra =
321372
if !addenda.is_empty() { format!(" ({})", addenda.join(", ")) } else { "".into() };
@@ -355,6 +406,10 @@ impl Configuration {
355406
fn ignoring_ascii_case(self) -> Self {
356407
Self { case_policy: CasePolicy::IgnoreAscii, ..self }
357408
}
409+
410+
fn times(self, times: impl Matcher<usize> + 'static) -> Self {
411+
Self { times: Some(Box::new(times)), ..self }
412+
}
358413
}
359414

360415
#[cfg(test)]
@@ -365,7 +420,7 @@ mod tests {
365420
#[cfg(not(google3))]
366421
use googletest::matchers;
367422
use googletest::{google_test, verify_that, Result};
368-
use matchers::{eq, not};
423+
use matchers::{eq, gt, not};
369424

370425
#[google_test]
371426
fn matches_string_reference_with_equal_string_reference() -> Result<()> {
@@ -496,6 +551,21 @@ mod tests {
496551
verify_that!("Some string", contains_substring("STR").ignoring_ascii_case())
497552
}
498553

554+
#[google_test]
555+
fn contains_substring_matches_correct_number_of_substrings() -> Result<()> {
556+
verify_that!("Some string", contains_substring("str").times(eq(1)))
557+
}
558+
559+
#[google_test]
560+
fn contains_substring_does_not_match_incorrect_number_of_substrings() -> Result<()> {
561+
verify_that!("Some string\nSome string", not(contains_substring("string").times(eq(1))))
562+
}
563+
564+
#[google_test]
565+
fn contains_substring_does_not_match_when_substrings_overlap() -> Result<()> {
566+
verify_that!("ababab", not(contains_substring("abab").times(eq(2))))
567+
}
568+
499569
#[google_test]
500570
fn starts_with_matches_string_reference_with_prefix() -> Result<()> {
501571
verify_that!("Some value", starts_with("Some"))
@@ -650,6 +720,15 @@ mod tests {
650720
)
651721
}
652722

723+
#[google_test]
724+
fn describes_itself_with_count_number() -> Result<()> {
725+
let matcher = contains_substring("A string").times(gt(2));
726+
verify_that!(
727+
Matcher::<&str>::describe(&matcher, MatcherResult::Matches),
728+
eq("contains a substring \"A string\" (count is greater than 2)")
729+
)
730+
}
731+
653732
#[google_test]
654733
fn describes_itself_for_matching_result_in_starts_with_mode() -> Result<()> {
655734
let matcher = starts_with("A string");

0 commit comments

Comments
 (0)