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 against_path = pattern.contains('.');
125 let mut hits: Vec<Hit> = records
126 .par_iter()
127 .filter(|r| kind.is_none_or(|k| r.kind == k))
128 .filter(|r| {
129 let text = if against_path { &r.path } else { &r.name };
130 glob::matches(pattern, text)
131 })
132 .map(|r| Hit {
133 record: r,
134 score: r.kind.weight(),
135 })
136 .collect();
137 hits.sort_by(|a, b| {
138 b.score
139 .cmp(&a.score)
140 .then_with(|| a.record.path.cmp(&b.record.path))
141 });
142 hits
143}
144
145fn fuzzy_search<'a>(
146 query: &str,
147 records: &'a [SchemaRecord],
148 kind: Option<Kind>,
149 parent: Option<&str>,
150) -> Vec<Hit<'a>> {
151 use rayon::prelude::*;
152 let mut hits: Vec<Hit> = records
156 .par_iter()
157 .filter(|r| kind.is_none_or(|k| r.kind == k))
158 .filter(|r| {
159 parent.is_none_or(|p| {
160 r.parent
161 .as_deref()
162 .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
163 })
164 })
165 .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
166 .collect();
167
168 hits.sort_by(|a, b| {
171 b.score
172 .cmp(&a.score)
173 .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
174 });
175 if let Some(top) = hits.first().map(|h| h.score) {
176 let floor = (top as f64 * TAIL_CUTOFF) as i64;
177 hits.retain(|h| h.score >= floor);
178 }
179 hits
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
187 let path = match parent {
188 Some(p) => format!("{p}.{name}"),
189 None => name.to_string(),
190 };
191 SchemaRecord {
192 path,
193 name: name.into(),
194 kind,
195 parent: parent.map(Into::into),
196 type_ref: None,
197 args: vec![],
198 description: None,
199 deprecated: None,
200 directives: vec![],
201 }
202 }
203
204 #[test]
205 fn parent_filter_resolves_a_real_type() {
206 let records = vec![
207 rec("employees", Some("Company"), Kind::Field),
208 rec("name", Some("CompanyProfile"), Kind::Field),
209 ];
210 assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
212 assert_eq!(parent_filter("company.employe", &records), Some("Company"));
213 assert_eq!(parent_filter("Comp.employe", &records), None);
215 assert_eq!(parent_filter("employe", &records), None);
216 }
217
218 #[test]
219 fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
220 let records = vec![
221 rec("employees", Some("Company"), Kind::Field),
222 rec("name", Some("CompanyProfile"), Kind::Field),
223 ];
224 assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
226 assert_eq!(parent_filter("Zebra.employe", &records), None);
228 }
229
230 #[test]
231 fn parent_filter_declines_an_ambiguous_misspelling() {
232 let records = vec![
233 rec("id", Some("Vser"), Kind::Field),
234 rec("id", Some("Usor"), Kind::Field),
235 ];
236 assert_eq!(parent_filter("User.id", &records), None);
238 }
239
240 #[test]
241 fn parent_filter_excludes_other_types() {
242 let records = vec![
243 rec("employees", Some("Company"), Kind::Field),
244 rec("employees", Some("CompanyProfile"), Kind::Field),
245 rec("employer", Some("CompanyMemberStats"), Kind::Field),
246 ];
247 let hits = search("Company.employe", &records, None, Some("Company"));
248 assert_eq!(hits.len(), 1);
249 assert_eq!(hits[0].record.path, "Company.employees");
250 }
251
252 #[test]
253 fn spaced_qualifier_rewrites_type_plus_word() {
254 let records = vec![
255 rec("name", Some("User"), Kind::Field),
256 rec("lastName", Some("User"), Kind::Field),
257 ];
258 assert_eq!(
259 spaced_qualifier("User name", &records).as_deref(),
260 Some("User.name")
261 );
262 assert_eq!(
263 spaced_qualifier("user name", &records).as_deref(),
264 Some("user.name")
265 );
266 assert_eq!(spaced_qualifier("cancel a subscription", &records), None);
268 assert_eq!(spaced_qualifier("User.name", &records), None);
269 assert_eq!(spaced_qualifier("employee summary", &records), None);
270 }
271
272 #[test]
273 fn named_hit_accepts_exact_and_boundary_words() {
274 let records = vec![rec("name", Some("User"), Kind::Field)];
275 let hits = search("User.name", &records, None, None);
276 assert!(named_hit("User.name", &hits));
277 assert!(named_hit("user.NAME", &hits));
278
279 let variants = vec![
282 rec("lastName", Some("User"), Kind::Field),
283 rec("fullName", Some("User"), Kind::Field),
284 ];
285 let hits = search("User.name", &variants, None, Some("User"));
286 assert!(named_hit("User.name", &hits));
287 }
288
289 #[test]
290 fn named_hit_rejects_mid_word_and_scattered_matches() {
291 let records = vec![rec("accountant", Some("User"), Kind::Field)];
292 let hits = search("count", &records, None, None);
294 assert!(!hits.is_empty());
295 assert!(!named_hit("count", &hits));
296 }
297
298 #[test]
299 fn wildcard_enumerates_a_types_members() {
300 let records = vec![
301 rec("email", Some("User"), Kind::Field),
302 rec("id", Some("User"), Kind::Field),
303 rec("email", Some("UserProfile"), Kind::Field),
304 rec("User", None, Kind::Object),
305 ];
306 let paths: Vec<&str> = search("User.*", &records, None, None)
307 .iter()
308 .map(|h| h.record.path.as_str())
309 .collect();
310 assert_eq!(paths, ["User.email", "User.id"]);
312 }
313
314 #[test]
315 fn wildcard_without_a_dot_matches_leaf_names() {
316 let records = vec![
317 rec("getUser", Some("Query"), Kind::Query),
318 rec("getCompany", Some("Query"), Kind::Query),
319 rec("forget", Some("User"), Kind::Field),
320 ];
321 let paths: Vec<&str> = search("get*", &records, None, None)
322 .iter()
323 .map(|h| h.record.path.as_str())
324 .collect();
325 assert_eq!(paths, ["Query.getCompany", "Query.getUser"]);
327 }
328
329 #[test]
330 fn wildcard_keeps_every_match_regardless_of_kind_weight() {
331 let records = vec![
334 rec("user", Some("Query"), Kind::Query),
335 rec("name", Some("User"), Kind::Field),
336 ];
337 assert_eq!(search("*", &records, None, None).len(), 2);
338 }
339
340 #[test]
341 fn weak_tail_is_cut_when_a_strong_match_exists() {
342 let records = vec![
343 rec("user", Some("Query"), Kind::Query),
344 rec("userProfile", Some("Query"), Kind::Query),
345 rec("uzszezr", Some("Query"), Kind::Query),
347 ];
348 let paths: Vec<&str> = search("user", &records, None, None)
349 .iter()
350 .map(|h| h.record.path.as_str())
351 .collect();
352 assert_eq!(paths, ["Query.user", "Query.userProfile"]);
353 }
354
355 #[test]
356 fn weak_matches_survive_when_nothing_stronger_exists() {
357 let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
358 assert_eq!(search("user", &records, None, None).len(), 1);
359 }
360
361 #[test]
362 fn search_returns_all_hits_above_the_cutoff() {
363 let records: Vec<SchemaRecord> = (0..50)
365 .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
366 .collect();
367 assert_eq!(search("user", &records, None, None).len(), 50);
368 }
369}