1use crate::protocol::RawRequest;
9use crate::symbols::SymbolMatch;
10use serde::Deserialize;
11use std::path::Path;
12
13#[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#[derive(Debug, Clone, Deserialize)]
25pub struct LspHints {
26 pub symbols: Vec<LspSymbolHint>,
27}
28
29fn strip_file_uri(path: &str) -> &str {
31 path.strip_prefix("file://").unwrap_or(path)
32}
33
34pub 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
53pub 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 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
96fn 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 #[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 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 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 #[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 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, 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 #[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}