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 has_exact(query: &str, hits: &[Hit]) -> bool {
62 let (leaf, _) = score::parse_qualified(query);
63 hits.iter()
64 .any(|h| h.record.name.eq_ignore_ascii_case(leaf))
65}
66
67pub fn search<'a>(
72 query: &str,
73 records: &'a [SchemaRecord],
74 kind: Option<Kind>,
75 parent: Option<&str>,
76) -> Vec<Hit<'a>> {
77 use rayon::prelude::*;
78 let mut hits: Vec<Hit> = records
82 .par_iter()
83 .filter(|r| kind.is_none_or(|k| r.kind == k))
84 .filter(|r| {
85 parent.is_none_or(|p| {
86 r.parent
87 .as_deref()
88 .is_some_and(|rp| rp.eq_ignore_ascii_case(p))
89 })
90 })
91 .filter_map(|r| score::score(query, r).map(|score| Hit { record: r, score }))
92 .collect();
93
94 hits.sort_by(|a, b| {
97 b.score
98 .cmp(&a.score)
99 .then_with(|| a.record.path.len().cmp(&b.record.path.len()))
100 });
101 if let Some(top) = hits.first().map(|h| h.score) {
102 let floor = (top as f64 * TAIL_CUTOFF) as i64;
103 hits.retain(|h| h.score >= floor);
104 }
105 hits
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn rec(name: &str, parent: Option<&str>, kind: Kind) -> SchemaRecord {
113 let path = match parent {
114 Some(p) => format!("{p}.{name}"),
115 None => name.to_string(),
116 };
117 SchemaRecord {
118 path,
119 name: name.into(),
120 kind,
121 parent: parent.map(Into::into),
122 type_ref: None,
123 args: vec![],
124 description: None,
125 deprecated: None,
126 directives: vec![],
127 }
128 }
129
130 #[test]
131 fn parent_filter_resolves_a_real_type() {
132 let records = vec![
133 rec("employees", Some("Company"), Kind::Field),
134 rec("name", Some("CompanyProfile"), Kind::Field),
135 ];
136 assert_eq!(parent_filter("Company.employe", &records), Some("Company"));
138 assert_eq!(parent_filter("company.employe", &records), Some("Company"));
139 assert_eq!(parent_filter("Comp.employe", &records), None);
141 assert_eq!(parent_filter("employe", &records), None);
142 }
143
144 #[test]
145 fn parent_filter_snaps_a_misspelled_type_to_the_closest() {
146 let records = vec![
147 rec("employees", Some("Company"), Kind::Field),
148 rec("name", Some("CompanyProfile"), Kind::Field),
149 ];
150 assert_eq!(parent_filter("Compnay.employe", &records), Some("Company"));
152 assert_eq!(parent_filter("Zebra.employe", &records), None);
154 }
155
156 #[test]
157 fn parent_filter_declines_an_ambiguous_misspelling() {
158 let records = vec![
159 rec("id", Some("Vser"), Kind::Field),
160 rec("id", Some("Usor"), Kind::Field),
161 ];
162 assert_eq!(parent_filter("User.id", &records), None);
164 }
165
166 #[test]
167 fn parent_filter_excludes_other_types() {
168 let records = vec![
169 rec("employees", Some("Company"), Kind::Field),
170 rec("employees", Some("CompanyProfile"), Kind::Field),
171 rec("employer", Some("CompanyMemberStats"), Kind::Field),
172 ];
173 let hits = search("Company.employe", &records, None, Some("Company"));
174 assert_eq!(hits.len(), 1);
175 assert_eq!(hits[0].record.path, "Company.employees");
176 }
177
178 #[test]
179 fn has_exact_matches_the_leaf_name_only() {
180 let records = vec![
181 rec("name", Some("User"), Kind::Field),
182 rec("username", Some("Query"), Kind::Query),
183 ];
184 let hits = search("User.name", &records, None, None);
185 assert!(has_exact("User.name", &hits));
186 assert!(has_exact("user.NAME", &hits));
187 let near = search("User.nam", &records, None, None);
188 assert!(!has_exact("User.nam", &near));
189 }
190
191 #[test]
192 fn weak_tail_is_cut_when_a_strong_match_exists() {
193 let records = vec![
194 rec("user", Some("Query"), Kind::Query),
195 rec("userProfile", Some("Query"), Kind::Query),
196 rec("uzszezr", Some("Query"), Kind::Query),
198 ];
199 let paths: Vec<&str> = search("user", &records, None, None)
200 .iter()
201 .map(|h| h.record.path.as_str())
202 .collect();
203 assert_eq!(paths, ["Query.user", "Query.userProfile"]);
204 }
205
206 #[test]
207 fn weak_matches_survive_when_nothing_stronger_exists() {
208 let records = vec![rec("uzszezr", Some("Query"), Kind::Query)];
209 assert_eq!(search("user", &records, None, None).len(), 1);
210 }
211
212 #[test]
213 fn search_returns_all_hits_above_the_cutoff() {
214 let records: Vec<SchemaRecord> = (0..50)
216 .map(|i| rec(&format!("user{i}"), Some("Query"), Kind::Query))
217 .collect();
218 assert_eq!(search("user", &records, None, None).len(), 50);
219 }
220}