use std::collections::BTreeMap;
#[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>,
},
}
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",
}
}
}
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}")
}
}
}
}
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(),
});
}
}
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 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 client feed is activated and ready",
"the client feed is activated and ready"
),
Some(BTreeMap::new())
);
assert_eq!(
match_pattern("I create a client", "I create a clients"),
None
);
assert_eq!(
match_pattern("I create a client", "so I create a client"),
None
);
}
#[test]
fn captures_split_on_leftmost_literal() {
let args = match_pattern(
"the client {name} is resolved",
"the client Bakker-${run:id} is resolved",
)
.unwrap();
assert_eq!(args["name"], "Bakker-${run:id}");
}
#[test]
fn multi_capture_binds_in_order() {
let args =
match_pattern("I search {index} for {term}", "I search clients for Jansen").unwrap();
assert_eq!(args["index"], "clients");
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);
}
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 client {value} is resolved");
let args = match_pattern("the client {name} is resolved", &text).unwrap();
prop_assert_eq!(args["name"].as_str(), value.as_str());
}
}
}
}