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 shorthand = trailing_dot(pattern).then(|| format!("{pattern}*"));
30 let pattern = shorthand.as_deref().unwrap_or(pattern);
31 let mut alternatives = Vec::new();
32 expand(pattern, &mut alternatives);
33 if alternatives.len() > MAX_ALTERNATIVES {
36 alternatives.truncate(MAX_ALTERNATIVES);
37 crate::status!(
38 "pattern expands past {MAX_ALTERNATIVES} alternatives — \
39 matching the first {MAX_ALTERNATIVES}"
40 );
41 }
42 Self { alternatives }
43 }
44
45 pub fn matches(&self, text: &str) -> bool {
47 self.alternatives.iter().any(|p| matches_one(p, text))
48 }
49
50 pub fn targets_path(&self) -> bool {
53 self.alternatives.iter().any(|a| a.contains('.'))
54 }
55}
56
57pub fn is_pattern(query: &str) -> bool {
63 if query.split_whitespace().count() != 1 {
64 return false;
65 }
66 query.contains('*')
67 || query.contains('?')
68 || trailing_dot(query)
69 || query
70 .find('{')
71 .zip(query.rfind('}'))
72 .is_some_and(|(open, close)| open < close)
73}
74
75fn trailing_dot(query: &str) -> bool {
79 query.len() > 1 && query.ends_with('.')
80}
81
82fn matches_one(pattern: &str, text: &str) -> bool {
85 let p: Vec<char> = pattern.chars().map(|c| c.to_ascii_lowercase()).collect();
86 let t: Vec<char> = text.chars().map(|c| c.to_ascii_lowercase()).collect();
87
88 let (mut pi, mut ti) = (0usize, 0usize);
89 let mut star: Option<usize> = None;
92 let mut resume = 0usize;
93
94 while ti < t.len() {
95 if pi < p.len() && p[pi] == '*' {
96 star = Some(pi);
97 pi += 1;
98 resume = ti;
99 } else if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
100 pi += 1;
101 ti += 1;
102 } else if let Some(s) = star {
103 pi = s + 1;
105 resume += 1;
106 ti = resume;
107 } else {
108 return false;
109 }
110 }
111 p[pi..].iter().all(|&c| c == '*')
113}
114
115fn expand(pattern: &str, out: &mut Vec<String>) {
117 if out.len() > MAX_ALTERNATIVES {
118 return;
119 }
120 let Some((open, close)) = first_group(pattern) else {
121 out.push(pattern.to_string());
122 return;
123 };
124 let (prefix, suffix) = (&pattern[..open], &pattern[close + 1..]);
125 for alt in split_alternatives(&pattern[open + 1..close]) {
126 expand(&format!("{prefix}{alt}{suffix}"), out);
127 if out.len() > MAX_ALTERNATIVES {
130 return;
131 }
132 }
133}
134
135fn first_group(pattern: &str) -> Option<(usize, usize)> {
137 let open = pattern.find('{')?;
138 let mut depth = 0usize;
139 for (i, c) in pattern[open..].char_indices() {
140 match c {
141 '{' => depth += 1,
142 '}' => {
143 depth -= 1;
144 if depth == 0 {
145 return Some((open, open + i));
146 }
147 }
148 _ => {}
149 }
150 }
151 None }
153
154fn split_alternatives(body: &str) -> Vec<&str> {
156 let mut out = Vec::new();
157 let mut depth = 0usize;
158 let mut start = 0usize;
159 for (i, c) in body.char_indices() {
160 match c {
161 '{' => depth += 1,
162 '}' => depth = depth.saturating_sub(1),
163 ',' if depth == 0 => {
164 out.push(&body[start..i]);
165 start = i + 1;
166 }
167 _ => {}
168 }
169 }
170 out.push(&body[start..]);
171 out
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn m(pattern: &str, text: &str) -> bool {
179 Pattern::new(pattern).matches(text)
180 }
181
182 #[test]
183 fn star_matches_any_run_including_dots() {
184 assert!(m("User.*", "User.email"));
185 assert!(m("User.*", "User.posts.first")); assert!(m("*.email", "User.email"));
187 assert!(m("*", "anything"));
188 assert!(m("*User*", "AdminUserProfile"));
189 }
190
191 #[test]
192 fn question_mark_matches_exactly_one_char() {
193 assert!(m("User.?d", "User.id"));
194 assert!(m("???", "abc"));
195 assert!(!m("???", "ab"));
196 assert!(!m("???", "abcd"));
197 assert!(!m("User.?", "User.id")); assert!(m("User.??", "User.id"));
199 }
200
201 #[test]
202 fn braces_expand_to_alternatives() {
203 assert!(m("User.{first,last}Name", "User.firstName"));
204 assert!(m("User.{first,last}Name", "User.lastName"));
205 assert!(!m("User.{first,last}Name", "User.middleName"));
206 assert!(m("{User,Company}.*", "Company.employees"));
208 assert!(m("*.{id,uuid}", "Order.uuid"));
209 }
210
211 #[test]
212 fn braces_nest_and_survive_malformed_input() {
213 assert!(m("User.{a,{b,c}}", "User.c"));
214 assert!(m("User.{a", "User.{a"));
216 assert!(!m("User.{a", "User.a"));
217 }
218
219 #[test]
220 fn matching_is_anchored_and_case_insensitive() {
221 assert!(m("user.*", "User.email"));
222 assert!(m("USER.*", "user.email"));
223 assert!(!m("User.*", "UserProfile.email"));
225 assert!(!m("get*", "forget"));
226 assert!(!m("*email", "User.emails"));
227 }
228
229 #[test]
230 fn a_metacharacter_free_pattern_is_a_literal_equality() {
231 assert!(m("User.email", "user.EMAIL"));
232 assert!(!m("User.email", "User.email2"));
233 assert!(!m("User", "User.email"));
234 }
235
236 #[test]
237 fn empty_and_star_only_edges() {
238 assert!(m("", ""));
239 assert!(!m("", "x"));
240 assert!(m("**", "x"));
241 assert!(m("a*", "a"));
242 }
243
244 #[test]
245 fn trailing_dot_is_shorthand_for_dot_star() {
246 assert!(is_pattern("User."));
247 assert!(m("User.", "User.email"));
248 assert!(m("User.", "User.posts.first"));
249 assert!(!m("User.", "UserProfile.email"));
251 assert!(!m("User.", "User"));
252 assert!(!is_pattern("."));
254 }
255
256 #[test]
257 fn targets_path_follows_the_dot() {
258 assert!(Pattern::new("User.*").targets_path());
259 assert!(!Pattern::new("get*").targets_path());
260 assert!(Pattern::new("{User.id,name}").targets_path());
261 }
262
263 #[test]
264 fn is_pattern_detects_metacharacters_but_not_prose() {
265 assert!(is_pattern("User.*"));
266 assert!(is_pattern("User.?d"));
267 assert!(is_pattern("User.{a,b}"));
268 assert!(!is_pattern("User.email"));
269 assert!(!is_pattern("how do I cancel a subscription?"));
271 assert!(!is_pattern("what does * mean"));
272 }
273
274 #[test]
275 fn expansion_is_capped() {
276 let pattern = "{a,b}".repeat(8);
278 assert_eq!(Pattern::new(&pattern).alternatives.len(), MAX_ALTERNATIVES);
279 let exact = "{a,b}".repeat(6); assert_eq!(Pattern::new(&exact).alternatives.len(), MAX_ALTERNATIVES);
282 }
283}