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 returns: Option<&str>,
111) -> Vec<Hit<'a>> {
112 let returns = returns.map(glob::Pattern::new);
115 let returns = returns.as_ref();
116 if glob::is_pattern(query) {
117 return glob_search(query, records, kind, returns);
118 }
119 fuzzy_search(query, records, kind, parent, returns)
120}
121
122pub fn matches_returns(r: &SchemaRecord, returns: Option<&glob::Pattern>) -> bool {
126 match returns {
127 None => true,
128 Some(p) => r.base_type().is_some_and(|t| p.matches(t)),
129 }
130}
131
132fn glob_search<'a>(
138 pattern: &str,
139 records: &'a [SchemaRecord],
140 kind: Option<Kind>,
141 returns: Option<&glob::Pattern>,
142) -> Vec<Hit<'a>> {
143 use rayon::prelude::*;
144 let pattern = glob::Pattern::new(pattern);
146 let against_path = pattern.targets_path();
147 let mut hits: Vec<Hit> = records
148 .par_iter()
149 .filter(|r| kind.is_none_or(|k| r.kind == k))
150 .filter(|r| matches_returns(r, returns))
151 .filter(|r| {
152 let text = if against_path { &r.path } else { &r.name };
153 pattern.matches(text)
154 })
155 .map(|r| Hit {
156 record: r,
157 score: r.kind.weight(),
158 })
159 .collect();
160 hits.sort_by(|a, b| {
161 b.score
162 .cmp(&a.score)
163 .then_with(|| a.record.path.cmp(&b.record.path))
164 });
165 hits
166}
167
168fn fuzzy_search<'a>(
169 query: &str,
170 records: &'a [SchemaRecord],
171 kind: Option<Kind>,
172 parent: Option<&str>,
173 returns: Option<&glob::Pattern>,
174) -> Vec<Hit<'a>> {
175 use rayon::prelude::*;
176 let mut hits: Vec<Hit> = records
180 .par_iter()
181 .filter(|r| kind.is_none_or(|k| r.kind == k))
182 .filter(|r| matches_returns(r, returns))
183 .filter(|r| {
184 parent.is_none_or(|p| {
185 r.parent
186 .as_deref()
187 .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
188 })
189 })
190 .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
191 .collect();
192
193 hits.sort_by(|a, b| {
196 b.score
197 .cmp(&a.score)
198 .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
199 });
200 if let Some(top) = hits.first().map(|h| h.score) {
201 let floor = (top as f64 * TAIL_CUTOFF) as i64;
202 hits.retain(|h| h.score >= floor);
203 }
204 hits
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
212 typed_rec(name, parent, kind, None)
213 }
214
215 fn typed_rec(
216 name: &str,
217 parent: Option<&str>,
218 kind: Kind,
219 type_ref: Option<&str>,
220 ) -> SchemaRecord {
221 let path = match parent {
222 Some(p) => format!("{p}.{name}"),
223 None => name.to_string(),
224 };
225 SchemaRecord {
226 path,
227 name: name.into(),
228 kind,
229 parent: parent.map(Into::into),
230 type_ref: type_ref.map(Into::into),
231 args: vec![],
232 description: None,
233 deprecated: None,
234 directives: vec![],
235 }
236 }
237
238 #[test]
239 fn parent_filter_resolves_a_real_type() {
240 let records = vec![
241 rec("employees", Some("Company"), Kind::Field),
242 rec("name", Some("CompanyProfile"), Kind::Field),
243 ];
244 assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
246 assert_eq!(parent_filter("company.employe", &records), Some("Company"));
247 assert_eq!(parent_filter("Comp.employe", &records), None);
249 assert_eq!(parent_filter("employe", &records), None);
250 }
251
252 #[test]
253 fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
254 let records = vec![
255 rec("employees", Some("Company"), Kind::Field),
256 rec("name", Some("CompanyProfile"), Kind::Field),
257 ];
258 assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
260 assert_eq!(parent_filter("Zebra.employe", &records), None);
262 }
263
264 #[test]
265 fn parent_filter_declines_an_ambiguous_misspelling() {
266 let records = vec![
267 rec("id", Some("Vser"), Kind::Field),
268 rec("id", Some("Usor"), Kind::Field),
269 ];
270 assert_eq!(parent_filter("User.id", &records), None);
272 }
273
274 #[test]
275 fn parent_filter_excludes_other_types() {
276 let records = vec![
277 rec("employees", Some("Company"), Kind::Field),
278 rec("employees", Some("CompanyProfile"), Kind::Field),
279 rec("employer", Some("CompanyMemberStats"), Kind::Field),
280 ];
281 let hits = search("Company.employe", &records, None, Some("Company"), None);
282 assert_eq!(hits.len(), 1);
283 assert_eq!(hits[0].record.path, "Company.employees");
284 }
285
286 #[test]
287 fn spaced_qualifier_rewrites_type_plus_word() {
288 let records = vec![
289 rec("name", Some("User"), Kind::Field),
290 rec("lastName", Some("User"), Kind::Field),
291 ];
292 assert_eq!(
293 spaced_qualifier("User name", &records).as_deref(),
294 Some("User.name")
295 );
296 assert_eq!(
297 spaced_qualifier("user name", &records).as_deref(),
298 Some("user.name")
299 );
300 assert_eq!(spaced_qualifier("cancel a subscription", &records), None);
302 assert_eq!(spaced_qualifier("User.name", &records), None);
303 assert_eq!(spaced_qualifier("employee summary", &records), None);
304 }
305
306 #[test]
307 fn named_hit_accepts_exact_and_boundary_words() {
308 let records = vec![rec("name", Some("User"), Kind::Field)];
309 let hits = search("User.name", &records, None, None, None);
310 assert!(named_hit("User.name", &hits));
311 assert!(named_hit("user.NAME", &hits));
312
313 let variants = vec![
316 rec("lastName", Some("User"), Kind::Field),
317 rec("fullName", Some("User"), Kind::Field),
318 ];
319 let hits = search("User.name", &variants, None, Some("User"), None);
320 assert!(named_hit("User.name", &hits));
321 }
322
323 #[test]
324 fn named_hit_rejects_mid_word_and_scattered_matches() {
325 let records = vec![rec("accountant", Some("User"), Kind::Field)];
326 let hits = search("count", &records, None, None, None);
328 assert!(!hits.is_empty());
329 assert!(!named_hit("count", &hits));
330 }
331
332 #[test]
333 fn wildcard_enumerates_a_types_members() {
334 let records = vec![
335 rec("email", Some("User"), Kind::Field),
336 rec("id", Some("User"), Kind::Field),
337 rec("email", Some("UserProfile"), Kind::Field),
338 rec("User", None, Kind::Object),
339 ];
340 let paths: Vec<&str> = search("User.*", &records, None, None, None)
341 .iter()
342 .map(|h| h.record.path.as_str())
343 .collect();
344 assert_eq!(paths, ["User.email", "User.id"]);
346 }
347
348 #[test]
349 fn brace_alternation_enumerates_each_branch() {
350 let records = vec![
351 rec("firstName", Some("User"), Kind::Field),
352 rec("lastName", Some("User"), Kind::Field),
353 rec("middleName", Some("User"), Kind::Field),
354 ];
355 let paths: Vec<&str> = search("User.{first,last}Name", &records, None, None, None)
356 .iter()
357 .map(|h| h.record.path.as_str())
358 .collect();
359 assert_eq!(paths, ["User.firstName", "User.lastName"]);
360 }
361
362 #[test]
363 fn wildcard_without_a_dot_matches_leaf_names() {
364 let records = vec![
365 rec("getUser", Some("Query"), Kind::Query),
366 rec("getCompany", Some("Query"), Kind::Query),
367 rec("forget", Some("User"), Kind::Field),
368 ];
369 let paths: Vec<&str> = search("get*", &records, None, None, None)
370 .iter()
371 .map(|h| h.record.path.as_str())
372 .collect();
373 assert_eq!(paths, ["Query.getCompany", "Query.getUser"]);
375 }
376
377 #[test]
378 fn wildcard_keeps_every_match_regardless_of_kind_weight() {
379 let records = vec![
382 rec("user", Some("Query"), Kind::Query),
383 rec("name", Some("User"), Kind::Field),
384 ];
385 assert_eq!(search("*", &records, None, None, None).len(), 2);
386 }
387
388 #[test]
389 fn returns_filter_finds_fields_by_return_type() {
390 let records = vec![
391 typed_rec("myEmployer", Some("Query"), Kind::Query, Some("Company")),
393 typed_rec(
394 "employees",
395 Some("Company"),
396 Kind::Field,
397 Some("[Employee!]!"),
398 ),
399 typed_rec("name", Some("Company"), Kind::Field, Some("String!")),
400 typed_rec("Company", None, Kind::Object, None),
401 ];
402 let paths: Vec<&str> = search("*", &records, None, None, Some("Company"))
403 .iter()
404 .map(|h| h.record.path.as_str())
405 .collect();
406 assert_eq!(paths, ["Query.myEmployer"]);
408
409 let paths: Vec<&str> = search("*", &records, None, None, Some("Employee"))
411 .iter()
412 .map(|h| h.record.path.as_str())
413 .collect();
414 assert_eq!(paths, ["Company.employees"]);
415 }
416
417 #[test]
418 fn returns_filter_accepts_wildcards_and_composes() {
419 let records = vec![
420 typed_rec("a", Some("Mutation"), Kind::Mutation, Some("APayload!")),
421 typed_rec("b", Some("Mutation"), Kind::Mutation, Some("BPayload!")),
422 typed_rec("c", Some("Mutation"), Kind::Mutation, Some("String")),
423 typed_rec("d", Some("Type"), Kind::Field, Some("APayload")),
424 ];
425 let paths: Vec<&str> = search("*", &records, Some(Kind::Mutation), None, Some("*Payload"))
427 .iter()
428 .map(|h| h.record.path.as_str())
429 .collect();
430 assert_eq!(paths, ["Mutation.a", "Mutation.b"]);
431 }
432
433 #[test]
434 fn weak_tail_is_cut_when_a_strong_match_exists() {
435 let records = vec![
436 rec("user", Some("Query"), Kind::Query),
437 rec("userProfile", Some("Query"), Kind::Query),
438 rec("uzszezr", Some("Query"), Kind::Query),
440 ];
441 let paths: Vec<&str> = search("user", &records, None, None, None)
442 .iter()
443 .map(|h| h.record.path.as_str())
444 .collect();
445 assert_eq!(paths, ["Query.user", "Query.userProfile"]);
446 }
447
448 #[test]
449 fn weak_matches_survive_when_nothing_stronger_exists() {
450 let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
451 assert_eq!(search("user", &records, None, None, None).len(), 1);
452 }
453
454 #[test]
455 fn search_returns_all_hits_above_the_cutoff() {
456 let records: Vec<SchemaRecord> = (0..50)
458 .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
459 .collect();
460 assert_eq!(search("user", &records, None, None, None).len(), 50);
461 }
462}