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