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