Skip to main content

gqls/search/
glob.rs

1//! Wildcard (glob) matching for enumeration queries — `User.*`, `*.email`,
2//! `get*`, `User.?d`, `User.{first,last}Name`.
3//!
4//! Deliberately small: `*` matches any run of characters, `?` exactly one
5//! (both spanning `.`, so `User.*` reaches nested paths), and `{a,b}` expands
6//! to alternatives shell-style. Everything else is literal and matching is
7//! anchored at both ends, so a pattern says exactly what it looks like.
8//! Case-insensitive, like the rest of search. There's no escape syntax —
9//! GraphQL names can't contain these characters anyway.
10
11/// Cap on brace expansion, so a pathological pattern can't blow up. Real
12/// patterns produce a handful of alternatives; hitting this is reported, never
13/// silently truncated.
14const MAX_ALTERNATIVES: usize = 64;
15
16/// A parsed wildcard pattern: brace groups expanded to their alternatives, any
17/// of which may match. Built once per query, then tested against every record.
18pub struct Pattern {
19    alternatives: Vec<String>,
20}
21
22impl Pattern {
23    /// Expand `pattern`'s brace groups. Malformed braces (unbalanced, or a `}`
24    /// before its `{`) are left literal rather than rejected — a query is a
25    /// user's guess, not a program.
26    pub fn new(pattern: &str) -> Self {
27        // `User.` is shorthand for `User.*` — a trailing dot reads as "and
28        // what's inside", and needs no shell quoting the way a bare `*` does.
29        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        // `expand` stops one past the cap, so this distinguishes "exactly at
34        // the cap" (fine) from "more were wanted" (reported, never silent).
35        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    /// Whether any alternative matches `text`.
46    pub fn matches(&self, text: &str) -> bool {
47        self.alternatives.iter().any(|p| matches_one(p, text))
48    }
49
50    /// Whether this pattern addresses qualified paths (`User.*`) rather than
51    /// leaf names (`get*`) — true when any alternative contains a `.`.
52    pub fn targets_path(&self) -> bool {
53        self.alternatives.iter().any(|a| a.contains('.'))
54    }
55}
56
57/// Whether a query is a wildcard pattern rather than something to search for.
58///
59/// Patterns are single tokens: a query containing whitespace is prose, so a
60/// natural-language phrase ending in `?` ("how do I cancel a subscription?")
61/// stays a semantic query instead of becoming a glob that matches nothing.
62pub 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
75/// Whether a query uses the `User.` shorthand for `User.*`. A lone `.` doesn't
76/// count — there's no type named there, so it stays an ordinary (fruitless)
77/// search rather than a pattern matching nothing.
78fn trailing_dot(query: &str) -> bool {
79    query.len() > 1 && query.ends_with('.')
80}
81
82/// Match a single brace-free pattern: linear two-pointer walk that backtracks
83/// to the most recent `*` — no recursion, no regex dependency.
84fn 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    // The last `*` seen, and where in `text` we resumed after it — together
90    // these let a failed match retry with the `*` swallowing one more char.
91    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            // Mismatch after a `*`: let it absorb one more char and retry.
104            pi = s + 1;
105            resume += 1;
106            ti = resume;
107        } else {
108            return false;
109        }
110    }
111    // Trailing `*`s can match the empty remainder; a `?` still needs a char.
112    p[pi..].iter().all(|&c| c == '*')
113}
114
115/// Recursively expand the first brace group, appending finished patterns.
116fn 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        // One past the cap, matching the top guard — `Pattern::new` needs that
128        // extra element to tell "at the cap" from "truncated" and report it.
129        if out.len() > MAX_ALTERNATIVES {
130            return;
131        }
132    }
133}
134
135/// Byte offsets of the first `{` and its matching `}`, honoring nesting.
136fn 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 // unbalanced — treat the `{` as a literal
152}
153
154/// Split a brace group's body on top-level commas, ignoring nested groups.
155fn 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")); // reaches nested paths
186        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")); // one char, not two
198        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        // combines with the other metacharacters
207        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        // unbalanced braces are literal, not an error
215        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        // anchored: `User.*` must not match a type merely starting with User
224        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        // same anchoring as the long form
250        assert!(!m("User.", "UserProfile.email"));
251        assert!(!m("User.", "User"));
252        // a lone dot isn't a pattern
253        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        // a phrase stays a search, even with a trailing question mark
270        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        // 2^8 = 256 combinations requested; the cap holds
277        let pattern = "{a,b}".repeat(8);
278        assert_eq!(Pattern::new(&pattern).alternatives.len(), MAX_ALTERNATIVES);
279        // a pattern landing exactly on the cap keeps every alternative
280        let exact = "{a,b}".repeat(6); // 2^6 = 64
281        assert_eq!(Pattern::new(&exact).alternatives.len(), MAX_ALTERNATIVES);
282    }
283}