Skip to main content

proef_core/
matcher.rs

1//! The `{name}` step matcher: cucumber-expression-style patterns binding Gherkin
2//! prose to macros (TECH-SPEC §4.3).
3//!
4//! A pattern is literal text with `{name}` captures (`I search for {term}`).
5//! Matching is **anchored and leftmost**: literals must appear in order (the
6//! whole text must be consumed), and each capture extends to the leftmost
7//! occurrence of the next literal. Captured values are trimmed; a value wrapped
8//! in symmetric double or single quotes sheds them (quotes preserve inner
9//! spaces and commas exactly).
10//!
11//! Guard rails ([`pattern_problems`], run at pack load — validation pass 1):
12//! a pattern must contain literal text to anchor on, adjacent captures are
13//! rejected (the single-pass matcher cannot split them), braces must be
14//! balanced, and every capture must name a declared param.
15
16use std::collections::BTreeMap;
17
18/// One token of a `match:` pattern.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Token {
21    /// Literal text that must appear verbatim.
22    Literal(String),
23    /// A `{name}` capture.
24    Capture(String),
25}
26
27/// Split a pattern into [`Token`]s. An unclosed `{` degrades the remainder into
28/// a literal — [`pattern_problems`] rejects it at load; the matcher stays total.
29pub fn tokenize(pattern: &str) -> Vec<Token> {
30    let mut tokens = Vec::new();
31    let mut rest = pattern;
32    while let Some(open) = rest.find('{') {
33        if open > 0 {
34            tokens.push(Token::Literal(rest[..open].to_owned()));
35        }
36        let after = &rest[open + 1..];
37        if let Some(close) = after.find('}') {
38            tokens.push(Token::Capture(after[..close].trim().to_owned()));
39            rest = &after[close + 1..];
40        } else {
41            tokens.push(Token::Literal(rest.to_owned()));
42            return tokens;
43        }
44    }
45    if !rest.is_empty() {
46        tokens.push(Token::Literal(rest.to_owned()));
47    }
48    tokens
49}
50
51/// Match `text` against `pattern`, returning the captured args, or `None` when
52/// the pattern does not apply. Total: never panics, any inputs.
53pub fn match_pattern(pattern: &str, text: &str) -> Option<BTreeMap<String, String>> {
54    let tokens = tokenize(pattern);
55    let mut args = BTreeMap::new();
56    let mut rest = text;
57    let mut index = 0;
58    while index < tokens.len() {
59        match &tokens[index] {
60            Token::Literal(lit) => rest = rest.strip_prefix(lit.as_str())?,
61            Token::Capture(name) => {
62                let next_literal = match tokens.get(index + 1) {
63                    Some(Token::Literal(lit)) if !lit.is_empty() => Some(lit.as_str()),
64                    _ => None,
65                };
66                let value = match next_literal {
67                    Some(lit) => {
68                        let end = rest.find(lit)?;
69                        let (value, remainder) = rest.split_at(end);
70                        rest = remainder;
71                        value
72                    }
73                    None => std::mem::take(&mut rest),
74                };
75                args.insert(name.clone(), shed_quotes(value.trim()).to_owned());
76            }
77        }
78        index += 1;
79    }
80    rest.is_empty().then_some(args)
81}
82
83/// Strip one symmetric pair of surrounding quotes (`"…"` or `'…'`), keeping the
84/// inner text exactly — the quoting mechanism that preserves spaces and commas.
85fn shed_quotes(value: &str) -> &str {
86    for quote in ['"', '\''] {
87        if value.len() >= 2
88            && let Some(inner) = value
89                .strip_prefix(quote)
90                .and_then(|v| v.strip_suffix(quote))
91        {
92            return inner;
93        }
94    }
95    value
96}
97
98/// One problem found in a `match:` pattern (validation pass 1), typed so each
99/// maps to a stable diagnostic code.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum PatternProblem {
102    /// No literal text to anchor on — a bare capture matches every step.
103    NoAnchor,
104    /// Two captures with nothing between them — the matcher cannot split them.
105    AdjacentCaptures {
106        /// First capture name.
107        first: String,
108        /// Second capture name.
109        second: String,
110    },
111    /// A stray `{`/`}` inside literal text (unclosed or unescaped).
112    UnsupportedBraces {
113        /// The literal fragment near the problem.
114        near: String,
115    },
116    /// An empty `{}` capture.
117    EmptyCapture,
118    /// A capture that names no declared param.
119    UnknownCapture {
120        /// The capture name as written.
121        name: String,
122        /// Closest declared param, when one is near.
123        suggestion: Option<String>,
124    },
125}
126
127impl PatternProblem {
128    /// The stable diagnostic code for this problem.
129    pub fn code(&self) -> &'static str {
130        match self {
131            Self::NoAnchor => "proef::pack::pattern_no_anchor",
132            Self::AdjacentCaptures { .. } => "proef::pack::adjacent_captures",
133            Self::UnsupportedBraces { .. } => "proef::pack::pattern_braces",
134            Self::EmptyCapture => "proef::pack::pattern_empty_capture",
135            Self::UnknownCapture { .. } => "proef::pack::pattern_unknown_capture",
136        }
137    }
138}
139
140impl std::fmt::Display for PatternProblem {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        match self {
143            Self::NoAnchor => f.write_str(
144                "pattern has no literal text to match on — a bare capture matches every step",
145            ),
146            Self::AdjacentCaptures { first, second } => write!(
147                f,
148                "adjacent captures `{{{first}}}{{{second}}}` are ambiguous — put literal text between them"
149            ),
150            Self::UnsupportedBraces { near } => write!(
151                f,
152                "unsupported `{{` or `}}` in pattern (near `{near}`) — captures are written `{{name}}`"
153            ),
154            Self::EmptyCapture => f.write_str("empty capture `{}`"),
155            Self::UnknownCapture { name, suggestion } => {
156                let hint = suggestion
157                    .as_ref()
158                    .map(|p| format!(" (did you mean `{p}`?)"))
159                    .unwrap_or_default();
160                write!(f, "capture `{{{name}}}` is not a declared param{hint}")
161            }
162        }
163    }
164}
165
166/// The problems found in a `match:` pattern (validation pass 1); empty = sound.
167pub fn pattern_problems(pattern: &str, params: &[String]) -> Vec<PatternProblem> {
168    let tokens = tokenize(pattern);
169    let mut problems = Vec::new();
170
171    let has_anchor = tokens
172        .iter()
173        .any(|t| matches!(t, Token::Literal(lit) if !lit.trim().is_empty()));
174    if !has_anchor {
175        problems.push(PatternProblem::NoAnchor);
176    }
177
178    for pair in tokens.windows(2) {
179        if let [Token::Capture(a), Token::Capture(b)] = pair {
180            problems.push(PatternProblem::AdjacentCaptures {
181                first: a.clone(),
182                second: b.clone(),
183            });
184        }
185    }
186
187    for token in &tokens {
188        match token {
189            Token::Literal(lit) if lit.contains('{') || lit.contains('}') => {
190                problems.push(PatternProblem::UnsupportedBraces {
191                    near: lit.trim().to_owned(),
192                });
193            }
194            Token::Capture(name) if name.is_empty() => {
195                problems.push(PatternProblem::EmptyCapture);
196            }
197            Token::Capture(name) if !params.iter().any(|p| p == name) => {
198                problems.push(PatternProblem::UnknownCapture {
199                    name: name.clone(),
200                    suggestion: closest(name, params.iter().map(String::as_str))
201                        .map(ToOwned::to_owned),
202                });
203            }
204            _ => {}
205        }
206    }
207    problems
208}
209
210/// The literal skeleton of a pattern (captures dropped) — the comparison basis
211/// for closest-pattern suggestions on unbound steps.
212pub fn literal_skeleton(pattern: &str) -> String {
213    tokenize(pattern)
214        .into_iter()
215        .filter_map(|t| match t {
216            Token::Literal(s) => Some(s),
217            Token::Capture(_) => None,
218        })
219        .collect()
220}
221
222/// The candidate closest to `input` by edit distance, within the shared
223/// "did you mean" threshold. `None` when nothing is close.
224pub fn closest<'a>(input: &str, candidates: impl Iterator<Item = &'a str>) -> Option<&'a str> {
225    candidates
226        .map(|c| (levenshtein(input, c), c))
227        .filter(|(distance, _)| *distance <= SUGGESTION_DISTANCE)
228        .min_by_key(|(distance, _)| *distance)
229        .map(|(_, c)| c)
230}
231
232/// Maximum edit distance for a "did you mean" suggestion.
233const SUGGESTION_DISTANCE: usize = 3;
234
235/// Levenshtein edit distance over chars (small inputs; O(a·b) rolling row).
236pub fn levenshtein(a: &str, b: &str) -> usize {
237    let b_chars: Vec<char> = b.chars().collect();
238    let mut row: Vec<usize> = (0..=b_chars.len()).collect();
239    for (i, ca) in a.chars().enumerate() {
240        let mut previous_diagonal = row[0];
241        row[0] = i + 1;
242        for (j, cb) in b_chars.iter().enumerate() {
243            let substitution = previous_diagonal + usize::from(ca != *cb);
244            previous_diagonal = row[j + 1];
245            row[j + 1] = substitution.min(row[j] + 1).min(previous_diagonal + 1);
246        }
247    }
248    row[b_chars.len()]
249}
250
251#[cfg(test)]
252mod tests {
253    #![allow(clippy::unwrap_used)]
254
255    use super::*;
256
257    fn params(names: &[&str]) -> Vec<String> {
258        names.iter().map(|s| (*s).to_owned()).collect()
259    }
260
261    #[test]
262    fn literal_pattern_matches_exactly() {
263        assert_eq!(
264            match_pattern(
265                "the client feed is activated and ready",
266                "the client feed is activated and ready"
267            ),
268            Some(BTreeMap::new())
269        );
270        assert_eq!(
271            match_pattern("I create a client", "I create a clients"),
272            None
273        );
274        assert_eq!(
275            match_pattern("I create a client", "so I create a client"),
276            None
277        );
278    }
279
280    #[test]
281    fn captures_split_on_leftmost_literal() {
282        let args = match_pattern(
283            "the client {name} is resolved",
284            "the client Bakker-${run:id} is resolved",
285        )
286        .unwrap();
287        assert_eq!(args["name"], "Bakker-${run:id}");
288    }
289
290    #[test]
291    fn multi_capture_binds_in_order() {
292        let args =
293            match_pattern("I search {index} for {term}", "I search clients for Jansen").unwrap();
294        assert_eq!(args["index"], "clients");
295        assert_eq!(args["term"], "Jansen");
296    }
297
298    #[test]
299    fn quoted_capture_preserves_inner_text() {
300        let args = match_pattern("I search for {term}", r#"I search for "Jansen, A. ""#).unwrap();
301        assert_eq!(args["term"], "Jansen, A. ");
302        let args = match_pattern("I search for {term}", "I search for 'de Vries'").unwrap();
303        assert_eq!(args["term"], "de Vries");
304    }
305
306    #[test]
307    fn unquoted_capture_is_trimmed() {
308        let args = match_pattern("I search for {term} now", "I search for   Jansen   now").unwrap();
309        assert_eq!(args["term"], "Jansen");
310    }
311
312    #[test]
313    fn trailing_capture_takes_the_rest() {
314        let args = match_pattern("say {message}", "say hello world").unwrap();
315        assert_eq!(args["message"], "hello world");
316    }
317
318    #[test]
319    fn guard_rails_reject_bad_patterns() {
320        assert!(
321            !pattern_problems("{a}", &params(&["a"])).is_empty(),
322            "no anchor"
323        );
324        assert!(
325            !pattern_problems("do {a}{b} now", &params(&["a", "b"])).is_empty(),
326            "adjacent captures"
327        );
328        assert!(
329            !pattern_problems("do {a", &params(&["a"])).is_empty(),
330            "unclosed brace"
331        );
332        assert!(
333            !pattern_problems("do {} now", &[]).is_empty(),
334            "empty capture"
335        );
336        assert!(
337            pattern_problems("do {a} now", &params(&["a"])).is_empty(),
338            "sound pattern"
339        );
340    }
341
342    #[test]
343    fn unknown_capture_gets_a_suggestion() {
344        let problems = pattern_problems("I log in as {rol}", &params(&["role"]));
345        assert_eq!(problems.len(), 1);
346        assert_eq!(problems[0].code(), "proef::pack::pattern_unknown_capture");
347        assert!(
348            problems[0].to_string().contains("did you mean `role`?"),
349            "{}",
350            problems[0]
351        );
352    }
353
354    #[test]
355    fn closest_respects_the_threshold() {
356        assert_eq!(
357            closest("serch", ["search", "create"].into_iter()),
358            Some("search")
359        );
360        assert_eq!(closest("zzzzzz", ["search", "create"].into_iter()), None);
361    }
362
363    mod properties {
364        #![allow(clippy::ignored_unit_patterns)]
365
366        use super::*;
367        use proptest::prelude::*;
368
369        proptest! {
370            /// Total on arbitrary inputs: never panics (fuzz target mirrors this).
371            #[test]
372            fn matcher_never_panics(pattern in ".{0,60}", text in ".{0,120}") {
373                let _ = match_pattern(&pattern, &text);
374                let _ = pattern_problems(&pattern, &[]);
375            }
376
377            /// A sound single-capture pattern round-trips a quoted value exactly.
378            #[test]
379            fn quote_round_trip(value in "[^\"{}]{0,40}") {
380                let text = format!("I search for \"{value}\" now");
381                let args = match_pattern("I search for {term} now", &text).unwrap();
382                prop_assert_eq!(args["term"].as_str(), value.as_str());
383            }
384
385            /// Adjacent captures are always rejected by the guard rails.
386            #[test]
387            fn adjacent_captures_always_rejected(a in "[a-z]{1,8}", b in "[a-z]{1,8}") {
388                let pattern = format!("go {{{a}}}{{{b}}} end");
389                let names = vec![a.clone(), b.clone()];
390                prop_assert!(!pattern_problems(&pattern, &names).is_empty());
391            }
392
393            /// Unquoted round-trip: generated capture text without quote/brace
394            /// noise survives bind → args intact (modulo the documented trim).
395            #[test]
396            fn unquoted_round_trip(value in "[a-zA-Z0-9_-]{1,30}") {
397                let text = format!("the client {value} is resolved");
398                let args = match_pattern("the client {name} is resolved", &text).unwrap();
399                prop_assert_eq!(args["name"].as_str(), value.as_str());
400            }
401        }
402    }
403}