Skip to main content

aft/
lsp_hints.rs

1//! LSP-enhanced symbol disambiguation.
2//!
3//! When the plugin has LSP access, it can attach `lsp_hints` to a request with
4//! file + line information for the symbol(s) in play. This module parses those
5//! hints and uses them to narrow ambiguous tree-sitter matches down to the
6//! single correct candidate.
7
8use crate::protocol::RawRequest;
9use crate::symbols::SymbolMatch;
10use serde::Deserialize;
11use std::path::Path;
12
13/// A single LSP-sourced symbol hint: name, file path, line number, and optional kind.
14#[derive(Debug, Clone, Deserialize)]
15pub struct LspSymbolHint {
16    pub name: String,
17    pub file: String,
18    pub line: u32,
19    #[serde(default)]
20    pub kind: Option<String>,
21}
22
23/// Collection of LSP symbol hints attached to a request.
24#[derive(Debug, Clone, Deserialize)]
25pub struct LspHints {
26    pub symbols: Vec<LspSymbolHint>,
27}
28
29/// Strip `file://` URI prefix from a path, returning the bare filesystem path.
30fn strip_file_uri(path: &str) -> &str {
31    path.strip_prefix("file://").unwrap_or(path)
32}
33
34/// Parse `lsp_hints` from a raw request.
35///
36/// Returns `Some(hints)` if `req.lsp_hints` is present and valid JSON matching
37/// the `LspHints` schema. Returns `None` (with a stderr warning) on malformed
38/// data, and `None` silently when the field is absent.
39pub fn parse_lsp_hints(req: &RawRequest) -> Option<LspHints> {
40    let value = req.lsp_hints.as_ref()?;
41    match serde_json::from_value::<LspHints>(value.clone()) {
42        Ok(hints) => {
43            log::debug!("lsp_hints: parsed {} symbol hints", hints.symbols.len());
44            Some(hints)
45        }
46        Err(e) => {
47            crate::slog_warn!("lsp_hints: ignoring malformed data: {}", e);
48            None
49        }
50    }
51}
52
53/// Use LSP hints to disambiguate multiple tree-sitter symbol matches.
54///
55/// For each candidate match, checks whether any hint's name + file + line aligns
56/// (the hint line falls within the symbol's start_line..=end_line range). If
57/// exactly one candidate aligns with a hint, returns just that match. Otherwise,
58/// returns all matches unchanged (graceful fallback).
59pub fn apply_lsp_disambiguation(matches: Vec<SymbolMatch>, hints: &LspHints) -> Vec<SymbolMatch> {
60    if matches.len() <= 1 || hints.symbols.is_empty() {
61        return matches;
62    }
63
64    let aligned_indices: Vec<usize> = matches
65        .iter()
66        .enumerate()
67        .filter_map(|(i, m)| {
68            let is_aligned = hints.symbols.iter().any(|hint| {
69                let hint_file = strip_file_uri(&hint.file);
70                hint.name == m.symbol.name
71                    && paths_match(hint_file, &m.file)
72                    && hint.line >= m.symbol.range.start_line
73                    && hint.line <= m.symbol.range.end_line
74            });
75            if is_aligned {
76                Some(i)
77            } else {
78                None
79            }
80        })
81        .collect();
82
83    // Only disambiguate if we narrowed to exactly one match.
84    // If zero or multiple still match, fall back to all original candidates.
85    if aligned_indices.len() == 1 {
86        let idx = aligned_indices[0];
87        matches
88            .into_iter()
89            .nth(idx)
90            .map_or_else(Vec::new, |m| vec![m])
91    } else {
92        matches
93    }
94}
95
96/// Check if two file paths refer to the same file.
97/// Compares canonical paths when possible, then falls back to separator-bounded suffix matching.
98fn paths_match(hint_path: &str, match_path: &str) -> bool {
99    if let (Ok(hint), Ok(m)) = (
100        std::fs::canonicalize(Path::new(hint_path)),
101        std::fs::canonicalize(Path::new(match_path)),
102    ) {
103        return hint == m;
104    }
105
106    let hint = hint_path.replace('\\', "/");
107    let m = match_path.replace('\\', "/");
108
109    if hint == m {
110        return true;
111    }
112
113    if hint.len() >= m.len() {
114        suffix_at_separator_boundary(&hint, &m)
115    } else {
116        suffix_at_separator_boundary(&m, &hint)
117    }
118}
119
120fn suffix_at_separator_boundary(longer: &str, shorter: &str) -> bool {
121    if shorter.is_empty() || longer.len() <= shorter.len() || !longer.ends_with(shorter) {
122        return false;
123    }
124
125    longer.as_bytes()[longer.len() - shorter.len() - 1] == b'/'
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::symbols::{Range, Symbol, SymbolKind, SymbolMatch};
132
133    fn make_request(lsp_hints: Option<serde_json::Value>) -> RawRequest {
134        RawRequest {
135            id: "test-1".into(),
136            command: "edit_symbol".into(),
137            lsp_hints,
138            session_id: None,
139            params: serde_json::json!({}),
140        }
141    }
142
143    fn make_match(
144        name: &str,
145        file: &str,
146        start_line: u32,
147        end_line: u32,
148        kind: SymbolKind,
149    ) -> SymbolMatch {
150        SymbolMatch {
151            symbol: Symbol {
152                name: name.into(),
153                kind,
154                range: Range {
155                    start_line,
156                    start_col: 0,
157                    end_line,
158                    end_col: 0,
159                },
160                signature: None,
161                scope_chain: vec![],
162                exported: true,
163                parent: None,
164            },
165            file: file.into(),
166        }
167    }
168
169    // --- Parsing tests ---
170
171    #[test]
172    fn parse_valid_hints() {
173        let req = make_request(Some(serde_json::json!({
174            "symbols": [
175                {"name": "process", "file": "src/app.ts", "line": 10, "kind": "function"},
176                {"name": "process", "file": "src/app.ts", "line": 25}
177            ]
178        })));
179        let hints = parse_lsp_hints(&req).unwrap();
180        assert_eq!(hints.symbols.len(), 2);
181        assert_eq!(hints.symbols[0].name, "process");
182        assert_eq!(hints.symbols[0].kind, Some("function".into()));
183        assert_eq!(hints.symbols[1].kind, None);
184    }
185
186    #[test]
187    fn parse_absent_hints_returns_none() {
188        let req = make_request(None);
189        assert!(parse_lsp_hints(&req).is_none());
190    }
191
192    #[test]
193    fn parse_malformed_json_returns_none() {
194        // Missing required "symbols" field
195        let req = make_request(Some(serde_json::json!({"bad": "data"})));
196        assert!(parse_lsp_hints(&req).is_none());
197    }
198
199    #[test]
200    fn parse_empty_symbols_array() {
201        let req = make_request(Some(serde_json::json!({"symbols": []})));
202        let hints = parse_lsp_hints(&req).unwrap();
203        assert!(hints.symbols.is_empty());
204    }
205
206    #[test]
207    fn parse_missing_required_field_in_hint() {
208        // Each hint requires name, file, line — missing "line" here
209        let req = make_request(Some(serde_json::json!({
210            "symbols": [{"name": "foo", "file": "bar.ts"}]
211        })));
212        assert!(parse_lsp_hints(&req).is_none());
213    }
214
215    // --- Disambiguation tests ---
216
217    #[test]
218    fn disambiguate_single_match_by_line() {
219        let matches = vec![
220            make_match("process", "src/app.ts", 2, 4, SymbolKind::Function),
221            make_match("process", "src/app.ts", 7, 10, SymbolKind::Method),
222        ];
223        let hints = LspHints {
224            symbols: vec![LspSymbolHint {
225                name: "process".into(),
226                file: "src/app.ts".into(),
227                line: 3,
228                kind: None,
229            }],
230        };
231        let result = apply_lsp_disambiguation(matches, &hints);
232        assert_eq!(result.len(), 1);
233        assert_eq!(result[0].symbol.range.start_line, 2);
234    }
235
236    #[test]
237    fn disambiguate_no_match_returns_all() {
238        let matches = vec![
239            make_match("process", "src/app.ts", 2, 4, SymbolKind::Function),
240            make_match("process", "src/app.ts", 7, 10, SymbolKind::Method),
241        ];
242        let hints = LspHints {
243            symbols: vec![LspSymbolHint {
244                name: "process".into(),
245                file: "other/file.ts".into(),
246                line: 99,
247                kind: None,
248            }],
249        };
250        let result = apply_lsp_disambiguation(matches, &hints);
251        assert_eq!(
252            result.len(),
253            2,
254            "no hint matches → fallback to all candidates"
255        );
256    }
257
258    #[test]
259    fn disambiguate_stale_hint_ignored() {
260        // Hint line doesn't fall in any symbol's range
261        let matches = vec![
262            make_match("process", "src/app.ts", 2, 4, SymbolKind::Function),
263            make_match("process", "src/app.ts", 7, 10, SymbolKind::Method),
264        ];
265        let hints = LspHints {
266            symbols: vec![LspSymbolHint {
267                name: "process".into(),
268                file: "src/app.ts".into(),
269                line: 50, // stale — doesn't match either range
270                kind: None,
271            }],
272        };
273        let result = apply_lsp_disambiguation(matches, &hints);
274        assert_eq!(
275            result.len(),
276            2,
277            "stale hint should fall back to all candidates"
278        );
279    }
280
281    #[test]
282    fn disambiguate_file_uri_stripped() {
283        let matches = vec![
284            make_match("handler", "src/api.ts", 10, 20, SymbolKind::Function),
285            make_match("handler", "src/api.ts", 30, 40, SymbolKind::Function),
286        ];
287        let hints = LspHints {
288            symbols: vec![LspSymbolHint {
289                name: "handler".into(),
290                file: "file://src/api.ts".into(),
291                line: 15,
292                kind: None,
293            }],
294        };
295        let result = apply_lsp_disambiguation(matches, &hints);
296        assert_eq!(result.len(), 1);
297        assert_eq!(result[0].symbol.range.start_line, 10);
298    }
299
300    #[test]
301    fn disambiguate_single_input_unchanged() {
302        let matches = vec![make_match("foo", "bar.ts", 1, 5, SymbolKind::Function)];
303        let hints = LspHints {
304            symbols: vec![LspSymbolHint {
305                name: "foo".into(),
306                file: "bar.ts".into(),
307                line: 3,
308                kind: None,
309            }],
310        };
311        let result = apply_lsp_disambiguation(matches, &hints);
312        assert_eq!(result.len(), 1);
313    }
314
315    // --- Path matching tests ---
316
317    #[test]
318    fn paths_match_exact() {
319        assert!(paths_match("src/app.ts", "src/app.ts"));
320    }
321
322    #[test]
323    fn paths_match_suffix() {
324        assert!(paths_match("/home/user/project/src/app.ts", "src/app.ts"));
325    }
326
327    #[test]
328    fn paths_match_filename_suffix_at_separator_boundary() {
329        assert!(paths_match("/home/user/project/src/app.ts", "app.ts"));
330    }
331
332    #[test]
333    fn paths_do_not_match_partial_filename_suffixes() {
334        assert!(!paths_match("foo/bar/baz.ts", "z.ts"));
335        assert!(!paths_match("foo/bar.ts", "ar.ts"));
336    }
337
338    #[test]
339    fn paths_no_match() {
340        assert!(!paths_match("src/other.ts", "src/app.ts"));
341    }
342}