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};
14
15fn 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
67fn 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 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 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 let server_id = lock_std(&self.router)
195 .resolve_any(ToolKind::WorkspaceSymbols)
196 .cloned()
197 .map_err(|reason| match reason {
198 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 if let Some(kind) = kind_filter {
261 symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind));
262 }
263
264 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 #[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 #[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}