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, BTreeSet};
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    /// The same capture written twice — a later match would silently
126    /// overwrite the earlier binding.
127    DuplicateCapture {
128        /// The repeated capture name.
129        name: String,
130    },
131}
132
133impl PatternProblem {
134    /// The stable diagnostic code for this problem.
135    pub fn code(&self) -> &'static str {
136        match self {
137            Self::NoAnchor => "proef::pack::pattern_no_anchor",
138            Self::AdjacentCaptures { .. } => "proef::pack::adjacent_captures",
139            Self::UnsupportedBraces { .. } => "proef::pack::pattern_braces",
140            Self::EmptyCapture => "proef::pack::pattern_empty_capture",
141            Self::UnknownCapture { .. } => "proef::pack::pattern_unknown_capture",
142            Self::DuplicateCapture { .. } => "proef::pack::pattern_duplicate_capture",
143        }
144    }
145}
146
147impl std::fmt::Display for PatternProblem {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        match self {
150            Self::NoAnchor => f.write_str(
151                "pattern has no literal text to match on — a bare capture matches every step",
152            ),
153            Self::AdjacentCaptures { first, second } => write!(
154                f,
155                "adjacent captures `{{{first}}}{{{second}}}` are ambiguous — put literal text between them"
156            ),
157            Self::UnsupportedBraces { near } => write!(
158                f,
159                "unsupported `{{` or `}}` in pattern (near `{near}`) — captures are written `{{name}}`"
160            ),
161            Self::EmptyCapture => f.write_str("empty capture `{}`"),
162            Self::UnknownCapture { name, suggestion } => {
163                let hint = suggestion
164                    .as_ref()
165                    .map(|p| format!(" (did you mean `{p}`?)"))
166                    .unwrap_or_default();
167                write!(f, "capture `{{{name}}}` is not a declared param{hint}")
168            }
169            Self::DuplicateCapture { name } => write!(
170                f,
171                "capture `{{{name}}}` appears more than once — a later match would silently overwrite the earlier value"
172            ),
173        }
174    }
175}
176
177/// The problems found in a `match:` pattern (validation pass 1); empty = sound.
178pub fn pattern_problems(pattern: &str, params: &[String]) -> Vec<PatternProblem> {
179    let tokens = tokenize(pattern);
180    let mut problems = Vec::new();
181
182    let has_anchor = tokens
183        .iter()
184        .any(|t| matches!(t, Token::Literal(lit) if !lit.trim().is_empty()));
185    if !has_anchor {
186        problems.push(PatternProblem::NoAnchor);
187    }
188
189    for pair in tokens.windows(2) {
190        if let [Token::Capture(a), Token::Capture(b)] = pair {
191            problems.push(PatternProblem::AdjacentCaptures {
192                first: a.clone(),
193                second: b.clone(),
194            });
195        }
196    }
197
198    let mut seen_names = BTreeSet::new();
199    for token in &tokens {
200        let Token::Capture(name) = token else {
201            continue;
202        };
203        if !seen_names.insert(name.as_str()) {
204            let dup = PatternProblem::DuplicateCapture { name: name.clone() };
205            if !problems.contains(&dup) {
206                problems.push(dup);
207            }
208        }
209    }
210
211    for token in &tokens {
212        match token {
213            Token::Literal(lit) if lit.contains('{') || lit.contains('}') => {
214                problems.push(PatternProblem::UnsupportedBraces {
215                    near: lit.trim().to_owned(),
216                });
217            }
218            Token::Capture(name) if name.is_empty() => {
219                problems.push(PatternProblem::EmptyCapture);
220            }
221            Token::Capture(name) if !params.iter().any(|p| p == name) => {
222                problems.push(PatternProblem::UnknownCapture {
223                    name: name.clone(),
224                    suggestion: closest(name, params.iter().map(String::as_str))
225                        .map(ToOwned::to_owned),
226                });
227            }
228            _ => {}
229        }
230    }
231    problems
232}
233
234/// The literal skeleton of a pattern (captures dropped) — the comparison basis
235/// for closest-pattern suggestions on unbound steps.
236pub fn literal_skeleton(pattern: &str) -> String {
237    tokenize(pattern)
238        .into_iter()
239        .filter_map(|t| match t {
240            Token::Literal(s) => Some(s),
241            Token::Capture(_) => None,
242        })
243        .collect()
244}
245
246/// Rank `pattern` against the partially-typed prose `typed`, for completion
247/// ordering. Lower sorts first: the returned tuple is `(tier, tiebreak)`.
248///
249/// Comparison is against the pattern's [`literal_skeleton`] (captures dropped —
250/// the prose the author actually types), case-insensitively, in tiers:
251/// tier 0 the skeleton starts with `typed`; tier 1 `typed` occurs inside the
252/// skeleton (tiebreak = the match position); tier 2 the edit distance between
253/// `typed` and the skeleton's leading `typed`-length slice (the prefix-aligned
254/// distance, not the whole-pattern distance). An empty `typed` is a prefix of
255/// every skeleton, so all patterns share `(0, 0)` and keep their prior order.
256///
257/// This is a distinct problem from [`closest`], which finds the single most
258/// likely mistyped *complete* step; the two coexist.
259pub fn prefix_rank(typed: &str, pattern: &str) -> (u8, usize) {
260    let skeleton = literal_skeleton(pattern).to_lowercase();
261    let typed = typed.to_lowercase();
262    if skeleton.starts_with(&typed) {
263        (0, 0)
264    } else if let Some(idx) = skeleton.find(&typed) {
265        (1, idx)
266    } else {
267        // Prefix-aligned distance: compare `typed` against only the leading
268        // `typed`-length slice of the skeleton, so divergence past the typed
269        // portion does not inflate the score the way whole-pattern distance does.
270        let n = typed.chars().count();
271        let prefix: String = skeleton.chars().take(n).collect();
272        (2, levenshtein(&typed, &prefix))
273    }
274}
275
276/// Group pattern macros that differ **only** in their captures — i.e. share a
277/// [`literal_skeleton`]. Returns, for each such macro, the sorted names of its
278/// near-duplicate siblings. Pure and deterministic (sorted throughout); the
279/// caller (`proef macros`) surfaces it as an authoring advisory, never a gate.
280///
281/// Skeleton-equality is the deliberately tight signal: two patterns whose fixed
282/// text is identical and that differ only where a `{capture}` sits are genuinely
283/// confusable, whereas patterns with distinct literals (`shows the note` vs
284/// `shows the attachment`) keep distinct skeletons and are left alone — so a
285/// legitimately similar family is not flagged.
286pub fn near_duplicate_macros<'a>(
287    macros: impl IntoIterator<Item = (&'a str, &'a str)>,
288) -> BTreeMap<String, Vec<String>> {
289    let mut by_skeleton: BTreeMap<String, Vec<&str>> = BTreeMap::new();
290    for (name, pattern) in macros {
291        by_skeleton
292            .entry(literal_skeleton(pattern))
293            .or_default()
294            .push(name);
295    }
296    let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
297    for names in by_skeleton.values_mut() {
298        if names.len() < 2 {
299            continue;
300        }
301        names.sort_unstable();
302        for &name in names.iter() {
303            let siblings = names
304                .iter()
305                .filter(|&&other| other != name)
306                .map(|&other| other.to_owned())
307                .collect();
308            out.insert(name.to_owned(), siblings);
309        }
310    }
311    out
312}
313
314/// The candidate closest to `input` by edit distance, within the shared
315/// "did you mean" threshold. `None` when nothing is close.
316pub fn closest<'a>(input: &str, candidates: impl Iterator<Item = &'a str>) -> Option<&'a str> {
317    candidates
318        .map(|c| (levenshtein(input, c), c))
319        .filter(|(distance, _)| *distance <= SUGGESTION_DISTANCE)
320        .min_by_key(|(distance, _)| *distance)
321        .map(|(_, c)| c)
322}
323
324/// Maximum edit distance for a "did you mean" suggestion.
325const SUGGESTION_DISTANCE: usize = 3;
326
327/// Levenshtein edit distance over chars (small inputs; O(a·b) rolling row).
328pub fn levenshtein(a: &str, b: &str) -> usize {
329    let b_chars: Vec<char> = b.chars().collect();
330    let mut row: Vec<usize> = (0..=b_chars.len()).collect();
331    for (i, ca) in a.chars().enumerate() {
332        let mut previous_diagonal = row[0];
333        row[0] = i + 1;
334        for (j, cb) in b_chars.iter().enumerate() {
335            let substitution = previous_diagonal + usize::from(ca != *cb);
336            previous_diagonal = row[j + 1];
337            row[j + 1] = substitution.min(row[j] + 1).min(previous_diagonal + 1);
338        }
339    }
340    row[b_chars.len()]
341}
342
343#[cfg(test)]
344mod tests {
345    #![allow(clippy::unwrap_used)]
346
347    use super::*;
348
349    fn params(names: &[&str]) -> Vec<String> {
350        names.iter().map(|s| (*s).to_owned()).collect()
351    }
352
353    #[test]
354    fn literal_pattern_matches_exactly() {
355        assert_eq!(
356            match_pattern(
357                "the activity channel is activated and ready",
358                "the activity channel is activated and ready"
359            ),
360            Some(BTreeMap::new())
361        );
362        assert_eq!(
363            match_pattern("I create a record", "I create a records"),
364            None
365        );
366        assert_eq!(
367            match_pattern("I create a record", "so I create a record"),
368            None
369        );
370    }
371
372    #[test]
373    fn captures_split_on_leftmost_literal() {
374        let args = match_pattern(
375            "the record {name} is resolved",
376            "the record W-${run:id} is resolved",
377        )
378        .unwrap();
379        assert_eq!(args["name"], "W-${run:id}");
380    }
381
382    #[test]
383    fn multi_capture_binds_in_order() {
384        let args =
385            match_pattern("I search {index} for {term}", "I search records for Jansen").unwrap();
386        assert_eq!(args["index"], "records");
387        assert_eq!(args["term"], "Jansen");
388    }
389
390    #[test]
391    fn quoted_capture_preserves_inner_text() {
392        let args = match_pattern("I search for {term}", r#"I search for "Jansen, A. ""#).unwrap();
393        assert_eq!(args["term"], "Jansen, A. ");
394        let args = match_pattern("I search for {term}", "I search for 'de Vries'").unwrap();
395        assert_eq!(args["term"], "de Vries");
396    }
397
398    #[test]
399    fn unquoted_capture_is_trimmed() {
400        let args = match_pattern("I search for {term} now", "I search for   Jansen   now").unwrap();
401        assert_eq!(args["term"], "Jansen");
402    }
403
404    #[test]
405    fn trailing_capture_takes_the_rest() {
406        let args = match_pattern("say {message}", "say hello world").unwrap();
407        assert_eq!(args["message"], "hello world");
408    }
409
410    #[test]
411    fn guard_rails_reject_bad_patterns() {
412        assert!(
413            !pattern_problems("{a}", &params(&["a"])).is_empty(),
414            "no anchor"
415        );
416        assert!(
417            !pattern_problems("do {a}{b} now", &params(&["a", "b"])).is_empty(),
418            "adjacent captures"
419        );
420        assert!(
421            !pattern_problems("do {a", &params(&["a"])).is_empty(),
422            "unclosed brace"
423        );
424        assert!(
425            !pattern_problems("do {} now", &[]).is_empty(),
426            "empty capture"
427        );
428        assert!(
429            pattern_problems("do {a} now", &params(&["a"])).is_empty(),
430            "sound pattern"
431        );
432    }
433
434    #[test]
435    fn unknown_capture_gets_a_suggestion() {
436        let problems = pattern_problems("I log in as {rol}", &params(&["role"]));
437        assert_eq!(problems.len(), 1);
438        assert_eq!(problems[0].code(), "proef::pack::pattern_unknown_capture");
439        assert!(
440            problems[0].to_string().contains("did you mean `role`?"),
441            "{}",
442            problems[0]
443        );
444    }
445
446    #[test]
447    fn closest_respects_the_threshold() {
448        assert_eq!(
449            closest("serch", ["search", "create"].into_iter()),
450            Some("search")
451        );
452        assert_eq!(closest("zzzzzz", ["search", "create"].into_iter()), None);
453    }
454
455    #[test]
456    fn near_duplicate_macros_flags_capture_only_differences() {
457        let dups = near_duplicate_macros([
458            ("loginRole", "the user {role} logs in"),
459            ("loginName", "the user {name} logs in"),
460            ("showNote", "the board shows the note"),
461            ("showItem", "the board shows the scheduled item"),
462        ]);
463        // Same skeleton "the user  logs in" → mutual near-duplicates.
464        assert_eq!(dups.get("loginRole"), Some(&vec!["loginName".to_owned()]));
465        assert_eq!(dups.get("loginName"), Some(&vec!["loginRole".to_owned()]));
466        // Distinct literals (`note` vs `scheduled item`) → not flagged.
467        assert!(
468            !dups.contains_key("showNote"),
469            "distinct literals stay unflagged"
470        );
471        assert!(!dups.contains_key("showItem"));
472    }
473
474    #[test]
475    fn duplicate_captures_are_rejected_once_per_name() {
476        let params = vec!["x".to_owned()];
477        let problems = pattern_problems("move {x} to {x} and {x}", &params);
478        let dups: Vec<_> = problems
479            .iter()
480            .filter(|p| matches!(p, PatternProblem::DuplicateCapture { name } if name == "x"))
481            .collect();
482        assert_eq!(dups.len(), 1, "{problems:?}");
483    }
484
485    #[test]
486    fn prefix_rank_tiers_prefix_over_substring_over_miss() {
487        // "I gr" is a prefix of "I greet {who}" (skeleton "I greet ") -> tier 0.
488        let greet = prefix_rank("I gr", "I greet {who}");
489        // "gr" appears inside "I grab {thing}" as a substring but not a prefix -> tier 1.
490        let grab = prefix_rank("gr", "I grab {thing}");
491        // "I gr" is neither prefix nor substring of "the note is saved" -> tier 2.
492        let note = prefix_rank("I gr", "the note is saved");
493        assert_eq!(greet.0, 0);
494        assert_eq!(grab.0, 1);
495        assert_eq!(note.0, 2);
496        // Ordering: prefix < substring < miss.
497        assert!(greet < grab);
498        assert!(grab < note);
499    }
500
501    #[test]
502    fn prefix_rank_prefix_match_beats_large_full_pattern_distance() {
503        // The bug fix: "I gr" is a full-pattern edit-distance of ~9 from
504        // "I greet {who}" (so `closest` would reject it), but prefix_rank ranks it
505        // top (tier 0) and well above an unrelated pattern.
506        let greet = prefix_rank("I gr", "I greet {who}");
507        let unrelated = prefix_rank("I gr", "the note is saved");
508        assert!(greet < unrelated);
509        // Sanity: closest, the old substrate, finds nothing at this distance.
510        assert!(closest("I gr", ["I greet {who}"].into_iter()).is_none());
511    }
512
513    #[test]
514    fn prefix_rank_tier2_uses_prefix_aligned_distance_not_full_pattern() {
515        // "I greex" is neither a prefix nor a substring of "I greet {who}" -> tier 2.
516        // Its distance is measured against the LEADING 7 chars of the skeleton
517        // ("i greet"), giving 1 — far smaller than against an unrelated pattern.
518        let near = prefix_rank("I greex", "I greet {who}");
519        let far = prefix_rank("I greex", "the note is saved");
520        assert_eq!(near.0, 2);
521        assert_eq!(far.0, 2);
522        assert_eq!(near.1, 1);
523        assert!(
524            near.1 < far.1,
525            "prefix-aligned distance ranks the near pattern first"
526        );
527    }
528
529    #[test]
530    fn prefix_rank_is_case_insensitive() {
531        assert_eq!(prefix_rank("i gr", "I greet {who}").0, 0);
532        assert_eq!(prefix_rank("I GR", "i greet {who}").0, 0);
533    }
534
535    #[test]
536    fn prefix_rank_empty_typed_is_uniform_tier0() {
537        // Empty prefix is a prefix of everything -> all (0, 0) -> stable order.
538        assert_eq!(prefix_rank("", "I greet {who}"), (0, 0));
539        assert_eq!(prefix_rank("", "the note is saved"), (0, 0));
540    }
541
542    mod properties {
543        #![allow(clippy::ignored_unit_patterns)]
544
545        use super::*;
546        use proptest::prelude::*;
547
548        proptest! {
549            /// Total on arbitrary inputs: never panics (fuzz target mirrors this).
550            #[test]
551            fn matcher_never_panics(pattern in ".{0,60}", text in ".{0,120}") {
552                let _ = match_pattern(&pattern, &text);
553                let _ = pattern_problems(&pattern, &[]);
554            }
555
556            /// A sound single-capture pattern round-trips a quoted value exactly.
557            #[test]
558            fn quote_round_trip(value in "[^\"{}]{0,40}") {
559                let text = format!("I search for \"{value}\" now");
560                let args = match_pattern("I search for {term} now", &text).unwrap();
561                prop_assert_eq!(args["term"].as_str(), value.as_str());
562            }
563
564            /// Adjacent captures are always rejected by the guard rails.
565            #[test]
566            fn adjacent_captures_always_rejected(a in "[a-z]{1,8}", b in "[a-z]{1,8}") {
567                let pattern = format!("go {{{a}}}{{{b}}} end");
568                let names = vec![a.clone(), b.clone()];
569                prop_assert!(!pattern_problems(&pattern, &names).is_empty());
570            }
571
572            /// Unquoted round-trip: generated capture text without quote/brace
573            /// noise survives bind → args intact (modulo the documented trim).
574            #[test]
575            fn unquoted_round_trip(value in "[a-zA-Z0-9_-]{1,30}") {
576                let text = format!("the record {value} is resolved");
577                let args = match_pattern("the record {name} is resolved", &text).unwrap();
578                prop_assert_eq!(args["name"].as_str(), value.as_str());
579            }
580        }
581    }
582}