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        let mut alternatives = Vec::new();
28        expand(pattern, &mut alternatives);
29        // `expand` stops one past the cap, so this distinguishes "exactly at
30        // the cap" (fine) from "more were wanted" (reported, never silent).
31        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    /// Whether any alternative matches `text`.
42    pub fn matches(&self, text: &str) -> bool {
43        self.alternatives.iter().any(|p| matches_one(p, text))
44    }
45
46    /// Whether this pattern addresses qualified paths (`User.*`) rather than
47    /// leaf names (`get*`) — true when any alternative contains a `.`.
48    pub fn targets_path(&self) -> bool {
49        self.alternatives.iter().any(|a| a.contains('.'))
50    }
51}
52
53/// Whether a query is a wildcard pattern rather than something to search for.
54///
55/// Patterns are single tokens: a query containing whitespace is prose, so a
56/// natural-language phrase ending in `?` ("how do I cancel a subscription?")
57/// stays a semantic query instead of becoming a glob that matches nothing.
58pub 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
70/// Match a single brace-free pattern: linear two-pointer walk that backtracks
71/// to the most recent `*` — no recursion, no regex dependency.
72fn 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    // The last `*` seen, and where in `text` we resumed after it — together
78    // these let a failed match retry with the `*` swallowing one more char.
79    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            // Mismatch after a `*`: let it absorb one more char and retry.
92            pi = s + 1;
93            resume += 1;
94            ti = resume;
95        } else {
96            return false;
97        }
98    }
99    // Trailing `*`s can match the empty remainder; a `?` still needs a char.
100    p[pi..].iter().all(|&c| c == '*')
101}
102
103/// Recursively expand the first brace group, appending finished patterns.
104fn 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        // One past the cap, matching the top guard — `Pattern::new` needs that
116        // extra element to tell "at the cap" from "truncated" and report it.
117        if out.len() > MAX_ALTERNATIVES {
118            return;
119        }
120    }
121}
122
123/// Byte offsets of the first `{` and its matching `}`, honoring nesting.
124fn 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 // unbalanced — treat the `{` as a literal
140}
141
142/// Split a brace group's body on top-level commas, ignoring nested groups.
143fn 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")); // reaches nested paths
174        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")); // one char, not two
186        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        // combines with the other metacharacters
195        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        // unbalanced braces are literal, not an error
203        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        // anchored: `User.*` must not match a type merely starting with User
212        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        // a phrase stays a search, even with a trailing question mark
246        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        // 2^8 = 256 combinations requested; the cap holds
253        let pattern = "{a,b}".repeat(8);
254        assert_eq!(Pattern::new(&pattern).alternatives.len(), MAX_ALTERNATIVES);
255        // a pattern landing exactly on the cap keeps every alternative
256        let exact = "{a,b}".repeat(6); // 2^6 = 64
257        assert_eq!(Pattern::new(&exact).alternatives.len(), MAX_ALTERNATIVES);
258    }
259}