1use crate::model::{Kind, SchemaRecord};
4
5pub mod glob;
6pub mod score;
7
8pub struct Hit<'a> {
9 pub record: &'a SchemaRecord,
10 pub score: i64,
11}
12
13const TAIL_CUTOFF: f64 = 0.4;
18
19pub fn parent_filter<'a>(query: &str, records: &'a [SchemaRecord]) -> Option<&'a str> {
28 let (_, Some(qualifier)) = score::parse_qualified(query) else {
29 return None;
30 };
31 let parents: std::collections::HashSet<&str> =
32 records.iter().filter_map(|r| r.parent.as_deref()).collect();
33 if let Some(exact) = parents.iter().find(|p| p.eq_ignore_ascii_case(qualifier)) {
34 return Some(exact);
35 }
36 let q = qualifier.to_ascii_lowercase();
38 let mut best: Option<(usize, &str)> = None;
39 let mut tied = false;
40 for p in parents {
41 let Some(d) = score::typo_distance(&q, &p.to_ascii_lowercase()) else {
42 continue;
43 };
44 match best {
45 Some((bd, _)) if d > bd => {}
46 Some((bd, _)) if d == bd => tied = true,
47 _ => {
48 best = Some((d, p));
49 tied = false;
50 }
51 }
52 }
53 match (best, tied) {
54 (Some((_, p)), false) => Some(p),
55 _ => None,
56 }
57}
58
59pub fn spaced_qualifier(query: &str, records: &[SchemaRecord]) -> Option<String> {
65 if query.contains('.') {
66 return None;
67 }
68 let mut words = query.split_whitespace();
69 let (Some(first), Some(second), None) = (words.next(), words.next(), words.next()) else {
70 return None;
71 };
72 records
73 .iter()
74 .filter_map(|r| r.parent.as_deref())
75 .any(|p| p.eq_ignore_ascii_case(first))
76 .then(|| format!("{first}.{second}"))
77}
78
79pub fn named_hit(query: &str, hits: &[Hit]) -> bool {
86 let (leaf, _) = score::parse_qualified(query);
87 let leaf = leaf.to_ascii_lowercase();
88 if leaf.is_empty() {
89 return false;
90 }
91 hits.iter().any(|h| {
92 let lower = h.record.name.to_ascii_lowercase();
93 let chars: Vec<char> = h.record.name.chars().collect();
94 let boundary = score::boundaries(&chars);
95 lower
96 .match_indices(&leaf)
97 .any(|(i, _)| boundary.get(i).copied().unwrap_or(false))
98 })
99}
100
101pub fn search<'a>(
106 query: &str,
107 records: &'a [SchemaRecord],
108 kind: Option<Kind>,
109 parent: Option<&str>,
110) -> Vec<Hit<'a>> {
111 if glob::is_pattern(query) {
112 return glob_search(query, records, kind);
113 }
114 fuzzy_search(query, records, kind, parent)
115}
116
117fn glob_search<'a>(pattern: &str, records: &'a [SchemaRecord], kind: Option<Kind>) -> Vec<Hit<'a>> {
123 use rayon::prelude::*;
124 let pattern = glob::Pattern::new(pattern);
126 let against_path = pattern.targets_path();
127 let mut hits: Vec<Hit> = records
128 .par_iter()
129 .filter(|r| kind.is_none_or(|k| r.kind == k))
130 .filter(|r| {
131 let text = if against_path { &r.path } else { &r.name };
132 pattern.matches(text)
133 })
134 .map(|r| Hit {
135 record: r,
136 score: r.kind.weight(),
137 })
138 .collect();
139 hits.sort_by(|a, b| {
140 b.score
141 .cmp(&a.score)
142 .then_with(|| a.record.path.cmp(&b.record.path))
143 });
144 hits
145}
146
147fn fuzzy_search<'a>(
148 query: &str,
149 records: &'a [SchemaRecord],
150 kind: Option<Kind>,
151 parent: Option<&str>,
152) -> Vec<Hit<'a>> {
153 use rayon::prelude::*;
154 let mut hits: Vec<Hit> = records
158 .par_iter()
159 .filter(|r| kind.is_none_or(|k| r.kind == k))
160 .filter(|r| {
161 parent.is_none_or(|p| {
162 r.parent
163 .as_deref()
164 .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
165 })
166 })
167 .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
168 .collect();
169
170 hits.sort_by(|a, b| {
173 b.score
174 .cmp(&a.score)
175 .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
176 });
177 if let Some(top) = hits.first().map(|h| h.score) {
178 let floor = (top as f64 * TAIL_CUTOFF) as i64;
179 hits.retain(|h| h.score >= floor);
180 }
181 hits
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
189 let path = match parent {
190 Some(p) => format!("{p}.{name}"),
191 None => name.to_string(),
192 };
193 SchemaRecord {
194 path,
195 name: name.into(),
196 kind,
197 parent: parent.map(Into::into),
198 type_ref: None,
199 args: vec![],
200 description: None,
201 deprecated: None,
202 directives: vec![],
203 }
204 }
205
206 #[test]
207 fn parent_filter_resolves_a_real_type() {
208 let records = vec![
209 rec("employees", Some("Company"), Kind::Field),
210 rec("name", Some("CompanyProfile"), Kind::Field),
211 ];
212 assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
214 assert_eq!(parent_filter("company.employe", &records), Some("Company"));
215 assert_eq!(parent_filter("Comp.employe", &records), None);
217 assert_eq!(parent_filter("employe", &records), None);
218 }
219
220 #[test]
221 fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
222 let records = vec![
223 rec("employees", Some("Company"), Kind::Field),
224 rec("name", Some("CompanyProfile"), Kind::Field),
225 ];
226 assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
228 assert_eq!(parent_filter("Zebra.employe", &records), None);
230 }
231
232 #[test]
233 fn parent_filter_declines_an_ambiguous_misspelling() {
234 let records = vec![
235 rec("id", Some("Vser"), Kind::Field),
236 rec("id", Some("Usor"), Kind::Field),
237 ];
238 assert_eq!(parent_filter("User.id", &records), None);
240 }
241
242 #[test]
243 fn parent_filter_excludes_other_types() {
244 let records = vec![
245 rec("employees", Some("Company"), Kind::Field),
246 rec("employees", Some("CompanyProfile"), Kind::Field),
247 rec("employer", Some("CompanyMemberStats"), Kind::Field),
248 ];
249 let hits = search("Company.employe", &records, None, Some("Company"));
250 assert_eq!(hits.len(), 1);
251 assert_eq!(hits[0].record.path, "Company.employees");
252 }
253
254 #[test]
255 fn spaced_qualifier_rewrites_type_plus_word() {
256 let records = vec![
257 rec("name", Some("User"), Kind::Field),
258 rec("lastName", Some("User"), Kind::Field),
259 ];
260 assert_eq!(
261 spaced_qualifier("User name", &records).as_deref(),
262 Some("User.name")
263 );
264 assert_eq!(
265 spaced_qualifier("user name", &records).as_deref(),
266 Some("user.name")
267 );
268 assert_eq!(spaced_qualifier("cancel a subscription", &records), None);
270 assert_eq!(spaced_qualifier("User.name", &records), None);
271 assert_eq!(spaced_qualifier("employee summary", &records), None);
272 }
273
274 #[test]
275 fn named_hit_accepts_exact_and_boundary_words() {
276 let records = vec![rec("name", Some("User"), Kind::Field)];
277 let hits = search("User.name", &records, None, None);
278 assert!(named_hit("User.name", &hits));
279 assert!(named_hit("user.NAME", &hits));
280
281 let variants = vec![
284 rec("lastName", Some("User"), Kind::Field),
285 rec("fullName", Some("User"), Kind::Field),
286 ];
287 let hits = search("User.name", &variants, None, Some("User"));
288 assert!(named_hit("User.name", &hits));
289 }
290
291 #[test]
292 fn named_hit_rejects_mid_word_and_scattered_matches() {
293 let records = vec![rec("accountant", Some("User"), Kind::Field)];
294 let hits = search("count", &records, None, None);
296 assert!(!hits.is_empty());
297 assert!(!named_hit("count", &hits));
298 }
299
300 #[test]
301 fn wildcard_enumerates_a_types_members() {
302 let records = vec![
303 rec("email", Some("User"), Kind::Field),
304 rec("id", Some("User"), Kind::Field),
305 rec("email", Some("UserProfile"), Kind::Field),
306 rec("User", None, Kind::Object),
307 ];
308 let paths: Vec<&str> = search("User.*", &records, None, None)
309 .iter()
310 .map(|h| h.record.path.as_str())
311 .collect();
312 assert_eq!(paths, ["User.email", "User.id"]);
314 }
315
316 #[test]
317 fn brace_alternation_enumerates_each_branch() {
318 let records = vec![
319 rec("firstName", Some("User"), Kind::Field),
320 rec("lastName", Some("User"), Kind::Field),
321 rec("middleName", Some("User"), Kind::Field),
322 ];
323 let paths: Vec<&str> = search("User.{first,last}Name", &records, None, None)
324 .iter()
325 .map(|h| h.record.path.as_str())
326 .collect();
327 assert_eq!(paths, ["User.firstName", "User.lastName"]);
328 }
329
330 #[test]
331 fn wildcard_without_a_dot_matches_leaf_names() {
332 let records = vec![
333 rec("getUser", Some("Query"), Kind::Query),
334 rec("getCompany", Some("Query"), Kind::Query),
335 rec("forget", Some("User"), Kind::Field),
336 ];
337 let paths: Vec<&str> = search("get*", &records, None, None)
338 .iter()
339 .map(|h| h.record.path.as_str())
340 .collect();
341 assert_eq!(paths, ["Query.getCompany", "Query.getUser"]);
343 }
344
345 #[test]
346 fn wildcard_keeps_every_match_regardless_of_kind_weight() {
347 let records = vec![
350 rec("user", Some("Query"), Kind::Query),
351 rec("name", Some("User"), Kind::Field),
352 ];
353 assert_eq!(search("*", &records, None, None).len(), 2);
354 }
355
356 #[test]
357 fn weak_tail_is_cut_when_a_strong_match_exists() {
358 let records = vec![
359 rec("user", Some("Query"), Kind::Query),
360 rec("userProfile", Some("Query"), Kind::Query),
361 rec("uzszezr", Some("Query"), Kind::Query),
363 ];
364 let paths: Vec<&str> = search("user", &records, None, None)
365 .iter()
366 .map(|h| h.record.path.as_str())
367 .collect();
368 assert_eq!(paths, ["Query.user", "Query.userProfile"]);
369 }
370
371 #[test]
372 fn weak_matches_survive_when_nothing_stronger_exists() {
373 let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
374 assert_eq!(search("user", &records, None, None).len(), 1);
375 }
376
377 #[test]
378 fn search_returns_all_hits_above_the_cutoff() {
379 let records: Vec<SchemaRecord> = (0..50)
381 .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
382 .collect();
383 assert_eq!(search("user", &records, None, None).len(), 50);
384 }
385}