Skip to main content

amql_engine/
repair.rs

1//! Detect broken annotation bindings and suggest repairs.
2//!
3//! Basic repair heuristics:
4//! - Detect bindings that are similar to other known binding names
5//! - Suggest similar bindings based on Levenshtein edit distance
6
7#[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/// A suggested fix for a broken annotation binding.
19#[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    /// Sidecar file path containing the broken binding.
26    pub file: RelativePath,
27    /// The original (potentially broken) binding value.
28    pub binding: Binding,
29    /// The suggested corrected binding.
30    pub suggestion: Binding,
31    /// Similarity score between 0.0 and 1.0.
32    pub confidence: f64,
33    /// Human-readable explanation of why this repair was suggested.
34    pub reason: String,
35}
36
37/// Find annotations with bindings that look potentially broken
38/// and suggest repairs based on other annotations and code element names.
39/// If `code_cache` is provided, code element names are included in the known names set.
40#[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/// Find annotations with bindings that look potentially broken (no code cache variant).
58#[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/// Suggest repairs using only annotation data (no code cache).
65///
66/// Always available regardless of feature flags. Use this from contexts
67/// where the code cache is unavailable (e.g. WASM).
68#[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/// Recursively collect code element names from a tree.
133#[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
145/// Levenshtein edit distance between two strings.
146/// Uses two-row optimization: O(n) memory instead of O(m×n).
147fn 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    /// Helper: build a minimal `Annotation` with just a binding.
191    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        // Arrange
204        let cases: &[(&str, &str, usize)] = &[
205            ("hello", "hello", 0), // identical strings
206            ("hello", "helo", 1),  // one deletion
207            ("abc", "xyz", 3),     // completely different
208        ];
209
210        // Act + Assert
211        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        // Arrange — similar bindings that should produce a suggestion
223        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        // Arrange — identical bindings (no suggestion expected)
234        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        // Arrange — distant bindings (no suggestion expected)
245        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        // Arrange — empty binding (no suggestion expected)
256        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        // Act
260        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
266        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        // Arrange — annotation binding similar to a code element name
299        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        // Act
340        let suggestions = super::suggest_repairs(&store, Some(&cache));
341
342        // Assert
343        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}