1use std::collections::{BTreeMap, BTreeSet};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Token {
21 Literal(String),
23 Capture(String),
25}
26
27pub 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
51pub 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
83fn 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#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum PatternProblem {
102 NoAnchor,
104 AdjacentCaptures {
106 first: String,
108 second: String,
110 },
111 UnsupportedBraces {
113 near: String,
115 },
116 EmptyCapture,
118 UnknownCapture {
120 name: String,
122 suggestion: Option<String>,
124 },
125 DuplicateCapture {
128 name: String,
130 },
131}
132
133impl PatternProblem {
134 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
177pub 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
234pub 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
246pub 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 let n = typed.chars().count();
271 let prefix: String = skeleton.chars().take(n).collect();
272 (2, levenshtein(&typed, &prefix))
273 }
274}
275
276pub 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
314pub 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
324const SUGGESTION_DISTANCE: usize = 3;
326
327pub 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}", ¶ms(&["a"])).is_empty(),
414 "no anchor"
415 );
416 assert!(
417 !pattern_problems("do {a}{b} now", ¶ms(&["a", "b"])).is_empty(),
418 "adjacent captures"
419 );
420 assert!(
421 !pattern_problems("do {a", ¶ms(&["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", ¶ms(&["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}", ¶ms(&["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 assert_eq!(dups.get("loginRole"), Some(&vec!["loginName".to_owned()]));
465 assert_eq!(dups.get("loginName"), Some(&vec!["loginRole".to_owned()]));
466 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}", ¶ms);
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 let greet = prefix_rank("I gr", "I greet {who}");
489 let grab = prefix_rank("gr", "I grab {thing}");
491 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 assert!(greet < grab);
498 assert!(grab < note);
499 }
500
501 #[test]
502 fn prefix_rank_prefix_match_beats_large_full_pattern_distance() {
503 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 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 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 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 #[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 #[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 #[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 #[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}