const MAX_ALTERNATIVES: usize = 64;
pub struct Pattern {
alternatives: Vec<String>,
}
impl Pattern {
pub fn new(pattern: &str) -> Self {
let shorthand = trailing_dot(pattern).then(|| format!("{pattern}*"));
let pattern = shorthand.as_deref().unwrap_or(pattern);
let mut alternatives = Vec::new();
expand(pattern, &mut alternatives);
if alternatives.len() > MAX_ALTERNATIVES {
alternatives.truncate(MAX_ALTERNATIVES);
crate::status!(
"pattern expands past {MAX_ALTERNATIVES} alternatives — \
matching the first {MAX_ALTERNATIVES}"
);
}
Self { alternatives }
}
pub fn matches(&self, text: &str) -> bool {
self.alternatives.iter().any(|p| matches_one(p, text))
}
pub fn targets_path(&self) -> bool {
self.alternatives.iter().any(|a| a.contains('.'))
}
}
pub fn is_pattern(query: &str) -> bool {
if query.split_whitespace().count() != 1 {
return false;
}
query.contains('*')
|| query.contains('?')
|| trailing_dot(query)
|| query
.find('{')
.zip(query.rfind('}'))
.is_some_and(|(open, close)| open < close)
}
fn trailing_dot(query: &str) -> bool {
query.len() > 1 && query.ends_with('.')
}
fn matches_one(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().map(|c| c.to_ascii_lowercase()).collect();
let t: Vec<char> = text.chars().map(|c| c.to_ascii_lowercase()).collect();
let (mut pi, mut ti) = (0usize, 0usize);
let mut star: Option<usize> = None;
let mut resume = 0usize;
while ti < t.len() {
if pi < p.len() && p[pi] == '*' {
star = Some(pi);
pi += 1;
resume = ti;
} else if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if let Some(s) = star {
pi = s + 1;
resume += 1;
ti = resume;
} else {
return false;
}
}
p[pi..].iter().all(|&c| c == '*')
}
fn expand(pattern: &str, out: &mut Vec<String>) {
if out.len() > MAX_ALTERNATIVES {
return;
}
let Some((open, close)) = first_group(pattern) else {
out.push(pattern.to_string());
return;
};
let (prefix, suffix) = (&pattern[..open], &pattern[close + 1..]);
for alt in split_alternatives(&pattern[open + 1..close]) {
expand(&format!("{prefix}{alt}{suffix}"), out);
if out.len() > MAX_ALTERNATIVES {
return;
}
}
}
fn first_group(pattern: &str) -> Option<(usize, usize)> {
let open = pattern.find('{')?;
let mut depth = 0usize;
for (i, c) in pattern[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some((open, open + i));
}
}
_ => {}
}
}
None }
fn split_alternatives(body: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0usize;
let mut start = 0usize;
for (i, c) in body.char_indices() {
match c {
'{' => depth += 1,
'}' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
out.push(&body[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&body[start..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn m(pattern: &str, text: &str) -> bool {
Pattern::new(pattern).matches(text)
}
#[test]
fn star_matches_any_run_including_dots() {
assert!(m("User.*", "User.email"));
assert!(m("User.*", "User.posts.first")); assert!(m("*.email", "User.email"));
assert!(m("*", "anything"));
assert!(m("*User*", "AdminUserProfile"));
}
#[test]
fn question_mark_matches_exactly_one_char() {
assert!(m("User.?d", "User.id"));
assert!(m("???", "abc"));
assert!(!m("???", "ab"));
assert!(!m("???", "abcd"));
assert!(!m("User.?", "User.id")); assert!(m("User.??", "User.id"));
}
#[test]
fn braces_expand_to_alternatives() {
assert!(m("User.{first,last}Name", "User.firstName"));
assert!(m("User.{first,last}Name", "User.lastName"));
assert!(!m("User.{first,last}Name", "User.middleName"));
assert!(m("{User,Company}.*", "Company.employees"));
assert!(m("*.{id,uuid}", "Order.uuid"));
}
#[test]
fn braces_nest_and_survive_malformed_input() {
assert!(m("User.{a,{b,c}}", "User.c"));
assert!(m("User.{a", "User.{a"));
assert!(!m("User.{a", "User.a"));
}
#[test]
fn matching_is_anchored_and_case_insensitive() {
assert!(m("user.*", "User.email"));
assert!(m("USER.*", "user.email"));
assert!(!m("User.*", "UserProfile.email"));
assert!(!m("get*", "forget"));
assert!(!m("*email", "User.emails"));
}
#[test]
fn a_metacharacter_free_pattern_is_a_literal_equality() {
assert!(m("User.email", "user.EMAIL"));
assert!(!m("User.email", "User.email2"));
assert!(!m("User", "User.email"));
}
#[test]
fn empty_and_star_only_edges() {
assert!(m("", ""));
assert!(!m("", "x"));
assert!(m("**", "x"));
assert!(m("a*", "a"));
}
#[test]
fn trailing_dot_is_shorthand_for_dot_star() {
assert!(is_pattern("User."));
assert!(m("User.", "User.email"));
assert!(m("User.", "User.posts.first"));
assert!(!m("User.", "UserProfile.email"));
assert!(!m("User.", "User"));
assert!(!is_pattern("."));
}
#[test]
fn targets_path_follows_the_dot() {
assert!(Pattern::new("User.*").targets_path());
assert!(!Pattern::new("get*").targets_path());
assert!(Pattern::new("{User.id,name}").targets_path());
}
#[test]
fn is_pattern_detects_metacharacters_but_not_prose() {
assert!(is_pattern("User.*"));
assert!(is_pattern("User.?d"));
assert!(is_pattern("User.{a,b}"));
assert!(!is_pattern("User.email"));
assert!(!is_pattern("how do I cancel a subscription?"));
assert!(!is_pattern("what does * mean"));
}
#[test]
fn expansion_is_capped() {
let pattern = "{a,b}".repeat(8);
assert_eq!(Pattern::new(&pattern).alternatives.len(), MAX_ALTERNATIVES);
let exact = "{a,b}".repeat(6); assert_eq!(Pattern::new(&exact).alternatives.len(), MAX_ALTERNATIVES);
}
}