1const MAX_ALTERNATIVES: usize = 64;
15
16pub struct Pattern {
19 alternatives: Vec<String>,
20}
21
22impl Pattern {
23 pub fn new(pattern: &str) -> Self {
27 let mut alternatives = Vec::new();
28 expand(pattern, &mut alternatives);
29 if alternatives.len() > MAX_ALTERNATIVES {
32 alternatives.truncate(MAX_ALTERNATIVES);
33 crate::status!(
34 "pattern expands past {MAX_ALTERNATIVES} alternatives — \
35 matching the first {MAX_ALTERNATIVES}"
36 );
37 }
38 Self { alternatives }
39 }
40
41 pub fn matches(&self, text: &str) -> bool {
43 self.alternatives.iter().any(|p| matches_one(p, text))
44 }
45
46 pub fn targets_path(&self) -> bool {
49 self.alternatives.iter().any(|a| a.contains('.'))
50 }
51}
52
53pub fn is_pattern(query: &str) -> bool {
59 if query.split_whitespace().count() != 1 {
60 return false;
61 }
62 query.contains('*')
63 || query.contains('?')
64 || query
65 .find('{')
66 .zip(query.rfind('}'))
67 .is_some_and(|(open, close)| open < close)
68}
69
70fn matches_one(pattern: &str, text: &str) -> bool {
73 let p: Vec<char> = pattern.chars().map(|c| c.to_ascii_lowercase()).collect();
74 let t: Vec<char> = text.chars().map(|c| c.to_ascii_lowercase()).collect();
75
76 let (mut pi, mut ti) = (0usize, 0usize);
77 let mut star: Option<usize> = None;
80 let mut resume = 0usize;
81
82 while ti < t.len() {
83 if pi < p.len() && p[pi] == '*' {
84 star = Some(pi);
85 pi += 1;
86 resume = ti;
87 } else if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
88 pi += 1;
89 ti += 1;
90 } else if let Some(s) = star {
91 pi = s + 1;
93 resume += 1;
94 ti = resume;
95 } else {
96 return false;
97 }
98 }
99 p[pi..].iter().all(|&c| c == '*')
101}
102
103fn expand(pattern: &str, out: &mut Vec<String>) {
105 if out.len() > MAX_ALTERNATIVES {
106 return;
107 }
108 let Some((open, close)) = first_group(pattern) else {
109 out.push(pattern.to_string());
110 return;
111 };
112 let (prefix, suffix) = (&pattern[..open], &pattern[close + 1..]);
113 for alt in split_alternatives(&pattern[open + 1..close]) {
114 expand(&format!("{prefix}{alt}{suffix}"), out);
115 if out.len() > MAX_ALTERNATIVES {
118 return;
119 }
120 }
121}
122
123fn first_group(pattern: &str) -> Option<(usize, usize)> {
125 let open = pattern.find('{')?;
126 let mut depth = 0usize;
127 for (i, c) in pattern[open..].char_indices() {
128 match c {
129 '{' => depth += 1,
130 '}' => {
131 depth -= 1;
132 if depth == 0 {
133 return Some((open, open + i));
134 }
135 }
136 _ => {}
137 }
138 }
139 None }
141
142fn split_alternatives(body: &str) -> Vec<&str> {
144 let mut out = Vec::new();
145 let mut depth = 0usize;
146 let mut start = 0usize;
147 for (i, c) in body.char_indices() {
148 match c {
149 '{' => depth += 1,
150 '}' => depth = depth.saturating_sub(1),
151 ',' if depth == 0 => {
152 out.push(&body[start..i]);
153 start = i + 1;
154 }
155 _ => {}
156 }
157 }
158 out.push(&body[start..]);
159 out
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn m(pattern: &str, text: &str) -> bool {
167 Pattern::new(pattern).matches(text)
168 }
169
170 #[test]
171 fn star_matches_any_run_including_dots() {
172 assert!(m("User.*", "User.email"));
173 assert!(m("User.*", "User.posts.first")); assert!(m("*.email", "User.email"));
175 assert!(m("*", "anything"));
176 assert!(m("*User*", "AdminUserProfile"));
177 }
178
179 #[test]
180 fn question_mark_matches_exactly_one_char() {
181 assert!(m("User.?d", "User.id"));
182 assert!(m("???", "abc"));
183 assert!(!m("???", "ab"));
184 assert!(!m("???", "abcd"));
185 assert!(!m("User.?", "User.id")); assert!(m("User.??", "User.id"));
187 }
188
189 #[test]
190 fn braces_expand_to_alternatives() {
191 assert!(m("User.{first,last}Name", "User.firstName"));
192 assert!(m("User.{first,last}Name", "User.lastName"));
193 assert!(!m("User.{first,last}Name", "User.middleName"));
194 assert!(m("{User,Company}.*", "Company.employees"));
196 assert!(m("*.{id,uuid}", "Order.uuid"));
197 }
198
199 #[test]
200 fn braces_nest_and_survive_malformed_input() {
201 assert!(m("User.{a,{b,c}}", "User.c"));
202 assert!(m("User.{a", "User.{a"));
204 assert!(!m("User.{a", "User.a"));
205 }
206
207 #[test]
208 fn matching_is_anchored_and_case_insensitive() {
209 assert!(m("user.*", "User.email"));
210 assert!(m("USER.*", "user.email"));
211 assert!(!m("User.*", "UserProfile.email"));
213 assert!(!m("get*", "forget"));
214 assert!(!m("*email", "User.emails"));
215 }
216
217 #[test]
218 fn a_metacharacter_free_pattern_is_a_literal_equality() {
219 assert!(m("User.email", "user.EMAIL"));
220 assert!(!m("User.email", "User.email2"));
221 assert!(!m("User", "User.email"));
222 }
223
224 #[test]
225 fn empty_and_star_only_edges() {
226 assert!(m("", ""));
227 assert!(!m("", "x"));
228 assert!(m("**", "x"));
229 assert!(m("a*", "a"));
230 }
231
232 #[test]
233 fn targets_path_follows_the_dot() {
234 assert!(Pattern::new("User.*").targets_path());
235 assert!(!Pattern::new("get*").targets_path());
236 assert!(Pattern::new("{User.id,name}").targets_path());
237 }
238
239 #[test]
240 fn is_pattern_detects_metacharacters_but_not_prose() {
241 assert!(is_pattern("User.*"));
242 assert!(is_pattern("User.?d"));
243 assert!(is_pattern("User.{a,b}"));
244 assert!(!is_pattern("User.email"));
245 assert!(!is_pattern("how do I cancel a subscription?"));
247 assert!(!is_pattern("what does * mean"));
248 }
249
250 #[test]
251 fn expansion_is_capped() {
252 let pattern = "{a,b}".repeat(8);
254 assert_eq!(Pattern::new(&pattern).alternatives.len(), MAX_ALTERNATIVES);
255 let exact = "{a,b}".repeat(6); assert_eq!(Pattern::new(&exact).alternatives.len(), MAX_ALTERNATIVES);
258 }
259}