1#[cfg(all(feature = "resolver", feature = "fs"))]
8use crate::code_cache::CodeCache;
9#[cfg(feature = "resolver")]
10use crate::resolver::CodeElement;
11use crate::store::AnnotationStore;
12#[cfg(feature = "resolver")]
13use crate::types::Scope;
14use crate::types::{Binding, RelativePath};
15use rustc_hash::FxHashSet;
16use serde::Serialize;
17
18#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
20#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
21#[cfg_attr(feature = "ts", ts(export))]
22#[cfg_attr(feature = "flow", flow(export))]
23#[derive(Debug, Clone, Serialize)]
24pub struct RepairSuggestion {
25 pub file: RelativePath,
27 pub binding: Binding,
29 pub suggestion: Binding,
31 pub confidence: f64,
33 pub reason: String,
35}
36
37#[cfg(all(feature = "resolver", feature = "fs"))]
41#[must_use]
42pub fn suggest_repairs(
43 store: &AnnotationStore,
44 code_cache: Option<&CodeCache>,
45) -> Vec<RepairSuggestion> {
46 let mut known_names: FxHashSet<Binding> = collect_annotation_names(store);
47
48 if let Some(cache) = code_cache {
49 for (_rel, element) in cache.elements_in_scope(&Scope::from("")) {
50 known_names.extend(collect_code_names(element));
51 }
52 }
53
54 find_similar_bindings(store, &known_names)
55}
56
57#[cfg(not(all(feature = "resolver", feature = "fs")))]
59#[must_use]
60pub fn suggest_repairs(store: &AnnotationStore) -> Vec<RepairSuggestion> {
61 suggest_repairs_from_annotations(store)
62}
63
64#[must_use]
69pub fn suggest_repairs_from_annotations(store: &AnnotationStore) -> Vec<RepairSuggestion> {
70 let known_names = collect_annotation_names(store);
71 find_similar_bindings(store, &known_names)
72}
73
74fn collect_annotation_names(store: &AnnotationStore) -> FxHashSet<Binding> {
75 let mut known_names: FxHashSet<Binding> = FxHashSet::default();
76 for file in store.annotated_files() {
77 for ann in store.get_file_annotations(&file) {
78 if !ann.binding.is_empty() {
79 known_names.insert(ann.binding.clone());
80 }
81 }
82 }
83 known_names
84}
85
86fn find_similar_bindings(
87 store: &AnnotationStore,
88 known_names: &FxHashSet<Binding>,
89) -> Vec<RepairSuggestion> {
90 let mut suggestions = Vec::new();
91 let known_names_vec: Vec<&Binding> = known_names.iter().collect();
92
93 for file in store.annotated_files() {
94 for ann in store.get_file_annotations(&file) {
95 if ann.binding.is_empty() {
96 continue;
97 }
98 let name = &ann.binding;
99
100 for known in &known_names_vec {
101 if *known == name {
102 continue;
103 }
104 let dist = levenshtein(name, known);
105 let max_len = name.len().max(known.len());
106 if max_len == 0 {
107 continue;
108 }
109 let similarity = 1.0 - (dist as f64 / max_len as f64);
110
111 if (0.7..1.0).contains(&similarity) {
112 suggestions.push(RepairSuggestion {
113 file: store.locator().sidecar_for(&file),
114 binding: ann.binding.clone(),
115 suggestion: (*known).clone(),
116 confidence: similarity,
117 reason: format!(
118 "Binding '{}' is similar to '{}' ({}% similar)",
119 name,
120 known,
121 (similarity * 100.0).round() as u32
122 ),
123 });
124 }
125 }
126 }
127 }
128
129 suggestions
130}
131
132#[cfg(feature = "resolver")]
134fn collect_code_names(element: &CodeElement) -> Vec<Binding> {
135 let mut out = Vec::new();
136 if !element.name.is_empty() {
137 out.push(Binding::from(element.name.as_ref()));
138 }
139 for child in &element.children {
140 out.extend(collect_code_names(child));
141 }
142 out
143}
144
145fn levenshtein(a: &str, b: &str) -> usize {
148 let m = a.len();
149 let n = b.len();
150 let a_bytes = a.as_bytes();
151 let b_bytes = b.as_bytes();
152
153 if m == 0 {
154 return n;
155 }
156 if n == 0 {
157 return m;
158 }
159
160 let mut prev = vec![0usize; n + 1];
161 let mut curr = vec![0usize; n + 1];
162
163 for (j, val) in prev.iter_mut().enumerate().take(n + 1) {
164 *val = j;
165 }
166
167 for i in 1..=m {
168 curr[0] = i;
169 for j in 1..=n {
170 curr[j] = if a_bytes[i - 1] == b_bytes[j - 1] {
171 prev[j - 1]
172 } else {
173 1 + prev[j].min(curr[j - 1]).min(prev[j - 1])
174 };
175 }
176 std::mem::swap(&mut prev, &mut curr);
177 }
178
179 prev[n]
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::store::{Annotation, AnnotationStore};
186 use crate::types::{Binding, RelativePath, TagName};
187 use rustc_hash::FxHashMap;
188 use std::path::Path;
189
190 fn ann(binding: &str, file: &str) -> Annotation {
192 Annotation {
193 tag: TagName::from("function"),
194 attrs: FxHashMap::default(),
195 binding: Binding::from(binding),
196 file: RelativePath::from(file),
197 children: vec![],
198 }
199 }
200
201 #[test]
202 fn levenshtein_distance() {
203 let cases: &[(&str, &str, usize)] = &[
205 ("hello", "hello", 0), ("hello", "helo", 1), ("abc", "xyz", 3), ];
209
210 for &(a, b, expected) in cases {
212 assert_eq!(
213 levenshtein(a, b),
214 expected,
215 "levenshtein(\"{a}\", \"{b}\") should be {expected}"
216 );
217 }
218 }
219
220 #[test]
221 fn suggest_repairs_test() {
222 let mut store_similar = AnnotationStore::new(Path::new("/project"));
224 store_similar.inject_test_data(
225 &RelativePath::from("src/a.rs"),
226 vec![ann("parse_selector", "src/a.rs")],
227 );
228 store_similar.inject_test_data(
229 &RelativePath::from("src/b.rs"),
230 vec![ann("parse_selectr", "src/b.rs")],
231 );
232
233 let mut store_identical = AnnotationStore::new(Path::new("/project"));
235 store_identical.inject_test_data(
236 &RelativePath::from("src/a.rs"),
237 vec![ann("parse_selector", "src/a.rs")],
238 );
239 store_identical.inject_test_data(
240 &RelativePath::from("src/b.rs"),
241 vec![ann("parse_selector", "src/b.rs")],
242 );
243
244 let mut store_distant = AnnotationStore::new(Path::new("/project"));
246 store_distant.inject_test_data(
247 &RelativePath::from("src/a.rs"),
248 vec![ann("foo", "src/a.rs")],
249 );
250 store_distant.inject_test_data(
251 &RelativePath::from("src/b.rs"),
252 vec![ann("bar_baz_qux", "src/b.rs")],
253 );
254
255 let mut store_empty = AnnotationStore::new(Path::new("/project"));
257 store_empty.inject_test_data(&RelativePath::from("src/a.rs"), vec![ann("", "src/a.rs")]);
258
259 let similar = super::suggest_repairs(&store_similar, None);
261 let identical = super::suggest_repairs(&store_identical, None);
262 let distant = super::suggest_repairs(&store_distant, None);
263 let empty = super::suggest_repairs(&store_empty, None);
264
265 assert!(
267 !similar.is_empty(),
268 "expected at least one repair suggestion for similar bindings"
269 );
270 let has_expected = similar.iter().any(|s| {
271 (s.binding == "parse_selectr" && s.suggestion == "parse_selector")
272 || (s.binding == "parse_selector" && s.suggestion == "parse_selectr")
273 });
274 assert!(
275 has_expected,
276 "expected a suggestion pairing parse_selectr and parse_selector, got: {similar:?}"
277 );
278
279 assert!(
280 identical.is_empty(),
281 "identical bindings should not produce suggestions, got: {identical:?}"
282 );
283 assert!(
284 distant.is_empty(),
285 "very different bindings should not produce suggestions, got: {distant:?}"
286 );
287 assert!(
288 empty.is_empty(),
289 "empty bindings should not produce suggestions, got: {empty:?}"
290 );
291 }
292
293 #[test]
294 fn suggest_repairs_with_code_cache() {
295 use crate::code_cache::CodeCache;
296 use crate::resolver::{CodeElement, SourceLocation};
297
298 let mut store = AnnotationStore::new(Path::new("/project"));
300 store.inject_test_data(
301 &RelativePath::from("src/a.rs"),
302 vec![ann("parse_selectr", "src/a.rs")],
303 );
304
305 let mut cache = CodeCache::new(Path::new("/project"));
306 cache.inject_test_data(
307 "src/selector.rs",
308 CodeElement {
309 tag: crate::types::TagName::from("module"),
310 name: crate::types::CodeElementName::from("selector.rs"),
311 attrs: rustc_hash::FxHashMap::default(),
312 children: vec![CodeElement {
313 tag: crate::types::TagName::from("function"),
314 name: crate::types::CodeElementName::from("parse_selector"),
315 attrs: rustc_hash::FxHashMap::default(),
316 children: vec![],
317 source: SourceLocation {
318 file: RelativePath::from("src/selector.rs"),
319 line: 1,
320 column: 0,
321 end_line: None,
322 end_column: None,
323 start_byte: 0,
324 end_byte: 0,
325 },
326 }],
327 source: SourceLocation {
328 file: RelativePath::from("src/selector.rs"),
329 line: 1,
330 column: 0,
331 end_line: None,
332 end_column: None,
333 start_byte: 0,
334 end_byte: 0,
335 },
336 },
337 );
338
339 let suggestions = super::suggest_repairs(&store, Some(&cache));
341
342 let has_expected = suggestions
344 .iter()
345 .any(|s| s.binding == "parse_selectr" && s.suggestion == "parse_selector");
346 assert!(
347 has_expected,
348 "expected a suggestion pairing parse_selectr with code element parse_selector, got: {suggestions:?}"
349 );
350 }
351}