1use crate::model::{Kind, SchemaRecord};
4
5pub mod score;
6
7pub struct Hit<'a> {
8 pub record: &'a SchemaRecord,
9 pub score: i64,
10}
11
12const TAIL_CUTOFF: f64 = 0.4;
17
18pub fn parent_filter<'a>(query: &str, records: &'a [SchemaRecord]) -> Option<&'a str> {
27 let (_, Some(qualifier)) = score::parse_qualified(query) else {
28 return None;
29 };
30 let parents: std::collections::HashSet<&str> =
31 records.iter().filter_map(|r| r.parent.as_deref()).collect();
32 if let Some(exact) = parents.iter().find(|p| p.eq_ignore_ascii_case(qualifier)) {
33 return Some(exact);
34 }
35 let q = qualifier.to_ascii_lowercase();
37 let mut best: Option<(usize, &str)> = None;
38 let mut tied = false;
39 for p in parents {
40 let Some(d) = score::typo_distance(&q, &p.to_ascii_lowercase()) else {
41 continue;
42 };
43 match best {
44 Some((bd, _)) if d > bd => {}
45 Some((bd, _)) if d == bd => tied = true,
46 _ => {
47 best = Some((d, p));
48 tied = false;
49 }
50 }
51 }
52 match (best, tied) {
53 (Some((_, p)), false) => Some(p),
54 _ => None,
55 }
56}
57
58pub fn spaced_qualifier(query: &str, records: &[SchemaRecord]) -> Option<String> {
64 if query.contains('.') {
65 return None;
66 }
67 let mut words = query.split_whitespace();
68 let (Some(first), Some(second), None) = (words.next(), words.next(), words.next()) else {
69 return None;
70 };
71 records
72 .iter()
73 .filter_map(|r| r.parent.as_deref())
74 .any(|p| p.eq_ignore_ascii_case(first))
75 .then(|| format!("{first}.{second}"))
76}
77
78pub fn named_hit(query: &str, hits: &[Hit]) -> bool {
85 let (leaf, _) = score::parse_qualified(query);
86 let leaf = leaf.to_ascii_lowercase();
87 if leaf.is_empty() {
88 return false;
89 }
90 hits.iter().any(|h| {
91 let lower = h.record.name.to_ascii_lowercase();
92 let chars: Vec<char> = h.record.name.chars().collect();
93 let boundary = score::boundaries(&chars);
94 lower
95 .match_indices(&leaf)
96 .any(|(i, _)| boundary.get(i).copied().unwrap_or(false))
97 })
98}
99
100pub fn search<'a>(
105 query: &str,
106 records: &'a [SchemaRecord],
107 kind: Option<Kind>,
108 parent: Option<&str>,
109) -> Vec<Hit<'a>> {
110 use rayon::prelude::*;
111 let mut hits: Vec<Hit> = records
115 .par_iter()
116 .filter(|r| kind.is_none_or(|k| r.kind == k))
117 .filter(|r| {
118 parent.is_none_or(|p| {
119 r.parent
120 .as_deref()
121 .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
122 })
123 })
124 .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
125 .collect();
126
127 hits.sort_by(|a, b| {
130 b.score
131 .cmp(&a.score)
132 .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
133 });
134 if let Some(top) = hits.first().map(|h| h.score) {
135 let floor = (top as f64 * TAIL_CUTOFF) as i64;
136 hits.retain(|h| h.score >= floor);
137 }
138 hits
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
146 let path = match parent {
147 Some(p) => format!("{p}.{name}"),
148 None => name.to_string(),
149 };
150 SchemaRecord {
151 path,
152 name: name.into(),
153 kind,
154 parent: parent.map(Into::into),
155 type_ref: None,
156 args: vec![],
157 description: None,
158 deprecated: None,
159 directives: vec![],
160 }
161 }
162
163 #[test]
164 fn parent_filter_resolves_a_real_type() {
165 let records = vec![
166 rec("employees", Some("Company"), Kind::Field),
167 rec("name", Some("CompanyProfile"), Kind::Field),
168 ];
169 assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
171 assert_eq!(parent_filter("company.employe", &records), Some("Company"));
172 assert_eq!(parent_filter("Comp.employe", &records), None);
174 assert_eq!(parent_filter("employe", &records), None);
175 }
176
177 #[test]
178 fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
179 let records = vec![
180 rec("employees", Some("Company"), Kind::Field),
181 rec("name", Some("CompanyProfile"), Kind::Field),
182 ];
183 assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
185 assert_eq!(parent_filter("Zebra.employe", &records), None);
187 }
188
189 #[test]
190 fn parent_filter_declines_an_ambiguous_misspelling() {
191 let records = vec![
192 rec("id", Some("Vser"), Kind::Field),
193 rec("id", Some("Usor"), Kind::Field),
194 ];
195 assert_eq!(parent_filter("User.id", &records), None);
197 }
198
199 #[test]
200 fn parent_filter_excludes_other_types() {
201 let records = vec![
202 rec("employees", Some("Company"), Kind::Field),
203 rec("employees", Some("CompanyProfile"), Kind::Field),
204 rec("employer", Some("CompanyMemberStats"), Kind::Field),
205 ];
206 let hits = search("Company.employe", &records, None, Some("Company"));
207 assert_eq!(hits.len(), 1);
208 assert_eq!(hits[0].record.path, "Company.employees");
209 }
210
211 #[test]
212 fn spaced_qualifier_rewrites_type_plus_word() {
213 let records = vec![
214 rec("name", Some("User"), Kind::Field),
215 rec("lastName", Some("User"), Kind::Field),
216 ];
217 assert_eq!(
218 spaced_qualifier("User name", &records).as_deref(),
219 Some("User.name")
220 );
221 assert_eq!(
222 spaced_qualifier("user name", &records).as_deref(),
223 Some("user.name")
224 );
225 assert_eq!(spaced_qualifier("cancel a subscription", &records), None);
227 assert_eq!(spaced_qualifier("User.name", &records), None);
228 assert_eq!(spaced_qualifier("employee summary", &records), None);
229 }
230
231 #[test]
232 fn named_hit_accepts_exact_and_boundary_words() {
233 let records = vec![rec("name", Some("User"), Kind::Field)];
234 let hits = search("User.name", &records, None, None);
235 assert!(named_hit("User.name", &hits));
236 assert!(named_hit("user.NAME", &hits));
237
238 let variants = vec![
241 rec("lastName", Some("User"), Kind::Field),
242 rec("fullName", Some("User"), Kind::Field),
243 ];
244 let hits = search("User.name", &variants, None, Some("User"));
245 assert!(named_hit("User.name", &hits));
246 }
247
248 #[test]
249 fn named_hit_rejects_mid_word_and_scattered_matches() {
250 let records = vec![rec("accountant", Some("User"), Kind::Field)];
251 let hits = search("count", &records, None, None);
253 assert!(!hits.is_empty());
254 assert!(!named_hit("count", &hits));
255 }
256
257 #[test]
258 fn weak_tail_is_cut_when_a_strong_match_exists() {
259 let records = vec![
260 rec("user", Some("Query"), Kind::Query),
261 rec("userProfile", Some("Query"), Kind::Query),
262 rec("uzszezr", Some("Query"), Kind::Query),
264 ];
265 let paths: Vec<&str> = search("user", &records, None, None)
266 .iter()
267 .map(|h| h.record.path.as_str())
268 .collect();
269 assert_eq!(paths, ["Query.user", "Query.userProfile"]);
270 }
271
272 #[test]
273 fn weak_matches_survive_when_nothing_stronger_exists() {
274 let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
275 assert_eq!(search("user", &records, None, None).len(), 1);
276 }
277
278 #[test]
279 fn search_returns_all_hits_above_the_cutoff() {
280 let records: Vec<SchemaRecord> = (0..50)
282 .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
283 .collect();
284 assert_eq!(search("user", &records, None, None).len(), 50);
285 }
286}