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