mcpls_core/bridge/translator/
symbols.rs1use lsp_types::{
4 DocumentSymbol, DocumentSymbolParams, PartialResultParams, TextDocumentIdentifier,
5 WorkDoneProgressParams, WorkspaceSymbolParams as LspWorkspaceSymbolParams,
6};
7
8use super::Translator;
9use super::dto::{DocumentSymbolsResult, Location, Symbol, WorkspaceSymbol, WorkspaceSymbolResult};
10use super::encoding_ctx::EncodingCtx;
11use crate::bridge::lock_std;
12use crate::config::{NoServerReason, ToolKind};
13use crate::error::{Error, Result};
14use crate::lsp::SUPPORTED_SYMBOL_KINDS;
15
16fn validate_workspace_symbol_params(query: &str, kind_filter: Option<&str>) -> Result<()> {
18 const MAX_QUERY_LENGTH: usize = 1000;
19
20 if query.len() > MAX_QUERY_LENGTH {
21 return Err(Error::InvalidToolParams(format!(
22 "Query too long: {} bytes (max {MAX_QUERY_LENGTH})",
23 query.len()
24 )));
25 }
26
27 if let Some(kind) = kind_filter
28 && !SUPPORTED_SYMBOL_KINDS
29 .iter()
30 .any(|k| format!("{k:?}").eq_ignore_ascii_case(kind))
31 {
32 let valid: Vec<String> = SUPPORTED_SYMBOL_KINDS
33 .iter()
34 .map(|k| format!("{k:?}"))
35 .collect();
36 return Err(Error::InvalidToolParams(format!(
37 "Invalid kind_filter: '{kind}'. Valid values: {valid:?}"
38 )));
39 }
40
41 Ok(())
42}
43
44fn convert_document_symbol<'a>(
51 symbol: DocumentSymbol,
52 ctx: &'a EncodingCtx,
53 uri: &'a lsp_types::Uri,
54) -> futures::future::BoxFuture<'a, Symbol> {
55 Box::pin(async move {
56 let range = ctx.normalize_range(uri, symbol.range).await;
57 let selection_range = ctx.normalize_range(uri, symbol.selection_range).await;
58 let children = match symbol.children {
59 Some(children) => {
60 let mut result = Vec::with_capacity(children.len());
61 for child in children {
62 result.push(convert_document_symbol(child, ctx, uri).await);
63 }
64 Some(result)
65 }
66 None => None,
67 };
68
69 Symbol {
70 name: symbol.name,
71 kind: format!("{:?}", symbol.kind),
72 range,
73 selection_range,
74 children,
75 }
76 })
77}
78
79impl Translator {
80 pub async fn handle_document_symbols(
87 &self,
88 file_path: String,
89 ) -> Result<DocumentSymbolsResult> {
90 let (server_id, client, uri) = self
91 .prepare_gated_document(
92 &file_path,
93 ToolKind::DocumentSymbols,
94 "documentSymbolProvider",
95 |caps| {
96 matches!(
97 caps.document_symbol_provider,
98 Some(
99 lsp_types::DocumentSymbolProvider::Bool(true)
100 | lsp_types::DocumentSymbolProvider::DocumentSymbolOptions(_)
101 )
102 )
103 },
104 )
105 .await?;
106 let ctx = self.encoding_ctx(&server_id);
107 let response_uri = uri.clone();
108
109 let params = DocumentSymbolParams {
110 text_document: TextDocumentIdentifier { uri },
111 work_done_progress_params: WorkDoneProgressParams::default(),
112 partial_result_params: PartialResultParams::default(),
113 };
114
115 let response = client
116 .request_typed::<lsp_types::DocumentSymbolRequest>(params, client.request_timeout())
117 .await?;
118
119 let symbols = match response {
120 Some(lsp_types::DocumentSymbolResponse::SymbolInformationList(symbols)) => {
121 let mut result = Vec::with_capacity(symbols.len());
122 for sym in symbols {
123 let range = ctx
124 .normalize_range(&sym.location.uri, sym.location.range)
125 .await;
126 let selection_range = range.clone();
127 result.push(Symbol {
128 name: sym.base_symbol_information.name,
129 kind: format!("{:?}", sym.base_symbol_information.kind),
130 range,
131 selection_range,
132 children: None,
133 });
134 }
135 result
136 }
137 Some(lsp_types::DocumentSymbolResponse::DocumentSymbolList(symbols)) => {
138 let mut result = Vec::with_capacity(symbols.len());
139 for sym in symbols {
140 result.push(convert_document_symbol(sym, &ctx, &response_uri).await);
141 }
142 result
143 }
144 None => vec![],
145 };
146
147 Ok(DocumentSymbolsResult { symbols })
148 }
149
150 #[allow(clippy::too_many_lines)]
157 pub async fn handle_workspace_symbol(
158 &self,
159 query: String,
160 kind_filter: Option<String>,
161 limit: u32,
162 ) -> Result<WorkspaceSymbolResult> {
163 validate_workspace_symbol_params(&query, kind_filter.as_deref())?;
164
165 let server_id = lock_std(&self.router)
170 .resolve_any(ToolKind::WorkspaceSymbols)
171 .cloned()
172 .map_err(|reason| match reason {
173 NoServerReason::NothingRegistered => {
179 if lock_std(&self.expected_servers).is_empty() {
180 Error::NoServerConfigured
181 } else {
182 Error::WorkspaceServersInitializing
183 }
184 }
185 NoServerReason::NoClaimant => Error::NoServerForWorkspaceTool {
186 tool: ToolKind::WorkspaceSymbols,
187 },
188 })?;
189 self.respawn_if_dead(&server_id).await?;
190 let client = lock_std(&self.lsp_clients).get(&server_id).cloned();
191 let client = client.ok_or_else(|| {
192 if lock_std(&self.expected_servers).contains(&server_id) {
193 Error::ServerInitializing {
194 server_id: server_id.clone(),
195 }
196 } else {
197 Error::NoServerConfigured
198 }
199 })?;
200 self.require_capability(&server_id, "workspaceSymbolProvider", |caps| {
201 matches!(
202 caps.workspace_symbol_provider,
203 Some(
204 lsp_types::WorkspaceSymbolProvider::Bool(true)
205 | lsp_types::WorkspaceSymbolProvider::WorkspaceSymbolOptions(_)
206 )
207 )
208 })?;
209
210 let params = LspWorkspaceSymbolParams {
211 query,
212 work_done_progress_params: WorkDoneProgressParams::default(),
213 partial_result_params: PartialResultParams::default(),
214 };
215
216 let response = client
217 .request_typed::<lsp_types::WorkspaceSymbolRequest>(params, client.request_timeout())
218 .await?;
219
220 let ctx = self.encoding_ctx(&server_id);
221 let mut symbols: Vec<WorkspaceSymbol> = Vec::new();
222 match response {
223 Some(lsp_types::WorkspaceSymbolResponse::SymbolInformationList(list)) => {
224 for sym in list {
225 let range = ctx
226 .normalize_range(&sym.location.uri, sym.location.range)
227 .await;
228 symbols.push(WorkspaceSymbol {
229 name: sym.base_symbol_information.name,
230 kind: format!("{:?}", sym.base_symbol_information.kind),
231 location: Location {
232 uri: sym.location.uri.to_string(),
233 range,
234 },
235 container_name: sym.base_symbol_information.container_name,
236 });
237 }
238 }
239 Some(lsp_types::WorkspaceSymbolResponse::WorkspaceSymbolList(list)) => {
240 for sym in list {
241 let (uri, range) = match sym.location {
242 lsp_types::WorkspaceSymbolLocation::Location(loc) => {
243 let range = ctx.normalize_range(&loc.uri, loc.range).await;
244 (loc.uri.to_string(), range)
245 }
246 lsp_types::WorkspaceSymbolLocation::LocationUriOnly(_) => continue,
254 };
255 symbols.push(WorkspaceSymbol {
256 name: sym.base_symbol_information.name,
257 kind: format!("{:?}", sym.base_symbol_information.kind),
258 location: Location { uri, range },
259 container_name: sym.base_symbol_information.container_name,
260 });
261 }
262 }
263 None => {}
264 }
265
266 if let Some(kind) = kind_filter {
268 symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind));
269 }
270
271 symbols.truncate(limit as usize);
273
274 Ok(WorkspaceSymbolResult { symbols })
275 }
276}
277
278#[cfg(test)]
279#[allow(clippy::unwrap_used, clippy::expect_used)]
280mod tests {
281 use std::collections::{HashMap, HashSet};
282 use std::fs;
283 use std::sync::Arc;
284 use std::time::Duration;
285
286 use tempfile::TempDir;
287 use tokio::io::BufReader;
288 use tokio::time::timeout;
289
290 use super::*;
291 use crate::bridge::translator::testing::*;
292 use crate::config::{ServerId, ToolRouter};
293
294 #[test]
301 fn test_symbol_kind_debug_rendering_is_pinned() {
302 assert_eq!(
303 format!("{:?}", lsp_types::SymbolKind::EnumMember),
304 "EnumMember"
305 );
306 }
307
308 #[test]
309 fn test_validate_workspace_symbol_params_accepts_known_kind() {
310 assert!(validate_workspace_symbol_params("q", Some("EnumMember")).is_ok());
311 }
312
313 #[test]
314 fn test_validate_workspace_symbol_params_rejects_unknown_kind() {
315 let result = validate_workspace_symbol_params("q", Some("NotAKind"));
316 assert!(matches!(result, Err(Error::InvalidToolParams(_))));
317 }
318
319 #[tokio::test]
320 async fn test_handle_workspace_symbol_no_server() {
321 let translator = Translator::new();
322 let result = translator
323 .handle_workspace_symbol("test".to_string(), None, 100)
324 .await;
325 assert!(matches!(result, Err(Error::NoServerConfigured)));
326 }
327
328 #[tokio::test]
334 async fn test_handle_workspace_symbol_reports_initializing_when_expected_but_not_registered() {
335 let translator = Translator::new();
336 translator.set_expected_servers(HashSet::from([ServerId::from("pyright")]));
337
338 let result = translator
339 .handle_workspace_symbol("test".to_string(), None, 100)
340 .await;
341 assert!(matches!(result, Err(Error::WorkspaceServersInitializing)));
342 }
343
344 #[tokio::test]
350 async fn test_handle_workspace_symbol_no_claimant_names_tool() {
351 let configs = vec![crate::config::LspServerConfig {
352 language_id: "python".to_string(),
353 command: "pyright-langserver".to_string(),
354 args: vec![],
355 env: HashMap::new(),
356 file_patterns: vec![],
357 initialization_options: None,
358 timeout_seconds: 30,
359 request_timeout_seconds: 30,
360 heuristics: None,
361 name: Some("pyright".to_string()),
362 handles: Some(vec![ToolKind::Hover]),
363 }];
364 let router = ToolRouter::from_configs(&configs).unwrap();
365 let translator = Translator::new().with_router(router);
366
367 let result = translator
368 .handle_workspace_symbol("test".to_string(), None, 100)
369 .await;
370 assert!(matches!(
371 result,
372 Err(Error::NoServerForWorkspaceTool {
373 tool: ToolKind::WorkspaceSymbols
374 })
375 ));
376 }
377
378 #[tokio::test]
384 async fn test_handle_document_symbols_flat_response_selection_range_matches_range() {
385 let dir = TempDir::new().unwrap();
386 let server_id = ServerId::from("rust");
387 let (translator, mut server) = translator_with_capabilities(
388 &dir,
389 &server_id,
390 lsp_types::ServerCapabilities {
391 document_symbol_provider: Some(lsp_types::DocumentSymbolProvider::Bool(true)),
392 ..Default::default()
393 },
394 );
395
396 let path = dir.path().join("main.rs");
397 fs::write(&path, "fn main() {}\n").unwrap();
398 let path_str = path.to_string_lossy().to_string();
399
400 let translator = Arc::new(translator);
401 let handle = {
402 let translator = Arc::clone(&translator);
403 tokio::spawn(async move { translator.handle_document_symbols(path_str).await })
404 };
405
406 let mut wire = BufReader::new(&mut server.write_stdout);
407 let opened = read_framed_message(&mut wire).await;
408 assert_eq!(opened["method"], "textDocument/didOpen");
409 let symbol_request = read_framed_message(&mut wire).await;
410 assert_eq!(symbol_request["method"], "textDocument/documentSymbol");
411
412 write_response(
413 &mut server.read_half_stdin,
414 &symbol_request["id"],
415 serde_json::json!([{
416 "name": "main",
417 "kind": 12,
418 "location": {
419 "uri": "file:///main.rs",
420 "range": {
421 "start": {"line": 0, "character": 0},
422 "end": {"line": 0, "character": 12},
423 },
424 },
425 }]),
426 )
427 .await;
428
429 let result = timeout(Duration::from_secs(2), handle)
430 .await
431 .expect("handler call should not hang")
432 .unwrap()
433 .expect("flat document symbol response should succeed");
434
435 assert_eq!(result.symbols.len(), 1);
436 assert_eq!(result.symbols[0].range, result.symbols[0].selection_range);
437 }
438
439 #[tokio::test]
445 async fn test_handle_workspace_symbol_drops_location_uri_only_entries() {
446 let dir = TempDir::new().unwrap();
447 let server_id = ServerId::from("rust");
448 let caps = lsp_types::ServerCapabilities {
449 workspace_symbol_provider: Some(lsp_types::WorkspaceSymbolProvider::Bool(true)),
450 ..Default::default()
451 };
452 let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
453
454 let translator = Arc::new(translator);
455 let handle = {
456 let translator = Arc::clone(&translator);
457 tokio::spawn(async move {
458 translator
459 .handle_workspace_symbol("foo".to_string(), None, 100)
460 .await
461 })
462 };
463
464 let mut wire = BufReader::new(&mut server.write_stdout);
465 let request = read_framed_message(&mut wire).await;
466 assert_eq!(request["method"], "workspace/symbol");
467
468 write_response(
475 &mut server.read_half_stdin,
476 &request["id"],
477 serde_json::json!([
478 {
479 "name": "with_range",
480 "kind": 12,
481 "location": {
482 "uri": "file:///a.rs",
483 "range": {
484 "start": {"line": 0, "character": 0},
485 "end": {"line": 0, "character": 5}
486 }
487 }
488 },
489 {
490 "name": "without_range",
491 "kind": 12,
492 "location": { "uri": "file:///b.rs" }
493 }
494 ]),
495 )
496 .await;
497
498 let result = timeout(Duration::from_secs(2), handle)
499 .await
500 .expect("handler call should not hang")
501 .unwrap()
502 .unwrap();
503
504 assert_eq!(
505 result.symbols.len(),
506 1,
507 "the range-less LocationUriOnly symbol must be dropped, not fabricated"
508 );
509 assert_eq!(result.symbols[0].name, "with_range");
510 }
511}