1use std::collections::BTreeMap;
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}
126
127impl PatternProblem {
128 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
166pub 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
210pub 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
222pub 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
232const SUGGESTION_DISTANCE: usize = 3;
234
235pub 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}", ¶ms(&["a"])).is_empty(),
322 "no anchor"
323 );
324 assert!(
325 !pattern_problems("do {a}{b} now", ¶ms(&["a", "b"])).is_empty(),
326 "adjacent captures"
327 );
328 assert!(
329 !pattern_problems("do {a", ¶ms(&["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", ¶ms(&["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}", ¶ms(&["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 #[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 #[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 #[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 #[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}