Skip to main content

rstest_bdd/
pattern.rs

1//! Step pattern handling and compilation.
2//! This module defines `StepPattern`, a lightweight wrapper around a pattern
3//! literal that compiles lazily to a regular expression.
4
5use crate::types::{PlaceholderSyntaxError, StepPatternError};
6use regex::Regex;
7use rstest_bdd_patterns::{PatternError, SpecificityScore, compile_regex_from_pattern};
8use std::hash::{Hash, Hasher};
9use std::sync::OnceLock;
10
11/// Pattern text used to match a step at runtime.
12#[derive(Debug)]
13pub struct StepPattern {
14    text: &'static str,
15    pub(crate) regex: OnceLock<Regex>,
16    specificity: OnceLock<SpecificityScore>,
17}
18
19// Equality and hashing are by the underlying literal text. This allows
20// `&'static StepPattern` to be used as a stable map key while keeping
21// semantics intuitive and independent of allocation identity.
22impl PartialEq for StepPattern {
23    fn eq(&self, other: &Self) -> bool {
24        self.text == other.text
25    }
26}
27
28impl Eq for StepPattern {}
29
30impl Hash for StepPattern {
31    fn hash<H: Hasher>(&self, state: &mut H) {
32        self.text.hash(state);
33    }
34}
35
36impl From<PatternError> for StepPatternError {
37    fn from(err: PatternError) -> Self {
38        match err {
39            PatternError::Placeholder(info) => Self::PlaceholderSyntax(
40                PlaceholderSyntaxError::new(info.message, info.position, info.placeholder),
41            ),
42            PatternError::Regex(e) => Self::InvalidPattern(e),
43        }
44    }
45}
46
47impl StepPattern {
48    /// Create a new pattern wrapper from a string literal.
49    #[must_use]
50    pub const fn new(value: &'static str) -> Self {
51        Self {
52            text: value,
53            regex: OnceLock::new(),
54            specificity: OnceLock::new(),
55        }
56    }
57
58    /// Access the underlying pattern string.
59    #[must_use]
60    pub const fn as_str(&self) -> &'static str {
61        self.text
62    }
63
64    /// Compile the pattern into a regular expression, caching the result.
65    ///
66    /// # Errors
67    /// Returns an error if the pattern contains invalid placeholders or the
68    /// generated regex fails to compile.
69    ///
70    /// # Notes
71    /// - This operation is idempotent. Subsequent calls after a successful
72    ///   compilation are no-ops.
73    /// - This method is thread-safe; concurrent calls may race to build a
74    ///   `Regex`, but only the first successful value is cached.
75    pub fn compile(&self) -> Result<(), StepPatternError> {
76        if self.regex.get().is_some() {
77            return Ok(());
78        }
79        let regex = compile_regex_from_pattern(self.text)?;
80        let _ = self.regex.set(regex);
81        Ok(())
82    }
83
84    /// Return the cached regular expression without checking compilation status.
85    ///
86    /// # Panics
87    /// Panics if called before [`compile`](Self::compile) has succeeded.
88    #[must_use]
89    #[expect(
90        clippy::expect_used,
91        reason = "internal method; callers guarantee prior compilation"
92    )]
93    pub(crate) fn regex_unchecked(&self) -> &Regex {
94        self.regex.get().expect("regex accessed before compilation")
95    }
96
97    /// Calculate and cache the specificity score for this pattern.
98    ///
99    /// Used to rank patterns when multiple match the same step text.
100    /// Higher scores indicate more specific patterns.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`StepPatternError`] if the pattern contains invalid syntax.
105    ///
106    /// # Notes
107    ///
108    /// - This operation is idempotent. Subsequent calls after a successful
109    ///   calculation are no-ops.
110    /// - This method is thread-safe; concurrent calls may race to compute
111    ///   the score, but only the first successful value is cached.
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use rstest_bdd::StepPattern;
117    ///
118    /// let specific = StepPattern::from("overlap apples");
119    /// let generic = StepPattern::from("overlap {item}");
120    ///
121    /// let specific_score = specific.specificity().expect("specific pattern is valid");
122    /// let generic_score = generic.specificity().expect("generic pattern is valid");
123    /// assert!(specific_score > generic_score);
124    /// ```
125    pub fn specificity(&self) -> Result<SpecificityScore, StepPatternError> {
126        if let Some(score) = self.specificity.get() {
127            return Ok(*score);
128        }
129        let score = SpecificityScore::calculate(self.text)?;
130        let _ = self.specificity.set(score);
131        Ok(score)
132    }
133}
134
135impl From<&'static str> for StepPattern {
136    fn from(value: &'static str) -> Self {
137        Self::new(value)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::ptr;
145
146    #[test]
147    #[expect(clippy::expect_used, reason = "test helper validates success path")]
148    fn regex_unchecked_returns_cached_regex_after_compilation() {
149        let pattern = StepPattern::from("literal text");
150        pattern.compile().expect("literal pattern should compile");
151
152        // Repeated calls return the same cached instance
153        let re1 = pattern.regex_unchecked();
154        let re2 = pattern.regex_unchecked();
155
156        assert!(ptr::eq(re1, re2));
157        assert!(re1.is_match("literal text"));
158    }
159
160    #[test]
161    #[expect(clippy::expect_used, reason = "test validates compilation")]
162    fn compile_is_idempotent() {
163        let pattern = StepPattern::from("literal text");
164
165        // First compile succeeds
166        pattern.compile().expect("literal pattern should compile");
167        let re1 = pattern.regex_unchecked();
168
169        // Second compile is a no-op and returns the same regex
170        pattern.compile().expect("recompile should succeed");
171        let re2 = pattern.regex_unchecked();
172
173        assert!(ptr::eq(re1, re2), "compile should be idempotent");
174    }
175
176    #[test]
177    #[should_panic(expected = "regex accessed before compilation")]
178    fn regex_unchecked_panics_without_prior_compilation() {
179        let pattern = StepPattern::from("literal text");
180        // This should panic because compile() was never called
181        let _ = pattern.regex_unchecked();
182    }
183}