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
101#[derive(Default, Clone, Copy)]
105pub struct Filters<'a> {
106 pub kind: Option<Kind>,
108 pub parent: Option<&'a str>,
110 pub returns: Option<&'a str>,
113}
114
115impl Filters<'_> {
116 pub fn compile(&self) -> Predicate<'_> {
119 Predicate {
120 kind: self.kind,
121 parent: self.parent,
122 returns: self.returns.map(glob::Pattern::new),
123 }
124 }
125}
126
127pub struct Predicate<'a> {
129 kind: Option<Kind>,
130 parent: Option<&'a str>,
131 returns: Option<glob::Pattern>,
132}
133
134impl Predicate<'_> {
135 pub fn accepts(&self, r: &SchemaRecord) -> bool {
139 self.kind.is_none_or(|k| r.kind == k)
140 && self.parent.is_none_or(|p| {
141 r.parent
142 .as_deref()
143 .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
144 })
145 && self
146 .returns
147 .as_ref()
148 .is_none_or(|pat| r.base_type().is_some_and(|t| pat.matches(t)))
149 }
150}
151
152pub fn search<'a>(query: &str, records: &'a [SchemaRecord], filters: Filters<'_>) -> Vec<Hit<'a>> {
157 let predicate = filters.compile();
158 if glob::is_pattern(query) {
159 return glob_search(query, records, &predicate);
160 }
161 fuzzy_search(query, records, &predicate)
162}
163
164fn glob_search<'a>(
170 pattern: &str,
171 records: &'a [SchemaRecord],
172 predicate: &Predicate<'_>,
173) -> Vec<Hit<'a>> {
174 use rayon::prelude::*;
175 let pattern = glob::Pattern::new(pattern);
177 let against_path = pattern.targets_path();
178 let mut hits: Vec<Hit> = records
179 .par_iter()
180 .filter(|r| predicate.accepts(r))
181 .filter(|r| {
182 let text = if against_path { &r.path } else { &r.name };
183 pattern.matches(text)
184 })
185 .map(|r| Hit {
186 record: r,
187 score: r.kind.weight(),
188 })
189 .collect();
190 hits.sort_by(|a, b| {
191 b.score
192 .cmp(&a.score)
193 .then_with(|| a.record.path.cmp(&b.record.path))
194 });
195 hits
196}
197
198fn fuzzy_search<'a>(
199 query: &str,
200 records: &'a [SchemaRecord],
201 predicate: &Predicate<'_>,
202) -> Vec<Hit<'a>> {
203 use rayon::prelude::*;
204 let mut hits: Vec<Hit> = records
208 .par_iter()
209 .filter(|r| predicate.accepts(r))
210 .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
211 .collect();
212
213 hits.sort_by(|a, b| {
216 b.score
217 .cmp(&a.score)
218 .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
219 });
220 if let Some(top) = hits.first().map(|h| h.score) {
221 let floor = (top as f64 * TAIL_CUTOFF) as i64;
222 hits.retain(|h| h.score >= floor);
223 }
224 hits
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
232 typed_rec(name, parent, kind, None)
233 }
234
235 fn typed_rec(
236 name: &str,
237 parent: Option<&str>,
238 kind: Kind,
239 type_ref: Option<&str>,
240 ) -> SchemaRecord {
241 let path = match parent {
242 Some(p) => format!("{p}.{name}"),
243 None => name.to_string(),
244 };
245 SchemaRecord {
246 path,
247 name: name.into(),
248 kind,
249 parent: parent.map(Into::into),
250 type_ref: type_ref.map(Into::into),
251 args: vec![],
252 description: None,
253 deprecated: None,
254 directives: vec![],
255 possible_types: vec![],
256 }
257 }
258
259 #[test]
260 fn parent_filter_resolves_a_real_type() {
261 let records = vec![
262 rec("employees", Some("Company"), Kind::Field),
263 rec("name", Some("CompanyProfile"), Kind::Field),
264 ];
265 assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
267 assert_eq!(parent_filter("company.employe", &records), Some("Company"));
268 assert_eq!(parent_filter("Comp.employe", &records), None);
270 assert_eq!(parent_filter("employe", &records), None);
271 }
272
273 #[test]
274 fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
275 let records = vec![
276 rec("employees", Some("Company"), Kind::Field),
277 rec("name", Some("CompanyProfile"), Kind::Field),
278 ];
279 assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
281 assert_eq!(parent_filter("Zebra.employe", &records), None);
283 }
284
285 #[test]
286 fn parent_filter_declines_an_ambiguous_misspelling() {
287 let records = vec![
288 rec("id", Some("Vser"), Kind::Field),
289 rec("id", Some("Usor"), Kind::Field),
290 ];
291 assert_eq!(parent_filter("User.id", &records), None);
293 }
294
295 #[test]
296 fn parent_filter_excludes_other_types() {
297 let records = vec![
298 rec("employees", Some("Company"), Kind::Field),
299 rec("employees", Some("CompanyProfile"), Kind::Field),
300 rec("employer", Some("CompanyMemberStats"), Kind::Field),
301 ];
302 let hits = search(
303 "Company.employe",
304 &records,
305 Filters {
306 parent: Some("Company"),
307 ..Default::default()
308 },
309 );
310 assert_eq!(hits.len(), 1);
311 assert_eq!(hits[0].record.path, "Company.employees");
312 }
313
314 #[test]
315 fn spaced_qualifier_rewrites_type_plus_word() {
316 let records = vec![
317 rec("name", Some("User"), Kind::Field),
318 rec("lastName", Some("User"), Kind::Field),
319 ];
320 assert_eq!(
321 spaced_qualifier("User name", &records).as_deref(),
322 Some("User.name")
323 );
324 assert_eq!(
325 spaced_qualifier("user name", &records).as_deref(),
326 Some("user.name")
327 );
328 assert_eq!(spaced_qualifier("cancel a subscription", &records), None);
330 assert_eq!(spaced_qualifier("User.name", &records), None);
331 assert_eq!(spaced_qualifier("employee summary", &records), None);
332 }
333
334 #[test]
335 fn named_hit_accepts_exact_and_boundary_words() {
336 let records = vec![rec("name", Some("User"), Kind::Field)];
337 let hits = search("User.name", &records, Default::default());
338 assert!(named_hit("User.name", &hits));
339 assert!(named_hit("user.NAME", &hits));
340
341 let variants = vec![
344 rec("lastName", Some("User"), Kind::Field),
345 rec("fullName", Some("User"), Kind::Field),
346 ];
347 let hits = search(
348 "User.name",
349 &variants,
350 Filters {
351 parent: Some("User"),
352 ..Default::default()
353 },
354 );
355 assert!(named_hit("User.name", &hits));
356 }
357
358 #[test]
359 fn named_hit_rejects_mid_word_and_scattered_matches() {
360 let records = vec![rec("accountant", Some("User"), Kind::Field)];
361 let hits = search("count", &records, Default::default());
363 assert!(!hits.is_empty());
364 assert!(!named_hit("count", &hits));
365 }
366
367 #[test]
368 fn wildcard_enumerates_a_types_members() {
369 let records = vec![
370 rec("email", Some("User"), Kind::Field),
371 rec("id", Some("User"), Kind::Field),
372 rec("email", Some("UserProfile"), Kind::Field),
373 rec("User", None, Kind::Object),
374 ];
375 let paths: Vec<&str> = search("User.*", &records, Default::default())
376 .iter()
377 .map(|h| h.record.path.as_str())
378 .collect();
379 assert_eq!(paths, ["User.email", "User.id"]);
381 }
382
383 #[test]
384 fn brace_alternation_enumerates_each_branch() {
385 let records = vec![
386 rec("firstName", Some("User"), Kind::Field),
387 rec("lastName", Some("User"), Kind::Field),
388 rec("middleName", Some("User"), Kind::Field),
389 ];
390 let paths: Vec<&str> = search("User.{first,last}Name", &records, Default::default())
391 .iter()
392 .map(|h| h.record.path.as_str())
393 .collect();
394 assert_eq!(paths, ["User.firstName", "User.lastName"]);
395 }
396
397 #[test]
398 fn wildcard_without_a_dot_matches_leaf_names() {
399 let records = vec![
400 rec("getUser", Some("Query"), Kind::Query),
401 rec("getCompany", Some("Query"), Kind::Query),
402 rec("forget", Some("User"), Kind::Field),
403 ];
404 let paths: Vec<&str> = search("get*", &records, Default::default())
405 .iter()
406 .map(|h| h.record.path.as_str())
407 .collect();
408 assert_eq!(paths, ["Query.getCompany", "Query.getUser"]);
410 }
411
412 #[test]
413 fn wildcard_keeps_every_match_regardless_of_kind_weight() {
414 let records = vec![
417 rec("user", Some("Query"), Kind::Query),
418 rec("name", Some("User"), Kind::Field),
419 ];
420 assert_eq!(search("*", &records, Default::default()).len(), 2);
421 }
422
423 #[test]
424 fn returns_filter_finds_fields_by_return_type() {
425 let records = vec![
426 typed_rec("myEmployer", Some("Query"), Kind::Query, Some("Company")),
428 typed_rec(
429 "employees",
430 Some("Company"),
431 Kind::Field,
432 Some("[Employee!]!"),
433 ),
434 typed_rec("name", Some("Company"), Kind::Field, Some("String!")),
435 typed_rec("Company", None, Kind::Object, None),
436 ];
437 let paths: Vec<&str> = search(
438 "*",
439 &records,
440 Filters {
441 returns: Some("Company"),
442 ..Default::default()
443 },
444 )
445 .iter()
446 .map(|h| h.record.path.as_str())
447 .collect();
448 assert_eq!(paths, ["Query.myEmployer"]);
450
451 let paths: Vec<&str> = search(
453 "*",
454 &records,
455 Filters {
456 returns: Some("Employee"),
457 ..Default::default()
458 },
459 )
460 .iter()
461 .map(|h| h.record.path.as_str())
462 .collect();
463 assert_eq!(paths, ["Company.employees"]);
464 }
465
466 #[test]
467 fn returns_filter_accepts_wildcards_and_composes() {
468 let records = vec![
469 typed_rec("a", Some("Mutation"), Kind::Mutation, Some("APayload!")),
470 typed_rec("b", Some("Mutation"), Kind::Mutation, Some("BPayload!")),
471 typed_rec("c", Some("Mutation"), Kind::Mutation, Some("String")),
472 typed_rec("d", Some("Type"), Kind::Field, Some("APayload")),
473 ];
474 let paths: Vec<&str> = search(
476 "*",
477 &records,
478 Filters {
479 kind: Some(Kind::Mutation),
480 returns: Some("*Payload"),
481 ..Default::default()
482 },
483 )
484 .iter()
485 .map(|h| h.record.path.as_str())
486 .collect();
487 assert_eq!(paths, ["Mutation.a", "Mutation.b"]);
488 }
489
490 #[test]
491 fn weak_tail_is_cut_when_a_strong_match_exists() {
492 let records = vec![
493 rec("user", Some("Query"), Kind::Query),
494 rec("userProfile", Some("Query"), Kind::Query),
495 rec("uzszezr", Some("Query"), Kind::Query),
497 ];
498 let paths: Vec<&str> = search("user", &records, Default::default())
499 .iter()
500 .map(|h| h.record.path.as_str())
501 .collect();
502 assert_eq!(paths, ["Query.user", "Query.userProfile"]);
503 }
504
505 #[test]
506 fn weak_matches_survive_when_nothing_stronger_exists() {
507 let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
508 assert_eq!(search("user", &records, Default::default()).len(), 1);
509 }
510
511 #[test]
512 fn search_returns_all_hits_above_the_cutoff() {
513 let records: Vec<SchemaRecord> = (0..50)
515 .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
516 .collect();
517 assert_eq!(search("user", &records, Default::default()).len(), 50);
518 }
519}