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 has_exact(query: &str, hits: &[Hit]) -> bool {
82 let (leaf, _) = score::parse_qualified(query);
83 hits.iter()
84 .any(|h| h.record.name.eq_ignore_ascii_case(leaf))
85}
86
87pub fn search<'a>(
92 query: &str,
93 records: &'a [SchemaRecord],
94 kind: Option<Kind>,
95 parent: Option<&str>,
96) -> Vec<Hit<'a>> {
97 use rayon::prelude::*;
98 let mut hits: Vec<Hit> = records
102 .par_iter()
103 .filter(|r| kind.is_none_or(|k| r.kind == k))
104 .filter(|r| {
105 parent.is_none_or(|p| {
106 r.parent
107 .as_deref()
108 .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
109 })
110 })
111 .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
112 .collect();
113
114 hits.sort_by(|a, b| {
117 b.score
118 .cmp(&a.score)
119 .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
120 });
121 if let Some(top) = hits.first().map(|h| h.score) {
122 let floor = (top as f64 * TAIL_CUTOFF) as i64;
123 hits.retain(|h| h.score >= floor);
124 }
125 hits
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
133 let path = match parent {
134 Some(p) => format!("{p}.{name}"),
135 None => name.to_string(),
136 };
137 SchemaRecord {
138 path,
139 name: name.into(),
140 kind,
141 parent: parent.map(Into::into),
142 type_ref: None,
143 args: vec![],
144 description: None,
145 deprecated: None,
146 directives: vec![],
147 }
148 }
149
150 #[test]
151 fn parent_filter_resolves_a_real_type() {
152 let records = vec![
153 rec("employees", Some("Company"), Kind::Field),
154 rec("name", Some("CompanyProfile"), Kind::Field),
155 ];
156 assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
158 assert_eq!(parent_filter("company.employe", &records), Some("Company"));
159 assert_eq!(parent_filter("Comp.employe", &records), None);
161 assert_eq!(parent_filter("employe", &records), None);
162 }
163
164 #[test]
165 fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
166 let records = vec![
167 rec("employees", Some("Company"), Kind::Field),
168 rec("name", Some("CompanyProfile"), Kind::Field),
169 ];
170 assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
172 assert_eq!(parent_filter("Zebra.employe", &records), None);
174 }
175
176 #[test]
177 fn parent_filter_declines_an_ambiguous_misspelling() {
178 let records = vec![
179 rec("id", Some("Vser"), Kind::Field),
180 rec("id", Some("Usor"), Kind::Field),
181 ];
182 assert_eq!(parent_filter("User.id", &records), None);
184 }
185
186 #[test]
187 fn parent_filter_excludes_other_types() {
188 let records = vec![
189 rec("employees", Some("Company"), Kind::Field),
190 rec("employees", Some("CompanyProfile"), Kind::Field),
191 rec("employer", Some("CompanyMemberStats"), Kind::Field),
192 ];
193 let hits = search("Company.employe", &records, None, Some("Company"));
194 assert_eq!(hits.len(), 1);
195 assert_eq!(hits[0].record.path, "Company.employees");
196 }
197
198 #[test]
199 fn spaced_qualifier_rewrites_type_plus_word() {
200 let records = vec![
201 rec("name", Some("User"), Kind::Field),
202 rec("lastName", Some("User"), Kind::Field),
203 ];
204 assert_eq!(
205 spaced_qualifier("User name", &records).as_deref(),
206 Some("User.name")
207 );
208 assert_eq!(
209 spaced_qualifier("user name", &records).as_deref(),
210 Some("user.name")
211 );
212 assert_eq!(spaced_qualifier("cancel a subscription", &records), None);
214 assert_eq!(spaced_qualifier("User.name", &records), None);
215 assert_eq!(spaced_qualifier("employee summary", &records), None);
216 }
217
218 #[test]
219 fn has_exact_matches_the_leaf_name_only() {
220 let records = vec![
221 rec("name", Some("User"), Kind::Field),
222 rec("username", Some("Query"), Kind::Query),
223 ];
224 let hits = search("User.name", &records, None, None);
225 assert!(has_exact("User.name", &hits));
226 assert!(has_exact("user.NAME", &hits));
227 let near = search("User.nam", &records, None, None);
228 assert!(!has_exact("User.nam", &near));
229 }
230
231 #[test]
232 fn weak_tail_is_cut_when_a_strong_match_exists() {
233 let records = vec![
234 rec("user", Some("Query"), Kind::Query),
235 rec("userProfile", Some("Query"), Kind::Query),
236 rec("uzszezr", Some("Query"), Kind::Query),
238 ];
239 let paths: Vec<&str> = search("user", &records, None, None)
240 .iter()
241 .map(|h| h.record.path.as_str())
242 .collect();
243 assert_eq!(paths, ["Query.user", "Query.userProfile"]);
244 }
245
246 #[test]
247 fn weak_matches_survive_when_nothing_stronger_exists() {
248 let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
249 assert_eq!(search("user", &records, None, None).len(), 1);
250 }
251
252 #[test]
253 fn search_returns_all_hits_above_the_cutoff() {
254 let records: Vec<SchemaRecord> = (0..50)
256 .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
257 .collect();
258 assert_eq!(search("user", &records, None, None).len(), 50);
259 }
260}