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};
14
15/// Validate parameters for `handle_workspace_symbol`.
16fn validate_workspace_symbol_params(query: &str, kind_filter: Option<&str>) -> Result<()> {
17    const MAX_QUERY_LENGTH: usize = 1000;
18    const VALID_SYMBOL_KINDS: &[&str] = &[
19        "File",
20        "Module",
21        "Namespace",
22        "Package",
23        "Class",
24        "Method",
25        "Property",
26        "Field",
27        "Constructor",
28        "Enum",
29        "Interface",
30        "Function",
31        "Variable",
32        "Constant",
33        "String",
34        "Number",
35        "Boolean",
36        "Array",
37        "Object",
38        "Key",
39        "Null",
40        "EnumMember",
41        "Struct",
42        "Event",
43        "Operator",
44        "TypeParameter",
45    ];
46
47    if query.len() > MAX_QUERY_LENGTH {
48        return Err(Error::InvalidToolParams(format!(
49            "Query too long: {} bytes (max {MAX_QUERY_LENGTH})",
50            query.len()
51        )));
52    }
53
54    if let Some(kind) = kind_filter
55        && !VALID_SYMBOL_KINDS
56            .iter()
57            .any(|k| k.eq_ignore_ascii_case(kind))
58    {
59        return Err(Error::InvalidToolParams(format!(
60            "Invalid kind_filter: '{kind}'. Valid values: {VALID_SYMBOL_KINDS:?}"
61        )));
62    }
63
64    Ok(())
65}
66
67/// Convert LSP document symbol to MCP symbol. `uri` is the queried
68/// document's own URI: nested `DocumentSymbol` entries have no URI of their
69/// own, since `textDocument/documentSymbol` is always scoped to one file.
70///
71/// Boxed because it recurses through `children` and an `async fn` cannot
72/// call itself directly (its future would have unbounded size).
73fn convert_document_symbol<'a>(
74    symbol: DocumentSymbol,
75    ctx: &'a EncodingCtx,
76    uri: &'a lsp_types::Uri,
77) -> futures::future::BoxFuture<'a, Symbol> {
78    Box::pin(async move {
79        let range = ctx.normalize_range(uri, symbol.range).await;
80        let selection_range = ctx.normalize_range(uri, symbol.selection_range).await;
81        let children = match symbol.children {
82            Some(children) => {
83                let mut result = Vec::with_capacity(children.len());
84                for child in children {
85                    result.push(convert_document_symbol(child, ctx, uri).await);
86                }
87                Some(result)
88            }
89            None => None,
90        };
91
92        Symbol {
93            name: symbol.name,
94            kind: format!("{:?}", symbol.kind),
95            range,
96            selection_range,
97            children,
98        }
99    })
100}
101
102impl Translator {
103    /// Handle document symbols request.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the LSP request fails, the file cannot be opened,
108    /// or the routed server does not advertise `documentSymbolProvider` support.
109    pub async fn handle_document_symbols(
110        &self,
111        file_path: String,
112    ) -> Result<DocumentSymbolsResult> {
113        let (server_id, client, uri) = self
114            .prepare_gated_document(
115                &file_path,
116                ToolKind::DocumentSymbols,
117                "documentSymbolProvider",
118                |caps| {
119                    matches!(
120                        caps.document_symbol_provider,
121                        Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_))
122                    )
123                },
124            )
125            .await?;
126        let ctx = self.encoding_ctx(&server_id);
127        let response_uri = uri.clone();
128
129        let params = DocumentSymbolParams {
130            text_document: TextDocumentIdentifier { uri },
131            work_done_progress_params: WorkDoneProgressParams::default(),
132            partial_result_params: PartialResultParams::default(),
133        };
134
135        let response: Option<lsp_types::DocumentSymbolResponse> = client
136            .request(
137                "textDocument/documentSymbol",
138                params,
139                client.request_timeout(),
140            )
141            .await?;
142
143        let symbols = match response {
144            Some(lsp_types::DocumentSymbolResponse::Flat(symbols)) => {
145                let mut result = Vec::with_capacity(symbols.len());
146                for sym in symbols {
147                    let range = ctx
148                        .normalize_range(&sym.location.uri, sym.location.range)
149                        .await;
150                    let selection_range = ctx
151                        .normalize_range(&sym.location.uri, sym.location.range)
152                        .await;
153                    result.push(Symbol {
154                        name: sym.name,
155                        kind: format!("{:?}", sym.kind),
156                        range,
157                        selection_range,
158                        children: None,
159                    });
160                }
161                result
162            }
163            Some(lsp_types::DocumentSymbolResponse::Nested(symbols)) => {
164                let mut result = Vec::with_capacity(symbols.len());
165                for sym in symbols {
166                    result.push(convert_document_symbol(sym, &ctx, &response_uri).await);
167                }
168                result
169            }
170            None => vec![],
171        };
172
173        Ok(DocumentSymbolsResult { symbols })
174    }
175
176    /// Handle workspace symbol search.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if the LSP request fails, no server is configured, or
181    /// the routed server does not advertise `workspaceSymbolProvider` support.
182    pub async fn handle_workspace_symbol(
183        &self,
184        query: String,
185        kind_filter: Option<String>,
186        limit: u32,
187    ) -> Result<WorkspaceSymbolResult> {
188        validate_workspace_symbol_params(&query, kind_filter.as_deref())?;
189
190        // Workspace search has no document, so it resolves via `resolve_any`
191        // rather than a per-language route. If the resolved server is not
192        // registered yet but is expected, tell the caller to wait and retry
193        // rather than implying nothing is configured.
194        let server_id = lock_std(&self.router)
195            .resolve_any(ToolKind::WorkspaceSymbols)
196            .cloned()
197            .map_err(|reason| match reason {
198                // `resolve_any` reports "nothing registered", which also
199                // covers a server that is configured but has not finished
200                // spawning yet -- check `expected_servers` (unavailable to
201                // `ToolRouter` itself) to tell the two apart, mirroring
202                // `get_client_for_file`'s `ServerInitializing` check below.
203                NoServerReason::NothingRegistered => {
204                    if lock_std(&self.expected_servers).is_empty() {
205                        Error::NoServerConfigured
206                    } else {
207                        Error::WorkspaceServersInitializing
208                    }
209                }
210                NoServerReason::NoClaimant => Error::NoServerForWorkspaceTool {
211                    tool: ToolKind::WorkspaceSymbols,
212                },
213            })?;
214        self.respawn_if_dead(&server_id).await?;
215        let client = lock_std(&self.lsp_clients).get(&server_id).cloned();
216        let client = client.ok_or_else(|| {
217            if lock_std(&self.expected_servers).contains(&server_id) {
218                Error::ServerInitializing {
219                    server_id: server_id.clone(),
220                }
221            } else {
222                Error::NoServerConfigured
223            }
224        })?;
225        self.require_capability(&server_id, "workspaceSymbolProvider", |caps| {
226            matches!(
227                caps.workspace_symbol_provider,
228                Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_))
229            )
230        })?;
231
232        let params = LspWorkspaceSymbolParams {
233            query,
234            work_done_progress_params: WorkDoneProgressParams::default(),
235            partial_result_params: PartialResultParams::default(),
236        };
237
238        let response: Option<Vec<lsp_types::SymbolInformation>> = client
239            .request("workspace/symbol", params, client.request_timeout())
240            .await?;
241
242        let ctx = self.encoding_ctx(&server_id);
243        let mut symbols: Vec<WorkspaceSymbol> = Vec::new();
244        for sym in response.unwrap_or_default() {
245            let range = ctx
246                .normalize_range(&sym.location.uri, sym.location.range)
247                .await;
248            symbols.push(WorkspaceSymbol {
249                name: sym.name,
250                kind: format!("{:?}", sym.kind),
251                location: Location {
252                    uri: sym.location.uri.to_string(),
253                    range,
254                },
255                container_name: sym.container_name,
256            });
257        }
258
259        // Apply kind filter if specified
260        if let Some(kind) = kind_filter {
261            symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind));
262        }
263
264        // Limit results
265        symbols.truncate(limit as usize);
266
267        Ok(WorkspaceSymbolResult { symbols })
268    }
269}
270
271#[cfg(test)]
272#[allow(clippy::unwrap_used, clippy::expect_used)]
273mod tests {
274    use std::collections::{HashMap, HashSet};
275
276    use super::*;
277    use crate::config::{ServerId, ToolRouter};
278
279    #[tokio::test]
280    async fn test_handle_workspace_symbol_no_server() {
281        let translator = Translator::new();
282        let result = translator
283            .handle_workspace_symbol("test".to_string(), None, 100)
284            .await;
285        assert!(matches!(result, Err(Error::NoServerConfigured)));
286    }
287
288    /// #242/S4 regression: a server is configured and still spawning (large
289    /// project load) rather than never having existed -- the router alone
290    /// cannot tell these apart (both look like "nothing registered"), so
291    /// `handle_workspace_symbol` must consult `expected_servers` to report
292    /// "still initializing" instead of the misleading "no server configured".
293    #[tokio::test]
294    async fn test_handle_workspace_symbol_reports_initializing_when_expected_but_not_registered() {
295        let translator = Translator::new();
296        translator.set_expected_servers(HashSet::from([ServerId::from("pyright")]));
297
298        let result = translator
299            .handle_workspace_symbol("test".to_string(), None, 100)
300            .await;
301        assert!(matches!(result, Err(Error::WorkspaceServersInitializing)));
302    }
303
304    /// #242 regression: a server *is* configured and running, it just
305    /// doesn't claim `workspace_symbols` and there is no catch-all -- the
306    /// error must name the tool rather than collapse into the generic
307    /// "no LSP server configured" message a client would also see if
308    /// nothing were running at all.
309    #[tokio::test]
310    async fn test_handle_workspace_symbol_no_claimant_names_tool() {
311        let configs = vec![crate::config::LspServerConfig {
312            language_id: "python".to_string(),
313            command: "pyright-langserver".to_string(),
314            args: vec![],
315            env: HashMap::new(),
316            file_patterns: vec![],
317            initialization_options: None,
318            timeout_seconds: 30,
319            request_timeout_seconds: 30,
320            heuristics: None,
321            name: Some("pyright".to_string()),
322            handles: Some(vec![ToolKind::Hover]),
323        }];
324        let router = ToolRouter::from_configs(&configs).unwrap();
325        let translator = Translator::new().with_router(router);
326
327        let result = translator
328            .handle_workspace_symbol("test".to_string(), None, 100)
329            .await;
330        assert!(matches!(
331            result,
332            Err(Error::NoServerForWorkspaceTool {
333                tool: ToolKind::WorkspaceSymbols
334            })
335        ));
336    }
337}