Skip to main content

mcpls_core/bridge/translator/
symbols.rs

1//! Document symbols and workspace symbol search handlers.
2
3use lsp_types::{
4    BaseSymbolInformation, DocumentSymbol, PartialResultParams, WorkDoneProgressParams,
5    WorkspaceSymbolParams as LspWorkspaceSymbolParams,
6};
7
8use super::Translator;
9use super::dto::{
10    DocumentSymbolsResult, Location, Symbol, WorkspaceSymbol, WorkspaceSymbolResult,
11    lsp_kind_to_u32,
12};
13use super::encoding_ctx::EncodingCtx;
14use super::kind_filter::SymbolKindFilter;
15use super::navigation::MAX_NORMALIZED_LOCATIONS;
16use super::positioned::{budgeted, document_request};
17use super::routing::{
18    Capability, FileLocalCapability, IndexingGate, PreparedDocument, WorkspaceRouteLookup,
19    lookup_workspace_route,
20};
21use crate::bridge::ClientPath;
22use crate::config::{ServerId, ToolKind};
23use crate::error::{Error, Result};
24use crate::util::lock_std;
25
26/// Validate `query`'s length for `handle_workspace_symbol`.
27fn validate_query_length(query: &str) -> Result<()> {
28    const MAX_QUERY_LENGTH: usize = 1000;
29
30    if query.len() > MAX_QUERY_LENGTH {
31        return Err(Error::InvalidToolParams(format!(
32            "Query too long: {} bytes (max {MAX_QUERY_LENGTH})",
33            query.len()
34        )));
35    }
36
37    Ok(())
38}
39
40/// A `textDocument/documentSymbol` answer together with what is needed to
41/// interpret it: the prepared document (kept alive so the document stays
42/// tracked) and the encoding context of the routed server.
43pub(super) struct FetchedSymbols {
44    pub(super) doc: PreparedDocument,
45    pub(super) ctx: EncodingCtx,
46    pub(super) response: Option<lsp_types::DocumentSymbolResponse>,
47}
48
49/// Convert LSP document symbol to MCP symbol. `uri` is the queried
50/// document's own URI: nested `DocumentSymbol` entries have no URI of their
51/// own, since `textDocument/documentSymbol` is always scoped to one file.
52///
53/// Boxed because it recurses through `children` and an `async fn` cannot
54/// call itself directly (its future would have unbounded size).
55fn convert_document_symbol<'a>(
56    symbol: DocumentSymbol,
57    ctx: &'a EncodingCtx,
58    uri: &'a lsp_types::Uri,
59) -> futures::future::BoxFuture<'a, Symbol> {
60    Box::pin(async move {
61        let range = ctx.normalize_range(uri, symbol.range).await;
62        let selection_range = ctx.normalize_range(uri, symbol.selection_range).await;
63        let children = match symbol.children {
64            Some(children) => {
65                let mut result = Vec::with_capacity(children.len());
66                for child in children {
67                    result.push(convert_document_symbol(child, ctx, uri).await);
68                }
69                Some(result)
70            }
71            None => None,
72        };
73
74        Symbol {
75            name: symbol.name,
76            kind: lsp_kind_to_u32(symbol.kind),
77            range,
78            selection_range,
79            children,
80        }
81    })
82}
83
84/// A `textDocument/documentSymbol` answer normalized into MCP coordinates.
85#[derive(Debug)]
86pub(super) enum DocumentSymbolTree {
87    /// Hierarchical `DocumentSymbol[]`: ancestry is the `children` nesting.
88    Hierarchical(Vec<Symbol>),
89    /// Legacy flat `SymbolInformation[]`: ancestry is at most `containerName`.
90    Flat(Vec<FlatSymbol>),
91}
92
93impl DocumentSymbolTree {
94    /// The symbols as `get_document_symbols` reports them.
95    fn into_symbols(self) -> Vec<Symbol> {
96        match self {
97            Self::Hierarchical(symbols) => symbols,
98            Self::Flat(symbols) => symbols.into_iter().map(|flat| flat.symbol).collect(),
99        }
100    }
101}
102
103/// One entry of a flat `SymbolInformation[]` answer.
104#[derive(Debug)]
105pub(super) struct FlatSymbol {
106    pub(super) symbol: Symbol,
107    pub(super) container_name: Option<String>,
108}
109
110/// Normalizes a `textDocument/documentSymbol` answer into MCP coordinates,
111/// keeping the flat `SymbolInformation` shape distinct so callers can tell
112/// how much ancestry the server reported.
113///
114/// `uri` is the queried document: a flat entry carries its own
115/// `location.uri`, which is not trusted (it feeds `normalize_range` into a
116/// file read), so every entry is normalized against `uri`, the already
117/// resolved document the request was made for.
118pub(super) async fn symbol_tree(
119    response: Option<lsp_types::DocumentSymbolResponse>,
120    ctx: &EncodingCtx,
121    uri: &lsp_types::Uri,
122) -> DocumentSymbolTree {
123    match response {
124        Some(lsp_types::DocumentSymbolResponse::SymbolInformationList(symbols)) => {
125            let mut result = Vec::with_capacity(symbols.len());
126            for sym in symbols {
127                let range = ctx.normalize_range(uri, sym.location.range).await;
128                let selection_range = range.clone();
129                result.push(FlatSymbol {
130                    symbol: Symbol {
131                        name: sym.base_symbol_information.name,
132                        kind: lsp_kind_to_u32(sym.base_symbol_information.kind),
133                        range,
134                        selection_range,
135                        children: None,
136                    },
137                    container_name: sym.base_symbol_information.container_name,
138                });
139            }
140            DocumentSymbolTree::Flat(result)
141        }
142        Some(lsp_types::DocumentSymbolResponse::DocumentSymbolList(symbols)) => {
143            let mut result = Vec::with_capacity(symbols.len());
144            for sym in symbols {
145                result.push(convert_document_symbol(sym, ctx, uri).await);
146            }
147            DocumentSymbolTree::Hierarchical(result)
148        }
149        None => DocumentSymbolTree::Hierarchical(vec![]),
150    }
151}
152
153impl Translator {
154    /// Handle document symbols request.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if the LSP request fails, the file cannot be opened,
159    /// or the routed server does not advertise `documentSymbolProvider` support.
160    pub async fn handle_document_symbols(
161        &self,
162        file_path: ClientPath,
163    ) -> Result<DocumentSymbolsResult> {
164        let FetchedSymbols { doc, ctx, response } =
165            self.request_document_symbols(&file_path).await?;
166        let tree = symbol_tree(response, &ctx, doc.uri()).await;
167
168        Ok(DocumentSymbolsResult {
169            symbols: tree.into_symbols(),
170            positions_degraded: ctx.positions_degraded(),
171        })
172    }
173
174    /// Request `textDocument/documentSymbol` for `file_path`, gated on the
175    /// routed server's `documentSymbolProvider` but not on indexing (the
176    /// answer is file-local).
177    ///
178    /// # Errors
179    ///
180    /// Routing, capability and document errors, or the LSP request's error.
181    pub(super) async fn request_document_symbols(
182        &self,
183        file_path: &ClientPath,
184    ) -> Result<FetchedSymbols> {
185        let doc = self
186            .prepare_gated_document(
187                file_path,
188                IndexingGate::FileLocal(FileLocalCapability::DocumentSymbols),
189            )
190            .await?;
191        self.fetch_document_symbols(doc).await
192    }
193
194    /// Requests `textDocument/documentSymbol` for an already prepared
195    /// document.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if the LSP request fails.
200    pub(super) async fn fetch_document_symbols(
201        &self,
202        doc: PreparedDocument,
203    ) -> Result<FetchedSymbols> {
204        let ctx = self.encoding_ctx(doc.server_id());
205        let response = document_request::<lsp_types::DocumentSymbolRequest>(&doc, ()).await?;
206        Ok(FetchedSymbols { doc, ctx, response })
207    }
208
209    /// Handle workspace symbol search.
210    ///
211    /// Deliberately not gated on indexing readiness, unlike other
212    /// whole-workspace queries (e.g. `references`, call hierarchy
213    /// incoming/outgoing calls): it resolves via `resolve_any` rather than a
214    /// per-file route, so it never goes through `prepare_gated_document`
215    /// (the only chokepoint `IndexingGate` applies to) at all. Whether/how to
216    /// gate it was deferred as a separate open question (spec FR-008) and
217    /// remains a known limitation (#423).
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the LSP request fails, no server is configured, or
222    /// the routed server does not advertise `workspaceSymbolProvider` support.
223    pub async fn handle_workspace_symbol(
224        &self,
225        query: String,
226        kind_filter: Option<SymbolKindFilter>,
227        limit: u32,
228    ) -> Result<WorkspaceSymbolResult> {
229        validate_query_length(&query)?;
230        let kind_filter = kind_filter.map(|filter| u32::from(filter.kind()));
231
232        let server_id = self.workspace_symbol_server()?;
233        self.respawn_if_dead(&server_id).await?;
234        let (client, expected) = {
235            let servers = lock_std(&self.servers);
236            (servers.client(&server_id), servers.is_expected(&server_id))
237        };
238        let client = client.ok_or_else(|| {
239            if expected {
240                Error::ServerInitializing {
241                    server_id: server_id.clone(),
242                }
243            } else {
244                Error::NoServerConfigured
245            }
246        })?;
247        self.require_capability(&server_id, Capability::WorkspaceSymbols)?;
248
249        let params = LspWorkspaceSymbolParams {
250            query,
251            work_done_progress_params: WorkDoneProgressParams::default(),
252            partial_result_params: PartialResultParams::default(),
253        };
254
255        let response = budgeted::<lsp_types::WorkspaceSymbolRequest>(&client, params).await?;
256
257        let ctx = self.encoding_ctx(&server_id);
258
259        // Collected without normalizing each symbol's range yet, so
260        // `kind_filter`/`limit` below can drop entries before paying for
261        // `EncodingCtx::normalize_range` (a disk read on a cache miss) on
262        // each one -- bounds the *normalization* loop's cost to `limit`;
263        // this collection loop itself still scans the full response (#474).
264        let mut raw_symbols = raw_workspace_symbols(response, &ctx);
265        let server_answered_empty = raw_symbols.is_empty();
266
267        if let Some(target) = kind_filter {
268            raw_symbols.retain(|s| s.kind == target);
269        }
270        // `limit` is clamped to MAX_NORMALIZED_LOCATIONS (else u32::MAX
271        // would reopen the unbounded normalization loop, see #474).
272        let effective_limit = (limit as usize).min(MAX_NORMALIZED_LOCATIONS);
273        let truncated = raw_symbols.len() > effective_limit;
274        raw_symbols.truncate(effective_limit);
275
276        let mut symbols = Vec::with_capacity(raw_symbols.len());
277        for raw in raw_symbols {
278            let range = ctx.normalize_range(&raw.uri, raw.range).await;
279            symbols.push(WorkspaceSymbol {
280                name: raw.name,
281                kind: raw.kind,
282                location: Location {
283                    uri: raw.uri.to_string(),
284                    range,
285                    out_of_workspace: raw.out_of_workspace,
286                },
287                container_name: raw.container_name,
288            });
289        }
290
291        let index_coverage = server_answered_empty.then(|| self.index_coverage(&server_id));
292        Ok(WorkspaceSymbolResult {
293            symbols,
294            truncated,
295            positions_degraded: ctx.positions_degraded(),
296            index_coverage,
297        })
298    }
299
300    /// The server a workspace symbol search goes to.
301    ///
302    /// Workspace search has no document, so it resolves via `resolve_any`
303    /// rather than a per-language route. If the resolved server is not
304    /// registered yet but is expected, tell the caller to wait and retry
305    /// rather than implying nothing is configured.
306    fn workspace_symbol_server(&self) -> Result<ServerId> {
307        let router = self.router_snapshot();
308        let lookup = {
309            let servers = lock_std(&self.servers);
310            lookup_workspace_route(
311                || router.resolve_any(ToolKind::WorkspaceSymbols).cloned(),
312                |id| servers.client(id).is_some(),
313                |id| servers.is_expected(id),
314                || !servers.any_expected(),
315            )
316        };
317        let server_id = match lookup {
318            WorkspaceRouteLookup::Registered(id) => id,
319            WorkspaceRouteLookup::Initializing(server_id) => {
320                return Err(Error::ServerInitializing { server_id });
321            }
322            WorkspaceRouteLookup::AllInitializing => {
323                return Err(Error::WorkspaceServersInitializing);
324            }
325            WorkspaceRouteLookup::Dangling(_) => return Err(Error::NoServerConfigured),
326            WorkspaceRouteLookup::NothingConfigured => {
327                let failures = self.startup_failures();
328                return Err(if failures.is_empty() {
329                    Error::NoServerConfigured
330                } else {
331                    Error::AllServersFailedToInit { failures }
332                });
333            }
334            // A claimant that failed to start was rebound away, so the live
335            // router sees no claimant for it -- report the failure instead.
336            WorkspaceRouteLookup::NoClaimant => {
337                return Err(self
338                    .configured_router
339                    .resolve_any(ToolKind::WorkspaceSymbols)
340                    .ok()
341                    .and_then(|id| self.startup_failure(id))
342                    .map_or(
343                        Error::NoServerForWorkspaceTool {
344                            tool: ToolKind::WorkspaceSymbols,
345                        },
346                        |failure| Error::ServerFailedToStart(Box::new(failure)),
347                    ));
348            }
349        };
350        Ok(server_id)
351    }
352}
353
354/// A workspace symbol not yet normalized into MCP coordinates -- lets
355/// [`Translator::handle_workspace_symbol`] apply `kind_filter`/`limit` before
356/// paying for [`EncodingCtx::normalize_range`] on each surviving entry (see
357/// #474).
358struct RawWorkspaceSymbol {
359    name: String,
360    kind: u32,
361    container_name: Option<String>,
362    out_of_workspace: bool,
363    uri: lsp_types::Uri,
364    range: lsp_types::Range,
365}
366
367impl RawWorkspaceSymbol {
368    fn new(
369        base: BaseSymbolInformation,
370        uri: lsp_types::Uri,
371        range: lsp_types::Range,
372        ctx: &EncodingCtx,
373    ) -> Self {
374        Self {
375            name: base.name,
376            kind: lsp_kind_to_u32(base.kind),
377            container_name: base.container_name,
378            out_of_workspace: ctx.is_out_of_workspace(&uri),
379            uri,
380            range,
381        }
382    }
383}
384
385/// The symbols of a workspace symbol response, in server order.
386///
387/// Not filtered to workspace roots -- like other read-only navigation results
388/// (see `WorkspaceRoots::admits_uri`'s doc comment), a legitimate
389/// workspace-symbol result routinely points outside the workspace (stdlib, a
390/// dependency), and any subsequent open/read of it still hits the inbound
391/// `WorkspaceRoots::validate` gate.
392fn raw_workspace_symbols(
393    response: Option<lsp_types::WorkspaceSymbolResponse>,
394    ctx: &EncodingCtx,
395) -> Vec<RawWorkspaceSymbol> {
396    match response {
397        Some(lsp_types::WorkspaceSymbolResponse::SymbolInformationList(list)) => list
398            .into_iter()
399            .map(|sym| {
400                RawWorkspaceSymbol::new(
401                    sym.base_symbol_information,
402                    sym.location.uri,
403                    sym.location.range,
404                    ctx,
405                )
406            })
407            .collect(),
408        Some(lsp_types::WorkspaceSymbolResponse::WorkspaceSymbolList(list)) => list
409            .into_iter()
410            .filter_map(|sym| match sym.location {
411                lsp_types::WorkspaceSymbolLocation::Location(loc) => Some(RawWorkspaceSymbol::new(
412                    sym.base_symbol_information,
413                    loc.uri,
414                    loc.range,
415                    ctx,
416                )),
417                // `LocationUriOnly` carries no range -- the server deliberately
418                // withheld it (e.g. to avoid computing it eagerly for every
419                // workspace-search result). The MCP `Location` DTO has no way
420                // to represent "no range", and a fabricated range (e.g. line 1)
421                // would be indistinguishable from a real symbol there, so the
422                // symbol is dropped rather than inventing coordinates.
423                lsp_types::WorkspaceSymbolLocation::LocationUriOnly(_) => None,
424            })
425            .collect(),
426        None => Vec::new(),
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use std::collections::HashSet;
433    use std::sync::Arc;
434    use std::time::Duration;
435    use std::{assert_matches, fs};
436
437    use tempfile::TempDir;
438    use tokio::io::BufReader;
439    use tokio::time::timeout;
440    use url::Url;
441
442    use super::*;
443    use crate::bridge::translator::dto::{IndexCoverage, PositionDegradation};
444    use crate::bridge::translator::testing::*;
445    use crate::config::{LanguageId, ServerCommand, ServerId, TimeoutSecs, ToolRouter, ToolSet};
446    use crate::test_lsp::client_path;
447
448    /// #355/#467 regression: `SymbolKindFilter`'s name-matching branch
449    /// accepts/rejects `kind_filter` values based on `SymbolKind`'s derived
450    /// `Debug` output, since `gen-lsp-types` provides no `as_str()`/`Display`.
451    /// This pins that assumption directly so a future `gen-lsp-types` bump
452    /// that changes the `Debug` rendering (e.g. back to a newtype) fails
453    /// loudly here instead of silently diverging from the input-filter
454    /// matching logic.
455    #[test]
456    fn test_symbol_kind_debug_rendering_is_pinned() {
457        assert_eq!(
458            format!("{:?}", lsp_types::SymbolKind::EnumMember),
459            "EnumMember"
460        );
461    }
462
463    /// #467 regression: the output `kind` field is the raw LSP wire-format
464    /// `u32`, not the `SymbolKind`'s `Debug` string -- pins the numeric
465    /// behavior that replaced the old, lossy `format!("{:?}", kind)`
466    /// rendering.
467    #[tokio::test]
468    async fn test_convert_document_symbol_kind_is_numeric() {
469        let symbol = DocumentSymbol {
470            name: "my_enum_member".to_string(),
471            detail: None,
472            kind: lsp_types::SymbolKind::EnumMember,
473            tags: None,
474            #[allow(
475                deprecated,
476                reason = "`deprecated` is a deprecated field of the LSP struct literal"
477            )]
478            deprecated: None,
479            range: lsp_types::Range {
480                start: lsp_types::Position {
481                    line: 0,
482                    character: 0,
483                },
484                end: lsp_types::Position {
485                    line: 0,
486                    character: 5,
487                },
488            },
489            selection_range: lsp_types::Range {
490                start: lsp_types::Position {
491                    line: 0,
492                    character: 0,
493                },
494                end: lsp_types::Position {
495                    line: 0,
496                    character: 5,
497                },
498            },
499            children: None,
500        };
501        let ctx = test_ctx();
502        let uri = lsp_types::Uri::from("file:///tmp/test.rs");
503        let result = convert_document_symbol(symbol, &ctx, &uri).await;
504        // SymbolKind::EnumMember is LSP integer 22.
505        assert_eq!(result.kind, 22u32);
506    }
507
508    #[tokio::test]
509    async fn test_handle_workspace_symbol_no_server() {
510        let translator = Translator::new();
511        let result = translator
512            .handle_workspace_symbol("test".to_string(), None, 100)
513            .await;
514        assert_matches!(result, Err(Error::NoServerConfigured));
515    }
516
517    /// #242/S4 regression: a server is configured and still spawning (large
518    /// project load) rather than never having existed -- the router alone
519    /// cannot tell these apart (both look like "nothing registered"), so
520    /// `handle_workspace_symbol` must consult the expected servers to report
521    /// "still initializing" instead of the misleading "no server configured".
522    #[tokio::test]
523    async fn test_handle_workspace_symbol_reports_initializing_when_expected_but_not_registered() {
524        let translator = Translator::new();
525        translator.set_expected_servers(HashSet::from([ServerId::from_static("pyright")]));
526
527        let result = translator
528            .handle_workspace_symbol("test".to_string(), None, 100)
529            .await;
530        assert_matches!(result, Err(Error::WorkspaceServersInitializing));
531    }
532
533    /// #527: workspace search with every server failed to start reports the
534    /// failures instead of "no server configured".
535    #[tokio::test]
536    async fn test_handle_workspace_symbol_reports_startup_failures() {
537        let translator = Translator::new();
538        translator.record_startup_failures(&[crate::error::ServerSpawnFailure {
539            server_id: ServerId::from_static("pyright"),
540            language_id: LanguageId::from_static("python"),
541            command: ServerCommand::from_static("pyright-langserver"),
542            reason: crate::error::StartupFailure::InitTaskPanicked,
543        }]);
544
545        let result = translator
546            .handle_workspace_symbol("test".to_string(), None, 100)
547            .await;
548
549        let Err(Error::AllServersFailedToInit { failures }) = result else {
550            panic!("expected AllServersFailedToInit, got {result:?}");
551        };
552        assert_eq!(failures.len(), 1);
553        assert_eq!(failures[0].server_id, ServerId::from_static("pyright"));
554    }
555
556    /// A failed server that would have claimed workspace search is reported
557    /// instead of "no server handles this tool" while another server lives.
558    #[tokio::test]
559    async fn test_handle_workspace_symbol_reports_failed_claimant() {
560        let mut live = crate::config::LspServerConfig::pyright();
561        live.name = Some(ServerId::from_static("live"));
562        live.handles = Some(ToolSet::new(vec![ToolKind::Hover]).unwrap());
563        let mut failing = crate::config::LspServerConfig::rust_analyzer();
564        failing.name = Some(ServerId::from_static("failing"));
565        failing.handles = Some(ToolSet::new(vec![ToolKind::WorkspaceSymbols]).unwrap());
566        let router = ToolRouter::from_configs([&live, &failing]).unwrap();
567        let failing_id = ServerId::from_static("failing");
568
569        let translator = Translator::new().with_router(router);
570        translator.record_startup_failures(&[crate::error::ServerSpawnFailure {
571            server_id: failing_id.clone(),
572            language_id: LanguageId::from_static("rust"),
573            command: ServerCommand::from_static("rust-analyzer"),
574            reason: crate::error::StartupFailure::InitTaskPanicked,
575        }]);
576        translator.rebind_router(&HashSet::from([ServerId::from_static("live")]));
577        translator.clear_expected_servers();
578
579        let result = translator
580            .handle_workspace_symbol("test".to_string(), None, 100)
581            .await;
582
583        assert_matches!(&result, Err(Error::ServerFailedToStart(f)) if f.server_id == failing_id,
584            "got {result:?}"
585        );
586    }
587
588    /// A server still expected to register wins over a recorded failure.
589    #[tokio::test]
590    async fn test_handle_workspace_symbol_initializing_wins_over_recorded_failure() {
591        let translator = Translator::new();
592        translator.set_expected_servers(HashSet::from([ServerId::from_static("pyright")]));
593        translator.record_startup_failures(&[crate::error::ServerSpawnFailure {
594            server_id: ServerId::from_static("other"),
595            language_id: LanguageId::from_static("go"),
596            command: ServerCommand::from_static("gopls"),
597            reason: crate::error::StartupFailure::InitTaskPanicked,
598        }]);
599
600        let result = translator
601            .handle_workspace_symbol("test".to_string(), None, 100)
602            .await;
603
604        assert_matches!(result, Err(Error::WorkspaceServersInitializing));
605    }
606
607    /// #242 regression: a server *is* configured and running, it just
608    /// doesn't claim `workspace_symbols` and there is no catch-all -- the
609    /// error must name the tool rather than collapse into the generic
610    /// "no LSP server configured" message a client would also see if
611    /// nothing were running at all.
612    #[tokio::test]
613    async fn test_handle_workspace_symbol_no_claimant_names_tool() {
614        let configs = vec![crate::config::LspServerConfig {
615            language_id: LanguageId::from_static("python"),
616            command: ServerCommand::from_static("pyright-langserver").into(),
617            args: crate::config::ServerArgs::default(),
618            env: crate::config::ServerEnv::default(),
619            file_patterns: vec![],
620            initialization_options: None,
621            settings: None,
622            timeout_seconds: TimeoutSecs::new(30).unwrap(),
623            request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
624            heuristics: None,
625            name: Some(ServerId::from_static("pyright")),
626            handles: Some(ToolSet::new(vec![ToolKind::Hover]).unwrap()),
627            indexing: crate::bridge::IndexingPolicy::Auto,
628        }];
629        let router = ToolRouter::from_configs(&configs).unwrap();
630        let translator = Translator::new().with_router(router);
631
632        let result = translator
633            .handle_workspace_symbol("test".to_string(), None, 100)
634            .await;
635        assert_matches!(
636            result,
637            Err(Error::NoServerForWorkspaceTool {
638                tool: ToolKind::WorkspaceSymbols
639            })
640        );
641    }
642
643    /// #361 regression: a `Flat` (`SymbolInformation`) document-symbol
644    /// response has only one range in the wire format, so `selection_range`
645    /// must equal `range` exactly -- not merely be numerically close, which
646    /// a bug re-deriving it via a second `normalize_range` call could still
647    /// produce if line-text resolution raced with a concurrent edit.
648    #[tokio::test]
649    async fn test_handle_document_symbols_flat_response_selection_range_matches_range() {
650        let dir = TempDir::new().unwrap();
651        let server_id = ServerId::from_static("rust");
652        let (translator, mut server) = translator_with_capabilities(
653            &dir,
654            &server_id,
655            lsp_types::ServerCapabilities {
656                document_symbol_provider: Some(lsp_types::DocumentSymbolProvider::Bool(true)),
657                ..Default::default()
658            },
659        );
660
661        let path = dir.path().join("main.rs");
662        fs::write(&path, "fn main() {}\n").unwrap();
663        let path_str = path.to_string_lossy().to_string();
664        let uri = Url::from_file_path(&path).unwrap().to_string();
665
666        let translator = Arc::new(translator);
667        let handle = {
668            let translator = Arc::clone(&translator);
669            tokio::spawn(async move {
670                translator
671                    .handle_document_symbols(client_path(path_str))
672                    .await
673            })
674        };
675
676        let mut wire = BufReader::new(&mut server.write_stdout);
677        let opened = read_framed_message(&mut wire).await;
678        assert_eq!(opened["method"], "textDocument/didOpen");
679        let symbol_request = read_framed_message(&mut wire).await;
680        assert_eq!(symbol_request["method"], "textDocument/documentSymbol");
681
682        write_response(
683            &mut server.read_half_stdin,
684            &symbol_request["id"],
685            serde_json::json!([{
686                "name": "main",
687                "kind": 12,
688                "location": {
689                    "uri": uri,
690                    "range": {
691                        "start": {"line": 0, "character": 0},
692                        "end": {"line": 0, "character": 12},
693                    },
694                },
695            }]),
696        )
697        .await;
698
699        let result = timeout(Duration::from_secs(2), handle)
700            .await
701            .expect("handler call should not hang")
702            .unwrap()
703            .expect("flat document symbol response should succeed");
704
705        assert_eq!(result.symbols.len(), 1);
706        assert_eq!(result.symbols[0].range, result.symbols[0].selection_range);
707    }
708
709    /// M2: a flat `SymbolInformation` document-symbol entry's `location.uri`
710    /// is never trusted for encoding conversion -- `document_symbols` is a
711    /// single-document request by construction, so every entry is
712    /// normalized against `response_uri` (the already-resolved, trusted
713    /// queried document), regardless of what the entry's own `location.uri`
714    /// says. Uses a UTF-8-negotiated server and multibyte content to prove
715    /// this: the entry names a nonexistent out-of-workspace URI, so if that
716    /// URI were used instead, the disk read would fail and the position
717    /// would fall back to the raw, unconverted byte offset (4) rather than
718    /// the correctly re-derived UTF-16 column (3).
719    #[tokio::test]
720    async fn test_handle_document_symbols_flat_response_normalizes_against_response_uri() {
721        let dir = TempDir::new().unwrap();
722        let server_id = ServerId::from_static("rust");
723        let (translator, mut server) = translator_with_capabilities_and_encoding(
724            &dir,
725            &server_id,
726            lsp_types::ServerCapabilities {
727                document_symbol_provider: Some(lsp_types::DocumentSymbolProvider::Bool(true)),
728                ..Default::default()
729            },
730            lsp_types::PositionEncodingKind::UTF8,
731        );
732
733        let path = dir.path().join("main.rs");
734        fs::write(&path, "aöb").unwrap();
735        let path_str = path.to_string_lossy().to_string();
736        let outside_uri = "file:///outside/workspace/does-not-exist.rs";
737
738        let translator = Arc::new(translator);
739        let handle = {
740            let translator = Arc::clone(&translator);
741            tokio::spawn(async move {
742                translator
743                    .handle_document_symbols(client_path(path_str))
744                    .await
745            })
746        };
747
748        let mut wire = BufReader::new(&mut server.write_stdout);
749        let opened = read_framed_message(&mut wire).await;
750        assert_eq!(opened["method"], "textDocument/didOpen");
751        let symbol_request = read_framed_message(&mut wire).await;
752        assert_eq!(symbol_request["method"], "textDocument/documentSymbol");
753
754        write_response(
755            &mut server.read_half_stdin,
756            &symbol_request["id"],
757            serde_json::json!([{
758                "name": "sym",
759                "kind": 12,
760                "location": {
761                    "uri": outside_uri,
762                    "range": {
763                        "start": {"line": 0, "character": 0},
764                        "end": {"line": 0, "character": 3},
765                    },
766                },
767            }]),
768        )
769        .await;
770
771        let result = timeout(Duration::from_secs(2), handle)
772            .await
773            .expect("handler call should not hang")
774            .unwrap()
775            .expect("flat document symbol response should succeed");
776
777        assert_eq!(result.symbols.len(), 1);
778        assert_eq!(
779            result.symbols[0].range.end.character, 3,
780            "must convert against the queried document's own content (\"aöb\"), not fail to \
781             read the entry's own (nonexistent, out-of-workspace) location.uri and fall back to \
782             the raw byte offset"
783        );
784    }
785
786    /// #497 end-to-end: `DocumentSymbolsResult::positions_degraded` must
787    /// become `true` when a returned range's line can't be resolved for
788    /// conversion under a non-UTF-16 server (here: a line past the queried
789    /// document's own EOF) -- proving the flag actually reaches the
790    /// caller-facing DTO, not just `EncodingCtx::positions_degraded()`
791    /// itself.
792    #[tokio::test]
793    async fn test_handle_document_symbols_sets_positions_degraded_for_unresolvable_line() {
794        let dir = TempDir::new().unwrap();
795        let server_id = ServerId::from_static("rust");
796        let (translator, mut server) = translator_with_capabilities_and_encoding(
797            &dir,
798            &server_id,
799            lsp_types::ServerCapabilities {
800                document_symbol_provider: Some(lsp_types::DocumentSymbolProvider::Bool(true)),
801                ..Default::default()
802            },
803            lsp_types::PositionEncodingKind::UTF8,
804        );
805
806        let path = dir.path().join("main.rs");
807        fs::write(&path, "aöb").unwrap();
808        let path_str = path.to_string_lossy().to_string();
809
810        let translator = Arc::new(translator);
811        let handle = {
812            let translator = Arc::clone(&translator);
813            tokio::spawn(async move {
814                translator
815                    .handle_document_symbols(client_path(path_str))
816                    .await
817            })
818        };
819
820        let mut wire = BufReader::new(&mut server.write_stdout);
821        let opened = read_framed_message(&mut wire).await;
822        assert_eq!(opened["method"], "textDocument/didOpen");
823        let symbol_request = read_framed_message(&mut wire).await;
824        assert_eq!(symbol_request["method"], "textDocument/documentSymbol");
825
826        write_response(
827            &mut server.read_half_stdin,
828            &symbol_request["id"],
829            serde_json::json!([{
830                "name": "sym",
831                "kind": 12,
832                // The file has one line -- line 5 doesn't exist, so its
833                // text can't be resolved for UTF-8 conversion.
834                "range": {
835                    "start": {"line": 5, "character": 0},
836                    "end": {"line": 5, "character": 1},
837                },
838                "selectionRange": {
839                    "start": {"line": 5, "character": 0},
840                    "end": {"line": 5, "character": 1},
841                },
842            }]),
843        )
844        .await;
845
846        let result = timeout(Duration::from_secs(2), handle)
847            .await
848            .expect("handler call should not hang")
849            .unwrap()
850            .expect("document symbol response should still succeed, just degraded");
851
852        assert_eq!(result.symbols.len(), 1);
853        assert_eq!(
854            result.positions_degraded,
855            Some(PositionDegradation::Response),
856            "a range whose line can't be resolved must mark the result degraded"
857        );
858    }
859
860    /// S1/S4 regression: a `workspace/symbol` response in the newer
861    /// `WorkspaceSymbol[]` shape can mix `Location` (has a range) and
862    /// `LocationUriOnly` (no range) entries in the same response. The
863    /// range-less entry must be dropped, not given a fabricated coordinate
864    /// that would be indistinguishable from a real symbol at that position.
865    #[tokio::test]
866    async fn test_handle_workspace_symbol_drops_location_uri_only_entries() {
867        let dir = TempDir::new().unwrap();
868        let server_id = ServerId::from_static("rust");
869        let caps = lsp_types::ServerCapabilities {
870            workspace_symbol_provider: Some(lsp_types::WorkspaceSymbolProvider::Bool(true)),
871            ..Default::default()
872        };
873        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
874        let a_uri = Url::from_file_path(dir.path().join("a.rs"))
875            .unwrap()
876            .to_string();
877        let b_uri = Url::from_file_path(dir.path().join("b.rs"))
878            .unwrap()
879            .to_string();
880
881        let translator = Arc::new(translator);
882        let handle = {
883            let translator = Arc::clone(&translator);
884            tokio::spawn(async move {
885                translator
886                    .handle_workspace_symbol("foo".to_string(), None, 100)
887                    .await
888            })
889        };
890
891        let mut wire = BufReader::new(&mut server.write_stdout);
892        let request = read_framed_message(&mut wire).await;
893        assert_eq!(request["method"], "workspace/symbol");
894
895        // Untagged `WorkspaceSymbolResponse` deserialization is all-or-nothing
896        // over the whole array: since `with_range`'s sibling below has no
897        // `location.range`, the array as a whole fails to deserialize as
898        // `Vec<SymbolInformation>` and falls through to `Vec<WorkspaceSymbol>`,
899        // where `with_range`'s location becomes `Location` and
900        // `without_range`'s becomes `LocationUriOnly`.
901        write_response(
902            &mut server.read_half_stdin,
903            &request["id"],
904            serde_json::json!([
905                {
906                    "name": "with_range",
907                    "kind": 12,
908                    "location": {
909                        "uri": a_uri,
910                        "range": {
911                            "start": {"line": 0, "character": 0},
912                            "end": {"line": 0, "character": 5}
913                        }
914                    }
915                },
916                {
917                    "name": "without_range",
918                    "kind": 12,
919                    "location": { "uri": b_uri }
920                }
921            ]),
922        )
923        .await;
924
925        let result = timeout(Duration::from_secs(2), handle)
926            .await
927            .expect("handler call should not hang")
928            .unwrap()
929            .unwrap();
930
931        assert_eq!(
932            result.symbols.len(),
933            1,
934            "the range-less LocationUriOnly symbol must be dropped, not fabricated"
935        );
936        assert_eq!(result.symbols[0].name, "with_range");
937        assert!(
938            result.index_coverage.is_none(),
939            "a non-empty result carries no coverage note"
940        );
941    }
942
943    /// #796: an empty answer carries the coverage note.
944    #[tokio::test]
945    async fn test_handle_workspace_symbol_empty_result_carries_index_coverage() {
946        let dir = TempDir::new().unwrap();
947        let server_id = ServerId::from_static("rust");
948        let caps = lsp_types::ServerCapabilities {
949            workspace_symbol_provider: Some(lsp_types::WorkspaceSymbolProvider::Bool(true)),
950            ..Default::default()
951        };
952        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
953        let translator = Arc::new(translator);
954        let handle = {
955            let translator = Arc::clone(&translator);
956            tokio::spawn(async move {
957                translator
958                    .handle_workspace_symbol("foo".to_string(), None, 100)
959                    .await
960            })
961        };
962
963        let mut wire = BufReader::new(&mut server.write_stdout);
964        let request = read_framed_message(&mut wire).await;
965        write_response(
966            &mut server.read_half_stdin,
967            &request["id"],
968            serde_json::Value::Null,
969        )
970        .await;
971
972        let result = handle.await.unwrap().unwrap();
973        assert!(result.symbols.is_empty());
974        assert_eq!(
975            result.index_coverage,
976            Some(IndexCoverage {
977                synced_documents: 0,
978                server_restarted: false,
979            })
980        );
981    }
982
983    /// #796: an answer emptied by `limit` is not an index-coverage question.
984    #[tokio::test]
985    async fn test_handle_workspace_symbol_limit_zero_carries_no_index_coverage() {
986        let dir = TempDir::new().unwrap();
987        let server_id = ServerId::from_static("rust");
988        let caps = lsp_types::ServerCapabilities {
989            workspace_symbol_provider: Some(lsp_types::WorkspaceSymbolProvider::Bool(true)),
990            ..Default::default()
991        };
992        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
993        let uri = Url::from_file_path(dir.path().join("a.rs"))
994            .unwrap()
995            .to_string();
996        let translator = Arc::new(translator);
997        let handle = {
998            let translator = Arc::clone(&translator);
999            tokio::spawn(async move {
1000                translator
1001                    .handle_workspace_symbol("foo".to_string(), None, 0)
1002                    .await
1003            })
1004        };
1005
1006        let mut wire = BufReader::new(&mut server.write_stdout);
1007        let request = read_framed_message(&mut wire).await;
1008        write_response(
1009            &mut server.read_half_stdin,
1010            &request["id"],
1011            serde_json::json!([{
1012                "name": "foo",
1013                "kind": 12,
1014                "location": {
1015                    "uri": uri,
1016                    "range": {
1017                        "start": {"line": 0, "character": 0},
1018                        "end": {"line": 0, "character": 3}
1019                    }
1020                }
1021            }]),
1022        )
1023        .await;
1024
1025        let result = handle.await.unwrap().unwrap();
1026        assert!(result.symbols.is_empty());
1027        assert!(result.truncated);
1028        assert!(result.index_coverage.is_none());
1029    }
1030
1031    /// #415 (revised: `search_workspace_symbols` is read-only navigation,
1032    /// same policy as `get_definition`/`get_references`/call hierarchy --
1033    /// see `WorkspaceRoots::admits_uri`'s doc comment): a result whose URI falls
1034    /// outside every configured workspace root must still be returned, e.g.
1035    /// a symbol defined in the standard library or a crates.io dependency.
1036    #[tokio::test]
1037    async fn test_handle_workspace_symbol_does_not_filter_out_of_workspace_location() {
1038        let dir = TempDir::new().unwrap();
1039        let server_id = ServerId::from_static("rust");
1040        let caps = lsp_types::ServerCapabilities {
1041            workspace_symbol_provider: Some(lsp_types::WorkspaceSymbolProvider::Bool(true)),
1042            ..Default::default()
1043        };
1044        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1045        let inside_uri = Url::from_file_path(canonical_dir(&dir).join("inside.rs"))
1046            .unwrap()
1047            .to_string();
1048        let outside_uri = "file:///outside/workspace/evil.rs";
1049
1050        let translator = Arc::new(translator);
1051        let handle = {
1052            let translator = Arc::clone(&translator);
1053            tokio::spawn(async move {
1054                translator
1055                    .handle_workspace_symbol("foo".to_string(), None, 100)
1056                    .await
1057            })
1058        };
1059
1060        let mut wire = BufReader::new(&mut server.write_stdout);
1061        let request = read_framed_message(&mut wire).await;
1062        assert_eq!(request["method"], "workspace/symbol");
1063
1064        write_response(
1065            &mut server.read_half_stdin,
1066            &request["id"],
1067            serde_json::json!([
1068                {
1069                    "name": "inside",
1070                    "kind": 12,
1071                    "location": {
1072                        "uri": inside_uri,
1073                        "range": {
1074                            "start": {"line": 0, "character": 0},
1075                            "end": {"line": 0, "character": 5}
1076                        }
1077                    }
1078                },
1079                {
1080                    "name": "outside",
1081                    "kind": 12,
1082                    "location": {
1083                        "uri": outside_uri,
1084                        "range": {
1085                            "start": {"line": 0, "character": 0},
1086                            "end": {"line": 0, "character": 4}
1087                        }
1088                    }
1089                }
1090            ]),
1091        )
1092        .await;
1093
1094        let result = timeout(Duration::from_secs(2), handle)
1095            .await
1096            .expect("handler call should not hang")
1097            .unwrap()
1098            .unwrap();
1099
1100        assert_eq!(
1101            result.symbols.len(),
1102            2,
1103            "both the in-workspace and out-of-workspace symbols must be returned"
1104        );
1105        assert!(result.symbols.iter().any(|s| s.name == "inside"));
1106        assert!(result.symbols.iter().any(|s| s.name == "outside"));
1107        assert!(
1108            !result
1109                .symbols
1110                .iter()
1111                .find(|s| s.name == "inside")
1112                .unwrap()
1113                .location
1114                .out_of_workspace,
1115            "an in-workspace symbol location must not be marked out_of_workspace"
1116        );
1117        assert!(
1118            result
1119                .symbols
1120                .iter()
1121                .find(|s| s.name == "outside")
1122                .unwrap()
1123                .location
1124                .out_of_workspace,
1125            "an out-of-workspace symbol location must be marked out_of_workspace"
1126        );
1127    }
1128
1129    /// Regression for #474: `limit` is a caller-supplied `u32` with no
1130    /// upper bound of its own -- `limit: u32::MAX` must still be clamped to
1131    /// `MAX_NORMALIZED_LOCATIONS`, not restore the unbounded normalization
1132    /// loop the cap exists to prevent.
1133    #[tokio::test]
1134    async fn test_handle_workspace_symbol_clamps_limit_to_max_normalized_locations() {
1135        let dir = TempDir::new().unwrap();
1136        let server_id = ServerId::from_static("rust");
1137        let caps = lsp_types::ServerCapabilities {
1138            workspace_symbol_provider: Some(lsp_types::WorkspaceSymbolProvider::Bool(true)),
1139            ..Default::default()
1140        };
1141        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1142        let uri = Url::from_file_path(dir.path().join("many.rs"))
1143            .unwrap()
1144            .to_string();
1145
1146        let translator = Arc::new(translator);
1147        let handle = {
1148            let translator = Arc::clone(&translator);
1149            tokio::spawn(async move {
1150                translator
1151                    .handle_workspace_symbol("foo".to_string(), None, u32::MAX)
1152                    .await
1153            })
1154        };
1155
1156        let mut wire = BufReader::new(&mut server.write_stdout);
1157        let request = read_framed_message(&mut wire).await;
1158        assert_eq!(request["method"], "workspace/symbol");
1159
1160        let symbols: Vec<serde_json::Value> = (0..MAX_NORMALIZED_LOCATIONS + 500)
1161            .map(|i| {
1162                serde_json::json!({
1163                    "name": format!("sym{i}"),
1164                    "kind": 12,
1165                    "location": {
1166                        "uri": uri,
1167                        "range": {
1168                            "start": {"line": 0, "character": 0},
1169                            "end": {"line": 0, "character": 3}
1170                        }
1171                    }
1172                })
1173            })
1174            .collect();
1175        write_response(
1176            &mut server.read_half_stdin,
1177            &request["id"],
1178            serde_json::json!(symbols),
1179        )
1180        .await;
1181
1182        let result = timeout(Duration::from_secs(5), handle)
1183            .await
1184            .expect("handler call should not hang")
1185            .unwrap()
1186            .unwrap();
1187
1188        assert_eq!(
1189            result.symbols.len(),
1190            MAX_NORMALIZED_LOCATIONS,
1191            "limit: u32::MAX must be clamped to MAX_NORMALIZED_LOCATIONS, not left unbounded"
1192        );
1193        assert!(
1194            result.truncated,
1195            "a limit clamped below what the caller asked for must set truncated: true"
1196        );
1197    }
1198
1199    /// `document_symbols` is single-file analysis, valid even mid-index
1200    /// (spec FR-008), so `IndexingGate::FileLocal` at its
1201    /// `prepare_gated_document` call site must mean it dispatches even
1202    /// while the routed server reports `IndexingState::Loading` -- unlike
1203    /// the whole-workspace tools, which would error in this state.
1204    #[tokio::test]
1205    async fn test_handle_document_symbols_dispatches_while_indexing_loading() {
1206        use crate::bridge::NotificationCache;
1207
1208        let dir = TempDir::new().unwrap();
1209        let server_id = ServerId::from_static("rust");
1210        let caps = lsp_types::ServerCapabilities {
1211            document_symbol_provider: Some(lsp_types::DocumentSymbolProvider::Bool(true)),
1212            ..Default::default()
1213        };
1214        let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
1215
1216        let cache = Arc::new(tokio::sync::Mutex::new(NotificationCache::new()));
1217        cache.lock().await.observe_indexing_signal(
1218            &server_id,
1219            "experimental/serverStatus",
1220            Some(&serde_json::json!({"quiescent": false})),
1221        );
1222        let translator = Arc::new(translator.with_notification_cache(cache));
1223
1224        let path = dir.path().join("main.rs");
1225        fs::write(&path, "fn main() {}").unwrap();
1226
1227        let handle = {
1228            let translator = Arc::clone(&translator);
1229            let path = path.to_string_lossy().to_string();
1230            tokio::spawn(async move { translator.handle_document_symbols(client_path(path)).await })
1231        };
1232
1233        let mut wire = BufReader::new(&mut server.write_stdout);
1234        let opened = read_framed_message(&mut wire).await;
1235        assert_eq!(opened["method"], "textDocument/didOpen");
1236        let request = read_framed_message(&mut wire).await;
1237        assert_eq!(request["method"], "textDocument/documentSymbol");
1238
1239        write_response(
1240            &mut server.read_half_stdin,
1241            &request["id"],
1242            serde_json::json!([]),
1243        )
1244        .await;
1245
1246        let result = timeout(Duration::from_secs(2), handle)
1247            .await
1248            .expect("handler call should not hang -- document_symbols must not be gated")
1249            .unwrap()
1250            .unwrap();
1251        assert!(result.symbols.is_empty());
1252    }
1253}