use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Token {
Literal(String),
Capture(String),
}
pub fn tokenize(pattern: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut rest = pattern;
while let Some(open) = rest.find('{') {
if open > 0 {
tokens.push(Token::Literal(rest[..open].to_owned()));
}
let after = &rest[open + 1..];
if let Some(close) = after.find('}') {
tokens.push(Token::Capture(after[..close].trim().to_owned()));
rest = &after[close + 1..];
} else {
tokens.push(Token::Literal(rest.to_owned()));
return tokens;
}
}
if !rest.is_empty() {
tokens.push(Token::Literal(rest.to_owned()));
}
tokens
}
pub fn match_pattern(pattern: &str, text: &str) -> Option<BTreeMap<String, String>> {
let tokens = tokenize(pattern);
let mut args = BTreeMap::new();
let mut rest = text;
let mut index = 0;
while index < tokens.len() {
match &tokens[index] {
Token::Literal(lit) => rest = rest.strip_prefix(lit.as_str())?,
Token::Capture(name) => {
let next_literal = match tokens.get(index + 1) {
Some(Token::Literal(lit)) if !lit.is_empty() => Some(lit.as_str()),
_ => None,
};
let value = match next_literal {
Some(lit) => {
let end = rest.find(lit)?;
let (value, remainder) = rest.split_at(end);
rest = remainder;
value
}
None => std::mem::take(&mut rest),
};
args.insert(name.clone(), shed_quotes(value.trim()).to_owned());
}
}
index += 1;
}
rest.is_empty().then_some(args)
}
fn shed_quotes(value: &str) -> &str {
for quote in ['"', '\''] {
if value.len() >= 2
&& let Some(inner) = value
.strip_prefix(quote)
.and_then(|v| v.strip_suffix(quote))
{
return inner;
}
}
value
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PatternProblem {
NoAnchor,
AdjacentCaptures {
first: String,
second: String,
},
UnsupportedBraces {
near: String,
},
EmptyCapture,
UnknownCapture {
name: String,
suggestion: Option<String>,
},
DuplicateCapture {
name: String,
},
}
impl PatternProblem {
pub fn code(&self) -> &'static str {
match self {
Self::NoAnchor => "proef::pack::pattern_no_anchor",
Self::AdjacentCaptures { .. } => "proef::pack::adjacent_captures",
Self::UnsupportedBraces { .. } => "proef::pack::pattern_braces",
Self::EmptyCapture => "proef::pack::pattern_empty_capture",
Self::UnknownCapture { .. } => "proef::pack::pattern_unknown_capture",
Self::DuplicateCapture { .. } => "proef::pack::pattern_duplicate_capture",
}
}
}
impl std::fmt::Display for PatternProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoAnchor => f.write_str(
"pattern has no literal text to match on — a bare capture matches every step",
),
Self::AdjacentCaptures { first, second } => write!(
f,
"adjacent captures `{{{first}}}{{{second}}}` are ambiguous — put literal text between them"
),
Self::UnsupportedBraces { near } => write!(
f,
"unsupported `{{` or `}}` in pattern (near `{near}`) — captures are written `{{name}}`"
),
Self::EmptyCapture => f.write_str("empty capture `{}`"),
Self::UnknownCapture { name, suggestion } => {
let hint = suggestion
.as_ref()
.map(|p| format!(" (did you mean `{p}`?)"))
.unwrap_or_default();
write!(f, "capture `{{{name}}}` is not a declared param{hint}")
}
Self::DuplicateCapture { name } => write!(
f,
"capture `{{{name}}}` appears more than once — a later match would silently overwrite the earlier value"
),
}
}
}
pub fn pattern_problems(pattern: &str, params: &[String]) -> Vec<PatternProblem> {
let tokens = tokenize(pattern);
let mut problems = Vec::new();
let has_anchor = tokens
.iter()
.any(|t| matches!(t, Token::Literal(lit) if !lit.trim().is_empty()));
if !has_anchor {
problems.push(PatternProblem::NoAnchor);
}
for pair in tokens.windows(2) {
if let [Token::Capture(a), Token::Capture(b)] = pair {
problems.push(PatternProblem::AdjacentCaptures {
first: a.clone(),
second: b.clone(),
});
}
}
let mut seen_names = BTreeSet::new();
for token in &tokens {
let Token::Capture(name) = token else {
continue;
};
if !seen_names.insert(name.as_str()) {
let dup = PatternProblem::DuplicateCapture { name: name.clone() };
if !problems.contains(&dup) {
problems.push(dup);
}
}
}
for token in &tokens {
match token {
Token::Literal(lit) if lit.contains('{') || lit.contains('}') => {
problems.push(PatternProblem::UnsupportedBraces {
near: lit.trim().to_owned(),
});
}
Token::Capture(name) if name.is_empty() => {
problems.push(PatternProblem::EmptyCapture);
}
Token::Capture(name) if !params.iter().any(|p| p == name) => {
problems.push(PatternProblem::UnknownCapture {
name: name.clone(),
suggestion: closest(name, params.iter().map(String::as_str))
.map(ToOwned::to_owned),
});
}
_ => {}
}
}
problems
}
pub fn literal_skeleton(pattern: &str) -> String {
tokenize(pattern)
.into_iter()
.filter_map(|t| match t {
Token::Literal(s) => Some(s),
Token::Capture(_) => None,
})
.collect()
}
pub fn prefix_rank(typed: &str, pattern: &str) -> (u8, usize) {
let skeleton = literal_skeleton(pattern).to_lowercase();
let typed = typed.to_lowercase();
if skeleton.starts_with(&typed) {
(0, 0)
} else if let Some(idx) = skeleton.find(&typed) {
(1, idx)
} else {
let n = typed.chars().count();
let prefix: String = skeleton.chars().take(n).collect();
(2, levenshtein(&typed, &prefix))
}
}
pub fn near_duplicate_macros<'a>(
macros: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> BTreeMap<String, Vec<String>> {
let mut by_skeleton: BTreeMap<String, Vec<&str>> = BTreeMap::new();
for (name, pattern) in macros {
by_skeleton
.entry(literal_skeleton(pattern))
.or_default()
.push(name);
}
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
for names in by_skeleton.values_mut() {
if names.len() < 2 {
continue;
}
names.sort_unstable();
for &name in names.iter() {
let siblings = names
.iter()
.filter(|&&other| other != name)
.map(|&other| other.to_owned())
.collect();
out.insert(name.to_owned(), siblings);
}
}
out
}
pub fn closest<'a>(input: &str, candidates: impl Iterator<Item = &'a str>) -> Option<&'a str> {
candidates
.map(|c| (levenshtein(input, c), c))
.filter(|(distance, _)| *distance <= SUGGESTION_DISTANCE)
.min_by_key(|(distance, _)| *distance)
.map(|(_, c)| c)
}
const SUGGESTION_DISTANCE: usize = 3;
pub fn levenshtein(a: &str, b: &str) -> usize {
let b_chars: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b_chars.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut previous_diagonal = row[0];
row[0] = i + 1;
for (j, cb) in b_chars.iter().enumerate() {
let substitution = previous_diagonal + usize::from(ca != *cb);
previous_diagonal = row[j + 1];
row[j + 1] = substitution.min(row[j] + 1).min(previous_diagonal + 1);
}
}
row[b_chars.len()]
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn params(names: &[&str]) -> Vec<String> {
names.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn literal_pattern_matches_exactly() {
assert_eq!(
match_pattern(
"the activity channel is activated and ready",
"the activity channel is activated and ready"
),
Some(BTreeMap::new())
);
assert_eq!(
match_pattern("I create a record", "I create a records"),
None
);
assert_eq!(
match_pattern("I create a record", "so I create a record"),
None
);
}
#[test]
fn captures_split_on_leftmost_literal() {
let args = match_pattern(
"the record {name} is resolved",
"the record W-${run:id} is resolved",
)
.unwrap();
assert_eq!(args["name"], "W-${run:id}");
}
#[test]
fn multi_capture_binds_in_order() {
let args =
match_pattern("I search {index} for {term}", "I search records for Jansen").unwrap();
assert_eq!(args["index"], "records");
assert_eq!(args["term"], "Jansen");
}
#[test]
fn quoted_capture_preserves_inner_text() {
let args = match_pattern("I search for {term}", r#"I search for "Jansen, A. ""#).unwrap();
assert_eq!(args["term"], "Jansen, A. ");
let args = match_pattern("I search for {term}", "I search for 'de Vries'").unwrap();
assert_eq!(args["term"], "de Vries");
}
#[test]
fn unquoted_capture_is_trimmed() {
let args = match_pattern("I search for {term} now", "I search for Jansen now").unwrap();
assert_eq!(args["term"], "Jansen");
}
#[test]
fn trailing_capture_takes_the_rest() {
let args = match_pattern("say {message}", "say hello world").unwrap();
assert_eq!(args["message"], "hello world");
}
#[test]
fn guard_rails_reject_bad_patterns() {
assert!(
!pattern_problems("{a}", ¶ms(&["a"])).is_empty(),
"no anchor"
);
assert!(
!pattern_problems("do {a}{b} now", ¶ms(&["a", "b"])).is_empty(),
"adjacent captures"
);
assert!(
!pattern_problems("do {a", ¶ms(&["a"])).is_empty(),
"unclosed brace"
);
assert!(
!pattern_problems("do {} now", &[]).is_empty(),
"empty capture"
);
assert!(
pattern_problems("do {a} now", ¶ms(&["a"])).is_empty(),
"sound pattern"
);
}
#[test]
fn unknown_capture_gets_a_suggestion() {
let problems = pattern_problems("I log in as {rol}", ¶ms(&["role"]));
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].code(), "proef::pack::pattern_unknown_capture");
assert!(
problems[0].to_string().contains("did you mean `role`?"),
"{}",
problems[0]
);
}
#[test]
fn closest_respects_the_threshold() {
assert_eq!(
closest("serch", ["search", "create"].into_iter()),
Some("search")
);
assert_eq!(closest("zzzzzz", ["search", "create"].into_iter()), None);
}
#[test]
fn near_duplicate_macros_flags_capture_only_differences() {
let dups = near_duplicate_macros([
("loginRole", "the user {role} logs in"),
("loginName", "the user {name} logs in"),
("showNote", "the board shows the note"),
("showItem", "the board shows the scheduled item"),
]);
assert_eq!(dups.get("loginRole"), Some(&vec!["loginName".to_owned()]));
assert_eq!(dups.get("loginName"), Some(&vec!["loginRole".to_owned()]));
assert!(
!dups.contains_key("showNote"),
"distinct literals stay unflagged"
);
assert!(!dups.contains_key("showItem"));
}
#[test]
fn duplicate_captures_are_rejected_once_per_name() {
let params = vec!["x".to_owned()];
let problems = pattern_problems("move {x} to {x} and {x}", ¶ms);
let dups: Vec<_> = problems
.iter()
.filter(|p| matches!(p, PatternProblem::DuplicateCapture { name } if name == "x"))
.collect();
assert_eq!(dups.len(), 1, "{problems:?}");
}
#[test]
fn prefix_rank_tiers_prefix_over_substring_over_miss() {
let greet = prefix_rank("I gr", "I greet {who}");
let grab = prefix_rank("gr", "I grab {thing}");
let note = prefix_rank("I gr", "the note is saved");
assert_eq!(greet.0, 0);
assert_eq!(grab.0, 1);
assert_eq!(note.0, 2);
assert!(greet < grab);
assert!(grab < note);
}
#[test]
fn prefix_rank_prefix_match_beats_large_full_pattern_distance() {
let greet = prefix_rank("I gr", "I greet {who}");
let unrelated = prefix_rank("I gr", "the note is saved");
assert!(greet < unrelated);
assert!(closest("I gr", ["I greet {who}"].into_iter()).is_none());
}
#[test]
fn prefix_rank_tier2_uses_prefix_aligned_distance_not_full_pattern() {
let near = prefix_rank("I greex", "I greet {who}");
let far = prefix_rank("I greex", "the note is saved");
assert_eq!(near.0, 2);
assert_eq!(far.0, 2);
assert_eq!(near.1, 1);
assert!(
near.1 < far.1,
"prefix-aligned distance ranks the near pattern first"
);
}
#[test]
fn prefix_rank_is_case_insensitive() {
assert_eq!(prefix_rank("i gr", "I greet {who}").0, 0);
assert_eq!(prefix_rank("I GR", "i greet {who}").0, 0);
}
#[test]
fn prefix_rank_empty_typed_is_uniform_tier0() {
assert_eq!(prefix_rank("", "I greet {who}"), (0, 0));
assert_eq!(prefix_rank("", "the note is saved"), (0, 0));
}
mod properties {
#![allow(clippy::ignored_unit_patterns)]
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn matcher_never_panics(pattern in ".{0,60}", text in ".{0,120}") {
let _ = match_pattern(&pattern, &text);
let _ = pattern_problems(&pattern, &[]);
}
#[test]
fn quote_round_trip(value in "[^\"{}]{0,40}") {
let text = format!("I search for \"{value}\" now");
let args = match_pattern("I search for {term} now", &text).unwrap();
prop_assert_eq!(args["term"].as_str(), value.as_str());
}
#[test]
fn adjacent_captures_always_rejected(a in "[a-z]{1,8}", b in "[a-z]{1,8}") {
let pattern = format!("go {{{a}}}{{{b}}} end");
let names = vec![a.clone(), b.clone()];
prop_assert!(!pattern_problems(&pattern, &names).is_empty());
}
#[test]
fn unquoted_round_trip(value in "[a-zA-Z0-9_-]{1,30}") {
let text = format!("the record {value} is resolved");
let args = match_pattern("the record {name} is resolved", &text).unwrap();
prop_assert_eq!(args["name"].as_str(), value.as_str());
}
}
}
}