Skip to main content

mcpls_core/bridge/translator/
symbols.rs

1//! Document symbols and workspace symbol search handlers.
2
3use 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
16/// Validate parameters for `handle_workspace_symbol`.
17fn 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
44/// Convert LSP document symbol to MCP symbol. `uri` is the queried
45/// document's own URI: nested `DocumentSymbol` entries have no URI of their
46/// own, since `textDocument/documentSymbol` is always scoped to one file.
47///
48/// Boxed because it recurses through `children` and an `async fn` cannot
49/// call itself directly (its future would have unbounded size).
50fn 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    /// Handle document symbols request.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the LSP request fails, the file cannot be opened,
85    /// or the routed server does not advertise `documentSymbolProvider` support.
86    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    /// Handle workspace symbol search.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if the LSP request fails, no server is configured, or
155    /// the routed server does not advertise `workspaceSymbolProvider` support.
156    #[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        // Workspace search has no document, so it resolves via `resolve_any`
166        // rather than a per-language route. If the resolved server is not
167        // registered yet but is expected, tell the caller to wait and retry
168        // rather than implying nothing is configured.
169        let server_id = lock_std(&self.router)
170            .resolve_any(ToolKind::WorkspaceSymbols)
171            .cloned()
172            .map_err(|reason| match reason {
173                // `resolve_any` reports "nothing registered", which also
174                // covers a server that is configured but has not finished
175                // spawning yet -- check `expected_servers` (unavailable to
176                // `ToolRouter` itself) to tell the two apart, mirroring
177                // `client_for_file`'s `ServerInitializing` check below.
178                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                        // `LocationUriOnly` carries no range -- the server
247                        // deliberately withheld it (e.g. to avoid computing it
248                        // eagerly for every workspace-search result). The MCP
249                        // `Location` DTO has no way to represent "no range", and
250                        // a fabricated range (e.g. line 1) would be
251                        // indistinguishable from a real symbol there, so the
252                        // symbol is dropped rather than inventing coordinates.
253                        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        // Apply kind filter if specified
267        if let Some(kind) = kind_filter {
268            symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind));
269        }
270
271        // Limit results
272        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    /// #355 regression: `validate_workspace_symbol_params` accepts/rejects
295    /// `kind_filter` values based on `SymbolKind`'s derived `Debug` output,
296    /// since `gen-lsp-types` provides no `as_str()`/`Display`. This pins that
297    /// assumption directly so a future `gen-lsp-types` bump that changes the
298    /// `Debug` rendering (e.g. back to a newtype) fails loudly here instead
299    /// of silently diverging from the DTO `kind` strings the responses emit.
300    #[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    /// #242/S4 regression: a server is configured and still spawning (large
329    /// project load) rather than never having existed -- the router alone
330    /// cannot tell these apart (both look like "nothing registered"), so
331    /// `handle_workspace_symbol` must consult `expected_servers` to report
332    /// "still initializing" instead of the misleading "no server configured".
333    #[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    /// #242 regression: a server *is* configured and running, it just
345    /// doesn't claim `workspace_symbols` and there is no catch-all -- the
346    /// error must name the tool rather than collapse into the generic
347    /// "no LSP server configured" message a client would also see if
348    /// nothing were running at all.
349    #[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    /// #361 regression: a `Flat` (`SymbolInformation`) document-symbol
379    /// response has only one range in the wire format, so `selection_range`
380    /// must equal `range` exactly -- not merely be numerically close, which
381    /// a bug re-deriving it via a second `normalize_range` call could still
382    /// produce if line-text resolution raced with a concurrent edit.
383    #[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    /// S1/S4 regression: a `workspace/symbol` response in the newer
440    /// `WorkspaceSymbol[]` shape can mix `Location` (has a range) and
441    /// `LocationUriOnly` (no range) entries in the same response. The
442    /// range-less entry must be dropped, not given a fabricated coordinate
443    /// that would be indistinguishable from a real symbol at that position.
444    #[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        // Untagged `WorkspaceSymbolResponse` deserialization is all-or-nothing
469        // over the whole array: since `with_range`'s sibling below has no
470        // `location.range`, the array as a whole fails to deserialize as
471        // `Vec<SymbolInformation>` and falls through to `Vec<WorkspaceSymbol>`,
472        // where `with_range`'s location becomes `Location` and
473        // `without_range`'s becomes `LocationUriOnly`.
474        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}