From ec062d05d32b9852699b6508672cc0a785901f48 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Wed, 5 Aug 2026 04:38:57 +0200 Subject: [PATCH] refactor(bridge): split translator.rs into submodules, inject Clock seam Split the 7148-line crates/mcpls-core/src/bridge/translator.rs (setup/ lifecycle, all 20 tool handlers, DTOs, and their tests) into bridge/ translator/mod.rs (Translator struct + lifecycle) plus twelve sibling modules grouped by domain: clock, respawn, routing, dto, encoding_ctx, navigation, diagnostics, edits, symbols, assist, call_hierarchy, and a shared testing fixture module. Matches the per-file test convention already used by bridge::state/bridge::notifications/bridge::encoding. Pure code motion: bridge/mod.rs's pub use translator::{...} re-export block and every Translator method signature are unchanged. Respawn-backoff bookkeeping (record_respawn_failure/success, reconcile_respawn_stability, respawn_backoff_remaining) previously called std::time::Instant::now() directly, so the backoff-expiry path could not be exercised deterministically in tests. Introduce a bridge::translator::clock::Clock trait, defaulted to SystemClock in production and backed by a #[cfg(test)]-only FakeClock, threaded through a new private Translator field. Add an end-to-end test driving respawn_if_dead through a full failure -> fail-fast -> elapsed-window -> successful-reattempt sequence, plus a unit test for reconcile_respawn_stability's proven-stable branch. No production behavior change; the two call sites that used Instant::elapsed/ duration_since directly now use saturating_duration_since for explicitness at the injection seam. Closes #300 Closes #292 --- CHANGELOG.md | 2 + crates/mcpls-core/src/bridge/translator.rs | 7250 ----------------- .../src/bridge/translator/assist.rs | 263 + .../src/bridge/translator/call_hierarchy.rs | 623 ++ .../mcpls-core/src/bridge/translator/clock.rs | 56 + .../src/bridge/translator/diagnostics.rs | 1335 +++ .../mcpls-core/src/bridge/translator/dto.rs | 382 + .../mcpls-core/src/bridge/translator/edits.rs | 775 ++ .../src/bridge/translator/encoding_ctx.rs | 267 + .../mcpls-core/src/bridge/translator/mod.rs | 608 ++ .../src/bridge/translator/navigation.rs | 377 + .../src/bridge/translator/respawn.rs | 967 +++ .../src/bridge/translator/routing.rs | 1475 ++++ .../src/bridge/translator/symbols.rs | 337 + .../src/bridge/translator/testing.rs | 249 + 15 files changed, 7716 insertions(+), 7250 deletions(-) delete mode 100644 crates/mcpls-core/src/bridge/translator.rs create mode 100644 crates/mcpls-core/src/bridge/translator/assist.rs create mode 100644 crates/mcpls-core/src/bridge/translator/call_hierarchy.rs create mode 100644 crates/mcpls-core/src/bridge/translator/clock.rs create mode 100644 crates/mcpls-core/src/bridge/translator/diagnostics.rs create mode 100644 crates/mcpls-core/src/bridge/translator/dto.rs create mode 100644 crates/mcpls-core/src/bridge/translator/edits.rs create mode 100644 crates/mcpls-core/src/bridge/translator/encoding_ctx.rs create mode 100644 crates/mcpls-core/src/bridge/translator/mod.rs create mode 100644 crates/mcpls-core/src/bridge/translator/navigation.rs create mode 100644 crates/mcpls-core/src/bridge/translator/respawn.rs create mode 100644 crates/mcpls-core/src/bridge/translator/routing.rs create mode 100644 crates/mcpls-core/src/bridge/translator/symbols.rs create mode 100644 crates/mcpls-core/src/bridge/translator/testing.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ce955475..2f1e0b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`bridge::translator` position-conversion helpers are now `async`** — Breaking change: `EncodingCtx`'s `to_lsp`/`to_mcp`/`normalize_range`/`denormalize_range`, roughly twenty `Translator` handler/helper methods that call them, and `diagnostics_from_cache_entry`/`merge_diagnostics` all gained `async`, needed to `.await` the disk-read fallback used by the negotiated-encoding fix below. No MCP tool's external request/response shape changed. Acceptable pre-1.0. (#290) - **`mcp::server`'s per-tool `annotations(...)` blocks replaced by a single central pass** — the identical `read_only_hint = true, destructive_hint = false, idempotent_hint = true` triple, previously repeated on all 20 `#[tool(...)]` attributes, is now applied once by `McplsServer::tool_router()`, which retags the impl block `#[tool_router(router = declared_tool_router)]` and fills in any route missing `annotations` via `ToolAnnotations::from_raw`. A tool that declares its own `annotations(...)` keeps them. No client-visible change: the resulting `Tool` values are byte-identical to the previous per-tool declarations (pinned by a new golden-snapshot test, `tool_surface.json`). Also collapsed the redundant `let result = { ... }; to_tool_result(result)` two-statement pattern in 19 of the 20 handlers down to a single `to_tool_result(...)` expression; `get_cached_diagnostics` keeps its `let` binding since its body is a multi-arm `match`, not a single expression. (#301) - **`mcp::tools`'s six position-only parameter wrappers collapsed into `PositionParams`** — `HoverParams`, `DefinitionParams`, `SignatureHelpParams`, `GoToImplementationParams`, `GoToTypeDefinitionParams`, and `CallHierarchyPrepareParams` each wrapped `PositionParams` with `#[serde(flatten)]` and added nothing: `rmcp`'s schema validation already strips the top-level `title`/`description` these wrappers carried before it reaches an MCP client, so the six were structurally identical to `PositionParams` itself. The six corresponding `#[tool]` handlers (`get_hover`, `get_definition`, `get_signature_help`, `go_to_implementation`, `go_to_type_definition`, `prepare_call_hierarchy`) now take `Parameters` directly. No client-visible schema or wire-format change. (#302) +- **`bridge::translator.rs` split into `bridge/translator/` submodules** — the single 7100+ line file (setup/lifecycle, all 20 tool handlers, DTOs, and their tests) is now `mod.rs` (the `Translator` struct and setup/lifecycle methods) plus twelve sibling modules grouped by domain (`clock`, `respawn`, `routing`, `dto`, `encoding_ctx`, `navigation`, `diagnostics`, `edits`, `symbols`, `assist`, `call_hierarchy`, and a shared `testing` fixture module), matching the existing per-file test convention used by `bridge::state`/`bridge::notifications`/`bridge::encoding`. Pure code motion — `bridge::translator`'s public re-export surface (`bridge/mod.rs`'s `pub use translator::{...}` block) and every `Translator` method signature are unchanged. (#300) +- **Respawn-backoff bookkeeping now goes through an injectable `Clock`** — `Translator::respawn_if_dead` and its backoff helpers (previously hardcoded to `std::time::Instant::now()`) now read time through a new `bridge::translator::clock::Clock` trait, defaulted to `SystemClock` in production. No production behavior change; this is a test-only seam (`Translator::with_clock`, `#[cfg(test)]`) that lets backoff-window tests advance a `FakeClock` deterministically instead of relying on real sleeps or incidental timing. Also switches the two call sites that used `Instant::elapsed`/`duration_since` directly to `saturating_duration_since`, for explicitness at the injection seam now that the clock reading is no longer guaranteed to be `SystemClock`; behavior is unchanged (`elapsed`/`duration_since` and `saturating_duration_since` are equivalent on current Rust). (#292) ### Removed diff --git a/crates/mcpls-core/src/bridge/translator.rs b/crates/mcpls-core/src/bridge/translator.rs deleted file mode 100644 index aa14ab57..00000000 --- a/crates/mcpls-core/src/bridge/translator.rs +++ /dev/null @@ -1,7250 +0,0 @@ -//! MCP to LSP translation layer. - -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex as StdMutex}; -use std::time::Instant; - -use lsp_types::{ - CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem, - CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams, - CallHierarchyPrepareParams as LspCallHierarchyPrepareParams, CompletionParams, - CompletionTriggerKind, DocumentFormattingParams, DocumentSymbol, DocumentSymbolParams, - FormattingOptions, GotoDefinitionParams, Hover, HoverContents, HoverParams as LspHoverParams, - InlayHintLabel, InlayHintParams, MarkedString, PartialResultParams, ReferenceContext, - ReferenceParams, RenameParams as LspRenameParams, - SignatureHelpParams as LspSignatureHelpParams, TextDocumentIdentifier, - TextDocumentPositionParams, WorkDoneProgressParams, WorkspaceEdit, - WorkspaceSymbolParams as LspWorkspaceSymbolParams, -}; -use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; -use tokio::time::Duration; - -use super::state::{ResourceLimits, detect_language, path_to_uri, uri_to_path}; -use super::{DiagnosticInfo, DocumentTracker, NotificationCache, lock_std}; -use crate::bridge::encoding::{PositionEncoding, lsp_to_mcp_position, mcp_to_lsp_position}; -use crate::config::{NoServerReason, ServerId, ToolKind, ToolRouter, base_language_id}; -use crate::error::{Error, Result}; -use crate::lsp::{LspClient, LspServer, ServerInitConfig}; - -/// Translator handles MCP tool calls by converting them to LSP requests. -/// -/// All fields use interior mutability so `Translator` can be shared via a -/// plain `Arc` with no outer lock: every LSP tool call would -/// otherwise serialize behind a single mutex for its entire round trip -/// (including the LSP request timeout), which is the root cause fixed here. -/// Each field is locked independently and only for the short, synchronous -/// section that touches it. In particular, the actual LSP request/response -/// round trip (`client.request(...)`) always runs with no lock held. -/// -/// `document_tracker` is no exception: `DocumentTracker` locks its own state -/// per-path internally (see its docs), so `prepare_document`'s call into -/// `ensure_open` never holds a lock shared across unrelated paths or -/// languages while it does that document's disk I/O and -/// `textDocument/didOpen`/`didChange` notify. -#[derive(Debug)] -pub struct Translator { - /// LSP clients indexed by routing identity. Locked only for the map - /// lookup/insert itself, never across an LSP request. - lsp_clients: Arc>>, - /// LSP servers indexed by routing identity (held for lifetime management). - lsp_servers: Arc>>, - /// Document state tracker. Locks its own state internally, per path. - document_tracker: Arc, - /// Resource limits `document_tracker` was last built with. Kept - /// alongside `document_tracker` so [`Self::with_extensions`] and - /// [`Self::with_resource_limits`] can each rebuild the tracker from - /// whichever of (limits, extension map) the other has already set, - /// regardless of call order -- see [`Self::with_resource_limits`]. - resource_limits: ResourceLimits, - /// Allowed workspace roots for path validation. Read-only after `serve()` - /// setup, so no lock is needed. - workspace_roots: Arc>, - /// Custom file extension to language ID mappings. Read-only after - /// `serve()` setup, so no lock is needed. - extension_map: Arc>, - /// Servers that are configured + applicable but may not have finished - /// initializing yet (background init). Used to return a clear "still - /// initializing" error instead of "no server configured". - expected_servers: Arc>>, - /// Per-tool routing table: resolves `(language, tool)` to a `ServerId`. - /// Locked independently so `rebind_router` (called from a background - /// task once registration completes) never contends with an in-flight - /// LSP round trip. - router: Arc>, - /// Configs needed to respawn a server if its process dies later, keyed - /// by routing identity. Populated once per server right after a - /// successful spawn (see [`Self::register_server_config`]); the respawn - /// path ([`Self::respawn_if_dead`]) is the only reader. - server_configs: Arc>>, - /// Per-server single-flight lock so concurrent callers that both observe - /// a dead process don't race to respawn it independently -- the loser - /// waits for the winner's attempt to finish (success or failure) and - /// then re-reads whatever ended up registered. See - /// [`Self::respawn_if_dead`]. - respawn_locks: Arc>>>>, - /// Consecutive respawn failures and last-attempt time per server, so a - /// crash-looping server backs off instead of eating a fresh - /// `timeout_seconds` on every tool call that arrives while it is down. - /// See [`Self::respawn_if_dead`]. - respawn_backoffs: Arc>>, - /// Diagnostics cache, shared with `serve_with`'s notification pump. - /// - /// `None` for a `Translator` built without [`Self::with_notification_cache`] - /// (e.g. most unit tests). When present, [`Self::respawn_if_dead`] uses - /// it to invalidate a respawned server's stale cached diagnostics -- - /// see that method's docs for why that matters. - notification_cache: Option>>, -} - -/// Tracks respawn attempts for one server, so [`Translator::respawn_if_dead`] -/// can back off a crash-looping process instead of retrying it on every -/// single tool call. -#[derive(Debug, Clone, Copy)] -struct RespawnBackoff { - /// Number of consecutive attempts that have not produced a server which - /// stayed alive for at least [`RESPAWN_BACKOFF_BASE`]. A spawn failure - /// counts immediately; a spawn that succeeds but is found dead again - /// within that window counts too, once that is discovered -- see - /// [`Translator::reconcile_respawn_stability`]. Without this, a server - /// that starts, completes `initialize`, and then crashes a second later - /// (a common real crash-loop shape) would bypass backoff entirely: each - /// "success" would otherwise look like a fresh, unbacked-off start. - consecutive_failures: u32, - /// When the most recent attempt was made, or (if `last_attempt_succeeded`) - /// when that success was last found to have not held up. - last_attempt: Instant, - /// Whether the most recent attempt completed `initialize` successfully. - /// `false` for an outright spawn failure. Also reset to `false` once a - /// "successful" respawn is found to have died again within the - /// stability window, so that discovery is applied only once. - last_attempt_succeeded: bool, -} - -/// Base delay before the first backed-off retry after a respawn failure. -const RESPAWN_BACKOFF_BASE: Duration = Duration::from_secs(1); - -/// Upper bound on the exponential backoff delay between respawn attempts. -const RESPAWN_BACKOFF_MAX: Duration = Duration::from_secs(30); - -/// Upper bound on how long [`Translator::shutdown_servers`] waits for a -/// single LSP server's graceful `shutdown`/`exit` handshake before giving up -/// and letting `kill_on_drop` terminate it instead. -const SERVER_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -impl Translator { - /// Create a new translator. - /// - /// Starts with an empty router: nothing is routable until [`Self::with_router`] - /// installs one, which matches having no servers registered. - #[must_use] - pub fn new() -> Self { - Self { - lsp_clients: Arc::new(StdMutex::new(HashMap::new())), - lsp_servers: Arc::new(StdMutex::new(HashMap::new())), - document_tracker: Arc::new(DocumentTracker::new( - ResourceLimits::default(), - HashMap::new(), - )), - resource_limits: ResourceLimits::default(), - workspace_roots: Arc::new(Vec::new()), - extension_map: Arc::new(HashMap::new()), - expected_servers: Arc::new(StdMutex::new(HashSet::new())), - router: Arc::new(StdMutex::new(ToolRouter::default())), - server_configs: Arc::new(StdMutex::new(HashMap::new())), - respawn_locks: Arc::new(StdMutex::new(HashMap::new())), - respawn_backoffs: Arc::new(StdMutex::new(HashMap::new())), - notification_cache: None, - } - } - - /// Set the workspace roots for path validation. - /// - /// Only called during single-owner setup, before the translator is - /// shared, so this replaces the `Arc` wholesale rather than locking. - pub fn set_workspace_roots(&mut self, roots: Vec) { - self.workspace_roots = Arc::new(roots); - } - - /// Give the translator a handle to the shared diagnostics cache, so the - /// respawn path can invalidate a respawned server's stale entries. - /// - /// Only called during single-owner setup (mirrors [`Self::with_router`]), - /// before the translator is shared -- `serve_with` passes the same - /// `Arc>` used by the notification pump tasks. - #[must_use] - pub fn with_notification_cache(mut self, cache: Arc>) -> Self { - self.notification_cache = Some(cache); - self - } - - /// Mark the set of servers that are expected (configured + applicable) - /// but may still be initializing in the background. - pub fn set_expected_servers(&self, servers: HashSet) { - *lock_std(&self.expected_servers) = servers; - } - - /// Clear the expected-servers set (e.g. after background init failed). - pub fn clear_expected_servers(&self) { - lock_std(&self.expected_servers).clear(); - } - - /// Install the per-tool routing table built from the applicable configs. - /// - /// Only called during single-owner setup, before the translator is - /// shared, so this replaces the `Arc`-wrapped router wholesale. - #[must_use] - pub fn with_router(mut self, router: ToolRouter) -> Self { - self.router = Arc::new(StdMutex::new(router)); - self - } - - /// Rebind the routing table to the set of servers that actually - /// registered, dropping or redirecting routes to servers that failed to - /// spawn. See `ToolRouter::rebind_to_registered` for the full semantics. - pub fn rebind_router(&self, registered: &HashSet) { - lock_std(&self.router).rebind_to_registered(registered); - } - - /// Whether `id` is the server the router currently resolves - /// `ToolKind::Diagnostics` to for `language_id`. - /// - /// Purpose-built for `register_servers`, which needs this to compute the - /// diagnostics-cache filter passed into each pump task, without exposing - /// the router's lock guard outside this module. - #[must_use] - pub fn is_diagnostics_route(&self, language_id: &str, id: &ServerId) -> bool { - lock_std(&self.router).resolve(language_id, ToolKind::Diagnostics) == Some(id) - } - - /// Negotiated [`PositionEncoding`] of the registered server `id`, or the - /// LSP spec's own default (UTF-16) if `id` is not currently registered. - /// - /// Note this falls back to UTF-16, not [`PositionEncoding::default`] - /// (UTF-8): UTF-16 is what an absent/unrecognized negotiation means per - /// the LSP spec and what [`crate::lsp::LspServer::spawn`] itself falls - /// back to, so this must match rather than use the bridge type's own - /// default, which exists only for `PositionEncoding`'s own internal use. - #[must_use] - pub(crate) fn position_encoding_for(&self, server_id: &ServerId) -> PositionEncoding { - lock_std(&self.lsp_servers) - .get(server_id) - .and_then(|server| PositionEncoding::from_lsp(server.position_encoding().as_str())) - .unwrap_or(PositionEncoding::Utf16) - } - - /// Build the [`EncodingCtx`] for converting positions/ranges in - /// responses from the registered server `id`. - fn encoding_ctx(&self, server_id: &ServerId) -> EncodingCtx { - EncodingCtx { - encoding: self.position_encoding_for(server_id), - tracker: self.document_tracker.clone(), - } - } - - /// Rebuilds `document_tracker` from `self.resource_limits` and - /// `self.extension_map`, whatever the two are currently set to. - /// - /// Called by every builder that touches either input ([`Self::with_extensions`], - /// [`Self::with_resource_limits`]), so each one only needs to set its own - /// field and call this -- it always reads *both* current values, so the - /// builders remain order-independent (see [`Self::with_resource_limits`]) - /// without each one needing to know the other's field. A future builder - /// that adds a third tracker input should follow the same pattern: - /// update its own field, then call this. - fn rebuild_document_tracker(&mut self) { - self.document_tracker = Arc::new(DocumentTracker::new( - self.resource_limits, - (*self.extension_map).clone(), - )); - } - - /// Configure custom file extension mappings. - /// - /// This method sets the extension map and updates the document tracker - /// to use the same mappings for language detection. - /// - /// Only called during single-owner setup, before the translator is - /// shared, so this replaces the `Arc`-wrapped fields wholesale. - #[must_use] - pub fn with_extensions(mut self, extension_map: HashMap) -> Self { - self.extension_map = Arc::new(extension_map); - self.rebuild_document_tracker(); - self - } - - /// Configure resource limits (max open documents, max file size) for the - /// document tracker. - /// - /// Only called during single-owner setup, before the translator is - /// shared. This builder and [`Self::with_extensions`] may be called in - /// either order -- each rebuilds `document_tracker` from *both* of - /// `self.resource_limits`/`self.extension_map`'s current values, - /// instead of one of them starting fresh from - /// `ResourceLimits::default()`/an empty extension map, which previously - /// meant whichever builder ran last silently discarded the other's - /// effect. - #[must_use] - pub fn with_resource_limits(mut self, limits: ResourceLimits) -> Self { - self.resource_limits = limits; - self.rebuild_document_tracker(); - self - } - - /// Register an LSP client under its routing identity. - /// - /// Only called once per server, from `register_servers` during initial - /// background init. The respawn path does not reuse this method: it - /// needs the previous client back (to fail its pending requests) and - /// must also reset `document_tracker` for the swapped-in server, neither - /// of which this method does. - pub fn register_client(&self, id: impl Into, client: LspClient) { - lock_std(&self.lsp_clients).insert(id.into(), client); - } - - /// Register an LSP server under its routing identity. - pub fn register_server(&self, id: impl Into, server: LspServer) { - lock_std(&self.lsp_servers).insert(id.into(), server); - } - - /// Store the config needed to respawn `id` if its process dies later. - /// - /// Called once per server, right after a successful spawn (see the - /// crate-root `register_servers`); [`Self::respawn_if_dead`] is the only - /// reader. - pub(crate) fn register_server_config(&self, id: impl Into, config: ServerInitConfig) { - lock_std(&self.server_configs).insert(id.into(), config); - } - - /// Number of currently registered LSP servers. - /// - /// Test-only: `lsp_servers` is private, so this is the one way a test - /// outside this module (e.g. `crate::tests`, exercising - /// [`Translator::shutdown_servers`] indirectly through `serve_with`'s - /// shutdown sequence) can observe that a registered server was actually - /// drained. - #[cfg(test)] - pub(crate) fn registered_server_count(&self) -> usize { - lock_std(&self.lsp_servers).len() - } - - /// Snapshot of currently open document paths, used for MCP resource listing. - #[must_use] - pub fn open_document_paths(&self) -> Vec { - self.document_tracker.open_paths() - } - - /// Whether a document is currently tracked as open. - #[must_use] - pub fn is_document_open(&self, path: &Path) -> bool { - self.document_tracker.is_open(path) - } - - /// The document tracker, shared with [`EncodingCtx`] so a cache-only - /// caller (e.g. `get_cached_diagnostics`) can still prefer tracked - /// in-memory content over a disk read when converting positions. - #[must_use] - pub(crate) const fn document_tracker(&self) -> &Arc { - &self.document_tracker - } - - /// Gracefully shut down every registered LSP server. - /// - /// Drains the registered LSP servers and, for each one concurrently, - /// sends the LSP `shutdown` request and `exit` notification via - /// [`LspServer::shutdown`], bounded by a fixed per-server timeout. A - /// server that errors or fails to respond in time is simply dropped - /// instead: its child process handle is `kill_on_drop(true)`, so the - /// process is killed rather than left running. Call this once, from the - /// top-level shutdown path, after the MCP transport has stopped - /// accepting new requests. - /// - /// # Limitations - /// - /// This only runs on the normal shutdown path (stdio EOF, `SIGTERM`/ - /// `SIGINT`, or the HTTP transport's own graceful shutdown). This crate's - /// workspace `[profile.release]` builds with `panic = "abort"`, so a - /// panic reachable from a request handler or background pump task in a - /// release build still terminates the process without unwinding — this - /// method never runs, and spawned LSP children are orphaned exactly as - /// before this fix. Making that path safe would need process-group - /// isolation (`kill_on_drop` alone doesn't help, since no `Drop` runs - /// either); tracked separately, out of scope here. - /// - /// `pub(crate)` rather than `pub`: this is meant for exactly one call - /// site (`serve_with`'s post-transport shutdown sequence), after the MCP - /// transport is already down. An external caller invoking it mid-session - /// would drain `lsp_servers` while `lsp_clients` (routing table) still - /// points at the now-shut-down servers, so in-flight tool calls would - /// resolve to a client whose server is gone. - pub(crate) async fn shutdown_servers(&self) { - let servers: Vec<(ServerId, LspServer)> = lock_std(&self.lsp_servers).drain().collect(); - if servers.is_empty() { - return; - } - - let mut tasks = tokio::task::JoinSet::new(); - for (id, server) in servers { - tasks.spawn(async move { - match tokio::time::timeout(SERVER_SHUTDOWN_TIMEOUT, server.shutdown()).await { - Ok(Ok(())) => tracing::debug!(%id, "LSP server shut down gracefully"), - Ok(Err(e)) => tracing::warn!( - %id, error = %e, - "LSP server shutdown handshake failed, killing process instead" - ), - Err(_) => tracing::warn!( - %id, timeout = ?SERVER_SHUTDOWN_TIMEOUT, - "LSP server did not shut down in time, killing process instead" - ), - } - }); - } - tasks.join_all().await; - } -} - -impl Default for Translator { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct DiagnosticRequestParams { - text_document: TextDocumentIdentifier, - #[serde(skip_serializing_if = "Option::is_none")] - identifier: Option, - #[serde(skip_serializing_if = "Option::is_none")] - previous_result_id: Option, - #[serde(flatten)] - work_done_progress_params: WorkDoneProgressParams, - #[serde(flatten)] - partial_result_params: PartialResultParams, -} - -fn diagnostic_request_params(text_document: TextDocumentIdentifier) -> DiagnosticRequestParams { - DiagnosticRequestParams { - text_document, - identifier: None, - previous_result_id: None, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - } -} - -/// Position in a document (1-based for MCP). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Position2D { - /// Line number (1-based). - pub line: u32, - /// Character offset (1-based). - pub character: u32, -} - -/// Range in a document (1-based for MCP). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Range { - /// Start position. - pub start: Position2D, - /// End position. - pub end: Position2D, -} - -/// Location in a document. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Location { - /// URI of the document. - pub uri: String, - /// Range within the document. - pub range: Range, -} - -/// Result of a hover request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HoverResult { - /// Hover contents as markdown string. - pub contents: String, - /// Optional range the hover applies to. - pub range: Option, -} - -/// Result of a definition request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DefinitionResult { - /// Locations of the definition. - pub locations: Vec, -} - -/// Result of a references request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReferencesResult { - /// Locations of all references. - pub locations: Vec, -} - -/// Diagnostic severity. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum DiagnosticSeverity { - /// Error diagnostic. - Error, - /// Warning diagnostic. - Warning, - /// Informational diagnostic. - Information, - /// Hint diagnostic. - Hint, -} - -/// A single diagnostic. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Diagnostic { - /// Range where the diagnostic applies. - pub range: Range, - /// Severity of the diagnostic. - pub severity: DiagnosticSeverity, - /// Diagnostic message. - pub message: String, - /// Optional diagnostic code. - pub code: Option, -} - -/// Result of a diagnostics request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DiagnosticsResult { - /// List of diagnostics for the document. - pub diagnostics: Vec, -} - -/// A text edit operation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TextEdit { - /// Range to replace. - pub range: Range, - /// New text. - pub new_text: String, -} - -/// Changes to a document. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DocumentChanges { - /// URI of the document. - pub uri: String, - /// List of edits to apply. - pub edits: Vec, -} - -/// Result of a rename request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RenameResult { - /// Changes to apply across documents. - pub changes: Vec, -} - -/// A completion item. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Completion { - /// Label of the completion. - pub label: String, - /// Kind of completion. - pub kind: Option, - /// Detail information. - pub detail: Option, - /// Documentation. - pub documentation: Option, -} - -/// Result of a completions request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CompletionsResult { - /// List of completion items. - pub items: Vec, -} - -/// A document symbol. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Symbol { - /// Name of the symbol. - pub name: String, - /// Kind of symbol. - pub kind: String, - /// Range of the symbol. - pub range: Range, - /// Selection range (identifier location). - pub selection_range: Range, - /// Child symbols. - #[serde(skip_serializing_if = "Option::is_none")] - pub children: Option>, -} - -/// Result of a document symbols request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DocumentSymbolsResult { - /// List of symbols in the document. - pub symbols: Vec, -} - -/// Result of a format document request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FormatDocumentResult { - /// List of edits to format the document. - pub edits: Vec, -} - -/// A workspace symbol. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceSymbol { - /// Name of the symbol. - pub name: String, - /// Kind of symbol. - pub kind: String, - /// Location of the symbol. - pub location: Location, - /// Optional container name (parent scope). - #[serde(skip_serializing_if = "Option::is_none")] - pub container_name: Option, -} - -/// Result of workspace symbol search. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceSymbolResult { - /// List of symbols found. - pub symbols: Vec, -} - -/// A single code action. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CodeAction { - /// Title of the code action. - pub title: String, - /// Kind of code action (quickfix, refactor, etc.). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - /// Diagnostics that this action resolves. - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub diagnostics: Vec, - /// Workspace edit to apply. - #[serde(skip_serializing_if = "Option::is_none")] - pub edit: Option, - /// Command to execute. - #[serde(skip_serializing_if = "Option::is_none")] - pub command: Option, - /// Whether this is the preferred action. - #[serde(default)] - pub is_preferred: bool, -} - -/// Description of a workspace edit. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceEditDescription { - /// Changes to apply to documents. - pub changes: Vec, -} - -/// Description of a command. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommandDescription { - /// Title of the command. - pub title: String, - /// Command identifier. - pub command: String, - /// Command arguments. - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub arguments: Vec, -} - -/// Result of code actions request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CodeActionsResult { - /// Available code actions. - pub actions: Vec, -} - -/// A call hierarchy item. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CallHierarchyItemResult { - /// Name of the symbol. - pub name: String, - /// LSP numeric symbol kind (e.g. 12 for Function). - pub kind: u32, - /// More detail for this item. - #[serde(skip_serializing_if = "Option::is_none")] - pub detail: Option, - /// URI of the document. - pub uri: String, - /// Range of the symbol. - pub range: Range, - /// Selection range (identifier location). - /// - /// Serialized as `selectionRange` (camelCase) so that the value returned by - /// `prepare_call_hierarchy` round-trips correctly when the MCP client passes - /// it back to `get_incoming_calls` / `get_outgoing_calls`, which deserialize - /// it as `lsp_types::CallHierarchyItem` (camelCase). - #[serde(rename = "selectionRange")] - pub selection_range: Range, - /// Opaque data to pass to incoming/outgoing calls. - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -/// Result of call hierarchy prepare request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CallHierarchyPrepareResult { - /// List of callable items at the position. - pub items: Vec, -} - -/// An incoming call (caller of the current item). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IncomingCall { - /// The item that calls the current item. - pub from: CallHierarchyItemResult, - /// Ranges where the call occurs. - pub from_ranges: Vec, -} - -/// Result of incoming calls request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IncomingCallsResult { - /// List of incoming calls. - pub calls: Vec, -} - -/// An outgoing call (callee from the current item). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OutgoingCall { - /// The item being called. - pub to: CallHierarchyItemResult, - /// Ranges where the call occurs. - pub from_ranges: Vec, -} - -/// Result of outgoing calls request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OutgoingCallsResult { - /// List of outgoing calls. - pub calls: Vec, -} - -/// Result of server logs request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerLogsResult { - /// List of log entries. - pub logs: Vec, -} - -/// Result of server messages request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerMessagesResult { - /// List of server messages. - pub messages: Vec, -} - -/// A single parameter in a signature. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SignatureParameter { - /// Label of the parameter. - pub label: String, - /// Optional documentation for the parameter. - #[serde(skip_serializing_if = "Option::is_none")] - pub documentation: Option, -} - -/// A single signature overload. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SignatureInfo { - /// Full label of the signature. - pub label: String, - /// Optional documentation for the signature. - #[serde(skip_serializing_if = "Option::is_none")] - pub documentation: Option, - /// Parameters of the signature. - pub parameters: Vec, -} - -/// Result of a signature help request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SignatureHelpResult { - /// Available signatures. - pub signatures: Vec, - /// Index of the active signature. - #[serde(skip_serializing_if = "Option::is_none")] - pub active_signature: Option, - /// Index of the active parameter within the active signature. - #[serde(skip_serializing_if = "Option::is_none")] - pub active_parameter: Option, -} - -/// Result of a go-to-implementation or go-to-type-definition request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LocationsResult { - /// Locations found. - pub locations: Vec, -} - -/// A single inlay hint entry. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InlayHintEntry { - /// Position of the hint (1-based MCP). - pub position: Position2D, - /// Label text for the hint. - pub label: String, - /// Hint kind (1 = Type, 2 = Parameter). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - /// Whether to add a space before the hint. - #[serde(skip_serializing_if = "Option::is_none")] - pub padding_left: Option, - /// Whether to add a space after the hint. - #[serde(skip_serializing_if = "Option::is_none")] - pub padding_right: Option, - /// Tooltip text. - #[serde(skip_serializing_if = "Option::is_none")] - pub tooltip: Option, -} - -/// Result of an inlay hints request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InlayHintsResult { - /// List of inlay hints. - pub hints: Vec, -} - -/// Maximum allowed position value for validation. -const MAX_POSITION_VALUE: u32 = 1_000_000; -/// Maximum allowed range size in lines. -const MAX_RANGE_LINES: u32 = 10_000; - -/// Validate that `path` is within one of `workspace_roots`. -/// -/// Free function (rather than a `Translator` method) so callers that only need -/// path validation — e.g. cache-only MCP handlers — can validate against a -/// cloned, lock-free snapshot of the workspace roots instead of locking the -/// full `Arc>`, which may be held elsewhere across a slow -/// in-flight LSP round-trip. -/// -/// # Errors -/// -/// Returns `Error::PathOutsideWorkspace` if the path is outside all workspace roots. -pub fn validate_path_against_roots(path: &Path, workspace_roots: &[PathBuf]) -> Result { - let canonical = path.canonicalize().map_err(|e| Error::FileIo { - path: path.to_path_buf(), - source: e, - })?; - - // If no workspace roots configured, allow any path (backward compatibility) - if workspace_roots.is_empty() { - return Ok(canonical); - } - - // Check if path is within any workspace root - for root in workspace_roots { - if let Ok(canonical_root) = root.canonicalize() - && canonical.starts_with(&canonical_root) - { - return Ok(canonical); - } - } - - Err(Error::PathOutsideWorkspace(path.to_path_buf())) -} - -/// Per-response encoding context: the negotiated [`PositionEncoding`] of the -/// LSP server that produced a response, used to convert every -/// position/range in that response between MCP's 1-based UTF-16 columns and -/// the server's own 0-based columns. -/// -/// A single MCP tool call is always answered by exactly one LSP server, so -/// one context covers every location in its response -- even when -/// individual locations point into other files (e.g. `references` results -/// spanning multiple documents): each conversion resolves the *referenced* -/// file's line text independently rather than assuming it matches the -/// originally queried document. -#[derive(Debug, Clone)] -struct EncodingCtx { - encoding: PositionEncoding, - /// Source of a tracked document's in-memory content -- the text mcpls - /// actually sent the server via `didOpen`/`didChange` -- consulted - /// before falling back to disk. See [`read_line_text`]. - tracker: Arc, -} - -/// Text of the 0-based `line`'th line of the file at `uri`, or `None` if it -/// cannot be resolved to a path, read, or has no such line. -/// -/// Only ever consulted when the negotiated encoding is not UTF-16 (see -/// [`EncodingCtx::to_lsp`]/[`EncodingCtx::to_mcp`]). Checks `tracker` first -/// (in-memory, no I/O) -- this is by construction both cheaper and more -/// correct than disk for any document mcpls has opened, since it is exactly -/// the text the server was told about, so it can't diverge from the -/// server's own view even if the file has since been edited on disk (see -/// #290 S1). Only a document `tracker` has never seen falls through to an -/// async disk read, matching `state.rs`'s `tokio::fs` convention so this -/// never blocks the executor thread. -async fn read_line_text( - uri: &lsp_types::Uri, - line: u32, - tracker: &DocumentTracker, -) -> Option { - let path = uri_to_path(uri)?; - if let Some(text) = tracker.line_text(&path, line) { - return Some(text); - } - let content = tokio::fs::read_to_string(&path).await.ok()?; - content.lines().nth(line as usize).map(str::to_string) -} - -impl EncodingCtx { - /// Convert an MCP position for the document at `uri` into an LSP - /// position in this context's negotiated encoding. - async fn to_lsp(&self, uri: &lsp_types::Uri, line: u32, character: u32) -> lsp_types::Position { - let line_text = if self.encoding == PositionEncoding::Utf16 { - None - } else { - let text = read_line_text(uri, line.saturating_sub(1), &self.tracker).await; - if text.is_none() { - tracing::warn!( - uri = uri.as_str(), - line, - encoding = self.encoding.to_lsp(), - "could not resolve line text for position conversion; passing MCP column \ - through unconverted, which is wrong for a non-UTF-16 server" - ); - } - text - }; - mcp_to_lsp_position(line, character, line_text.as_deref(), self.encoding) - } - - /// Convert an LSP position (in this context's negotiated encoding) from - /// the document at `uri` into an MCP position. - async fn to_mcp(&self, uri: &lsp_types::Uri, pos: lsp_types::Position) -> Position2D { - let line_text = if self.encoding == PositionEncoding::Utf16 { - None - } else { - let text = read_line_text(uri, pos.line, &self.tracker).await; - if text.is_none() { - tracing::warn!( - uri = uri.as_str(), - line = pos.line, - encoding = self.encoding.to_lsp(), - "could not resolve line text for position conversion; passing server \ - column through unconverted, which is wrong for a non-UTF-16 server" - ); - } - text - }; - let (line, character) = lsp_to_mcp_position(pos, line_text.as_deref(), self.encoding); - Position2D { line, character } - } - - /// Convert an LSP range (in this context's negotiated encoding) from the - /// document at `uri` into an MCP range. - async fn normalize_range(&self, uri: &lsp_types::Uri, range: lsp_types::Range) -> Range { - Range { - start: self.to_mcp(uri, range.start).await, - end: self.to_mcp(uri, range.end).await, - } - } - - /// Convert an MCP range for the document at `uri` back into an LSP range - /// in this context's negotiated encoding -- the inverse of - /// [`Self::normalize_range`]. - async fn denormalize_range(&self, uri: &lsp_types::Uri, range: &Range) -> lsp_types::Range { - lsp_types::Range { - start: self - .to_lsp(uri, range.start.line, range.start.character) - .await, - end: self.to_lsp(uri, range.end.line, range.end.character).await, - } - } -} - -impl Translator { - /// Validate that a path is within allowed workspace boundaries. - /// - /// # Errors - /// - /// Returns `Error::PathOutsideWorkspace` if the path is outside all workspace roots. - pub(crate) fn validate_path(&self, path: &Path) -> Result { - validate_path_against_roots(path, &self.workspace_roots) - } - - /// Whether the server tracked under `id` is registered and has exited. - /// - /// Returns `false` ("not dead") for an `id` that isn't registered at - /// all -- that's the separate `ServerInitializing`/`NoServerForTool` - /// concern callers already handle, not something the respawn path - /// should react to -- and for any `try_wait` error, on the conservative - /// assumption that a health check that itself failed should not trigger - /// a respawn. - fn is_server_dead(&self, id: &ServerId) -> bool { - lock_std(&self.lsp_servers) - .get_mut(id) - .and_then(|server| server.has_exited().ok()) - .unwrap_or(false) - } - - /// Return the shared single-flight lock for `id`, creating it on first - /// use. - /// - /// Two concurrent callers racing to respawn the same server both get a - /// clone of the *same* underlying `Mutex`, so awaiting it actually - /// serializes them instead of letting both proceed independently. - fn respawn_lock(&self, id: &ServerId) -> Arc> { - Arc::clone( - lock_std(&self.respawn_locks) - .entry(id.clone()) - .or_insert_with(|| Arc::new(Mutex::new(()))), - ) - } - - /// Remaining backoff delay before `id` may be respawned again, or - /// `None` if it may be attempted right now. - /// - /// Only consults recorded *failures* -- a server with no recorded - /// attempt is never backed off. A server whose last attempt "succeeded" - /// is reconciled by [`Self::reconcile_respawn_stability`] (called by - /// [`Self::respawn_if_dead`] before this) into either a failure (died - /// again too soon) or removed entirely (proven stable), so by the time - /// this runs, a lingering "succeeded" entry never reaches here. - fn respawn_backoff_remaining(&self, id: &ServerId) -> Option { - let (consecutive_failures, last_attempt) = { - let entry = lock_std(&self.respawn_backoffs).get(id).copied()?; - (entry.consecutive_failures, entry.last_attempt) - }; - if consecutive_failures == 0 { - return None; - } - let shift = consecutive_failures.saturating_sub(1).min(5); - let delay = RESPAWN_BACKOFF_BASE - .saturating_mul(1 << shift) - .min(RESPAWN_BACKOFF_MAX); - let elapsed = last_attempt.elapsed(); - (elapsed < delay).then(|| delay.saturating_sub(elapsed)) - } - - /// Records a failed respawn attempt for `id`, extending its backoff. - fn record_respawn_failure(&self, id: &ServerId) { - let mut backoffs = lock_std(&self.respawn_backoffs); - let entry = backoffs - .entry(id.clone()) - .or_insert_with(|| RespawnBackoff { - consecutive_failures: 0, - last_attempt: Instant::now(), - last_attempt_succeeded: false, - }); - entry.consecutive_failures = entry.consecutive_failures.saturating_add(1); - entry.last_attempt = Instant::now(); - entry.last_attempt_succeeded = false; - drop(backoffs); - } - - /// Records that a respawn attempt for `id` completed `initialize` - /// successfully. - /// - /// Does *not* clear `consecutive_failures`: whether this attempt - /// actually broke the crash loop is only known once the server either - /// stays alive for a while or is found dead again -- see - /// [`Self::reconcile_respawn_stability`], which is what acts on this - /// entry. - fn record_respawn_success(&self, id: &ServerId) { - let mut backoffs = lock_std(&self.respawn_backoffs); - let entry = backoffs - .entry(id.clone()) - .or_insert_with(|| RespawnBackoff { - consecutive_failures: 0, - last_attempt: Instant::now(), - last_attempt_succeeded: true, - }); - entry.last_attempt = Instant::now(); - entry.last_attempt_succeeded = true; - drop(backoffs); - } - - /// Reconciles `id`'s backoff state against a *newly observed* death, - /// before deciding whether to back off this respawn attempt. - /// - /// A no-op unless the last recorded attempt "succeeded" ([`Self::record_respawn_success`]): - /// - If it has since survived at least [`RESPAWN_BACKOFF_BASE`], it is - /// treated as proven stable and its backoff state is cleared -- a - /// later, unrelated crash starts a fresh backoff sequence rather than - /// inheriting history from a long-resolved incident. - /// - Otherwise, the server died again before proving itself: this - /// counts as a failure (extending `consecutive_failures`) instead of - /// being silently forgotten. Without this, a server that starts, - /// completes `initialize`, and crashes again a moment later would - /// bypass backoff entirely -- every such cycle would look like a - /// fresh, unbacked-off start, spawning one child process per tool - /// call forever. - fn reconcile_respawn_stability(&self, id: &ServerId) { - let Some(entry) = lock_std(&self.respawn_backoffs).get(id).copied() else { - return; - }; - if !entry.last_attempt_succeeded { - return; - } - if entry.last_attempt.elapsed() >= RESPAWN_BACKOFF_BASE { - lock_std(&self.respawn_backoffs).remove(id); - } else { - let mut backoffs = lock_std(&self.respawn_backoffs); - if let Some(current) = backoffs.get_mut(id) { - current.consecutive_failures = current.consecutive_failures.saturating_add(1); - current.last_attempt = Instant::now(); - current.last_attempt_succeeded = false; - } - } - } - - /// Detect whether the server routed to `id` has crashed and, if so, - /// eagerly respawn and re-initialize it before returning. - /// - /// A no-op if `id` names a server that was never registered (routing - /// resolved to it, but it hasn't started yet or never will) or is still - /// alive. - /// - /// # Concurrency - /// - /// Multiple callers can race in here for the same `id` -- e.g. two tool - /// calls landing back-to-back right after the process dies. They - /// single-flight on [`Self::respawn_lock`]: the first to acquire it - /// performs the actual respawn; everyone else waits for that attempt to - /// finish (or fail), rechecks, and finds nothing left to do. - /// - /// Requests still parked in the dead client's `pending_requests` are - /// failed immediately via [`LspClient::fail_pending_requests`] instead - /// of being left to time out on their own. - /// - /// The respawned process has no memory of any document the old one had - /// open, so this also clears `document_tracker`'s per-server sync - /// history for `id` -- otherwise `ensure_open` would send `didChange` - /// instead of `didOpen` for a document the new process never saw. Any - /// diagnostics cached from the old connection are invalidated (see - /// [`Self::with_notification_cache`]) rather than left to be merged into - /// fresh pulls as if still current. - /// - /// Diagnostics and other push notifications from the new process itself - /// are drained and discarded rather than wired into the existing pump - /// task: the pump's remaining dependencies (resource subscriptions, peer - /// handle) live in `serve_with`'s scope, not the translator's, so - /// reconnecting live push for a respawned server is out of scope for - /// this fix -- it does not resume until the whole mcpls process - /// restarts, but stale data is no longer served as current. - /// - /// A crash-looping server (repeated respawn failures) backs off - /// exponentially (`RESPAWN_BACKOFF_BASE` up to `RESPAWN_BACKOFF_MAX`) - /// instead of retrying on every single tool call, each of which would - /// otherwise cost up to a full `timeout_seconds` inside `initialize`. - /// - /// # Errors - /// - /// Returns [`Error::ServerUnavailable`] if no respawn config was ever - /// registered for `id`, or if it is currently within its backoff - /// window. Returns whatever error `LspServer::spawn` produced (e.g. its - /// command is no longer on `PATH`, or `initialize` fails again) if an - /// actual respawn attempt failed. - async fn respawn_if_dead(&self, id: &ServerId) -> Result<()> { - if !self.is_server_dead(id) { - return Ok(()); - } - - let lock = self.respawn_lock(id); - let _guard = lock.lock().await; - - // Another caller may have already respawned it while we waited. - if !self.is_server_dead(id) { - return Ok(()); - } - - self.reconcile_respawn_stability(id); - - if let Some(remaining) = self.respawn_backoff_remaining(id) { - tracing::warn!( - "LSP server '{id}' is crash-looping, backing off for {remaining:?} \ - before the next respawn attempt" - ); - return Err(Error::ServerUnavailable { - server_id: id.clone(), - reason: format!("crash-looping, retry in {remaining:?}"), - }); - } - - let Some(config) = lock_std(&self.server_configs).get(id).cloned() else { - return Err(Error::ServerUnavailable { - server_id: id.clone(), - reason: "no respawn config registered for this server".to_string(), - }); - }; - let language_id = config.server_config.language_id.clone(); - - tracing::warn!("LSP server '{id}' has crashed, respawning"); - let mut new_server = match LspServer::spawn(config).await { - Ok(server) => { - self.record_respawn_success(id); - server - } - Err(err) => { - self.record_respawn_failure(id); - return Err(err); - } - }; - let new_client = new_server.client().clone(); - let mut notification_rx = new_server.take_notification_rx(); - tokio::spawn(async move { while notification_rx.recv().await.is_some() {} }); - - let old_client = lock_std(&self.lsp_clients).insert(id.clone(), new_client); - let old_server = lock_std(&self.lsp_servers).insert(id.clone(), new_server); - drop(old_server); // dropped after the `lsp_servers` guard, not under it - - self.document_tracker.forget_server(id); - - // Only the diagnostics-route server for this language ever writes - // to the cache (see `diagnostics_pump`'s `caches_diagnostics` gate - // in the crate root) -- clearing a non-route server's synced URIs - // would delete the *healthy* diagnostics server's valid entries for - // those same files instead. And the route server's own cache - // entries are not limited to documents mcpls ever opened (it - // publishes workspace-wide, e.g. `cargo check` diagnostics), so a - // per-URI clear scoped to synced documents would miss most of what - // needs invalidating. - // - // `clear_server_diagnostics` scopes the clear to just this server's - // own entries, tracked via `NotificationCache`'s per-server - // ownership map (#266) -- a crashed rust-analyzer no longer wipes a - // healthy pyright's cached diagnostics for Python files in the same - // workspace. - // - // This clear is not atomic with the swap above: a caller that reads - // `lsp_clients` between the swap and this point sees the new client - // and could read a not-yet-cleared cache entry. In practice - // `handle_diagnostics` only reads the cache after a full LSP pull - // round-trip, so this window is negligible. - if self.is_diagnostics_route(&language_id, id) - && let Some(cache) = &self.notification_cache - { - cache.lock().await.clear_server_diagnostics(id); - } - - if let Some(old_client) = old_client { - old_client.fail_pending_requests().await; - } - - tracing::info!("LSP server '{id}' respawned successfully"); - Ok(()) - } - - /// Resolve the client and routing identity for `path`/`tool`, giving the - /// resolved server a chance to be respawned first if its process has - /// died. - /// - /// Thin async wrapper around [`Self::get_client_for_file`] (kept - /// synchronous so its existing unit tests don't need a runtime): this is - /// the entry point async handlers call instead, so a dead server is - /// transparently replaced before its stale client is handed back. - async fn resolve_client_for_file( - &self, - path: &Path, - tool: ToolKind, - ) -> Result<(ServerId, LspClient)> { - let (id, client) = self.get_client_for_file(path, tool)?; - self.respawn_if_dead(&id).await?; - let client = lock_std(&self.lsp_clients) - .get(&id) - .cloned() - .unwrap_or(client); - Ok((id, client)) - } - - /// Resolve the server that should handle `tool` for the file at `path`, - /// returning both its routing identity and a cloned client. - /// - /// Tries the file's detected language first, then (if that has no route) - /// its React base language (`.tsx` falling back from `typescriptreact` to - /// `typescript`, and similarly for `.jsx`) -- in that order, so an - /// explicit `typescriptreact` server still wins over the `typescript` - /// fallback when both are configured. - /// - /// Locks `router`, `lsp_clients`, and (on the not-yet-registered path) - /// `expected_servers` only for their respective lookups — every guard is - /// dropped before this method returns. - fn get_client_for_file(&self, path: &Path, tool: ToolKind) -> Result<(ServerId, LspClient)> { - let language = detect_language(path, &self.extension_map); - let mut candidates: Vec<&str> = vec![language.as_str()]; - if let Some(base) = base_language_id(&language) { - candidates.push(base); - } - - for lang in &candidates { - let resolved = lock_std(&self.router).resolve(lang, tool).cloned(); - let Some(id) = resolved else { continue }; - - let found = lock_std(&self.lsp_clients).get(&id).cloned(); - if let Some(client) = found { - return Ok((id, client)); - } - // A route naming a server that is still initializing (e.g. a - // large Unity solution loading via OmniSharp) -- tell the caller - // to wait and retry rather than implying no server is configured. - if lock_std(&self.expected_servers).contains(&id) { - return Err(Error::ServerInitializing { server_id: id }); - } - // Unreachable once registration has rebound the router - // (`Translator::rebind_router`) -- a route can only name a - // registered server after that point. Logged rather than - // `debug_assert!`-panicked: this method is reachable by any - // library consumer calling `with_router` without registering - // matching clients, not just internal misuse. - tracing::error!( - "router route names server '{id}' for tool '{tool}' that is neither \ - registered nor expected" - ); - return Err(Error::NoServerForTool { - language_id: (*lang).to_string(), - tool, - }); - } - - let has_language = { - let router = lock_std(&self.router); - candidates.iter().any(|lang| router.has_language(lang)) - }; - if has_language { - Err(Error::NoServerForTool { - language_id: language, - tool, - }) - } else { - Err(Error::NoServerForLanguage(language)) - } - } - - /// Validate `file_path`, then resolve its routed client via - /// [`Self::resolve_client_for_file`] (respawn-aware), without opening - /// the document. - /// - /// Split out from [`Self::prepare_document`] so [`Self::prepare_gated_document`] - /// can check the routed server's capabilities *before* `ensure_open` sends - /// `textDocument/didOpen` -- a server rejected by the gate should never - /// observe an open notification for a request it can't service. Also - /// used directly by handlers that already have a resolved `PathBuf` - /// (from `parse_file_uri`) but still need capability gating, e.g. - /// `handle_incoming_calls`/`handle_outgoing_calls`. - async fn resolve_validated_client_for_file( - &self, - file_path: &str, - tool: ToolKind, - ) -> Result<(ServerId, LspClient, PathBuf)> { - let path = PathBuf::from(file_path); - let validated_path = self.validate_path(&path)?; - let (server_id, client) = self.resolve_client_for_file(&validated_path, tool).await?; - Ok((server_id, client, validated_path)) - } - - /// Resolve the LSP client and ensure the document is open. - /// - /// This is the "prepare" phase shared by every LSP-round-trip handler: - /// it validates the path, selects the client via - /// [`Self::resolve_validated_client_for_file`] (respawn-aware), and - /// calls `ensure_open`, which locks the document tracker's state only - /// for the given path. The returned client and URI are owned values, so - /// the caller can issue the actual LSP request (the "execute" phase) - /// without holding any lock across the network round trip. - /// - /// `ensure_open`'s own awaits (a `stat`, optionally a re-read of the - /// file, and the `textDocument/didOpen`/`didChange` notify) run under a - /// lock scoped to `validated_path` alone — see [`DocumentTracker::ensure_open`] - /// — so a slow or wedged language server cannot stall `prepare_document` - /// calls for unrelated files. (Per-tool routing, #228, means the same - /// file can be routed to more than one server; a wedged server-A notify - /// still holds this path's lock and can therefore delay a healthy - /// server-B call for that *same* file.) - async fn prepare_document( - &self, - file_path: &str, - tool: ToolKind, - ) -> Result<(ServerId, LspClient, lsp_types::Uri)> { - let (server_id, client, validated_path) = self - .resolve_validated_client_for_file(file_path, tool) - .await?; - let uri = self - .document_tracker - .ensure_open(&validated_path, &server_id, &client) - .await?; - Ok((server_id, client, uri)) - } - - /// Like [`Self::prepare_document`], but checks `capability` against the - /// routed server's `ServerCapabilities` *before* opening the document -- - /// see [`Self::resolve_client_for_file`]'s doc comment for why the - /// ordering matters. - /// - /// # Errors - /// - /// Returns [`Error::CapabilityNotSupported`] if the routed server's - /// `ServerCapabilities` explicitly does not advertise `capability`. - async fn prepare_gated_document( - &self, - file_path: &str, - tool: ToolKind, - capability: &'static str, - supported: impl FnOnce(&lsp_types::ServerCapabilities) -> bool, - ) -> Result<(ServerId, LspClient, lsp_types::Uri)> { - let (server_id, client, validated_path) = self - .resolve_validated_client_for_file(file_path, tool) - .await?; - self.require_capability(&server_id, capability, supported)?; - let uri = self - .document_tracker - .ensure_open(&validated_path, &server_id, &client) - .await?; - Ok((server_id, client, uri)) - } - - /// Verify the routed server advertises support for a capability before - /// dispatching a capability-gated LSP request. - /// - /// Production always registers an [`LspServer`] alongside its - /// [`LspClient`] in the same `register_servers` step (see `lib.rs`), so in - /// practice a registered client always has known capabilities. If no - /// `LspServer` is registered for `server_id` regardless -- a client - /// registered without its server, which only happens in tests, or a - /// narrow window during registration where the two maps are inserted - /// under separate locks -- the capability is assumed supported rather - /// than blocking the request: this mirrors the graceful-degradation - /// stance used elsewhere in `Translator` when capability information is - /// unavailable rather than known-absent. - /// - /// Note: this checks the `ServerCapabilities` snapshot captured at - /// `initialize` time. A server that advertises a capability later via - /// `client/registerCapability` (dynamic registration) is not reflected - /// here and will be incorrectly rejected; mcpls does not currently apply - /// dynamic registrations back onto the stored capabilities. - /// - /// # Errors - /// - /// Returns [`Error::CapabilityNotSupported`] if the registered server's - /// `ServerCapabilities` explicitly does not advertise `capability`. - fn require_capability( - &self, - server_id: &ServerId, - capability: &'static str, - supported: impl FnOnce(&lsp_types::ServerCapabilities) -> bool, - ) -> Result<()> { - let servers = lock_std(&self.lsp_servers); - match servers.get(server_id) { - Some(server) if !supported(server.capabilities()) => { - Err(Error::CapabilityNotSupported { - server_id: server_id.clone(), - capability, - }) - } - _ => Ok(()), - } - } - - /// Parse and validate a file URI, returning the validated path. - /// - /// # Errors - /// - /// Returns an error if: - /// - The URI doesn't have a file:// scheme - /// - The path is outside workspace boundaries - fn parse_file_uri(&self, uri: &lsp_types::Uri) -> Result { - let uri_str = uri.as_str(); - - // Validate file:// scheme - if !uri_str.starts_with("file://") { - return Err(Error::InvalidToolParams(format!( - "Invalid URI scheme, expected file:// but got: {uri_str}" - ))); - } - - // Extract path after file:// - let path_str = &uri_str["file://".len()..]; - - // Handle Windows paths: file:///C:/path -> /C:/path -> C:/path - // On Windows, URIs have format file:///C:/path, so we need to strip the leading / - #[cfg(windows)] - let path_str = if path_str.len() >= 3 - && path_str.starts_with('/') - && path_str.chars().nth(2) == Some(':') - { - &path_str[1..] - } else { - path_str - }; - - let path = PathBuf::from(path_str); - - // Validate path is within workspace - self.validate_path(&path) - } - - /// Handle hover request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `hoverProvider` support. - pub async fn handle_hover( - &self, - file_path: String, - line: u32, - character: u32, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document(&file_path, ToolKind::Hover, "hoverProvider", |caps| { - matches!( - caps.hover_provider, - Some( - lsp_types::HoverProviderCapability::Simple(true) - | lsp_types::HoverProviderCapability::Options(_) - ) - ) - }) - .await?; - let ctx = self.encoding_ctx(&server_id); - let lsp_position = ctx.to_lsp(&uri, line, character).await; - let response_uri = uri.clone(); - - let params = LspHoverParams { - text_document_position_params: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: lsp_position, - }, - work_done_progress_params: WorkDoneProgressParams::default(), - }; - - let response: Option = client - .request("textDocument/hover", params, client.request_timeout()) - .await?; - - let result = match response { - Some(hover) => { - let contents = extract_hover_contents(hover.contents); - let range = match hover.range { - Some(r) => Some(ctx.normalize_range(&response_uri, r).await), - None => None, - }; - HoverResult { contents, range } - } - None => HoverResult { - contents: "No hover information available".to_string(), - range: None, - }, - }; - - Ok(result) - } - - /// Handle definition request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `definitionProvider` support. - pub async fn handle_definition( - &self, - file_path: String, - line: u32, - character: u32, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::Definition, - "definitionProvider", - |caps| { - matches!( - caps.definition_provider, - Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) - ) - }, - ) - .await?; - let ctx = self.encoding_ctx(&server_id); - let lsp_position = ctx.to_lsp(&uri, line, character).await; - - let params = GotoDefinitionParams { - text_document_position_params: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: lsp_position, - }, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - }; - - let response: Option = client - .request("textDocument/definition", params, client.request_timeout()) - .await?; - - let result = DefinitionResult { - locations: goto_response_to_locations(response, &ctx).await, - }; - - Ok(result) - } - - /// Handle references request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `referencesProvider` support. - pub async fn handle_references( - &self, - file_path: String, - line: u32, - character: u32, - include_declaration: bool, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::References, - "referencesProvider", - |caps| { - matches!( - caps.references_provider, - Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) - ) - }, - ) - .await?; - let ctx = self.encoding_ctx(&server_id); - let lsp_position = ctx.to_lsp(&uri, line, character).await; - - let params = ReferenceParams { - text_document_position: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: lsp_position, - }, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - context: ReferenceContext { - include_declaration, - }, - }; - - let response: Option> = client - .request("textDocument/references", params, client.request_timeout()) - .await?; - - let locations = response.unwrap_or_default(); - - let mut result_locations = Vec::with_capacity(locations.len()); - for loc in locations { - result_locations.push(Location { - uri: loc.uri.to_string(), - range: ctx.normalize_range(&loc.uri, loc.range).await, - }); - } - let result = ReferencesResult { - locations: result_locations, - }; - - Ok(result) - } - - /// Handle diagnostics request. - /// - /// Merges the LSP pull-model response (`textDocument/diagnostic`) with - /// whatever is already cached from `textDocument/publishDiagnostics` push - /// notifications for the same file, so this returns the same diagnostics - /// `get_cached_diagnostics` would for the file at the same point in time - /// (see #244 — rust-analyzer's pull endpoint omits flycheck/clippy-sourced - /// diagnostics, and empirically also some native ones, that are only ever - /// delivered via the push path). If the pull request itself fails (e.g. a - /// push-only server answering `-32601`, or a timeout), a non-empty cache - /// entry is returned as a cache-only result instead of propagating the - /// error, since the cache is not required to be fresher than the pull - /// response to be useful here. - /// - /// The cache is read only after the pull request settles (success or - /// failure) and held only for the lookup itself — never across the LSP - /// round-trip — matching the lock-ordering discipline documented on - /// `cached_diagnostics_uri`. Like `get_cached_diagnostics`, the cache is - /// treated as eventually consistent: a cached entry may reflect a - /// slightly older document version than the fresh pull result if an edit - /// landed inside the server's flycheck debounce window. - /// - /// # Errors - /// - /// Returns an error if the LSP pull request fails and the cache holds no - /// diagnostics for the file either, or if the file cannot be opened. - pub async fn handle_diagnostics( - &self, - file_path: String, - notification_cache: &Mutex, - ) -> Result { - let (server_id, client, uri) = self - .prepare_document(&file_path, ToolKind::Diagnostics) - .await?; - let ctx = self.encoding_ctx(&server_id); - - let params = diagnostic_request_params(TextDocumentIdentifier { uri: uri.clone() }); - - let pull_response: Result = client - .request("textDocument/diagnostic", params, client.request_timeout()) - .await; - - let diag_info = { - let cache = notification_cache.lock().await; - cache.get_diagnostics(uri.as_str()).cloned() - }; - - match pull_response { - Ok(response) => { - let items = match response { - lsp_types::DocumentDiagnosticReportResult::Report(report) => match report { - lsp_types::DocumentDiagnosticReport::Full(full) => { - full.full_document_diagnostic_report.items - } - lsp_types::DocumentDiagnosticReport::Unchanged(_) => vec![], - }, - lsp_types::DocumentDiagnosticReportResult::Partial(_) => vec![], - }; - let mut diagnostics = Vec::with_capacity(items.len()); - for d in &items { - diagnostics.push(diagnostic_to_mcp(d, &ctx, &uri).await); - } - let pull = DiagnosticsResult { diagnostics }; - Ok(Self::merge_diagnostics( - pull, - diag_info.as_ref(), - ctx.encoding, - &self.document_tracker, - ) - .await) - } - Err(e) => { - let cache_only = Self::diagnostics_from_cache_entry( - diag_info.as_ref(), - ctx.encoding, - &self.document_tracker, - ) - .await; - if cache_only.diagnostics.is_empty() { - Err(e) - } else { - Ok(cache_only) - } - } - } - } - - /// Handle rename request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `renameProvider` support. - pub async fn handle_rename( - &self, - file_path: String, - line: u32, - character: u32, - new_name: String, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document(&file_path, ToolKind::Rename, "renameProvider", |caps| { - matches!( - caps.rename_provider, - Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) - ) - }) - .await?; - let ctx = self.encoding_ctx(&server_id); - let lsp_position = ctx.to_lsp(&uri, line, character).await; - - let params = LspRenameParams { - text_document_position: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: lsp_position, - }, - new_name, - work_done_progress_params: WorkDoneProgressParams::default(), - }; - - let response: Option = client - .request("textDocument/rename", params, client.request_timeout()) - .await?; - - let changes = if let Some(edit) = response { - let mut result_changes = Vec::new(); - - // Prefer the legacy `changes` map (HashMap>). - if let Some(changes_map) = edit.changes { - for (uri, edits) in changes_map { - let mut text_edits = Vec::with_capacity(edits.len()); - for e in edits { - text_edits.push(TextEdit { - range: ctx.normalize_range(&uri, e.range).await, - new_text: e.new_text, - }); - } - result_changes.push(DocumentChanges { - uri: uri.to_string(), - edits: text_edits, - }); - } - } - - // Also handle `documentChanges` (array format returned by rust-analyzer). - if result_changes.is_empty() { - let text_doc_edits = match edit.document_changes { - Some(lsp_types::DocumentChanges::Edits(edits)) => edits, - Some(lsp_types::DocumentChanges::Operations(ops)) => ops - .into_iter() - .filter_map(|op| match op { - lsp_types::DocumentChangeOperation::Edit(e) => Some(e), - lsp_types::DocumentChangeOperation::Op(_) => None, - }) - .collect(), - None => vec![], - }; - for tde in text_doc_edits { - let edit_uri = &tde.text_document.uri; - let mut text_edits = Vec::with_capacity(tde.edits.len()); - for one_of in tde.edits { - text_edits.push(match one_of { - lsp_types::OneOf::Left(te) => TextEdit { - range: ctx.normalize_range(edit_uri, te.range).await, - new_text: te.new_text, - }, - lsp_types::OneOf::Right(ate) => TextEdit { - range: ctx.normalize_range(edit_uri, ate.text_edit.range).await, - new_text: ate.text_edit.new_text, - }, - }); - } - result_changes.push(DocumentChanges { - uri: edit_uri.to_string(), - edits: text_edits, - }); - } - } - - result_changes - } else { - vec![] - }; - - Ok(RenameResult { changes }) - } - - /// Handle completions request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `completionProvider` support. - pub async fn handle_completions( - &self, - file_path: String, - line: u32, - character: u32, - trigger: Option, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::Completions, - "completionProvider", - |caps| caps.completion_provider.is_some(), - ) - .await?; - let lsp_position = self - .encoding_ctx(&server_id) - .to_lsp(&uri, line, character) - .await; - - let context = trigger.map(|trigger_char| lsp_types::CompletionContext { - trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER, - trigger_character: Some(trigger_char), - }); - - let params = CompletionParams { - text_document_position: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: lsp_position, - }, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - context, - }; - - let response: Option = client - .request( - "textDocument/completion", - params, - client.completion_timeout(), - ) - .await?; - - let items = match response { - Some(lsp_types::CompletionResponse::Array(items)) => items, - Some(lsp_types::CompletionResponse::List(list)) => list.items, - None => vec![], - }; - - let result = CompletionsResult { - items: items - .into_iter() - .map(|item| Completion { - label: item.label, - kind: item.kind.map(|k| format!("{k:?}")), - detail: item.detail, - documentation: item.documentation.map(|doc| match doc { - lsp_types::Documentation::String(s) => s, - lsp_types::Documentation::MarkupContent(m) => m.value, - }), - }) - .collect(), - }; - - Ok(result) - } - - /// Handle document symbols request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `documentSymbolProvider` support. - pub async fn handle_document_symbols( - &self, - file_path: String, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::DocumentSymbols, - "documentSymbolProvider", - |caps| { - matches!( - caps.document_symbol_provider, - Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) - ) - }, - ) - .await?; - let ctx = self.encoding_ctx(&server_id); - let response_uri = uri.clone(); - - let params = DocumentSymbolParams { - text_document: TextDocumentIdentifier { uri }, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - }; - - let response: Option = client - .request( - "textDocument/documentSymbol", - params, - client.request_timeout(), - ) - .await?; - - let symbols = match response { - Some(lsp_types::DocumentSymbolResponse::Flat(symbols)) => { - let mut result = Vec::with_capacity(symbols.len()); - for sym in symbols { - let range = ctx - .normalize_range(&sym.location.uri, sym.location.range) - .await; - let selection_range = ctx - .normalize_range(&sym.location.uri, sym.location.range) - .await; - result.push(Symbol { - name: sym.name, - kind: format!("{:?}", sym.kind), - range, - selection_range, - children: None, - }); - } - result - } - Some(lsp_types::DocumentSymbolResponse::Nested(symbols)) => { - let mut result = Vec::with_capacity(symbols.len()); - for sym in symbols { - result.push(convert_document_symbol(sym, &ctx, &response_uri).await); - } - result - } - None => vec![], - }; - - Ok(DocumentSymbolsResult { symbols }) - } - - /// Handle format document request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `documentFormattingProvider` support. - pub async fn handle_format_document( - &self, - file_path: String, - tab_size: u32, - insert_spaces: bool, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::FormatDocument, - "documentFormattingProvider", - |caps| { - matches!( - caps.document_formatting_provider, - Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) - ) - }, - ) - .await?; - let ctx = self.encoding_ctx(&server_id); - let response_uri = uri.clone(); - - let params = DocumentFormattingParams { - text_document: TextDocumentIdentifier { uri }, - options: FormattingOptions { - tab_size, - insert_spaces, - ..Default::default() - }, - work_done_progress_params: WorkDoneProgressParams::default(), - }; - - let response: Option> = client - .request("textDocument/formatting", params, client.request_timeout()) - .await?; - - let edits = response.unwrap_or_default(); - - let mut result_edits = Vec::with_capacity(edits.len()); - for edit in edits { - result_edits.push(TextEdit { - range: ctx.normalize_range(&response_uri, edit.range).await, - new_text: edit.new_text, - }); - } - let result = FormatDocumentResult { - edits: result_edits, - }; - - Ok(result) - } - - /// Handle workspace symbol search. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, no server is configured, or - /// the routed server does not advertise `workspaceSymbolProvider` support. - pub async fn handle_workspace_symbol( - &self, - query: String, - kind_filter: Option, - limit: u32, - ) -> Result { - validate_workspace_symbol_params(&query, kind_filter.as_deref())?; - - // Workspace search has no document, so it resolves via `resolve_any` - // rather than a per-language route. If the resolved server is not - // registered yet but is expected, tell the caller to wait and retry - // rather than implying nothing is configured. - let server_id = lock_std(&self.router) - .resolve_any(ToolKind::WorkspaceSymbols) - .cloned() - .map_err(|reason| match reason { - // `resolve_any` reports "nothing registered", which also - // covers a server that is configured but has not finished - // spawning yet -- check `expected_servers` (unavailable to - // `ToolRouter` itself) to tell the two apart, mirroring - // `get_client_for_file`'s `ServerInitializing` check below. - NoServerReason::NothingRegistered => { - if lock_std(&self.expected_servers).is_empty() { - Error::NoServerConfigured - } else { - Error::WorkspaceServersInitializing - } - } - NoServerReason::NoClaimant => Error::NoServerForWorkspaceTool { - tool: ToolKind::WorkspaceSymbols, - }, - })?; - self.respawn_if_dead(&server_id).await?; - let client = lock_std(&self.lsp_clients).get(&server_id).cloned(); - let client = client.ok_or_else(|| { - if lock_std(&self.expected_servers).contains(&server_id) { - Error::ServerInitializing { - server_id: server_id.clone(), - } - } else { - Error::NoServerConfigured - } - })?; - self.require_capability(&server_id, "workspaceSymbolProvider", |caps| { - matches!( - caps.workspace_symbol_provider, - Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) - ) - })?; - - let params = LspWorkspaceSymbolParams { - query, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - }; - - let response: Option> = client - .request("workspace/symbol", params, client.request_timeout()) - .await?; - - let ctx = self.encoding_ctx(&server_id); - let mut symbols: Vec = Vec::new(); - for sym in response.unwrap_or_default() { - let range = ctx - .normalize_range(&sym.location.uri, sym.location.range) - .await; - symbols.push(WorkspaceSymbol { - name: sym.name, - kind: format!("{:?}", sym.kind), - location: Location { - uri: sym.location.uri.to_string(), - range, - }, - container_name: sym.container_name, - }); - } - - // Apply kind filter if specified - if let Some(kind) = kind_filter { - symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind)); - } - - // Limit results - symbols.truncate(limit as usize); - - Ok(WorkspaceSymbolResult { symbols }) - } - - /// Handle code actions request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `codeActionProvider` support. - pub async fn handle_code_actions( - &self, - file_path: String, - start_line: u32, - start_character: u32, - end_line: u32, - end_character: u32, - kind_filter: Option, - ) -> Result { - validate_code_action_params( - start_line, - start_character, - end_line, - end_character, - kind_filter.as_deref(), - )?; - - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::CodeActions, - "codeActionProvider", - |caps| { - matches!( - caps.code_action_provider, - Some( - lsp_types::CodeActionProviderCapability::Simple(true) - | lsp_types::CodeActionProviderCapability::Options(_) - ) - ) - }, - ) - .await?; - let ctx = self.encoding_ctx(&server_id); - let response_uri = uri.clone(); - - let range = lsp_types::Range { - start: ctx.to_lsp(&uri, start_line, start_character).await, - end: ctx.to_lsp(&uri, end_line, end_character).await, - }; - - // Build context with optional kind filter - let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]); - - // Pass empty diagnostics context — rust-analyzer generates code actions - // based on cursor position and its internal analysis state, not on the - // passed diagnostics. Passing stale cached diagnostics (which may lack - // the internal `data` field ra uses for fix mapping) suppresses results. - let context_diagnostics: Vec = vec![]; - - let params = lsp_types::CodeActionParams { - text_document: TextDocumentIdentifier { uri }, - range, - context: lsp_types::CodeActionContext { - diagnostics: context_diagnostics, - only, - trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED), - }, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - }; - - let response: Option = client - .request("textDocument/codeAction", params, client.request_timeout()) - .await?; - let response_vec = response.unwrap_or_default(); - let mut actions = Vec::with_capacity(response_vec.len()); - - for action_or_command in response_vec { - let action = match action_or_command { - lsp_types::CodeActionOrCommand::CodeAction(action) => { - convert_code_action(action, &ctx, &response_uri).await - } - lsp_types::CodeActionOrCommand::Command(cmd) => { - let arguments = cmd.arguments.unwrap_or_else(Vec::new); - CodeAction { - title: cmd.title.clone(), - kind: None, - diagnostics: Vec::new(), - edit: None, - command: Some(CommandDescription { - title: cmd.title, - command: cmd.command, - arguments, - }), - is_preferred: false, - } - } - }; - actions.push(action); - } - - Ok(CodeActionsResult { actions }) - } - - /// Handle call hierarchy prepare request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `callHierarchyProvider` support. - pub async fn handle_call_hierarchy_prepare( - &self, - file_path: String, - line: u32, - character: u32, - ) -> Result { - // Validate position bounds - if line < 1 || character < 1 { - return Err(Error::InvalidToolParams( - "Line and character positions must be >= 1".to_string(), - )); - } - - if line > MAX_POSITION_VALUE || character > MAX_POSITION_VALUE { - return Err(Error::InvalidToolParams(format!( - "Position values must be <= {MAX_POSITION_VALUE}" - ))); - } - - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::CallHierarchy, - "callHierarchyProvider", - call_hierarchy_provider_supported, - ) - .await?; - let ctx = self.encoding_ctx(&server_id); - let lsp_position = ctx.to_lsp(&uri, line, character).await; - - let params = LspCallHierarchyPrepareParams { - text_document_position_params: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: lsp_position, - }, - work_done_progress_params: WorkDoneProgressParams::default(), - }; - - let response: Option> = client - .request( - "textDocument/prepareCallHierarchy", - params, - client.request_timeout(), - ) - .await?; - - // Pre-allocate and build result - let lsp_items = response.unwrap_or_default(); - let mut items = Vec::with_capacity(lsp_items.len()); - for item in lsp_items { - items.push(convert_call_hierarchy_item(item, &ctx).await); - } - - Ok(CallHierarchyPrepareResult { items }) - } - - /// Handle incoming calls request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the item is invalid, or the - /// routed server does not advertise `callHierarchyProvider` support. - pub async fn handle_incoming_calls( - &self, - item: serde_json::Value, - ) -> Result { - // Deserialize as our own type (1-based coords). - let parsed = parse_mcp_call_hierarchy_item(item)?; - - // Parse and validate the URI. Resolved with the same ToolKind as - // `handle_call_hierarchy_prepare` -- the opaque item this call - // receives is only meaningful to the server that produced it, and - // that server is guaranteed to be the same one `prepare` synced the - // document to since both resolve via the same (language, tool) route. - let path = self.parse_file_uri(&parsed.uri)?; - let (server_id, client) = self - .resolve_client_for_file(&path, ToolKind::CallHierarchy) - .await?; - self.require_capability( - &server_id, - "callHierarchyProvider", - call_hierarchy_provider_supported, - )?; - let ctx = self.encoding_ctx(&server_id); - let lsp_item = call_hierarchy_item_to_lsp(parsed, &ctx).await; - - let params = CallHierarchyIncomingCallsParams { - item: lsp_item, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - }; - - let response: Option> = client - .request( - "callHierarchy/incomingCalls", - params, - client.request_timeout(), - ) - .await?; - - // Pre-allocate and build result - let lsp_calls = response.unwrap_or_default(); - let mut calls = Vec::with_capacity(lsp_calls.len()); - - for call in lsp_calls { - // Per the LSP spec, `fromRanges` are ranges within the *caller's* - // document (`call.from.uri`), not the queried item's document. - let from_uri = call.from.uri.clone(); - let from_ranges = { - let mut ranges = Vec::with_capacity(call.from_ranges.len()); - for range in call.from_ranges { - ranges.push(ctx.normalize_range(&from_uri, range).await); - } - ranges - }; - - calls.push(IncomingCall { - from: convert_call_hierarchy_item(call.from, &ctx).await, - from_ranges, - }); - } - - Ok(IncomingCallsResult { calls }) - } - - /// Handle outgoing calls request. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the item is invalid, or the - /// routed server does not advertise `callHierarchyProvider` support. - pub async fn handle_outgoing_calls( - &self, - item: serde_json::Value, - ) -> Result { - // Deserialize as our own type (1-based coords). - let parsed = parse_mcp_call_hierarchy_item(item)?; - - // Parse and validate the URI. Same ToolKind/route as `prepare` and - // `handle_incoming_calls` -- see that function's comment. - let path = self.parse_file_uri(&parsed.uri)?; - let (server_id, client) = self - .resolve_client_for_file(&path, ToolKind::CallHierarchy) - .await?; - self.require_capability( - &server_id, - "callHierarchyProvider", - call_hierarchy_provider_supported, - )?; - let ctx = self.encoding_ctx(&server_id); - // Per the LSP spec, an outgoing call's `fromRanges` are ranges within - // the *queried* item's own document, not the callee's (`call.to.uri`). - let source_uri = parsed.uri.clone(); - let lsp_item = call_hierarchy_item_to_lsp(parsed, &ctx).await; - - let params = CallHierarchyOutgoingCallsParams { - item: lsp_item, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - }; - - let response: Option> = client - .request( - "callHierarchy/outgoingCalls", - params, - client.request_timeout(), - ) - .await?; - - // Pre-allocate and build result - let lsp_calls = response.unwrap_or_default(); - let mut calls = Vec::with_capacity(lsp_calls.len()); - - for call in lsp_calls { - let from_ranges = { - let mut ranges = Vec::with_capacity(call.from_ranges.len()); - for range in call.from_ranges { - ranges.push(ctx.normalize_range(&source_uri, range).await); - } - ranges - }; - - calls.push(OutgoingCall { - to: convert_call_hierarchy_item(call.to, &ctx).await, - from_ranges, - }); - } - - Ok(OutgoingCallsResult { calls }) - } - - /// Resolve the LSP-side cache key (URI string) for a cached-diagnostics lookup. - /// - /// Split out from the cache read itself so callers (e.g. the - /// `get_cached_diagnostics` MCP tool) can do the path `canonicalize()` and - /// workspace-boundary check *before* taking the `NotificationCache` lock — - /// that lock is also needed by `diagnostics_pump` to store incoming - /// notifications, so nothing that isn't a plain map lookup should run - /// while it's held. - /// - /// # Errors - /// - /// Returns an error if the path is invalid or outside workspace boundaries. - pub fn cached_diagnostics_uri(workspace_roots: &[PathBuf], file_path: &str) -> Result { - let path = PathBuf::from(file_path); - let validated_path = validate_path_against_roots(&path, workspace_roots)?; - - // Use path_to_uri (strips \\?\ on Windows) so the key matches what - // rust-analyzer stores in publishDiagnostics notifications. - Ok(path_to_uri(&validated_path)?.to_string()) - } - - /// Convert a cached diagnostics entry into the MCP-facing result shape. - /// - /// Takes an already-cloned `Option<&DiagnosticInfo>` (out of the - /// `NotificationCache` lock) rather than the cache itself, so this - /// mapping — which is not a bounded operation for a large diagnostics set - /// — never runs while the cache is locked. - /// - /// `encoding` is the negotiated encoding of the server that published - /// these diagnostics; pass `PositionEncoding::Utf16` when no live server - /// context is available (e.g. a cache-only read with no resolved owner). - #[must_use] - pub async fn diagnostics_from_cache_entry( - diag_info: Option<&DiagnosticInfo>, - encoding: PositionEncoding, - tracker: &Arc, - ) -> DiagnosticsResult { - let diagnostics = match diag_info { - Some(diag_info) => { - let ctx = EncodingCtx { - encoding, - tracker: tracker.clone(), - }; - let mut result = Vec::with_capacity(diag_info.diagnostics.len()); - for d in &diag_info.diagnostics { - result.push(diagnostic_to_mcp(d, &ctx, &diag_info.uri).await); - } - result - } - None => Vec::new(), - }; - - DiagnosticsResult { diagnostics } - } - - /// Merge push-model diagnostics from the notification cache into a - /// pull-model (`textDocument/diagnostic`) result. - /// - /// rust-analyzer's pull endpoint omits diagnostics that are only ever - /// delivered via `textDocument/publishDiagnostics` push notifications — - /// not just flycheck/clippy lints, but empirically (verified against a - /// live rust-analyzer 1.97.1 session, see #244) some native diagnostics - /// too. Those are cached separately in `NotificationCache`. - /// - /// Where the *same* logical problem is reported through both paths, the - /// two representations were observed to differ in both `range` and - /// rendered `message`. Captured example, a "not all trait items - /// implemented" (E0046) error for one `impl` block: pull reported range - /// `(96,7)-(96,12)` (the trait name) with message "not all trait items - /// implemented, missing: `fn hello`"; the push notification for the same - /// error reported range `(95,1)-(95,32)` (the impl block) with message - /// "not all trait items implemented, missing: `hello`\nmissing `hello` - /// in implementation" — same `code`/`severity`, adjacent but distinct - /// ranges, different message text. Exact field equality never dedups - /// cases like that. - /// - /// Given that, a cache entry is treated as a duplicate of a pull entry - /// when both carry a `code`, the `(severity, code)` pair matches, *and* - /// the two ranges are either overlapping or start within - /// `DUPLICATE_RANGE_PROXIMITY_LINES` lines of each other — close - /// enough to be the same underlying model divergence, not two distinct - /// occurrences of the same error class (e.g. two unrelated `E0308` - /// mismatches at different call sites in one file, one caught only - /// natively and one only by flycheck). Diagnostics with no `code` fall - /// back to full-field equality, since there is no cheaper stable - /// identity available for them. - /// - /// Output is sorted by `(start.line, start.character)` so merged - /// cache-only entries don't land out of document order after the - /// pull-model ones. - #[must_use] - pub async fn merge_diagnostics( - mut pull: DiagnosticsResult, - diag_info: Option<&DiagnosticInfo>, - encoding: PositionEncoding, - tracker: &Arc, - ) -> DiagnosticsResult { - /// Start-line distance within which same-code, same-severity - /// diagnostics from the two models are still considered the same - /// underlying problem. Derived from the captured E0046 case above - /// (1 line apart); wide enough to absorb span drift between - /// rust-analyzer's own spans and rustc's, narrow enough that two - /// genuinely distinct same-code errors elsewhere in a file are not - /// collapsed into one. - const DUPLICATE_RANGE_PROXIMITY_LINES: u32 = 3; - - fn position_le(a: &Position2D, b: &Position2D) -> bool { - (a.line, a.character) <= (b.line, b.character) - } - - fn ranges_close(a: &Range, b: &Range) -> bool { - let overlaps = position_le(&a.start, &b.end) && position_le(&b.start, &a.end); - overlaps || a.start.line.abs_diff(b.start.line) <= DUPLICATE_RANGE_PROXIMITY_LINES - } - - fn is_duplicate(pull: &[Diagnostic], candidate: &Diagnostic) -> bool { - pull.iter().any(|p| match (&candidate.code, &p.code) { - (Some(c), Some(pc)) if c == pc && p.severity == candidate.severity => { - ranges_close(&p.range, &candidate.range) - } - _ => p == candidate, - }) - } - - let cached = Self::diagnostics_from_cache_entry(diag_info, encoding, tracker) - .await - .diagnostics; - let new_diagnostics: Vec<_> = cached - .into_iter() - .filter(|c| !is_duplicate(&pull.diagnostics, c)) - .collect(); - pull.diagnostics.extend(new_diagnostics); - pull.diagnostics - .sort_by_key(|d| (d.range.start.line, d.range.start.character)); - pull - } - - /// Handle server logs request. - /// - /// # Errors - /// - /// Returns an error if the `min_level` parameter is invalid. - pub fn handle_server_logs( - cache: &NotificationCache, - limit: usize, - min_level: Option, - ) -> Result { - use crate::bridge::notifications::LogLevel; - - let min_level_filter = if let Some(level_str) = min_level { - let level = match level_str.to_lowercase().as_str() { - "error" => LogLevel::Error, - "warning" => LogLevel::Warning, - "info" => LogLevel::Info, - "debug" => LogLevel::Debug, - _ => { - return Err(Error::InvalidToolParams(format!( - "Invalid min_level: '{level_str}'. Valid values: error, warning, info, debug" - ))); - } - }; - Some(level) - } else { - None - }; - - let all_logs = cache.logs(); - - let logs: Vec<_> = all_logs - .iter() - .filter(|log| { - min_level_filter.is_none_or(|min| match min { - LogLevel::Error => matches!(log.level, LogLevel::Error), - LogLevel::Warning => matches!(log.level, LogLevel::Error | LogLevel::Warning), - LogLevel::Info => !matches!(log.level, LogLevel::Debug), - LogLevel::Debug => true, - }) - }) - .take(limit) - .cloned() - .collect(); - - Ok(ServerLogsResult { logs }) - } - - /// Handle server messages request. - /// - /// # Errors - /// - /// This method does not return errors. - pub fn handle_server_messages( - cache: &NotificationCache, - limit: usize, - ) -> Result { - let all_messages = cache.messages(); - let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect(); - Ok(ServerMessagesResult { messages }) - } - - /// Handle signature help request (`textDocument/signatureHelp`). - /// - /// Returns parameter signatures and documentation while typing a function call. - /// `context` is omitted (None) — the server infers trigger state from position. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `signatureHelpProvider` support. - pub async fn handle_signature_help( - &self, - file_path: String, - line: u32, - character: u32, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::SignatureHelp, - "signatureHelpProvider", - |caps| caps.signature_help_provider.is_some(), - ) - .await?; - let lsp_position = self - .encoding_ctx(&server_id) - .to_lsp(&uri, line, character) - .await; - - let params = LspSignatureHelpParams { - text_document_position_params: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: lsp_position, - }, - work_done_progress_params: WorkDoneProgressParams::default(), - context: None, - }; - - let response: Option = client - .request( - "textDocument/signatureHelp", - params, - client.request_timeout(), - ) - .await?; - - let result = match response { - Some(sig_help) => SignatureHelpResult { - signatures: sig_help - .signatures - .into_iter() - .map(|sig| SignatureInfo { - label: sig.label, - documentation: sig.documentation.map(extract_documentation), - parameters: sig - .parameters - .unwrap_or_default() - .into_iter() - .map(|p| SignatureParameter { - label: match p.label { - lsp_types::ParameterLabel::Simple(s) => s, - lsp_types::ParameterLabel::LabelOffsets([start, end]) => { - format!("[{start},{end}]") - } - }, - documentation: p.documentation.map(extract_documentation), - }) - .collect(), - }) - .collect(), - active_signature: sig_help.active_signature, - active_parameter: sig_help.active_parameter, - }, - None => SignatureHelpResult { - signatures: vec![], - active_signature: None, - active_parameter: None, - }, - }; - - Ok(result) - } - - /// Handle go-to-implementation request (`textDocument/implementation`). - /// - /// Returns the locations of trait method or interface member implementations. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `implementationProvider` support. - pub async fn handle_implementation( - &self, - file_path: String, - line: u32, - character: u32, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::Implementation, - "implementationProvider", - |caps| { - matches!( - caps.implementation_provider, - Some( - lsp_types::ImplementationProviderCapability::Simple(true) - | lsp_types::ImplementationProviderCapability::Options(_) - ) - ) - }, - ) - .await?; - let ctx = self.encoding_ctx(&server_id); - let lsp_position = ctx.to_lsp(&uri, line, character).await; - - let params = GotoDefinitionParams { - text_document_position_params: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: lsp_position, - }, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - }; - - let response: Option = client - .request( - "textDocument/implementation", - params, - client.request_timeout(), - ) - .await?; - - Ok(LocationsResult { - locations: goto_response_to_locations(response, &ctx).await, - }) - } - - /// Handle go-to-type-definition request (`textDocument/typeDefinition`). - /// - /// Returns the type definition location of the expression at position. Distinct - /// from go-to-definition for variable bindings where definition and type differ. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `typeDefinitionProvider` support. - pub async fn handle_type_definition( - &self, - file_path: String, - line: u32, - character: u32, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::TypeDefinition, - "typeDefinitionProvider", - |caps| { - matches!( - caps.type_definition_provider, - Some( - lsp_types::TypeDefinitionProviderCapability::Simple(true) - | lsp_types::TypeDefinitionProviderCapability::Options(_) - ) - ) - }, - ) - .await?; - let ctx = self.encoding_ctx(&server_id); - let lsp_position = ctx.to_lsp(&uri, line, character).await; - - let params = GotoDefinitionParams { - text_document_position_params: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: lsp_position, - }, - work_done_progress_params: WorkDoneProgressParams::default(), - partial_result_params: PartialResultParams::default(), - }; - - let response: Option = client - .request( - "textDocument/typeDefinition", - params, - client.request_timeout(), - ) - .await?; - - Ok(LocationsResult { - locations: goto_response_to_locations(response, &ctx).await, - }) - } - - /// Handle inlay hints request (`textDocument/inlayHint`). - /// - /// Returns inferred type and parameter annotations the editor would render inline. - /// Output positions are in MCP 1-based form. - /// - /// # Errors - /// - /// Returns an error if the LSP request fails, the file cannot be opened, - /// or the routed server does not advertise `inlayHintProvider` support. - pub async fn handle_inlay_hints( - &self, - file_path: String, - start_line: u32, - start_character: u32, - end_line: u32, - end_character: u32, - ) -> Result { - let (server_id, client, uri) = self - .prepare_gated_document( - &file_path, - ToolKind::InlayHints, - "inlayHintProvider", - |caps| { - matches!( - caps.inlay_hint_provider, - Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) - ) - }, - ) - .await?; - let ctx = self.encoding_ctx(&server_id); - let response_uri = uri.clone(); - - let lsp_start = ctx.to_lsp(&uri, start_line, start_character).await; - let lsp_end = ctx.to_lsp(&uri, end_line, end_character).await; - - let params = InlayHintParams { - text_document: TextDocumentIdentifier { uri }, - range: lsp_types::Range { - start: lsp_start, - end: lsp_end, - }, - work_done_progress_params: WorkDoneProgressParams::default(), - }; - - let response: Option> = client - .request("textDocument/inlayHint", params, client.request_timeout()) - .await?; - - let mut hints = Vec::new(); - for hint in response.unwrap_or_default() { - let position = ctx.to_mcp(&response_uri, hint.position).await; - let label = match hint.label { - InlayHintLabel::String(s) => s, - InlayHintLabel::LabelParts(parts) => parts - .into_iter() - .map(|p| p.value) - .collect::>() - .concat(), - }; - let tooltip = hint.tooltip.map(|t| match t { - lsp_types::InlayHintTooltip::String(s) => s, - lsp_types::InlayHintTooltip::MarkupContent(m) => m.value, - }); - hints.push(InlayHintEntry { - position, - label, - kind: hint.kind.and_then(|k| { - serde_json::to_value(k) - .ok() - .and_then(|v| v.as_i64()) - .and_then(|n| u8::try_from(n).ok()) - }), - padding_left: hint.padding_left, - padding_right: hint.padding_right, - tooltip, - }); - } - - Ok(InlayHintsResult { hints }) - } -} - -/// Extract hover contents as markdown string. -/// Convert LSP `Documentation` to a plain string. -fn extract_documentation(doc: lsp_types::Documentation) -> String { - match doc { - lsp_types::Documentation::String(s) => s, - lsp_types::Documentation::MarkupContent(m) => m.value, - } -} - -/// Normalize a `GotoDefinitionResponse` into a flat list of MCP `Location` values. -async fn goto_response_to_locations( - response: Option, - ctx: &EncodingCtx, -) -> Vec { - let lsp_locs: Vec = match response { - Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc], - Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs, - Some(lsp_types::GotoDefinitionResponse::Link(links)) => links - .into_iter() - .map(|link| lsp_types::Location { - uri: link.target_uri, - range: link.target_selection_range, - }) - .collect(), - None => vec![], - }; - - let mut locations = Vec::with_capacity(lsp_locs.len()); - for loc in lsp_locs { - locations.push(Location { - uri: loc.uri.to_string(), - range: ctx.normalize_range(&loc.uri, loc.range).await, - }); - } - locations -} - -fn extract_hover_contents(contents: HoverContents) -> String { - match contents { - HoverContents::Scalar(marked_string) => marked_string_to_string(marked_string), - HoverContents::Array(marked_strings) => marked_strings - .into_iter() - .map(marked_string_to_string) - .collect::>() - .join("\n\n"), - HoverContents::Markup(markup) => markup.value, - } -} - -/// Convert a marked string to a plain string. -fn marked_string_to_string(marked: MarkedString) -> String { - match marked { - MarkedString::String(s) => s, - MarkedString::LanguageString(ls) => format!("```{}\n{}\n```", ls.language, ls.value), - } -} - -/// Convert LSP range to MCP range (0-based to 1-based). -/// Validate parameters for `handle_code_actions`. -fn validate_code_action_params( - start_line: u32, - start_character: u32, - end_line: u32, - end_character: u32, - kind_filter: Option<&str>, -) -> Result<()> { - const VALID_ACTION_KINDS: &[&str] = &[ - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports", - ]; - - if let Some(kind) = kind_filter - && !VALID_ACTION_KINDS - .iter() - .any(|k| k.eq_ignore_ascii_case(kind)) - { - return Err(Error::InvalidToolParams(format!( - "Invalid kind_filter: '{kind}'. Valid values: {VALID_ACTION_KINDS:?}" - ))); - } - - if start_line < 1 || start_character < 1 || end_line < 1 || end_character < 1 { - return Err(Error::InvalidToolParams( - "Line and character positions must be >= 1".to_string(), - )); - } - - if start_line > MAX_POSITION_VALUE - || start_character > MAX_POSITION_VALUE - || end_line > MAX_POSITION_VALUE - || end_character > MAX_POSITION_VALUE - { - return Err(Error::InvalidToolParams(format!( - "Position values must be <= {MAX_POSITION_VALUE}" - ))); - } - - if end_line.saturating_sub(start_line) > MAX_RANGE_LINES { - return Err(Error::InvalidToolParams(format!( - "Range size must be <= {MAX_RANGE_LINES} lines" - ))); - } - - if start_line > end_line || (start_line == end_line && start_character > end_character) { - return Err(Error::InvalidToolParams( - "Start position must be before or equal to end position".to_string(), - )); - } - - Ok(()) -} - -/// Validate parameters for `handle_workspace_symbol`. -fn validate_workspace_symbol_params(query: &str, kind_filter: Option<&str>) -> Result<()> { - const MAX_QUERY_LENGTH: usize = 1000; - const VALID_SYMBOL_KINDS: &[&str] = &[ - "File", - "Module", - "Namespace", - "Package", - "Class", - "Method", - "Property", - "Field", - "Constructor", - "Enum", - "Interface", - "Function", - "Variable", - "Constant", - "String", - "Number", - "Boolean", - "Array", - "Object", - "Key", - "Null", - "EnumMember", - "Struct", - "Event", - "Operator", - "TypeParameter", - ]; - - if query.len() > MAX_QUERY_LENGTH { - return Err(Error::InvalidToolParams(format!( - "Query too long: {} chars (max {MAX_QUERY_LENGTH})", - query.len() - ))); - } - - if let Some(kind) = kind_filter - && !VALID_SYMBOL_KINDS - .iter() - .any(|k| k.eq_ignore_ascii_case(kind)) - { - return Err(Error::InvalidToolParams(format!( - "Invalid kind_filter: '{kind}'. Valid values: {VALID_SYMBOL_KINDS:?}" - ))); - } - - Ok(()) -} - -/// Whether a server's capabilities advertise `callHierarchyProvider` support. -/// -/// Shared by `handle_call_hierarchy_prepare`, `handle_incoming_calls`, and -/// `handle_outgoing_calls`, which all gate on the same capability field. -const fn call_hierarchy_provider_supported(caps: &lsp_types::ServerCapabilities) -> bool { - matches!( - caps.call_hierarchy_provider, - Some( - lsp_types::CallHierarchyServerCapability::Simple(true) - | lsp_types::CallHierarchyServerCapability::Options(_) - ) - ) -} - -/// Parsed form of an MCP-facing `CallHierarchyItemResult` JSON value (1-based -/// coordinates), before its ranges are converted back to the routed server's -/// negotiated encoding -- which requires resolving that server first (from -/// [`Self::uri`]), so that step is left to callers via -/// [`call_hierarchy_item_to_lsp`]. -struct ParsedCallHierarchyItem { - uri: lsp_types::Uri, - mcp: CallHierarchyItemResult, -} - -/// Deserialize an MCP-facing `CallHierarchyItemResult` JSON value and parse -/// its URI. -/// -/// MCP clients receive `CallHierarchyItemResult` from `prepare_call_hierarchy` -/// and pass it back opaquely to `get_incoming_calls` / `get_outgoing_calls`. -fn parse_mcp_call_hierarchy_item(item: serde_json::Value) -> Result { - let mcp: CallHierarchyItemResult = serde_json::from_value(item) - .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?; - - let uri = mcp.uri.parse::().map_err(|e| { - Error::InvalidToolParams(format!("Invalid URI in call hierarchy item: {e}")) - })?; - - Ok(ParsedCallHierarchyItem { uri, mcp }) -} - -/// Convert a parsed MCP call hierarchy item (1-based coordinates) into a -/// `lsp_types::CallHierarchyItem` (0-based, in `ctx`'s negotiated encoding). -async fn call_hierarchy_item_to_lsp( - parsed: ParsedCallHierarchyItem, - ctx: &EncodingCtx, -) -> CallHierarchyItem { - let ParsedCallHierarchyItem { uri, mcp } = parsed; - - // Round-trip via serde: `convert_call_hierarchy_item` stored the kind as a u32 - // by serialising `SymbolKind`; we reverse this to reconstruct the same value. - let kind: lsp_types::SymbolKind = serde_json::from_value(serde_json::json!(mcp.kind)) - .unwrap_or(lsp_types::SymbolKind::FUNCTION); - let range = ctx.denormalize_range(&uri, &mcp.range).await; - let selection_range = ctx.denormalize_range(&uri, &mcp.selection_range).await; - - CallHierarchyItem { - name: mcp.name, - kind, - tags: None, - detail: mcp.detail, - uri, - range, - selection_range, - data: mcp.data, - } -} - -/// Convert an LSP diagnostic into the MCP-facing `Diagnostic` shape. -/// -/// Shared by both the pull-model (`handle_diagnostics`) and cache-derived -/// (`diagnostics_from_cache_entry`) diagnostic paths, so their output never -/// diverges in formatting — `merge_diagnostics`'s dedup logic depends on -/// both sides mapping severity/code identically. -async fn diagnostic_to_mcp( - diag: &lsp_types::Diagnostic, - ctx: &EncodingCtx, - uri: &lsp_types::Uri, -) -> Diagnostic { - Diagnostic { - range: ctx.normalize_range(uri, diag.range).await, - severity: match diag.severity { - Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error, - Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning, - Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint, - // INFORMATION and None (no severity reported) both fall here. - _ => DiagnosticSeverity::Information, - }, - message: diag.message.clone(), - code: diag.code.as_ref().map(|c| match c { - lsp_types::NumberOrString::Number(n) => n.to_string(), - lsp_types::NumberOrString::String(s) => s.clone(), - }), - } -} - -/// Convert LSP document symbol to MCP symbol. `uri` is the queried -/// document's own URI: nested `DocumentSymbol` entries have no URI of their -/// own, since `textDocument/documentSymbol` is always scoped to one file. -/// -/// Boxed because it recurses through `children` and an `async fn` cannot -/// call itself directly (its future would have unbounded size). -fn convert_document_symbol<'a>( - symbol: DocumentSymbol, - ctx: &'a EncodingCtx, - uri: &'a lsp_types::Uri, -) -> futures::future::BoxFuture<'a, Symbol> { - Box::pin(async move { - let range = ctx.normalize_range(uri, symbol.range).await; - let selection_range = ctx.normalize_range(uri, symbol.selection_range).await; - let children = match symbol.children { - Some(children) => { - let mut result = Vec::with_capacity(children.len()); - for child in children { - result.push(convert_document_symbol(child, ctx, uri).await); - } - Some(result) - } - None => None, - }; - - Symbol { - name: symbol.name, - kind: format!("{:?}", symbol.kind), - range, - selection_range, - children, - } - }) -} - -/// Convert LSP call hierarchy item to MCP call hierarchy item. -async fn convert_call_hierarchy_item( - item: CallHierarchyItem, - ctx: &EncodingCtx, -) -> CallHierarchyItemResult { - let range = ctx.normalize_range(&item.uri, item.range).await; - let selection_range = ctx.normalize_range(&item.uri, item.selection_range).await; - - CallHierarchyItemResult { - name: item.name, - kind: serde_json::to_value(item.kind) - .ok() - .and_then(|v| v.as_u64()) - .and_then(|n| u32::try_from(n).ok()) - .unwrap_or(0), - detail: item.detail, - uri: item.uri.to_string(), - range, - selection_range, - data: item.data, - } -} - -/// Convert LSP code action to MCP code action. `uri` is the queried -/// document's own URI, used for the action's `diagnostics` (always scoped to -/// the requested document); `edit.changes` carries its own per-file URIs. -async fn convert_code_action( - action: lsp_types::CodeAction, - ctx: &EncodingCtx, - uri: &lsp_types::Uri, -) -> CodeAction { - let diagnostics = match action.diagnostics { - Some(diags) => { - let mut result = Vec::with_capacity(diags.len()); - for d in &diags { - result.push(diagnostic_to_mcp(d, ctx, uri).await); - } - result - } - None => Vec::new(), - }; - - let edit = match action.edit { - Some(edit) => { - let changes = match edit.changes { - Some(changes_map) => { - let mut result = Vec::with_capacity(changes_map.len()); - for (uri, edits) in changes_map { - let mut text_edits = Vec::with_capacity(edits.len()); - for e in edits { - text_edits.push(TextEdit { - range: ctx.normalize_range(&uri, e.range).await, - new_text: e.new_text, - }); - } - result.push(DocumentChanges { - uri: uri.to_string(), - edits: text_edits, - }); - } - result - } - None => Vec::new(), - }; - Some(WorkspaceEditDescription { changes }) - } - None => None, - }; - - let command = action.command.map(|cmd| { - let arguments = cmd.arguments.unwrap_or_else(Vec::new); - CommandDescription { - title: cmd.title, - command: cmd.command, - arguments, - } - }); - - CodeAction { - title: action.title, - kind: action.kind.map(|k| k.as_str().to_string()), - diagnostics, - edit, - command, - is_preferred: action.is_preferred.unwrap_or(false), - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use std::fs; - - use tempfile::TempDir; - use url::Url; - - use super::super::state::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE}; - use super::*; - - /// A UTF-16 `EncodingCtx`, matching the pre-negotiation behavior: no - /// disk reads, pure line/column offsetting. - fn test_ctx() -> EncodingCtx { - test_ctx_with(PositionEncoding::Utf16) - } - - /// An `EncodingCtx` with a fresh, empty `DocumentTracker` -- suitable for - /// tests that need a non-UTF-16 encoding and don't care about the - /// tracker fast path (e.g. exercising the disk-read fallback directly). - fn test_ctx_with(encoding: PositionEncoding) -> EncodingCtx { - EncodingCtx { - encoding, - tracker: Arc::new(DocumentTracker::new( - ResourceLimits::default(), - HashMap::new(), - )), - } - } - - fn test_uri() -> lsp_types::Uri { - "file:///test.rs".parse().unwrap() - } - - /// A fresh, empty `DocumentTracker` for tests that call - /// `diagnostics_from_cache_entry`/`merge_diagnostics` directly and don't - /// care about the tracker fast path. - fn test_tracker() -> Arc { - Arc::new(DocumentTracker::new( - ResourceLimits::default(), - HashMap::new(), - )) - } - - #[test] - fn test_translator_new() { - let translator = Translator::new(); - assert_eq!(translator.workspace_roots.len(), 0); - assert_eq!(lock_std(&translator.lsp_clients).len(), 0); - assert_eq!(lock_std(&translator.lsp_servers).len(), 0); - } - - /// `with_resource_limits` called before `with_extensions` (the order - /// `serve()` uses) must reach `document_tracker`. - #[test] - fn test_with_resource_limits_applies_before_with_extensions() { - let limits = ResourceLimits { - max_documents: 1, - max_file_size: 0, - }; - let translator = Translator::new() - .with_resource_limits(limits) - .with_extensions(HashMap::new()); - - translator - .document_tracker - .open(PathBuf::from("/tmp/a.rs"), "a".to_string()) - .unwrap(); - let err = translator - .document_tracker - .open(PathBuf::from("/tmp/b.rs"), "b".to_string()) - .unwrap_err(); - assert!(matches!(err, Error::DocumentLimitExceeded { max: 1, .. })); - } - - /// `with_resource_limits` called *after* `with_extensions` (the reverse - /// of `serve()`'s order) must still reach `document_tracker` -- the two - /// builders must not clobber each other regardless of call order. See - /// `Translator::with_resource_limits`'s docs. - /// - /// Uses a non-empty extension map (unlike the "before" test above) and - /// asserts it survived `with_resource_limits`'s rebuild by checking the - /// tracked document's resolved `language_id` -- a bug that dropped the - /// extension map (e.g. rebuilding from `HashMap::new()` instead of - /// `self.extension_map`) would leave `max_documents` correct but the - /// extension map silently empty, which the "before" test alone cannot - /// detect. - #[test] - fn test_with_resource_limits_applies_after_with_extensions() { - let limits = ResourceLimits { - max_documents: 1, - max_file_size: 0, - }; - let translator = Translator::new() - .with_extensions(HashMap::from([("rs".to_string(), "rust".to_string())])) - .with_resource_limits(limits); - - let path = PathBuf::from("/tmp/a.rs"); - translator - .document_tracker - .open(path.clone(), "a".to_string()) - .unwrap(); - let err = translator - .document_tracker - .open(PathBuf::from("/tmp/b.rs"), "b".to_string()) - .unwrap_err(); - assert!(matches!(err, Error::DocumentLimitExceeded { max: 1, .. })); - - let state = translator.document_tracker.close(&path).unwrap(); - assert_eq!(state.language_id(), "rust"); - } - - #[test] - fn test_set_workspace_roots() { - let mut translator = Translator::new(); - let roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")]; - translator.set_workspace_roots(roots.clone()); - assert_eq!(*translator.workspace_roots, roots); - } - - #[test] - fn test_register_server() { - let translator = Translator::new(); - - // Initial state: no servers registered - assert_eq!(lock_std(&translator.lsp_servers).len(), 0); - - // The register_server method exists and is callable - // Full integration testing with real LspServer is done in integration tests - // This unit test verifies the method signature and basic functionality - - // Note: We can't easily construct an LspServer in a unit test without async - // and a real LSP server process. The actual registration functionality is - // tested in integration tests (see rust_analyzer_tests.rs). - // This test verifies the data structure is properly initialized. - } - - /// #241: `shutdown_servers` on an empty registry must return immediately - /// rather than blocking (e.g. on a `JoinSet` that's never populated). - #[tokio::test] - async fn test_shutdown_servers_empty_registry_returns_promptly() { - let translator = Translator::new(); - - let result = - tokio::time::timeout(Duration::from_secs(1), translator.shutdown_servers()).await; - - assert!( - result.is_ok(), - "shutdown_servers must return promptly when no servers are registered" - ); - } - - /// #241: `shutdown_servers` must drain every registered `LspServer` — - /// this is the core behavior the issue is about (orphaned LSP children - /// on shutdown). Uses `fake_lsp_server()` (mock `echo`/`cat` child - /// processes, real `LspServer`, see `lsp::lifecycle`), which won't - /// answer the LSP `shutdown` handshake — proving the drain completes, - /// via the timeout/error fallback path, without hanging on - /// non-responsive servers. - #[tokio::test] - async fn test_shutdown_servers_drains_registered_servers() { - let translator = Translator::new(); - translator.register_server("server-a", crate::lsp::fake_lsp_server()); - translator.register_server("server-b", crate::lsp::fake_lsp_server()); - assert_eq!(lock_std(&translator.lsp_servers).len(), 2); - - // Bounded well above `SERVER_SHUTDOWN_TIMEOUT` (10s) so a genuine - // regression (a hang) still fails the test instead of the harness - // itself timing out ambiguously. - let result = - tokio::time::timeout(Duration::from_secs(20), translator.shutdown_servers()).await; - - assert!( - result.is_ok(), - "shutdown_servers must not hang against non-responsive mock servers" - ); - assert_eq!( - lock_std(&translator.lsp_servers).len(), - 0, - "all registered servers must be drained" - ); - } - - #[test] - fn test_get_client_for_file_server_initializing_when_expected() { - // A configured/applicable language whose LSP client has not registered - // yet (large solution still loading via OmniSharp) must surface - // ServerInitializing — "wait and retry" — not NoServerForLanguage. - let path = PathBuf::from("/ws/Assets/Scripts/Player.cs"); - let lang = detect_language(&path, &HashMap::new()); - let id = ServerId::from(lang.clone()); - - let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)])); - let mut expected = HashSet::new(); - expected.insert(id.clone()); - translator.set_expected_servers(expected); - - let err = translator - .get_client_for_file(&path, ToolKind::Hover) - .unwrap_err(); - assert!(matches!(err, Error::ServerInitializing { server_id } if server_id == id)); - } - - #[test] - fn test_get_client_for_file_no_server_when_not_expected() { - // When no route is configured for the language at all, the error - // stays NoServerForLanguage. - let translator = Translator::new(); - let path = PathBuf::from("/ws/Assets/Scripts/Player.cs"); - let lang = detect_language(&path, &translator.extension_map); - - let err = translator - .get_client_for_file(&path, ToolKind::Hover) - .unwrap_err(); - assert!(matches!(err, Error::NoServerForLanguage(ref l) if *l == lang)); - } - - #[test] - fn test_clear_expected_servers_reverts_to_no_server_after_all_routes_dropped() { - // Mirrors the real `serve_with` flow: `rebind_router` (called from - // `register_servers`/the all-failed path) drops routes to servers - // that never registered, then `clear_expected_servers` runs under - // the same lock. Subsequent lookups must fall back to - // NoServerForLanguage rather than keep implying the server is still - // on its way. - let path = PathBuf::from("/ws/Assets/Scripts/Player.cs"); - let lang = detect_language(&path, &HashMap::new()); - let id = ServerId::from(lang.clone()); - - let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)])); - let mut expected = HashSet::new(); - expected.insert(id); - translator.set_expected_servers(expected); - - translator.rebind_router(&HashSet::new()); - translator.clear_expected_servers(); - - let err = translator - .get_client_for_file(&path, ToolKind::Hover) - .unwrap_err(); - assert!(matches!(err, Error::NoServerForLanguage(_))); - } - - // ------------------------------------------------------------------ - // #249: dead-server detection and respawn - // ------------------------------------------------------------------ - - // These three are pure logic (no process spawning), so they run on - // every platform rather than being swept under `respawn_tests`'s - // `#[cfg(unix)]` gate below -- otherwise Windows CI would have zero - // #249 coverage at all. - #[test] - fn test_respawn_lock_is_shared_across_lookups_for_same_id() { - let translator = Translator::new(); - let id = ServerId::from("rust"); - - let first = translator.respawn_lock(&id); - let second = translator.respawn_lock(&id); - - assert!( - Arc::ptr_eq(&first, &second), - "two lookups for the same id must return the same underlying lock, \ - otherwise concurrent respawns would not actually be serialized" - ); - } - - #[test] - fn test_respawn_lock_differs_across_ids() { - let translator = Translator::new(); - - let rust_lock = translator.respawn_lock(&ServerId::from("rust")); - let python_lock = translator.respawn_lock(&ServerId::from("python")); - - assert!(!Arc::ptr_eq(&rust_lock, &python_lock)); - } - - #[test] - fn test_is_server_dead_false_when_not_registered() { - let translator = Translator::new(); - assert!(!translator.is_server_dead(&ServerId::from("rust"))); - } - - // Gated `#[cfg(unix)]`: this module's fake-LSP-server test double is a - // hand-written `sh` script (POSIX parameter expansion, `printf`-framed - // LSP responses, file-based invocation counters), which has no - // equivalent on Windows. CI's "Test (unit)" job matrix includes - // `windows-latest`. - #[cfg(unix)] - mod respawn_tests { - use std::path::Path; - - use tokio::time::Duration; - - use super::*; - use crate::config::LspServerConfig; - - /// Writes a `sh` script that answers the LSP `initialize` handshake - /// with a canned response -- request id `1`, since a freshly spawned - /// `LspClient`'s request counter always starts there -- and then - /// exits shortly after, so `LspServer::spawn` succeeds but the - /// process is already dead moments later. Stands in for "the server - /// was alive, then crashed" without needing a real language server - /// binary. - /// - /// The brief sleep before exiting matters: `LspServer::spawn` sends - /// the `initialized` notification right after the `initialize` - /// response arrives, and without it the process can (racily) have - /// already exited by the time that notification is written to its - /// stdin, failing the spawn itself instead of the respawn this is - /// meant to seed. - fn write_crash_after_init_script(dir: &Path) -> PathBuf { - let script_path = dir.join("crash_after_init.sh"); - let body = r#"body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}' -printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body" -sleep 0.3 -"#; - fs::write(&script_path, body).unwrap(); - script_path - } - - /// Like [`write_crash_after_init_script`], but stays alive for - /// `sleep_secs` after responding instead of exiting immediately. - fn write_responder_script(dir: &Path, sleep_secs: u64) -> PathBuf { - let script_path = dir.join("responder.sh"); - let template = r#"body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}' -printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body" -sleep __SLEEP__ -"#; - fs::write( - &script_path, - template.replace("__SLEEP__", &sleep_secs.to_string()), - ) - .unwrap(); - script_path - } - - fn stub_server_config(id: &str, script: &Path) -> ServerInitConfig { - ServerInitConfig { - server_config: LspServerConfig { - language_id: id.to_string(), - command: "sh".to_string(), - args: vec![script.to_string_lossy().to_string()], - env: HashMap::new(), - file_patterns: vec![], - initialization_options: None, - timeout_seconds: 5, - request_timeout_seconds: 5, - heuristics: None, - name: Some(id.to_string()), - handles: None, - }, - workspace_roots: vec![], - initialization_options: None, - position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()], - notification_tx: None, - } - } - - /// Polls `is_server_dead` until it reports `true`, bounding the wait - /// so a broken script fails the test instead of hanging it. - async fn wait_until_dead(translator: &Translator, id: &ServerId) { - tokio::time::timeout(Duration::from_secs(2), async { - loop { - if translator.is_server_dead(id) { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("seed server never reported as exited"); - } - - #[tokio::test] - async fn test_respawn_if_dead_noop_when_server_alive() { - let dir = TempDir::new().unwrap(); - let script = write_responder_script(dir.path(), 1); - let id = ServerId::from("rust"); - let config = stub_server_config("rust", &script); - - let server = LspServer::spawn(config).await.unwrap(); - let translator = Translator::new(); - translator.register_client(id.clone(), server.client().clone()); - translator.register_server(id.clone(), server); - // Deliberately no `register_server_config`: if a respawn were - // (wrongly) attempted despite the server being alive, the - // missing config would surface as `Error::ServerUnavailable` - // instead of quietly succeeding -- so `Ok(())` here is proof - // the alive fast path skipped respawning entirely. - - assert!(translator.respawn_if_dead(&id).await.is_ok()); - } - - #[tokio::test] - async fn test_respawn_if_dead_errors_when_no_config_registered() { - let dir = TempDir::new().unwrap(); - let script = write_crash_after_init_script(dir.path()); - let id = ServerId::from("rust"); - let config = stub_server_config("rust", &script); - - let server = LspServer::spawn(config).await.unwrap(); - let translator = Translator::new(); - translator.register_client(id.clone(), server.client().clone()); - translator.register_server(id.clone(), server); - wait_until_dead(&translator, &id).await; - - let err = translator.respawn_if_dead(&id).await.unwrap_err(); - assert!( - matches!(err, Error::ServerUnavailable { .. }), - "got {err:?}" - ); - } - - #[tokio::test] - async fn test_respawn_if_dead_propagates_spawn_failure() { - let dir = TempDir::new().unwrap(); - let script = write_crash_after_init_script(dir.path()); - let id = ServerId::from("rust"); - let seed_config = stub_server_config("rust", &script); - - let server = LspServer::spawn(seed_config).await.unwrap(); - let translator = Translator::new(); - translator.register_client(id.clone(), server.client().clone()); - translator.register_server(id.clone(), server); - wait_until_dead(&translator, &id).await; - - let mut broken = stub_server_config("rust", &script); - broken.server_config.command = "nonexistent-lsp-cmd-xyz".to_string(); - translator.register_server_config(id.clone(), broken); - - let err = translator.respawn_if_dead(&id).await.unwrap_err(); - assert!( - matches!(err, Error::ServerSpawnFailed { .. }), - "got {err:?}" - ); - } - - /// #249: two concurrent tool calls that both observe the same dead - /// server must not each perform their own respawn -- only one - /// replacement process should ever be spawned, and both callers - /// must still resolve successfully. - /// - /// The fake server script counts every invocation and, on its - /// first run only, exits right after answering `initialize` - /// (simulating "was alive, then crashed"); every later invocation - /// answers and then sleeps, standing in for a healthy replacement. - /// If single-flighting were broken, both concurrent callers would - /// spawn their own replacement and the invocation count would be - /// 3 (seed + two independent respawns) instead of 2 (seed + one - /// shared respawn). - #[tokio::test] - async fn test_respawn_if_dead_single_flights_concurrent_callers() { - let dir = TempDir::new().unwrap(); - let marker = dir.path().join("marker"); - let counter = dir.path().join("invocations"); - let script_path = dir.path().join("flaky.sh"); - let template = r#"echo x >> "__COUNTER__" -if [ -f "__MARKER__" ]; then - body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}' - printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body" - sleep 1 -else - touch "__MARKER__" - body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}' - printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body" - sleep 0.3 -fi -"#; - let script_body = template - .replace("__COUNTER__", &counter.display().to_string()) - .replace("__MARKER__", &marker.display().to_string()); - fs::write(&script_path, script_body).unwrap(); - - let id = ServerId::from("rust"); - let config = stub_server_config("rust", &script_path); - - let seed = LspServer::spawn(config.clone()).await.unwrap(); - let translator = Arc::new(Translator::new()); - translator.register_client(id.clone(), seed.client().clone()); - translator.register_server(id.clone(), seed); - translator.register_server_config(id.clone(), config); - wait_until_dead(&translator, &id).await; - - let (t1, id1) = (Arc::clone(&translator), id.clone()); - let (t2, id2) = (Arc::clone(&translator), id.clone()); - let (r1, r2) = tokio::join!( - tokio::spawn(async move { t1.respawn_if_dead(&id1).await }), - tokio::spawn(async move { t2.respawn_if_dead(&id2).await }), - ); - assert!(r1.unwrap().is_ok()); - assert!(r2.unwrap().is_ok()); - - let invocations = fs::read_to_string(&counter).unwrap(); - assert_eq!( - invocations.lines().count(), - 2, - "expected exactly one seed spawn + one single-flighted \ - respawn, got:\n{invocations}" - ); - } - - /// #249 S2 regression: a second `respawn_if_dead` call within the - /// backoff window must fail fast via `Error::ServerUnavailable` - /// instead of repeating a real spawn attempt -- proven by the - /// *kind* of error changing between the two calls, not by timing: - /// the first call's failure is the genuine `LspServer::spawn` error - /// (`Error::ServerSpawnFailed`, from a command that does not - /// exist), and the second, immediately following, is the distinct - /// backoff error. - #[tokio::test] - async fn test_respawn_if_dead_backs_off_after_repeated_failure() { - let dir = TempDir::new().unwrap(); - let seed_script = write_crash_after_init_script(dir.path()); - let id = ServerId::from("rust"); - let seed_config = stub_server_config("rust", &seed_script); - - let seed = LspServer::spawn(seed_config).await.unwrap(); - let translator = Translator::new(); - translator.register_client(id.clone(), seed.client().clone()); - translator.register_server(id.clone(), seed); - wait_until_dead(&translator, &id).await; - - let mut broken = stub_server_config("rust", &seed_script); - broken.server_config.command = "nonexistent-lsp-cmd-xyz".to_string(); - translator.register_server_config(id.clone(), broken); - - let err1 = translator.respawn_if_dead(&id).await.unwrap_err(); - assert!( - matches!(err1, Error::ServerSpawnFailed { .. }), - "first attempt should be a real (failed) spawn, got {err1:?}" - ); - - let err2 = translator.respawn_if_dead(&id).await.unwrap_err(); - assert!( - matches!(err2, Error::ServerUnavailable { .. }), - "second call within the backoff window must fail fast \ - without attempting another real spawn, got {err2:?}" - ); - } - - /// #249 R3 regression: a respawn that *succeeds* (completes - /// `initialize`) but dies again almost immediately must still - /// engage backoff -- this is the more realistic crash-loop shape - /// (start, initialize, then OOM-die a second later) than an - /// outright spawn failure, and without this fix every such cycle - /// looked like a fresh, unbacked-off start, spawning one child - /// process per tool call forever. - #[tokio::test] - async fn test_respawn_if_dead_backs_off_after_quick_recrash_following_success() { - let dir = TempDir::new().unwrap(); - let seed_script = write_crash_after_init_script(dir.path()); - let id = ServerId::from("rust"); - let seed_config = stub_server_config("rust", &seed_script); - - let seed = LspServer::spawn(seed_config).await.unwrap(); - let translator = Translator::new(); - translator.register_client(id.clone(), seed.client().clone()); - translator.register_server(id.clone(), seed); - wait_until_dead(&translator, &id).await; - - // Reuse the same crash-after-init script as the respawn target: - // every attempt completes `initialize` successfully, then dies - // ~0.3s later -- a post-init crash loop, not a spawn failure. - translator.register_server_config(id.clone(), stub_server_config("rust", &seed_script)); - - translator - .respawn_if_dead(&id) - .await - .expect("the replacement completes initialize, so this attempt succeeds"); - wait_until_dead(&translator, &id).await; - - let err = translator.respawn_if_dead(&id).await.unwrap_err(); - assert!( - matches!(err, Error::ServerUnavailable { .. }), - "a respawn that dies again within the stability window must \ - back off instead of being treated as a fresh attempt, got {err:?}" - ); - } - - /// #249 C1 regression: respawning the *diagnostics-route* server - /// for a language must invalidate that server's diagnostics cache - /// entries, rather than leaving stale entries to be merged into - /// fresh pull results as if still current -- the crashed process's - /// pump is gone and will never update or clear them itself. - /// - /// Covers the "under-clear" failure mode a scoped-to-synced-URIs - /// clear has: a real diagnostics-route server (e.g. rust-analyzer) - /// publishes workspace-wide (`cargo check` results for files never - /// opened through mcpls), so `never_opened_uri` below stands in for - /// an entry that must still be cleared despite never having gone - /// through `ensure_open`. - /// - /// #266 S2 regression (over-clear direction, multi-language case): - /// `other_language_uri` is owned by a *different* diagnostics-route - /// server (e.g. pyright for Python, in the same workspace as the - /// rust-analyzer under test here) and must survive -- `clear_server_diagnostics` - /// replaced a workspace-wide `clear_all_diagnostics` that used to - /// wipe every language's cache on any single server's respawn. - #[tokio::test] - async fn test_respawn_if_dead_clears_diagnostics_cache_when_diagnostics_route() { - let dir = TempDir::new().unwrap(); - let seed_script = write_crash_after_init_script(dir.path()); - let id = ServerId::from("rust"); - let seed_config = stub_server_config("rust", &seed_script); - - let seed = LspServer::spawn(seed_config).await.unwrap(); - - let cache = Arc::new(Mutex::new(crate::bridge::NotificationCache::new())); - let translator = Translator::new() - .with_router(ToolRouter::catch_all([(id.clone(), "rust".to_string())])) - .with_notification_cache(Arc::clone(&cache)); - translator.register_client(id.clone(), seed.client().clone()); - translator.register_server(id.clone(), seed); - - let synced_uri: lsp_types::Uri = "file:///workspace/opened.rs".parse().unwrap(); - let never_opened_uri: lsp_types::Uri = - "file:///workspace/never_opened.rs".parse().unwrap(); - let other_language_uri: lsp_types::Uri = "file:///workspace/main.py".parse().unwrap(); - cache - .lock() - .await - .store_diagnostics(&id, &synced_uri, None, vec![]); - cache - .lock() - .await - .store_diagnostics(&id, &never_opened_uri, None, vec![]); - cache.lock().await.store_diagnostics( - &ServerId::from("python"), - &other_language_uri, - None, - vec![], - ); - - wait_until_dead(&translator, &id).await; - - let respawn_script = write_responder_script(dir.path(), 1); - translator - .register_server_config(id.clone(), stub_server_config("rust", &respawn_script)); - - translator.respawn_if_dead(&id).await.unwrap(); - - let guard = cache.lock().await; - assert!( - guard.get_diagnostics(synced_uri.as_str()).is_none(), - "diagnostics attributed to the crashed connection must be \ - invalidated on respawn, not served as current" - ); - assert!( - guard.get_diagnostics(never_opened_uri.as_str()).is_none(), - "workspace-wide diagnostics for a file mcpls never opened \ - must also be invalidated, not just synced documents" - ); - assert!( - guard.get_diagnostics(other_language_uri.as_str()).is_some(), - "a different diagnostics-route server's entries must survive \ - an unrelated server's respawn-triggered cache clear" - ); - drop(guard); - } - - /// #249 C1 regression (over-clear direction): respawning a server - /// that is *not* the diagnostics route for its language must not - /// touch the cache at all -- otherwise a crashed hover-only server - /// would wipe out a healthy, still-running diagnostics server's - /// valid entries for the same files. - #[tokio::test] - async fn test_respawn_if_dead_does_not_clear_cache_when_not_diagnostics_route() { - use crate::config::LspServerConfig; - - let dir = TempDir::new().unwrap(); - let seed_script = write_crash_after_init_script(dir.path()); - let hover_id = ServerId::from("hover-only"); - let hover_seed_config = stub_server_config("hover-only", &seed_script); - - let seed = LspServer::spawn(hover_seed_config).await.unwrap(); - - // `hover_id` handles only Hover; a separate (never-registered - // here, purely routing-table) server is the catch-all and thus - // the diagnostics route. - let configs = [ - LspServerConfig { - language_id: "rust".to_string(), - command: "sh".to_string(), - args: vec![], - env: HashMap::new(), - file_patterns: vec![], - initialization_options: None, - timeout_seconds: 5, - request_timeout_seconds: 5, - heuristics: None, - name: Some("hover-only".to_string()), - handles: Some(vec![ToolKind::Hover]), - }, - LspServerConfig { - language_id: "rust".to_string(), - command: "sh".to_string(), - args: vec![], - env: HashMap::new(), - file_patterns: vec![], - initialization_options: None, - timeout_seconds: 5, - request_timeout_seconds: 5, - heuristics: None, - name: Some("diag-catchall".to_string()), - handles: None, - }, - ]; - let router = ToolRouter::from_configs(configs.iter()).unwrap(); - - let cache = Arc::new(Mutex::new(crate::bridge::NotificationCache::new())); - let translator = Translator::new() - .with_router(router) - .with_notification_cache(Arc::clone(&cache)); - translator.register_client(hover_id.clone(), seed.client().clone()); - translator.register_server(hover_id.clone(), seed); - - let owned_by_healthy_server: lsp_types::Uri = - "file:///workspace/still_healthy.rs".parse().unwrap(); - cache - .lock() - .await - .store_diagnostics(&hover_id, &owned_by_healthy_server, None, vec![]); - - wait_until_dead(&translator, &hover_id).await; - - let respawn_script = write_responder_script(dir.path(), 1); - // `language_id` must match the router's ("rust"), not the - // routing identity ("hover-only"): otherwise `is_diagnostics_route` - // returns `false` because of a language mismatch rather than - // because of the `handles: Some([Hover])` restriction this test - // means to exercise, which would pass for the wrong reason. - let mut respawn_config = stub_server_config("hover-only", &respawn_script); - respawn_config.server_config.language_id = "rust".to_string(); - translator.register_server_config(hover_id.clone(), respawn_config); - - translator.respawn_if_dead(&hover_id).await.unwrap(); - - assert!( - cache - .lock() - .await - .get_diagnostics(owned_by_healthy_server.as_str()) - .is_some(), - "respawning a non-diagnostics-route server must not clear \ - the diagnostics-route server's cache entries" - ); - } - - /// #249 test-gap closure: proves `resolve_client_for_file`'s - /// dead-server branch is actually reached through the shared - /// entry point every public tool handler (`handle_hover`, - /// `handle_definition`, ...) funnels through -- not just through - /// the private `respawn_if_dead`/`is_server_dead` calls the other - /// tests in this module make directly. - #[tokio::test] - async fn test_prepare_document_respawns_dead_server_through_shared_entry_point() { - let dir = TempDir::new().unwrap(); - let workspace = dir.path(); - let file_path = workspace.join("main.rs"); - fs::write(&file_path, "fn main() {}").unwrap(); - - let seed_script = write_crash_after_init_script(dir.path()); - let id = ServerId::from("rust"); - let seed_config = stub_server_config("rust", &seed_script); - - let seed = LspServer::spawn(seed_config).await.unwrap(); - let mut translator = Translator::new() - .with_router(ToolRouter::catch_all([(id.clone(), "rust".to_string())])) - .with_extensions(HashMap::from([("rs".to_string(), "rust".to_string())])); - translator.set_workspace_roots(vec![workspace.to_path_buf()]); - translator.register_client(id.clone(), seed.client().clone()); - translator.register_server(id.clone(), seed); - wait_until_dead(&translator, &id).await; - - let respawn_script = write_responder_script(dir.path(), 1); - translator - .register_server_config(id.clone(), stub_server_config("rust", &respawn_script)); - - let result = translator - .prepare_document(&file_path.to_string_lossy(), ToolKind::Hover) - .await; - assert!(result.is_ok(), "got {result:?}"); - - assert!( - !translator.is_server_dead(&id), - "the respawned replacement should be alive" - ); - } - } - - #[test] - fn test_diagnostic_request_params_omit_optional_null_fields() { - let uri = "file:///test.ts".parse().unwrap(); - let params = diagnostic_request_params(TextDocumentIdentifier { uri }); - let value = serde_json::to_value(params).unwrap(); - - assert_eq!(value["textDocument"]["uri"], "file:///test.ts"); - assert!(value.get("identifier").is_none()); - assert!(value.get("previousResultId").is_none()); - } - - #[test] - fn test_validate_path_no_workspace_roots() { - let translator = Translator::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - // With no workspace roots, any valid path should be accepted - let result = translator.validate_path(&test_file); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_path_within_workspace() { - let mut translator = Translator::new(); - let temp_dir = TempDir::new().unwrap(); - let workspace_root = temp_dir.path().to_path_buf(); - translator.set_workspace_roots(vec![workspace_root]); - - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let result = translator.validate_path(&test_file); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_path_outside_workspace() { - let mut translator = Translator::new(); - let temp_dir1 = TempDir::new().unwrap(); - let temp_dir2 = TempDir::new().unwrap(); - - // Set workspace root to temp_dir1 - translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]); - - // Create file in temp_dir2 (outside workspace) - let test_file = temp_dir2.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let result = translator.validate_path(&test_file); - assert!(matches!(result, Err(Error::PathOutsideWorkspace(_)))); - } - - #[tokio::test] - async fn test_normalize_range() { - let lsp_range = lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 0, - }, - end: lsp_types::Position { - line: 2, - character: 5, - }, - }; - - let mcp_range = test_ctx().normalize_range(&test_uri(), lsp_range).await; - assert_eq!(mcp_range.start.line, 1); - assert_eq!(mcp_range.start.character, 1); - assert_eq!(mcp_range.end.line, 3); - assert_eq!(mcp_range.end.character, 6); - } - - /// End-to-end proof that a non-UTF-16 `EncodingCtx` is actually wired to - /// `read_line_text`/disk, not just correct in isolation at the - /// `encoding.rs` function level: a real temp file with a multibyte line - /// ("héllo"), converted through `EncodingCtx::to_lsp` for a document the - /// tracker has never seen (forcing the disk-read fallback). - #[tokio::test] - async fn test_encoding_ctx_utf8_reads_disk_line_text_for_untracked_document() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("multibyte.rs"); - fs::write(&path, "héllo").unwrap(); - let uri = path_to_uri(&path).unwrap(); - - let ctx = test_ctx_with(PositionEncoding::Utf8); - let lsp_pos = ctx.to_lsp(&uri, 1, 3).await; - // "hé" is 3 bytes in UTF-8 (h=1, é=2); MCP column 3 (UTF-16, after - // "hé") must re-derive to that byte offset via the disk-read line - // text, matching the `encoding.rs`-level math for the same input. - assert_eq!(lsp_pos.character, 3); - } - - /// C3/S1: when a document is tracked, `EncodingCtx` must prefer its - /// in-memory content over disk -- both cheaper (no I/O) and more correct - /// when they've diverged. Here disk holds stale ASCII ("hello", no - /// accent) while the tracker holds the live multibyte content - /// ("héllo"); if conversion used disk instead, MCP column 3 would - /// re-derive to LSP byte offset 2 (ASCII, no multibyte char) instead of - /// 3 (multibyte-correct) -- so this distinguishes the two sources rather - /// than merely tolerating either. - #[tokio::test] - async fn test_encoding_ctx_utf8_prefers_tracked_content_over_stale_disk() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("tracked.rs"); - fs::write(&path, "hello").unwrap(); // stale: no accent - - let tracker = Arc::new(DocumentTracker::new( - ResourceLimits::default(), - HashMap::new(), - )); - let uri = tracker.open(path.clone(), "héllo".to_string()).unwrap(); // live: accent - - let ctx = EncodingCtx { - encoding: PositionEncoding::Utf8, - tracker, - }; - let lsp_pos = ctx.to_lsp(&uri, 1, 3).await; - assert_eq!( - lsp_pos.character, 3, - "must convert against the tracker's live content (\"héllo\" -> byte 3), not disk's \ - stale content (\"hello\" -> byte 2)" - ); - } - - /// A single `EncodingCtx` answering one MCP tool call may still need to - /// convert positions in several different files (e.g. `references` - /// results spanning multiple documents) -- each conversion must resolve - /// *that* location's own file, never reuse or leak another file's line - /// text. Two untracked files with different content at the same - /// byte offset make a wrong-file conversion produce a visibly different - /// (wrong) answer: byte offset 3 is UTF-16 column 3 in "héllo" but - /// column 4 in the all-ASCII "hello". - #[tokio::test] - async fn test_normalize_range_multi_file_converts_each_location_against_its_own_uri() { - let dir = TempDir::new().unwrap(); - let path_a = dir.path().join("a.rs"); - fs::write(&path_a, "héllo").unwrap(); - let uri_a = path_to_uri(&path_a).unwrap(); - - let path_b = dir.path().join("b.rs"); - fs::write(&path_b, "hello").unwrap(); - let uri_b = path_to_uri(&path_b).unwrap(); - - let lsp_range = lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 0, - }, - end: lsp_types::Position { - line: 0, - character: 3, - }, - }; - - let ctx = test_ctx_with(PositionEncoding::Utf8); - let range_a = ctx.normalize_range(&uri_a, lsp_range).await; - let range_b = ctx.normalize_range(&uri_b, lsp_range).await; - - assert_eq!( - range_a.end.character, 3, - "must convert against a.rs's own content" - ); - assert_eq!( - range_b.end.character, 4, - "must convert against b.rs's own content" - ); - } - - #[test] - fn test_extract_hover_contents_string() { - let marked_string = lsp_types::MarkedString::String("Test hover".to_string()); - let contents = lsp_types::HoverContents::Scalar(marked_string); - let result = extract_hover_contents(contents); - assert_eq!(result, "Test hover"); - } - - #[test] - fn test_extract_hover_contents_language_string() { - let marked_string = lsp_types::MarkedString::LanguageString(lsp_types::LanguageString { - language: "rust".to_string(), - value: "fn main() {}".to_string(), - }); - let contents = lsp_types::HoverContents::Scalar(marked_string); - let result = extract_hover_contents(contents); - assert_eq!(result, "```rust\nfn main() {}\n```"); - } - - #[test] - fn test_extract_hover_contents_markup() { - let markup = lsp_types::MarkupContent { - kind: lsp_types::MarkupKind::Markdown, - value: "# Documentation".to_string(), - }; - let contents = lsp_types::HoverContents::Markup(markup); - let result = extract_hover_contents(contents); - assert_eq!(result, "# Documentation"); - } - - #[tokio::test] - async fn test_handle_workspace_symbol_no_server() { - let translator = Translator::new(); - let result = translator - .handle_workspace_symbol("test".to_string(), None, 100) - .await; - assert!(matches!(result, Err(Error::NoServerConfigured))); - } - - /// #242/S4 regression: a server is configured and still spawning (large - /// project load) rather than never having existed -- the router alone - /// cannot tell these apart (both look like "nothing registered"), so - /// `handle_workspace_symbol` must consult `expected_servers` to report - /// "still initializing" instead of the misleading "no server configured". - #[tokio::test] - async fn test_handle_workspace_symbol_reports_initializing_when_expected_but_not_registered() { - let translator = Translator::new(); - translator.set_expected_servers(HashSet::from([ServerId::from("pyright")])); - - let result = translator - .handle_workspace_symbol("test".to_string(), None, 100) - .await; - assert!(matches!(result, Err(Error::WorkspaceServersInitializing))); - } - - /// #242 regression: a server *is* configured and running, it just - /// doesn't claim `workspace_symbols` and there is no catch-all -- the - /// error must name the tool rather than collapse into the generic - /// "no LSP server configured" message a client would also see if - /// nothing were running at all. - #[tokio::test] - async fn test_handle_workspace_symbol_no_claimant_names_tool() { - let configs = vec![crate::config::LspServerConfig { - language_id: "python".to_string(), - command: "pyright-langserver".to_string(), - args: vec![], - env: HashMap::new(), - file_patterns: vec![], - initialization_options: None, - timeout_seconds: 30, - request_timeout_seconds: 30, - heuristics: None, - name: Some("pyright".to_string()), - handles: Some(vec![ToolKind::Hover]), - }]; - let router = ToolRouter::from_configs(&configs).unwrap(); - let translator = Translator::new().with_router(router); - - let result = translator - .handle_workspace_symbol("test".to_string(), None, 100) - .await; - assert!(matches!( - result, - Err(Error::NoServerForWorkspaceTool { - tool: ToolKind::WorkspaceSymbols - }) - )); - } - - #[tokio::test] - async fn test_handle_code_actions_invalid_kind() { - let translator = Translator::new(); - let result = translator - .handle_code_actions( - "/tmp/test.rs".to_string(), - 1, - 1, - 1, - 10, - Some("invalid_kind".to_string()), - ) - .await; - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_handle_code_actions_valid_kind_quickfix() { - use tempfile::TempDir; - - let translator = Translator::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let result = translator - .handle_code_actions( - test_file.to_str().unwrap().to_string(), - 1, - 1, - 1, - 10, - Some("quickfix".to_string()), - ) - .await; - // Will fail due to no LSP server, but validates kind is accepted - assert!(result.is_err()); - assert!(!matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_handle_code_actions_valid_kind_refactor() { - use tempfile::TempDir; - - let translator = Translator::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let result = translator - .handle_code_actions( - test_file.to_str().unwrap().to_string(), - 1, - 1, - 1, - 10, - Some("refactor".to_string()), - ) - .await; - assert!(result.is_err()); - assert!(!matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_handle_code_actions_valid_kind_refactor_extract() { - use tempfile::TempDir; - - let translator = Translator::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let result = translator - .handle_code_actions( - test_file.to_str().unwrap().to_string(), - 1, - 1, - 1, - 10, - Some("refactor.extract".to_string()), - ) - .await; - assert!(result.is_err()); - assert!(!matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_handle_code_actions_valid_kind_source() { - use tempfile::TempDir; - - let translator = Translator::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let result = translator - .handle_code_actions( - test_file.to_str().unwrap().to_string(), - 1, - 1, - 1, - 10, - Some("source.organizeImports".to_string()), - ) - .await; - assert!(result.is_err()); - assert!(!matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_handle_code_actions_invalid_range_zero() { - let translator = Translator::new(); - let result = translator - .handle_code_actions("/tmp/test.rs".to_string(), 0, 1, 1, 10, None) - .await; - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_handle_code_actions_invalid_range_order() { - let translator = Translator::new(); - let result = translator - .handle_code_actions("/tmp/test.rs".to_string(), 10, 5, 5, 1, None) - .await; - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_handle_code_actions_empty_range() { - use tempfile::TempDir; - - let translator = Translator::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - // Empty range (same position) should be valid - let result = translator - .handle_code_actions(test_file.to_str().unwrap().to_string(), 1, 5, 1, 5, None) - .await; - // Will fail due to no LSP server, but validates range is accepted - assert!(result.is_err()); - assert!(!matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_convert_code_action_minimal() { - let lsp_action = lsp_types::CodeAction { - title: "Fix issue".to_string(), - kind: None, - diagnostics: None, - edit: None, - command: None, - is_preferred: None, - disabled: None, - data: None, - }; - - let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await; - assert_eq!(result.title, "Fix issue"); - assert!(result.kind.is_none()); - assert!(result.diagnostics.is_empty()); - assert!(result.edit.is_none()); - assert!(result.command.is_none()); - assert!(!result.is_preferred); - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn test_convert_code_action_with_diagnostics_all_severities() { - let lsp_diagnostics = vec![ - lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 0, - }, - end: lsp_types::Position { - line: 0, - character: 5, - }, - }, - severity: Some(lsp_types::DiagnosticSeverity::ERROR), - message: "Error message".to_string(), - code: Some(lsp_types::NumberOrString::Number(1)), - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - }, - lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { - line: 1, - character: 0, - }, - end: lsp_types::Position { - line: 1, - character: 5, - }, - }, - severity: Some(lsp_types::DiagnosticSeverity::WARNING), - message: "Warning message".to_string(), - code: Some(lsp_types::NumberOrString::String("W001".to_string())), - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - }, - lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { - line: 2, - character: 0, - }, - end: lsp_types::Position { - line: 2, - character: 5, - }, - }, - severity: Some(lsp_types::DiagnosticSeverity::INFORMATION), - message: "Info message".to_string(), - code: None, - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - }, - lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { - line: 3, - character: 0, - }, - end: lsp_types::Position { - line: 3, - character: 5, - }, - }, - severity: Some(lsp_types::DiagnosticSeverity::HINT), - message: "Hint message".to_string(), - code: None, - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - }, - ]; - - let lsp_action = lsp_types::CodeAction { - title: "Fix all issues".to_string(), - kind: Some(lsp_types::CodeActionKind::QUICKFIX), - diagnostics: Some(lsp_diagnostics), - edit: None, - command: None, - is_preferred: None, - disabled: None, - data: None, - }; - - let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await; - assert_eq!(result.diagnostics.len(), 4); - assert!(matches!( - result.diagnostics[0].severity, - DiagnosticSeverity::Error - )); - assert!(matches!( - result.diagnostics[1].severity, - DiagnosticSeverity::Warning - )); - assert!(matches!( - result.diagnostics[2].severity, - DiagnosticSeverity::Information - )); - assert!(matches!( - result.diagnostics[3].severity, - DiagnosticSeverity::Hint - )); - assert_eq!(result.diagnostics[0].code, Some("1".to_string())); - assert_eq!(result.diagnostics[1].code, Some("W001".to_string())); - } - - #[tokio::test] - #[allow(clippy::mutable_key_type)] - async fn test_convert_code_action_with_workspace_edit() { - use std::collections::HashMap; - use std::str::FromStr; - - let uri = lsp_types::Uri::from_str("file:///test.rs").unwrap(); - let mut changes_map = HashMap::new(); - changes_map.insert( - uri, - vec![lsp_types::TextEdit { - range: lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 0, - }, - end: lsp_types::Position { - line: 0, - character: 5, - }, - }, - new_text: "fixed".to_string(), - }], - ); - - let lsp_action = lsp_types::CodeAction { - title: "Apply fix".to_string(), - kind: Some(lsp_types::CodeActionKind::QUICKFIX), - diagnostics: None, - edit: Some(lsp_types::WorkspaceEdit { - changes: Some(changes_map), - document_changes: None, - change_annotations: None, - }), - command: None, - is_preferred: Some(true), - disabled: None, - data: None, - }; - - let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await; - assert!(result.edit.is_some()); - let edit = result.edit.unwrap(); - assert_eq!(edit.changes.len(), 1); - assert_eq!(edit.changes[0].uri, "file:///test.rs"); - assert_eq!(edit.changes[0].edits.len(), 1); - assert_eq!(edit.changes[0].edits[0].new_text, "fixed"); - assert!(result.is_preferred); - } - - #[tokio::test] - async fn test_convert_code_action_with_command() { - let lsp_action = lsp_types::CodeAction { - title: "Run command".to_string(), - kind: Some(lsp_types::CodeActionKind::REFACTOR), - diagnostics: None, - edit: None, - command: Some(lsp_types::Command { - title: "Execute refactor".to_string(), - command: "refactor.extract".to_string(), - arguments: Some(vec![serde_json::json!("arg1"), serde_json::json!(42)]), - }), - is_preferred: None, - disabled: None, - data: None, - }; - - let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await; - assert!(result.command.is_some()); - let cmd = result.command.unwrap(); - assert_eq!(cmd.title, "Execute refactor"); - assert_eq!(cmd.command, "refactor.extract"); - assert_eq!(cmd.arguments.len(), 2); - } - - #[tokio::test] - async fn test_handle_call_hierarchy_prepare_invalid_position_zero() { - let translator = Translator::new(); - let result = translator - .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 0, 1) - .await; - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - - let result = translator - .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 0) - .await; - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_handle_call_hierarchy_prepare_invalid_position_too_large() { - let translator = Translator::new(); - let result = translator - .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1_000_001, 1) - .await; - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - - let result = translator - .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 1_000_001) - .await; - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_handle_incoming_calls_invalid_json() { - let translator = Translator::new(); - let invalid_item = serde_json::json!({"invalid": "structure"}); - let result = translator.handle_incoming_calls(invalid_item).await; - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_handle_outgoing_calls_invalid_json() { - let translator = Translator::new(); - let invalid_item = serde_json::json!({"invalid": "structure"}); - let result = translator.handle_outgoing_calls(invalid_item).await; - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_parse_file_uri_invalid_scheme() { - let translator = Translator::new(); - let uri: lsp_types::Uri = "http://example.com/file.rs".parse().unwrap(); - let result = translator.parse_file_uri(&uri); - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[tokio::test] - async fn test_parse_file_uri_valid_scheme() { - let translator = Translator::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - // Use url crate for cross-platform file URI creation - let file_url = Url::from_file_path(&test_file).unwrap(); - let uri: lsp_types::Uri = file_url.as_str().parse().unwrap(); - let result = translator.parse_file_uri(&uri); - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_handle_cached_diagnostics_empty() { - let cache = NotificationCache::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let cache_key = - Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap(); - let diag_info = cache.get_diagnostics(&cache_key).cloned(); - let diags = Translator::diagnostics_from_cache_entry( - diag_info.as_ref(), - PositionEncoding::Utf16, - &test_tracker(), - ) - .await; - assert_eq!(diags.diagnostics.len(), 0); - } - - #[test] - fn test_handle_server_logs_with_filter() { - use crate::bridge::notifications::LogLevel; - - let mut cache = NotificationCache::new(); - - // Add some logs - cache.store_log(LogLevel::Error, "error msg".to_string()); - cache.store_log(LogLevel::Warning, "warning msg".to_string()); - cache.store_log(LogLevel::Info, "info msg".to_string()); - cache.store_log(LogLevel::Debug, "debug msg".to_string()); - - // Test with error filter - let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string())); - assert!(result.is_ok()); - let logs = result.unwrap(); - assert_eq!(logs.logs.len(), 1); - assert_eq!(logs.logs[0].message, "error msg"); - - // Test with warning filter (includes error and warning) - let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string())); - assert!(result.is_ok()); - let logs = result.unwrap(); - assert_eq!(logs.logs.len(), 2); - - // Test with info filter (excludes debug) - let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string())); - assert!(result.is_ok()); - let logs = result.unwrap(); - assert_eq!(logs.logs.len(), 3); - - // Test with debug filter (includes all) - let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string())); - assert!(result.is_ok()); - let logs = result.unwrap(); - assert_eq!(logs.logs.len(), 4); - - // Test with invalid filter - let result = Translator::handle_server_logs(&cache, 10, Some("invalid".to_string())); - assert!(matches!(result, Err(Error::InvalidToolParams(_)))); - } - - #[test] - fn test_handle_server_messages_limit() { - use crate::bridge::notifications::MessageType; - - let mut cache = NotificationCache::new(); - - // Add some messages - for i in 0..10 { - cache.store_message(MessageType::Info, format!("message {i}")); - } - - // Test limit - let result = Translator::handle_server_messages(&cache, 5); - assert!(result.is_ok()); - let messages = result.unwrap(); - assert_eq!(messages.messages.len(), 5); - assert_eq!(messages.messages[0].message, "message 0"); - assert_eq!(messages.messages[4].message, "message 4"); - - // Test limit larger than available - let result = Translator::handle_server_messages(&cache, 100); - assert!(result.is_ok()); - let messages = result.unwrap(); - assert_eq!(messages.messages.len(), 10); - } - - #[tokio::test] - async fn test_handle_cached_diagnostics_with_data() { - let mut cache = NotificationCache::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let canonical_path = test_file.canonicalize().unwrap(); - let uri: lsp_types::Uri = Url::from_file_path(&canonical_path) - .unwrap() - .as_str() - .parse() - .unwrap(); - let diagnostic = lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 0, - }, - end: lsp_types::Position { - line: 0, - character: 5, - }, - }, - severity: Some(lsp_types::DiagnosticSeverity::ERROR), - message: "test error".to_string(), - code: Some(lsp_types::NumberOrString::String("E001".to_string())), - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - }; - - cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]); - - let cache_key = - Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap(); - let diag_info = cache.get_diagnostics(&cache_key).cloned(); - let diags = Translator::diagnostics_from_cache_entry( - diag_info.as_ref(), - PositionEncoding::Utf16, - &test_tracker(), - ) - .await; - assert_eq!(diags.diagnostics.len(), 1); - assert_eq!(diags.diagnostics[0].message, "test error"); - assert_eq!(diags.diagnostics[0].code, Some("E001".to_string())); - assert!(matches!( - diags.diagnostics[0].severity, - DiagnosticSeverity::Error - )); - assert_eq!(diags.diagnostics[0].range.start.line, 1); - assert_eq!(diags.diagnostics[0].range.start.character, 1); - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn test_handle_cached_diagnostics_multiple_severities() { - let mut cache = NotificationCache::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let canonical_path = test_file.canonicalize().unwrap(); - let uri: lsp_types::Uri = Url::from_file_path(&canonical_path) - .unwrap() - .as_str() - .parse() - .unwrap(); - let diagnostics = vec![ - lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 0, - }, - end: lsp_types::Position { - line: 0, - character: 5, - }, - }, - severity: Some(lsp_types::DiagnosticSeverity::ERROR), - message: "error".to_string(), - code: None, - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - }, - lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { - line: 1, - character: 0, - }, - end: lsp_types::Position { - line: 1, - character: 5, - }, - }, - severity: Some(lsp_types::DiagnosticSeverity::WARNING), - message: "warning".to_string(), - code: None, - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - }, - lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { - line: 2, - character: 0, - }, - end: lsp_types::Position { - line: 2, - character: 5, - }, - }, - severity: Some(lsp_types::DiagnosticSeverity::INFORMATION), - message: "info".to_string(), - code: None, - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - }, - lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { - line: 3, - character: 0, - }, - end: lsp_types::Position { - line: 3, - character: 5, - }, - }, - severity: Some(lsp_types::DiagnosticSeverity::HINT), - message: "hint".to_string(), - code: None, - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - }, - ]; - - cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), diagnostics); - - let cache_key = - Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap(); - let diag_info = cache.get_diagnostics(&cache_key).cloned(); - let diags = Translator::diagnostics_from_cache_entry( - diag_info.as_ref(), - PositionEncoding::Utf16, - &test_tracker(), - ) - .await; - assert_eq!(diags.diagnostics.len(), 4); - assert!(matches!( - diags.diagnostics[0].severity, - DiagnosticSeverity::Error - )); - assert!(matches!( - diags.diagnostics[1].severity, - DiagnosticSeverity::Warning - )); - assert!(matches!( - diags.diagnostics[2].severity, - DiagnosticSeverity::Information - )); - assert!(matches!( - diags.diagnostics[3].severity, - DiagnosticSeverity::Hint - )); - } - - #[tokio::test] - async fn test_handle_cached_diagnostics_with_numeric_code() { - let mut cache = NotificationCache::new(); - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let canonical_path = test_file.canonicalize().unwrap(); - let uri: lsp_types::Uri = Url::from_file_path(&canonical_path) - .unwrap() - .as_str() - .parse() - .unwrap(); - let diagnostic = lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 0, - }, - end: lsp_types::Position { - line: 0, - character: 5, - }, - }, - severity: Some(lsp_types::DiagnosticSeverity::ERROR), - message: "test error".to_string(), - code: Some(lsp_types::NumberOrString::Number(42)), - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - }; - - cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]); - - let cache_key = - Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap(); - let diag_info = cache.get_diagnostics(&cache_key).cloned(); - let diags = Translator::diagnostics_from_cache_entry( - diag_info.as_ref(), - PositionEncoding::Utf16, - &test_tracker(), - ) - .await; - assert_eq!(diags.diagnostics.len(), 1); - assert_eq!(diags.diagnostics[0].code, Some("42".to_string())); - } - - #[test] - fn test_handle_cached_diagnostics_invalid_path() { - let result = Translator::cached_diagnostics_uri(&[], "/nonexistent/path/file.rs"); - assert!(matches!(result, Err(Error::FileIo { .. }))); - } - - /// Builds an LSP-side diagnostic for `merge_diagnostics` cache fixtures. - fn lsp_diag( - line: u32, - end_character: u32, - severity: lsp_types::DiagnosticSeverity, - message: &str, - code: Option<&str>, - ) -> lsp_types::Diagnostic { - lsp_types::Diagnostic { - range: lsp_types::Range { - start: lsp_types::Position { line, character: 0 }, - end: lsp_types::Position { - line, - character: end_character, - }, - }, - severity: Some(severity), - message: message.to_string(), - code: code.map(|c| lsp_types::NumberOrString::String(c.to_string())), - source: None, - code_description: None, - related_information: None, - tags: None, - data: None, - } - } - - fn diag_info(diagnostics: Vec) -> DiagnosticInfo { - DiagnosticInfo { - uri: "file:///test.rs".parse().unwrap(), - version: Some(1), - diagnostics, - } - } - - #[tokio::test] - async fn test_merge_diagnostics_cache_only_appends_to_empty_pull() { - let pull = DiagnosticsResult { - diagnostics: vec![], - }; - let cache = diag_info(vec![lsp_diag( - 0, - 10, - lsp_types::DiagnosticSeverity::WARNING, - "unused import: `std::fmt`", - None, - )]); - - let merged = Translator::merge_diagnostics( - pull, - Some(&cache), - PositionEncoding::Utf16, - &test_tracker(), - ) - .await; - - assert_eq!(merged.diagnostics.len(), 1); - assert_eq!(merged.diagnostics[0].message, "unused import: `std::fmt`"); - assert!(matches!( - merged.diagnostics[0].severity, - DiagnosticSeverity::Warning - )); - } - - #[tokio::test] - async fn test_merge_diagnostics_exact_duplicate_not_repeated() { - // Same range/severity/message/code as the cache entry below, expressed - // in the 1-based MCP shape `diagnostics_from_cache_entry` would produce. - let pull_diag = Diagnostic { - range: Range { - start: Position2D { - line: 1, - character: 1, - }, - end: Position2D { - line: 1, - character: 11, - }, - }, - severity: DiagnosticSeverity::Error, - message: "mismatched types".to_string(), - code: Some("E0308".to_string()), - }; - let pull = DiagnosticsResult { - diagnostics: vec![pull_diag.clone()], - }; - let cache = diag_info(vec![lsp_diag( - 0, - 10, - lsp_types::DiagnosticSeverity::ERROR, - "mismatched types", - Some("E0308"), - )]); - - let merged = Translator::merge_diagnostics( - pull, - Some(&cache), - PositionEncoding::Utf16, - &test_tracker(), - ) - .await; - - assert_eq!(merged.diagnostics.len(), 1); - assert_eq!(merged.diagnostics[0], pull_diag); - } - - #[tokio::test] - async fn test_merge_diagnostics_no_cache_entry_returns_pull_unchanged() { - let pull_diag = Diagnostic { - range: Range { - start: Position2D { - line: 1, - character: 1, - }, - end: Position2D { - line: 1, - character: 5, - }, - }, - severity: DiagnosticSeverity::Error, - message: "syntax error".to_string(), - code: None, - }; - let pull = DiagnosticsResult { - diagnostics: vec![pull_diag.clone()], - }; - - let merged = - Translator::merge_diagnostics(pull, None, PositionEncoding::Utf16, &test_tracker()) - .await; - - assert_eq!(merged.diagnostics, vec![pull_diag]); - } - - #[tokio::test] - async fn test_merge_diagnostics_multiple_distinct_cache_entries_all_appear() { - let pull = DiagnosticsResult { - diagnostics: vec![], - }; - let cache = diag_info(vec![ - lsp_diag( - 0, - 10, - lsp_types::DiagnosticSeverity::WARNING, - "unused import: `std::fmt`", - None, - ), - lsp_diag( - 5, - 8, - lsp_types::DiagnosticSeverity::WARNING, - "function `helper` is never used", - None, - ), - ]); - - let merged = Translator::merge_diagnostics( - pull, - Some(&cache), - PositionEncoding::Utf16, - &test_tracker(), - ) - .await; - - assert_eq!(merged.diagnostics.len(), 2); - assert!( - merged - .diagnostics - .iter() - .any(|d| d.message == "unused import: `std::fmt`") - ); - assert!( - merged - .diagnostics - .iter() - .any(|d| d.message == "function `helper` is never used") - ); - } - - #[tokio::test] - async fn test_merge_diagnostics_same_range_different_message_not_deduped() { - let pull_diag = Diagnostic { - range: Range { - start: Position2D { - line: 1, - character: 1, - }, - end: Position2D { - line: 1, - character: 11, - }, - }, - severity: DiagnosticSeverity::Error, - message: "mismatched types".to_string(), - code: None, - }; - let pull = DiagnosticsResult { - diagnostics: vec![pull_diag], - }; - // Same range and severity as the pull diagnostic, but a different - // message — must be treated as a distinct diagnostic, not a duplicate. - let cache = diag_info(vec![lsp_diag( - 0, - 10, - lsp_types::DiagnosticSeverity::ERROR, - "expected `i32`, found `&str`", - None, - )]); - - let merged = Translator::merge_diagnostics( - pull, - Some(&cache), - PositionEncoding::Utf16, - &test_tracker(), - ) - .await; - - assert_eq!(merged.diagnostics.len(), 2); - } - - /// Pins a cross-model duplicate shape verified empirically against a live - /// rust-analyzer 1.97.1 session (#244): the pull and push diagnostics for - /// the *same* "not all trait items implemented" (E0046) error had - /// different ranges (trait name vs. impl block) and different messages - /// (terse vs. rustc's full rendering), but shared `code` and `severity`. - /// Exact-field dedup would report this twice; the `(severity, code)` - /// fingerprint must collapse it to one entry. - #[tokio::test] - async fn test_merge_diagnostics_same_code_different_range_and_message_deduped() { - let pull_diag = Diagnostic { - range: Range { - start: Position2D { - line: 96, - character: 7, - }, - end: Position2D { - line: 96, - character: 12, - }, - }, - severity: DiagnosticSeverity::Error, - message: "not all trait items implemented, missing: `fn hello`".to_string(), - code: Some("E0046".to_string()), - }; - let pull = DiagnosticsResult { - diagnostics: vec![pull_diag.clone()], - }; - // Same code and severity, but a different range and a longer, - // differently-worded message -- the rustc-rendered push side of the - // same underlying error. - let cache = diag_info(vec![lsp_diag( - 94, - 31, - lsp_types::DiagnosticSeverity::ERROR, - "not all trait items implemented, missing: `hello`\nmissing `hello` in implementation", - Some("E0046"), - )]); - - let merged = Translator::merge_diagnostics( - pull, - Some(&cache), - PositionEncoding::Utf16, - &test_tracker(), - ) - .await; - - assert_eq!(merged.diagnostics.len(), 1); - assert_eq!(merged.diagnostics[0], pull_diag); - } - - /// Regression: `merge_diagnostics`'s `(severity, code)` fingerprint alone - /// is coarser than full-field equality and cannot tell apart two - /// genuinely distinct diagnostics that happen to share `code` and - /// `severity` -- e.g. two separate `E0308` mismatched-type errors at - /// different locations in the same file, one caught only by native - /// (pull) analysis and a second, unrelated one caught only by - /// flycheck/cargo check (cache), such as an error inside macro-expanded - /// code the native pass did not evaluate. This previously caused the - /// cache-only entry to be silently dropped -- reproducing #244's exact - /// failure mode, just relocated from "no merge" to "over-eager dedup". - /// - /// The range-proximity check on `is_duplicate` (see `merge_diagnostics`) - /// closes this: these two diagnostics are 45 lines apart, far outside - /// `DUPLICATE_RANGE_PROXIMITY_LINES`, so both must survive the merge. - #[tokio::test] - async fn test_merge_diagnostics_same_code_distinct_diagnostics_at_different_locations_both_kept() - { - let pull_diag = Diagnostic { - range: Range { - start: Position2D { - line: 5, - character: 9, - }, - end: Position2D { - line: 5, - character: 20, - }, - }, - severity: DiagnosticSeverity::Error, - message: "mismatched types: expected `i32`, found `&str`".to_string(), - code: Some("E0308".to_string()), - }; - let pull = DiagnosticsResult { - diagnostics: vec![pull_diag.clone()], - }; - // A second, unrelated E0308 at a completely different location with - // a completely different message -- a real, distinct diagnostic, - // not a duplicate of pull_diag. - let cache = diag_info(vec![lsp_diag( - 49, - 22, - lsp_types::DiagnosticSeverity::ERROR, - "mismatched types: expected `String`, found `Vec`", - Some("E0308"), - )]); - - let merged = Translator::merge_diagnostics( - pull, - Some(&cache), - PositionEncoding::Utf16, - &test_tracker(), - ) - .await; - - assert_eq!(merged.diagnostics.len(), 2); - assert_eq!(merged.diagnostics[0], pull_diag); - assert_eq!( - merged.diagnostics[1].message, - "mismatched types: expected `String`, found `Vec`" - ); - } - - #[test] - fn test_handle_server_logs_no_filter() { - use crate::bridge::notifications::LogLevel; - - let mut cache = NotificationCache::new(); - - cache.store_log(LogLevel::Error, "error msg".to_string()); - cache.store_log(LogLevel::Warning, "warning msg".to_string()); - cache.store_log(LogLevel::Info, "info msg".to_string()); - cache.store_log(LogLevel::Debug, "debug msg".to_string()); - - let result = Translator::handle_server_logs(&cache, 10, None); - assert!(result.is_ok()); - let logs = result.unwrap(); - assert_eq!(logs.logs.len(), 4); - } - - #[test] - fn test_handle_server_logs_error_filter_strict() { - use crate::bridge::notifications::LogLevel; - - let mut cache = NotificationCache::new(); - - cache.store_log(LogLevel::Error, "error msg".to_string()); - cache.store_log(LogLevel::Warning, "warning msg".to_string()); - cache.store_log(LogLevel::Info, "info msg".to_string()); - - let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string())); - assert!(result.is_ok()); - let logs = result.unwrap(); - assert_eq!(logs.logs.len(), 1); - assert_eq!(logs.logs[0].message, "error msg"); - } - - #[test] - fn test_handle_server_logs_warning_filter_includes_errors() { - use crate::bridge::notifications::LogLevel; - - let mut cache = NotificationCache::new(); - - cache.store_log(LogLevel::Error, "error msg".to_string()); - cache.store_log(LogLevel::Warning, "warning msg".to_string()); - cache.store_log(LogLevel::Info, "info msg".to_string()); - - let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string())); - assert!(result.is_ok()); - let logs = result.unwrap(); - assert_eq!(logs.logs.len(), 2); - } - - #[test] - fn test_handle_server_logs_info_filter_excludes_debug() { - use crate::bridge::notifications::LogLevel; - - let mut cache = NotificationCache::new(); - - cache.store_log(LogLevel::Error, "error msg".to_string()); - cache.store_log(LogLevel::Info, "info msg".to_string()); - cache.store_log(LogLevel::Debug, "debug msg".to_string()); - - let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string())); - assert!(result.is_ok()); - let logs = result.unwrap(); - assert_eq!(logs.logs.len(), 2); - } - - #[test] - fn test_handle_server_logs_debug_filter_includes_all() { - use crate::bridge::notifications::LogLevel; - - let mut cache = NotificationCache::new(); - - cache.store_log(LogLevel::Error, "error msg".to_string()); - cache.store_log(LogLevel::Warning, "warning msg".to_string()); - cache.store_log(LogLevel::Info, "info msg".to_string()); - cache.store_log(LogLevel::Debug, "debug msg".to_string()); - - let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string())); - assert!(result.is_ok()); - let logs = result.unwrap(); - assert_eq!(logs.logs.len(), 4); - } - - #[test] - fn test_handle_server_logs_limit_applies_after_filter() { - use crate::bridge::notifications::LogLevel; - - let mut cache = NotificationCache::new(); - - for i in 0..10 { - cache.store_log(LogLevel::Error, format!("error {i}")); - } - - let result = Translator::handle_server_logs(&cache, 5, Some("error".to_string())); - assert!(result.is_ok()); - let logs = result.unwrap(); - assert_eq!(logs.logs.len(), 5); - assert_eq!(logs.logs[0].message, "error 0"); - assert_eq!(logs.logs[4].message, "error 4"); - } - - #[test] - fn test_handle_server_logs_case_insensitive_level() { - use crate::bridge::notifications::LogLevel; - - let mut cache = NotificationCache::new(); - - cache.store_log(LogLevel::Error, "error msg".to_string()); - - let result = Translator::handle_server_logs(&cache, 10, Some("ERROR".to_string())); - assert!(result.is_ok()); - - let result = Translator::handle_server_logs(&cache, 10, Some("Error".to_string())); - assert!(result.is_ok()); - - let result = Translator::handle_server_logs(&cache, 10, Some("eRrOr".to_string())); - assert!(result.is_ok()); - } - - #[test] - fn test_handle_server_messages_empty() { - let cache = NotificationCache::new(); - - let result = Translator::handle_server_messages(&cache, 10); - assert!(result.is_ok()); - let messages = result.unwrap(); - assert_eq!(messages.messages.len(), 0); - } - - #[test] - fn test_handle_server_messages_with_different_types() { - use crate::bridge::notifications::MessageType; - - let mut cache = NotificationCache::new(); - - cache.store_message(MessageType::Error, "error".to_string()); - cache.store_message(MessageType::Warning, "warning".to_string()); - cache.store_message(MessageType::Info, "info".to_string()); - cache.store_message(MessageType::Log, "log".to_string()); - - let result = Translator::handle_server_messages(&cache, 10); - assert!(result.is_ok()); - let messages = result.unwrap(); - assert_eq!(messages.messages.len(), 4); - assert_eq!(messages.messages[0].message, "error"); - assert_eq!(messages.messages[1].message, "warning"); - assert_eq!(messages.messages[2].message, "info"); - assert_eq!(messages.messages[3].message, "log"); - } - - #[test] - fn test_handle_server_messages_zero_limit() { - use crate::bridge::notifications::MessageType; - - let mut cache = NotificationCache::new(); - - cache.store_message(MessageType::Info, "test".to_string()); - - let result = Translator::handle_server_messages(&cache, 0); - assert!(result.is_ok()); - let messages = result.unwrap(); - assert_eq!(messages.messages.len(), 0); - } - - #[test] - fn test_handle_cached_diagnostics_path_outside_workspace() { - let temp_dir1 = TempDir::new().unwrap(); - let temp_dir2 = TempDir::new().unwrap(); - - let workspace_roots = vec![temp_dir1.path().to_path_buf()]; - - let test_file = temp_dir2.path().join("test.rs"); - fs::write(&test_file, "fn main() {}").unwrap(); - - let result = - Translator::cached_diagnostics_uri(&workspace_roots, test_file.to_str().unwrap()); - assert!(matches!(result, Err(Error::PathOutsideWorkspace(_)))); - } - - #[test] - fn test_translator_with_custom_extensions() { - let mut extension_map = HashMap::new(); - extension_map.insert("nu".to_string(), "nushell".to_string()); - extension_map.insert("customext".to_string(), "customlang".to_string()); - - let translator = Translator::new().with_extensions(extension_map.clone()); - - assert_eq!(translator.extension_map.len(), 2); - assert_eq!( - translator.extension_map.get("nu"), - Some(&"nushell".to_string()) - ); - assert_eq!( - translator.extension_map.get("customext"), - Some(&"customlang".to_string()) - ); - } - - #[test] - fn test_get_client_for_file_uses_custom_extension() { - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("script.nu"); - fs::write(&test_file, "echo hello").unwrap(); - - let mut extension_map = HashMap::new(); - extension_map.insert("nu".to_string(), "nushell".to_string()); - - let translator = Translator::new().with_extensions(extension_map); - - let result = translator.get_client_for_file(&test_file, ToolKind::Hover); - - assert!(result.is_err()); - if let Err(Error::NoServerForLanguage(lang)) = result { - assert_eq!(lang, "nushell"); - } else { - panic!("Expected NoServerForLanguage(nushell) error"); - } - } - - #[test] - fn test_get_client_for_file_falls_back_to_default() { - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("unknown.xyz"); - fs::write(&test_file, "content").unwrap(); - - let mut extension_map = HashMap::new(); - extension_map.insert("rs".to_string(), "rust".to_string()); - - let translator = Translator::new().with_extensions(extension_map); - - let result = translator.get_client_for_file(&test_file, ToolKind::Hover); - - assert!(result.is_err()); - if let Err(Error::NoServerForLanguage(lang)) = result { - assert_eq!(lang, "plaintext"); - } else { - panic!("Expected NoServerForLanguage(plaintext) error"); - } - } - - #[test] - fn test_get_client_for_file_routes_tsx_to_typescript_server() { - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("component.tsx"); - fs::write(&test_file, "export const Component = () =>
").unwrap(); - - let mut extension_map = HashMap::new(); - extension_map.insert("tsx".to_string(), "typescriptreact".to_string()); - - let translator = Translator::new() - .with_extensions(extension_map) - .with_router(ToolRouter::catch_all([( - ServerId::from("typescript"), - "typescript".to_string(), - )])); - translator.register_client( - "typescript".to_string(), - LspClient::new(crate::config::LspServerConfig::typescript()), - ); - - let (_id, client) = translator - .get_client_for_file(&test_file, ToolKind::Hover) - .unwrap(); - assert_eq!(client.language_id(), "typescript"); - } - - #[test] - fn test_get_client_for_file_prefers_exact_react_server() { - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("component.tsx"); - fs::write(&test_file, "export const Component = () =>
").unwrap(); - - let mut extension_map = HashMap::new(); - extension_map.insert("tsx".to_string(), "typescriptreact".to_string()); - - let typescript_react_config = crate::config::LspServerConfig { - language_id: "typescriptreact".to_string(), - command: "typescript-language-server".to_string(), - args: vec!["--stdio".to_string()], - env: HashMap::new(), - file_patterns: vec!["**/*.tsx".to_string()], - initialization_options: None, - timeout_seconds: 30, - request_timeout_seconds: 30, - heuristics: None, - name: None, - handles: None, - }; - - let translator = Translator::new() - .with_extensions(extension_map) - .with_router(ToolRouter::catch_all([ - (ServerId::from("typescript"), "typescript".to_string()), - ( - ServerId::from("typescriptreact"), - "typescriptreact".to_string(), - ), - ])); - translator.register_client( - "typescript".to_string(), - LspClient::new(crate::config::LspServerConfig::typescript()), - ); - translator.register_client( - "typescriptreact".to_string(), - LspClient::new(typescript_react_config), - ); - - let (_id, client) = translator - .get_client_for_file(&test_file, ToolKind::Hover) - .unwrap(); - assert_eq!(client.language_id(), "typescriptreact"); - } - - #[test] - fn test_get_client_for_file_routes_jsx_to_javascript_server() { - let temp_dir = TempDir::new().unwrap(); - let test_file = temp_dir.path().join("component.jsx"); - fs::write(&test_file, "export const Component = () =>
").unwrap(); - - let mut extension_map = HashMap::new(); - extension_map.insert("jsx".to_string(), "javascriptreact".to_string()); - - let javascript_config = crate::config::LspServerConfig { - language_id: "javascript".to_string(), - command: "typescript-language-server".to_string(), - args: vec!["--stdio".to_string()], - env: HashMap::new(), - file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()], - initialization_options: None, - timeout_seconds: 30, - request_timeout_seconds: 30, - heuristics: None, - name: None, - handles: None, - }; - let translator = Translator::new() - .with_extensions(extension_map) - .with_router(ToolRouter::catch_all([( - ServerId::from("javascript"), - "javascript".to_string(), - )])); - translator.register_client("javascript".to_string(), LspClient::new(javascript_config)); - - let (_id, client) = translator - .get_client_for_file(&test_file, ToolKind::Hover) - .unwrap(); - assert_eq!(client.language_id(), "javascript"); - } - - #[tokio::test] - async fn test_serve_initializes_translator_with_extensions() { - use crate::config::{LanguageExtensionMapping, WorkspaceConfig}; - - let language_extensions = vec![ - LanguageExtensionMapping { - extensions: vec!["nu".to_string()], - language_id: "nushell".to_string(), - }, - LanguageExtensionMapping { - extensions: vec!["rs".to_string()], - language_id: "rust".to_string(), - }, - ]; - - let config = crate::config::ServerConfig { - workspace: WorkspaceConfig { - roots: vec![PathBuf::from("/tmp/test-workspace")], - position_encodings: vec!["utf-8".to_string()], - language_extensions: language_extensions.clone(), - heuristics_max_depth: 10, - max_documents: DEFAULT_MAX_DOCUMENTS, - max_file_size: DEFAULT_MAX_FILE_SIZE, - }, - lsp_servers: vec![], - project_config_ignored: false, - }; - - let extension_map = config.build_effective_extension_map(); - assert_eq!(extension_map.get("nu"), Some(&"nushell".to_string())); - assert_eq!(extension_map.get("rs"), Some(&"rust".to_string())); - - // serve() starts in protocol-only mode when no LSP servers are configured; - // it may return a transport error but must not return NoServersAvailable. - let result = crate::serve(config).await; - if let Err(ref err) = result { - assert!( - !matches!(err, crate::error::Error::NoServersAvailable(_)), - "serve() must not return NoServersAvailable for empty lsp_servers config" - ); - } - } - - #[tokio::test] - async fn test_convert_call_hierarchy_item_kind_is_numeric() { - let item = lsp_types::CallHierarchyItem { - name: "my_fn".to_string(), - kind: lsp_types::SymbolKind::FUNCTION, - tags: None, - detail: None, - uri: "file:///tmp/test.rs".parse().unwrap(), - range: lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 0, - }, - end: lsp_types::Position { - line: 0, - character: 5, - }, - }, - selection_range: lsp_types::Range { - start: lsp_types::Position { - line: 0, - character: 0, - }, - end: lsp_types::Position { - line: 0, - character: 5, - }, - }, - data: None, - }; - let result = convert_call_hierarchy_item(item, &test_ctx()).await; - // SymbolKind::FUNCTION is LSP integer 12 - assert_eq!(result.kind, 12u32); - assert_eq!(result.name, "my_fn"); - } - - // ------------------------------------------------------------------ - // Lock-latency regression tests (#108, #159) - // ------------------------------------------------------------------ - // - // These use two `cat` child processes as a fake LSP transport, the same - // technique as `bridge::state::tests::fake_lsp_client` (duplicated here - // since that helper is private to its own test module): `cat` on the - // "write" half echoes back whatever mcpls sends it, letting a test read - // outbound requests/notifications off `write_stdout`; `cat` on the "read" - // half relays whatever a test writes to `read_half_stdin` back to the - // client as if it came from a real server, letting a test fabricate - // responses with controlled timing. - - use std::process::Stdio; - - use serde_json::Value as JsonValue; - use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; - use tokio::process::{Child, ChildStdin, ChildStdout, Command}; - use tokio::time::timeout; - - use crate::config::LspServerConfig; - use crate::lsp::LspTransport; - - struct FakeServer { - _write_half: Child, - _read_half: Child, - read_half_stdin: ChildStdin, - write_stdout: ChildStdout, - } - - fn fake_lsp_client() -> (LspClient, FakeServer) { - let mut write_half = Command::new("cat") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .unwrap(); - let write_stdin = write_half.stdin.take().unwrap(); - let write_stdout = write_half.stdout.take().unwrap(); - - let mut read_half = Command::new("cat") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .unwrap(); - let read_stdout = read_half.stdout.take().unwrap(); - let read_stdin = read_half.stdin.take().unwrap(); - - let transport = LspTransport::new(write_stdin, read_stdout); - let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport); - - ( - client, - FakeServer { - _write_half: write_half, - _read_half: read_half, - read_half_stdin: read_stdin, - write_stdout, - }, - ) - } - - /// Reads one `Content-Length`-framed JSON-RPC message off `reader`. - /// - /// `reader` must be reused across calls, not recreated per message: a - /// fresh `BufReader` would silently drop any bytes of a later message it - /// over-read into its internal buffer while parsing an earlier one. - async fn read_framed_message(reader: &mut BufReader<&mut ChildStdout>) -> JsonValue { - let mut content_length = None; - let mut line = String::new(); - loop { - line.clear(); - reader.read_line(&mut line).await.unwrap(); - if line == "\r\n" || line == "\n" { - break; - } - if let Some((key, value)) = line.trim_end().split_once(':') - && key.trim().eq_ignore_ascii_case("content-length") - { - content_length = Some(value.trim().parse::().unwrap()); - } - } - let mut buf = vec![0u8; content_length.unwrap()]; - reader.read_exact(&mut buf).await.unwrap(); - serde_json::from_slice(&buf).unwrap() - } - - /// Writes a framed JSON-RPC success response, as a real LSP server would. - async fn write_response(stdin: &mut ChildStdin, id: &JsonValue, result: JsonValue) { - let message = serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "result": result, - }); - let content = serde_json::to_string(&message).unwrap(); - let header = format!("Content-Length: {}\r\n\r\n", content.len()); - stdin.write_all(header.as_bytes()).await.unwrap(); - stdin.write_all(content.as_bytes()).await.unwrap(); - stdin.flush().await.unwrap(); - } - - /// Writes a framed JSON-RPC error response, e.g. to simulate a push-only - /// server answering `textDocument/diagnostic` with method-not-found. - async fn write_error_response( - stdin: &mut ChildStdin, - id: &JsonValue, - code: i64, - message: &str, - ) { - let response = serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": code, - "message": message, - }, - }); - let content = serde_json::to_string(&response).unwrap(); - let header = format!("Content-Length: {}\r\n\r\n", content.len()); - stdin.write_all(header.as_bytes()).await.unwrap(); - stdin.write_all(content.as_bytes()).await.unwrap(); - stdin.flush().await.unwrap(); - } - - #[tokio::test] - async fn test_concurrent_handlers_on_different_files_do_not_serialize() { - // Before the fix, Translator was shared as Arc>, so - // handling one LSP request held that lock across the `.await` on the - // response -- blocking every other tool call, even for a completely - // different file and language server, until the first request - // completed or timed out (up to 30s). With interior mutability, a - // concurrent call for a different file must complete without waiting - // on an unrelated in-flight request. - let dir = TempDir::new().unwrap(); - let mut extensions = HashMap::new(); - extensions.insert("aa".to_string(), "lang_a".to_string()); - extensions.insert("bb".to_string(), "lang_b".to_string()); - - let mut translator = - Translator::new() - .with_extensions(extensions) - .with_router(ToolRouter::catch_all([ - (ServerId::from("lang_a"), "lang_a".to_string()), - (ServerId::from("lang_b"), "lang_b".to_string()), - ])); - translator.set_workspace_roots(vec![dir.path().to_path_buf()]); - - let (client_a, mut server_a) = fake_lsp_client(); - let (client_b, mut server_b) = fake_lsp_client(); - translator.register_client("lang_a".to_string(), client_a); - translator.register_client("lang_b".to_string(), client_b); - - let path_a = dir.path().join("file.aa"); - let path_b = dir.path().join("file.bb"); - fs::write(&path_a, "content a").unwrap(); - fs::write(&path_b, "content b").unwrap(); - - let translator = Arc::new(translator); - - // `server_a` is never given a response, simulating a slow server. If - // any translator-held lock still spanned the LSP round trip, this - // task blocking forever would also block the "fast" call below. - let slow = { - let translator = Arc::clone(&translator); - let path = path_a.to_string_lossy().to_string(); - tokio::spawn(async move { translator.handle_hover(path, 1, 1).await }) - }; - - // Wait for the slow task to actually reach its LSP request (i.e. the - // request bytes were written to the wire) before treating it as - // "in-flight", so the test doesn't race the spawned task's startup. - let mut wire_a = BufReader::new(&mut server_a.write_stdout); - let opened_a = read_framed_message(&mut wire_a).await; - assert_eq!(opened_a["method"], "textDocument/didOpen"); - let hover_request_a = read_framed_message(&mut wire_a).await; - assert_eq!(hover_request_a["method"], "textDocument/hover"); - - // The fast path: a concurrent call for a different file/server. - let fast = { - let translator = Arc::clone(&translator); - let path = path_b.to_string_lossy().to_string(); - tokio::spawn(async move { translator.handle_hover(path, 1, 1).await }) - }; - - let mut wire_b = BufReader::new(&mut server_b.write_stdout); - let opened_b = read_framed_message(&mut wire_b).await; - assert_eq!(opened_b["method"], "textDocument/didOpen"); - let hover_request_b = read_framed_message(&mut wire_b).await; - assert_eq!(hover_request_b["method"], "textDocument/hover"); - write_response( - &mut server_b.read_half_stdin, - &hover_request_b["id"], - JsonValue::Null, - ) - .await; - - let fast_result = timeout(Duration::from_secs(2), fast) - .await - .expect("fast call must not be blocked by the slow in-flight request") - .unwrap(); - assert!(fast_result.is_ok()); - - assert!( - !slow.is_finished(), - "slow call should still be waiting on its (never-sent) response" - ); - slow.abort(); - } - - #[tokio::test] - async fn test_concurrent_ensure_open_same_path_sends_single_did_open() { - // Regression test: concurrent handler calls for the SAME path must - // serialize on that path's `ensure_open` lock (see `DocumentTracker::lock_path`) - // so they can't both observe "not open yet" and both send didOpen. - let dir = TempDir::new().unwrap(); - let mut extensions = HashMap::new(); - extensions.insert("aa".to_string(), "lang_a".to_string()); - - let mut translator = - Translator::new() - .with_extensions(extensions) - .with_router(ToolRouter::catch_all([( - ServerId::from("lang_a"), - "lang_a".to_string(), - )])); - translator.set_workspace_roots(vec![dir.path().to_path_buf()]); - - let (client, mut server) = fake_lsp_client(); - translator.register_client("lang_a".to_string(), client); - - let path = dir.path().join("file.aa"); - fs::write(&path, "content").unwrap(); - - let concurrent_calls = 4; - - let translator = Arc::new(translator); - let path_str = path.to_string_lossy().to_string(); - - let handles: Vec<_> = (0..concurrent_calls) - .map(|_| { - let translator = Arc::clone(&translator); - let path_str = path_str.clone(); - tokio::spawn(async move { translator.handle_hover(path_str, 1, 1).await }) - }) - .collect(); - - let mut wire = BufReader::new(&mut server.write_stdout); - let opened = read_framed_message(&mut wire).await; - assert_eq!(opened["method"], "textDocument/didOpen"); - - for _ in 0..concurrent_calls { - let request = read_framed_message(&mut wire).await; - assert_eq!( - request["method"], "textDocument/hover", - "no second didOpen must appear ahead of the hover requests" - ); - write_response(&mut server.read_half_stdin, &request["id"], JsonValue::Null).await; - } - - for handle in handles { - let result = timeout(Duration::from_secs(2), handle) - .await - .expect("handler call should not hang") - .unwrap(); - assert!(result.is_ok()); - } - } - - /// #174 §12's own headline dispatch scenario: "pyright/pylsp fixture -- - /// hover -> pyright, diagnostics -> pylsp, rename (unclaimed) -> - /// `NoServerForTool`", exercised through `Translator`'s public handlers - /// end to end rather than through `ToolRouter`'s unit tests alone. - #[tokio::test] - async fn test_dispatch_routes_hover_and_diagnostics_to_different_servers() { - let dir = TempDir::new().unwrap(); - let mut extensions = HashMap::new(); - extensions.insert("py".to_string(), "python".to_string()); - - let pyright_id = ServerId::from("pyright"); - let pylsp_id = ServerId::from("pylsp"); - let configs = vec![ - LspServerConfig { - language_id: "python".to_string(), - command: "pyright-langserver".to_string(), - args: vec![], - env: HashMap::new(), - file_patterns: vec![], - initialization_options: None, - timeout_seconds: 30, - request_timeout_seconds: 30, - heuristics: None, - name: Some("pyright".to_string()), - handles: Some(vec![ToolKind::Hover]), - }, - LspServerConfig { - language_id: "python".to_string(), - command: "pylsp".to_string(), - args: vec![], - env: HashMap::new(), - file_patterns: vec![], - initialization_options: None, - timeout_seconds: 30, - request_timeout_seconds: 30, - heuristics: None, - name: Some("pylsp".to_string()), - handles: Some(vec![ToolKind::Diagnostics]), - }, - ]; - let router = ToolRouter::from_configs(&configs).unwrap(); - - let mut translator = Translator::new() - .with_extensions(extensions) - .with_router(router); - translator.set_workspace_roots(vec![dir.path().to_path_buf()]); - - let (client_pyright, mut server_pyright) = fake_lsp_client(); - let (client_pylsp, mut server_pylsp) = fake_lsp_client(); - translator.register_client(pyright_id, client_pyright); - translator.register_client(pylsp_id, client_pylsp); - - let path = dir.path().join("main.py"); - fs::write(&path, "x = 1").unwrap(); - let path_str = path.to_string_lossy().to_string(); - - let translator = Arc::new(translator); - - // rename is claimed by neither server -> NoServerForTool, checked - // first so it can't be masked by either server's wire state. - let rename_result = translator - .handle_rename(path_str.clone(), 1, 1, "renamed".to_string()) - .await; - assert!( - matches!( - rename_result, - Err(Error::NoServerForTool { - tool: ToolKind::Rename, - .. - }) - ), - "expected NoServerForTool for rename, got {rename_result:?}" - ); - - // hover must route to pyright: didOpen + hover request on its wire. - let hover = { - let translator = Arc::clone(&translator); - let path_str = path_str.clone(); - tokio::spawn(async move { translator.handle_hover(path_str, 1, 1).await }) - }; - let mut wire_pyright = BufReader::new(&mut server_pyright.write_stdout); - let opened = read_framed_message(&mut wire_pyright).await; - assert_eq!(opened["method"], "textDocument/didOpen"); - let hover_request = read_framed_message(&mut wire_pyright).await; - assert_eq!(hover_request["method"], "textDocument/hover"); - write_response( - &mut server_pyright.read_half_stdin, - &hover_request["id"], - JsonValue::Null, - ) - .await; - hover - .await - .unwrap() - .expect("hover routed to pyright must succeed"); - - // diagnostics must route to pylsp, independently of pyright: its own - // didOpen (a second server's first sync of the same path) followed - // by the diagnostic request on pylsp's wire, never pyright's. - let diagnostics = { - let translator = Arc::clone(&translator); - let notification_cache = Arc::new(Mutex::new(NotificationCache::new())); - tokio::spawn(async move { - translator - .handle_diagnostics(path_str, ¬ification_cache) - .await - }) - }; - let mut wire_pylsp = BufReader::new(&mut server_pylsp.write_stdout); - let opened = read_framed_message(&mut wire_pylsp).await; - assert_eq!(opened["method"], "textDocument/didOpen"); - let diag_request = read_framed_message(&mut wire_pylsp).await; - assert_eq!(diag_request["method"], "textDocument/diagnostic"); - // Routing is proven by the request landing on pylsp's wire; abort - // rather than crafting a well-formed DocumentDiagnosticReportResult. - diagnostics.abort(); - } - - /// S1 regression (#244): a push-only server (or one that times out) - /// answering `textDocument/diagnostic` with an LSP error must not - /// discard diagnostics `handle_diagnostics` already knows about from the - /// cache -- it should return the cache-only result instead of `Err`. - #[tokio::test] - async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() { - let dir = TempDir::new().unwrap(); - let mut extensions = HashMap::new(); - extensions.insert("rs".to_string(), "rust".to_string()); - - let mut translator = - Translator::new() - .with_extensions(extensions) - .with_router(ToolRouter::catch_all([( - ServerId::from("rust"), - "rust".to_string(), - )])); - translator.set_workspace_roots(vec![dir.path().to_path_buf()]); - - let (client, mut server) = fake_lsp_client(); - translator.register_client("rust".to_string(), client); - - let path = dir.path().join("lib.rs"); - fs::write(&path, "fn main() {}").unwrap(); - let path_str = path.to_string_lossy().to_string(); - - // Prime the cache under the exact URI handle_diagnostics will look - // up (path_to_uri over the canonicalized path, same as - // document_tracker uses to open the document). - let canonical = path.canonicalize().unwrap(); - let uri = path_to_uri(&canonical).unwrap(); - let notification_cache = Mutex::new(NotificationCache::new()); - { - let mut cache = notification_cache.lock().await; - cache.store_diagnostics( - &ServerId::from("rust"), - &uri, - Some(1), - vec![lsp_diag( - 0, - 4, - lsp_types::DiagnosticSeverity::WARNING, - "unused import: `std::fmt`", - None, - )], - ); - } - - let translator = Arc::new(translator); - let handle = { - let translator = Arc::clone(&translator); - tokio::spawn(async move { - translator - .handle_diagnostics(path_str, ¬ification_cache) - .await - }) - }; - - let mut wire = BufReader::new(&mut server.write_stdout); - let opened = read_framed_message(&mut wire).await; - assert_eq!(opened["method"], "textDocument/didOpen"); - let diag_request = read_framed_message(&mut wire).await; - assert_eq!(diag_request["method"], "textDocument/diagnostic"); - write_error_response( - &mut server.read_half_stdin, - &diag_request["id"], - -32601, - "method not found", - ) - .await; - - let result = timeout(Duration::from_secs(2), handle) - .await - .expect("handler call should not hang") - .unwrap(); - - let diagnostics = result.expect("cache-only fallback should succeed despite pull error"); - assert_eq!(diagnostics.diagnostics.len(), 1); - assert_eq!( - diagnostics.diagnostics[0].message, - "unused import: `std::fmt`" - ); - } - - /// S1 counterpart: when the cache is also empty, the pull error must - /// still propagate -- there is nothing to fall back to. - #[tokio::test] - async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() { - let dir = TempDir::new().unwrap(); - let mut extensions = HashMap::new(); - extensions.insert("rs".to_string(), "rust".to_string()); - - let mut translator = - Translator::new() - .with_extensions(extensions) - .with_router(ToolRouter::catch_all([( - ServerId::from("rust"), - "rust".to_string(), - )])); - translator.set_workspace_roots(vec![dir.path().to_path_buf()]); - - let (client, mut server) = fake_lsp_client(); - translator.register_client("rust".to_string(), client); - - let path = dir.path().join("lib.rs"); - fs::write(&path, "fn main() {}").unwrap(); - let path_str = path.to_string_lossy().to_string(); - - let notification_cache = Mutex::new(NotificationCache::new()); - - let translator = Arc::new(translator); - let handle = { - let translator = Arc::clone(&translator); - tokio::spawn(async move { - translator - .handle_diagnostics(path_str, ¬ification_cache) - .await - }) - }; - - let mut wire = BufReader::new(&mut server.write_stdout); - let opened = read_framed_message(&mut wire).await; - assert_eq!(opened["method"], "textDocument/didOpen"); - let diag_request = read_framed_message(&mut wire).await; - assert_eq!(diag_request["method"], "textDocument/diagnostic"); - write_error_response( - &mut server.read_half_stdin, - &diag_request["id"], - -32601, - "method not found", - ) - .await; - - let result = timeout(Duration::from_secs(2), handle) - .await - .expect("handler call should not hang") - .unwrap(); - - assert!( - result.is_err(), - "pull error with no cache data must propagate, got {result:?}" - ); - } - - // ------------------------------------------------------------------ - // Capability gate tests (#240) - // ------------------------------------------------------------------ - - /// No `LspServer` registered for `server_id` (only a raw `LspClient`, as - /// most tests in this module do) -- capability is unknown, so the gate - /// must not block the request. - #[test] - fn test_require_capability_ok_when_server_not_registered() { - let translator = Translator::new(); - let result = - translator.require_capability(&ServerId::from("rust"), "renameProvider", |_| false); - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_require_capability_ok_when_capability_present() { - let translator = Translator::new(); - let server_id = ServerId::from("rust"); - let caps = lsp_types::ServerCapabilities { - rename_provider: Some(lsp_types::OneOf::Left(true)), - ..Default::default() - }; - translator.register_server(server_id.clone(), LspServer::new_for_test(caps)); - - let result = translator.require_capability(&server_id, "renameProvider", |c| { - matches!( - c.rename_provider, - Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) - ) - }); - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_require_capability_err_when_capability_absent() { - let translator = Translator::new(); - let server_id = ServerId::from("rust"); - let caps = lsp_types::ServerCapabilities::default(); - translator.register_server(server_id.clone(), LspServer::new_for_test(caps)); - - let result = translator.require_capability(&server_id, "renameProvider", |c| { - matches!( - c.rename_provider, - Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) - ) - }); - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "renameProvider", - .. - }) - )); - } - - /// Builds a single-server translator routed to `server_id` for every tool, - /// with a registered `LspServer` fixture carrying `capabilities` (default - /// capabilities advertise nothing). - fn translator_with_capabilities( - dir: &TempDir, - server_id: &ServerId, - capabilities: lsp_types::ServerCapabilities, - ) -> (Translator, FakeServer) { - let mut extensions = HashMap::new(); - extensions.insert("rs".to_string(), "rust".to_string()); - - let mut translator = - Translator::new() - .with_extensions(extensions) - .with_router(ToolRouter::catch_all([( - server_id.clone(), - "rust".to_string(), - )])); - translator.set_workspace_roots(vec![dir.path().to_path_buf()]); - - let (client, server) = fake_lsp_client(); - translator.register_client(server_id.clone(), client); - translator.register_server(server_id.clone(), LspServer::new_for_test(capabilities)); - - (translator, server) - } - - /// As [`translator_with_capabilities`], but with a caller-chosen - /// negotiated `position_encoding` -- for tests exercising a non-UTF-16 - /// `EncodingCtx` conversion path through a full mocked LSP round trip. - fn translator_with_capabilities_and_encoding( - dir: &TempDir, - server_id: &ServerId, - capabilities: lsp_types::ServerCapabilities, - position_encoding: lsp_types::PositionEncodingKind, - ) -> (Translator, FakeServer) { - let mut extensions = HashMap::new(); - extensions.insert("rs".to_string(), "rust".to_string()); - - let mut translator = - Translator::new() - .with_extensions(extensions) - .with_router(ToolRouter::catch_all([( - server_id.clone(), - "rust".to_string(), - )])); - translator.set_workspace_roots(vec![dir.path().to_path_buf()]); - - let (client, server) = fake_lsp_client(); - translator.register_client(server_id.clone(), client); - translator.register_server( - server_id.clone(), - LspServer::new_for_test_with_encoding(capabilities, position_encoding), - ); - - (translator, server) - } - - #[tokio::test] - async fn test_handle_rename_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_rename( - path.to_string_lossy().to_string(), - 1, - 1, - "renamed".to_string(), - ) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "renameProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_code_actions_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_code_actions(path.to_string_lossy().to_string(), 1, 1, 1, 5, None) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "codeActionProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_signature_help_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_signature_help(path.to_string_lossy().to_string(), 1, 1) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "signatureHelpProvider", - .. - }) - )); - } - - /// `handle_incoming_calls` resolves its server via `get_client_for_file` - /// directly (not `prepare_document`), a separate code path from the other - /// gated handlers -- exercise it explicitly. - #[tokio::test] - async fn test_handle_incoming_calls_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - let uri = Url::from_file_path(&path).unwrap().to_string(); - - let item = serde_json::json!({ - "name": "test_function", - "kind": 12, - "uri": uri, - "range": { - "start": {"line": 1, "character": 1}, - "end": {"line": 1, "character": 10} - }, - "selectionRange": { - "start": {"line": 1, "character": 1}, - "end": {"line": 1, "character": 10} - } - }); - - let result = translator.handle_incoming_calls(item).await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "callHierarchyProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_outgoing_calls_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - let uri = Url::from_file_path(&path).unwrap().to_string(); - - let item = serde_json::json!({ - "name": "test_function", - "kind": 12, - "uri": uri, - "range": { - "start": {"line": 1, "character": 1}, - "end": {"line": 1, "character": 10} - }, - "selectionRange": { - "start": {"line": 1, "character": 1}, - "end": {"line": 1, "character": 10} - } - }); - - let result = translator.handle_outgoing_calls(item).await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "callHierarchyProvider", - .. - }) - )); - } - - /// Per the LSP spec, an incoming call's `fromRanges` are ranges within - /// the *caller's* document (`call.from.uri`), not the queried item's - /// document -- `handle_incoming_calls` must convert them against - /// `caller.rs`'s own content, not `queried.rs`'s. Uses a UTF-8-negotiated - /// server and two files with different multibyte content, so converting - /// against the wrong file's line text produces a different, wrong - /// answer: `"aöb"` (caller) puts LSP byte offset 3 at UTF-16 column 3 - /// (`ö` is 2 UTF-8 bytes / 1 UTF-16 unit), while the ASCII `"abc"` - /// (queried item) would put the same byte offset at column 4. - #[tokio::test] - async fn test_handle_incoming_calls_from_ranges_convert_against_callers_own_uri() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let caps = lsp_types::ServerCapabilities { - call_hierarchy_provider: Some(lsp_types::CallHierarchyServerCapability::Simple(true)), - ..Default::default() - }; - let (translator, mut server) = translator_with_capabilities_and_encoding( - &dir, - &server_id, - caps, - lsp_types::PositionEncodingKind::UTF8, - ); - - let queried_path = dir.path().join("queried.rs"); - fs::write(&queried_path, "abc").unwrap(); - let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string(); - - let caller_path = dir.path().join("caller.rs"); - fs::write(&caller_path, "aöb").unwrap(); - let caller_uri = Url::from_file_path(&caller_path).unwrap().to_string(); - - let item = CallHierarchyItemResult { - name: "queried_fn".to_string(), - kind: 12, - detail: None, - uri: queried_uri, - range: Range { - start: Position2D { - line: 1, - character: 1, - }, - end: Position2D { - line: 1, - character: 4, - }, - }, - selection_range: Range { - start: Position2D { - line: 1, - character: 1, - }, - end: Position2D { - line: 1, - character: 4, - }, - }, - data: None, - }; - - let translator = Arc::new(translator); - let handle = { - let translator = Arc::clone(&translator); - let item = serde_json::to_value(item).unwrap(); - tokio::spawn(async move { translator.handle_incoming_calls(item).await }) - }; - - let mut wire = BufReader::new(&mut server.write_stdout); - let request = read_framed_message(&mut wire).await; - assert_eq!(request["method"], "callHierarchy/incomingCalls"); - - write_response( - &mut server.read_half_stdin, - &request["id"], - serde_json::json!([{ - "from": { - "name": "caller_fn", - "kind": 12, - "uri": caller_uri, - "range": { - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 1} - }, - "selectionRange": { - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 1} - } - }, - "fromRanges": [{ - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 3} - }] - }]), - ) - .await; - - let result = timeout(Duration::from_secs(2), handle) - .await - .expect("handler call should not hang") - .unwrap() - .unwrap(); - - assert_eq!(result.calls.len(), 1); - let from_range = &result.calls[0].from_ranges[0]; - assert_eq!( - from_range.end.character, 3, - "fromRanges must convert against the caller's own file (\"aöb\"), not the queried \ - item's (\"abc\") -- a byte offset of 3 is UTF-16 column 3 in the former, 4 in the \ - latter" - ); - } - - /// Per the LSP spec, an outgoing call's `fromRanges` are ranges within - /// the *queried* item's own document, not the callee's (`call.to.uri`) -- - /// the inverse directional convention from incoming calls, tested above. - #[tokio::test] - async fn test_handle_outgoing_calls_from_ranges_convert_against_queried_uri() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let caps = lsp_types::ServerCapabilities { - call_hierarchy_provider: Some(lsp_types::CallHierarchyServerCapability::Simple(true)), - ..Default::default() - }; - let (translator, mut server) = translator_with_capabilities_and_encoding( - &dir, - &server_id, - caps, - lsp_types::PositionEncodingKind::UTF8, - ); - - let queried_path = dir.path().join("queried.rs"); - fs::write(&queried_path, "aöb").unwrap(); - let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string(); - - let callee_path = dir.path().join("callee.rs"); - fs::write(&callee_path, "abc").unwrap(); - let callee_uri = Url::from_file_path(&callee_path).unwrap().to_string(); - - let item = CallHierarchyItemResult { - name: "queried_fn".to_string(), - kind: 12, - detail: None, - uri: queried_uri, - range: Range { - start: Position2D { - line: 1, - character: 1, - }, - end: Position2D { - line: 1, - character: 4, - }, - }, - selection_range: Range { - start: Position2D { - line: 1, - character: 1, - }, - end: Position2D { - line: 1, - character: 4, - }, - }, - data: None, - }; - - let translator = Arc::new(translator); - let handle = { - let translator = Arc::clone(&translator); - let item = serde_json::to_value(item).unwrap(); - tokio::spawn(async move { translator.handle_outgoing_calls(item).await }) - }; - - let mut wire = BufReader::new(&mut server.write_stdout); - let request = read_framed_message(&mut wire).await; - assert_eq!(request["method"], "callHierarchy/outgoingCalls"); - - write_response( - &mut server.read_half_stdin, - &request["id"], - serde_json::json!([{ - "to": { - "name": "callee_fn", - "kind": 12, - "uri": callee_uri, - "range": { - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 1} - }, - "selectionRange": { - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 1} - } - }, - "fromRanges": [{ - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 3} - }] - }]), - ) - .await; - - let result = timeout(Duration::from_secs(2), handle) - .await - .expect("handler call should not hang") - .unwrap() - .unwrap(); - - assert_eq!(result.calls.len(), 1); - let from_range = &result.calls[0].from_ranges[0]; - assert_eq!( - from_range.end.character, 3, - "fromRanges must convert against the queried item's own file (\"aöb\"), not the \ - callee's (\"abc\") -- a byte offset of 3 is UTF-16 column 3 in the former, 4 in \ - the latter" - ); - } - - #[tokio::test] - async fn test_handle_format_document_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_format_document(path.to_string_lossy().to_string(), 4, true) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "documentFormattingProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_call_hierarchy_prepare_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_call_hierarchy_prepare(path.to_string_lossy().to_string(), 1, 1) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "callHierarchyProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_inlay_hints_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_inlay_hints(path.to_string_lossy().to_string(), 1, 1, 10, 1) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "inlayHintProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_hover_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_hover(path.to_string_lossy().to_string(), 1, 1) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "hoverProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_definition_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_definition(path.to_string_lossy().to_string(), 1, 1) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "definitionProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_references_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_references(path.to_string_lossy().to_string(), 1, 1, false) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "referencesProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_completions_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_completions(path.to_string_lossy().to_string(), 1, 1, None) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "completionProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_document_symbols_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_document_symbols(path.to_string_lossy().to_string()) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "documentSymbolProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_workspace_symbol_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let result = translator - .handle_workspace_symbol("main".to_string(), None, 100) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "workspaceSymbolProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_implementation_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_implementation(path.to_string_lossy().to_string(), 1, 1) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "implementationProvider", - .. - }) - )); - } - - #[tokio::test] - async fn test_handle_type_definition_blocked_when_capability_not_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let (translator, _server) = translator_with_capabilities( - &dir, - &server_id, - lsp_types::ServerCapabilities::default(), - ); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - - let result = translator - .handle_type_definition(path.to_string_lossy().to_string(), 1, 1) - .await; - - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "typeDefinitionProvider", - .. - }) - )); - } - - /// Explicit `Some(OneOf::Left(false))` -- as distinct from an absent - /// (`None`) field -- must also be rejected: some servers advertise a - /// provider field with an explicit `false` rather than omitting it. - #[tokio::test] - async fn test_require_capability_err_when_capability_explicitly_false() { - let translator = Translator::new(); - let server_id = ServerId::from("rust"); - let caps = lsp_types::ServerCapabilities { - rename_provider: Some(lsp_types::OneOf::Left(false)), - ..Default::default() - }; - translator.register_server(server_id.clone(), LspServer::new_for_test(caps)); - - let result = translator.require_capability(&server_id, "renameProvider", |c| { - matches!( - c.rename_provider, - Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) - ) - }); - assert!(matches!( - result, - Err(Error::CapabilityNotSupported { - capability: "renameProvider", - .. - }) - )); - } - - /// Positive path: when the routed server *does* advertise the gated - /// capability, the gate must let the request proceed into dispatch rather - /// than short-circuiting with `CapabilityNotSupported`. Drives the fake - /// wire to answer the request so the call completes quickly instead of - /// idling out its internal 30s request timeout. - #[tokio::test] - async fn test_handle_rename_proceeds_when_capability_supported() { - let dir = TempDir::new().unwrap(); - let server_id = ServerId::from("rust"); - let caps = lsp_types::ServerCapabilities { - rename_provider: Some(lsp_types::OneOf::Left(true)), - ..Default::default() - }; - let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps); - - let path = dir.path().join("main.rs"); - fs::write(&path, "fn main() {}").unwrap(); - let path_str = path.to_string_lossy().to_string(); - - let translator = Arc::new(translator); - let handle = { - let translator = Arc::clone(&translator); - tokio::spawn(async move { - translator - .handle_rename(path_str, 1, 1, "renamed".to_string()) - .await - }) - }; - - let mut wire = BufReader::new(&mut server.write_stdout); - let opened = read_framed_message(&mut wire).await; - assert_eq!(opened["method"], "textDocument/didOpen"); - let rename_request = read_framed_message(&mut wire).await; - assert_eq!(rename_request["method"], "textDocument/rename"); - write_response( - &mut server.read_half_stdin, - &rename_request["id"], - JsonValue::Null, - ) - .await; - - let result = timeout(Duration::from_secs(2), handle) - .await - .expect("handler call should not hang") - .unwrap(); - - assert!( - !matches!(result, Err(Error::CapabilityNotSupported { .. })), - "capability is supported, gate must not block dispatch, got {result:?}" - ); - assert!( - result.is_ok(), - "fake server answered, expected Ok: {result:?}" - ); - } -} diff --git a/crates/mcpls-core/src/bridge/translator/assist.rs b/crates/mcpls-core/src/bridge/translator/assist.rs new file mode 100644 index 00000000..62d250f7 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/assist.rs @@ -0,0 +1,263 @@ +//! Completions, signature help, and inlay hints handlers. + +use lsp_types::{ + CompletionParams, CompletionTriggerKind, InlayHintLabel, InlayHintParams, PartialResultParams, + SignatureHelpParams as LspSignatureHelpParams, TextDocumentIdentifier, + TextDocumentPositionParams, WorkDoneProgressParams, +}; + +use super::Translator; +use super::dto::{ + Completion, CompletionsResult, InlayHintEntry, InlayHintsResult, SignatureHelpResult, + SignatureInfo, SignatureParameter, +}; +use crate::config::ToolKind; +use crate::error::Result; + +/// Extract hover contents as markdown string. +/// Convert LSP `Documentation` to a plain string. +fn extract_documentation(doc: lsp_types::Documentation) -> String { + match doc { + lsp_types::Documentation::String(s) => s, + lsp_types::Documentation::MarkupContent(m) => m.value, + } +} + +impl Translator { + /// Handle completions request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `completionProvider` support. + pub async fn handle_completions( + &self, + file_path: String, + line: u32, + character: u32, + trigger: Option, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::Completions, + "completionProvider", + |caps| caps.completion_provider.is_some(), + ) + .await?; + let lsp_position = self + .encoding_ctx(&server_id) + .to_lsp(&uri, line, character) + .await; + + let context = trigger.map(|trigger_char| lsp_types::CompletionContext { + trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_character: Some(trigger_char), + }); + + let params = CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context, + }; + + let response: Option = client + .request( + "textDocument/completion", + params, + client.completion_timeout(), + ) + .await?; + + let items = match response { + Some(lsp_types::CompletionResponse::Array(items)) => items, + Some(lsp_types::CompletionResponse::List(list)) => list.items, + None => vec![], + }; + + let result = CompletionsResult { + items: items + .into_iter() + .map(|item| Completion { + label: item.label, + kind: item.kind.map(|k| format!("{k:?}")), + detail: item.detail, + documentation: item.documentation.map(|doc| match doc { + lsp_types::Documentation::String(s) => s, + lsp_types::Documentation::MarkupContent(m) => m.value, + }), + }) + .collect(), + }; + + Ok(result) + } + + /// Handle signature help request (`textDocument/signatureHelp`). + /// + /// Returns parameter signatures and documentation while typing a function call. + /// `context` is omitted (None) — the server infers trigger state from position. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `signatureHelpProvider` support. + pub async fn handle_signature_help( + &self, + file_path: String, + line: u32, + character: u32, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::SignatureHelp, + "signatureHelpProvider", + |caps| caps.signature_help_provider.is_some(), + ) + .await?; + let lsp_position = self + .encoding_ctx(&server_id) + .to_lsp(&uri, line, character) + .await; + + let params = LspSignatureHelpParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + context: None, + }; + + let response: Option = client + .request( + "textDocument/signatureHelp", + params, + client.request_timeout(), + ) + .await?; + + let result = match response { + Some(sig_help) => SignatureHelpResult { + signatures: sig_help + .signatures + .into_iter() + .map(|sig| SignatureInfo { + label: sig.label, + documentation: sig.documentation.map(extract_documentation), + parameters: sig + .parameters + .unwrap_or_default() + .into_iter() + .map(|p| SignatureParameter { + label: match p.label { + lsp_types::ParameterLabel::Simple(s) => s, + lsp_types::ParameterLabel::LabelOffsets([start, end]) => { + format!("[{start},{end}]") + } + }, + documentation: p.documentation.map(extract_documentation), + }) + .collect(), + }) + .collect(), + active_signature: sig_help.active_signature, + active_parameter: sig_help.active_parameter, + }, + None => SignatureHelpResult { + signatures: vec![], + active_signature: None, + active_parameter: None, + }, + }; + + Ok(result) + } + + /// Handle inlay hints request (`textDocument/inlayHint`). + /// + /// Returns inferred type and parameter annotations the editor would render inline. + /// Output positions are in MCP 1-based form. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `inlayHintProvider` support. + pub async fn handle_inlay_hints( + &self, + file_path: String, + start_line: u32, + start_character: u32, + end_line: u32, + end_character: u32, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::InlayHints, + "inlayHintProvider", + |caps| { + matches!( + caps.inlay_hint_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ) + }, + ) + .await?; + let ctx = self.encoding_ctx(&server_id); + let response_uri = uri.clone(); + + let lsp_start = ctx.to_lsp(&uri, start_line, start_character).await; + let lsp_end = ctx.to_lsp(&uri, end_line, end_character).await; + + let params = InlayHintParams { + text_document: TextDocumentIdentifier { uri }, + range: lsp_types::Range { + start: lsp_start, + end: lsp_end, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }; + + let response: Option> = client + .request("textDocument/inlayHint", params, client.request_timeout()) + .await?; + + let mut hints = Vec::new(); + for hint in response.unwrap_or_default() { + let position = ctx.to_mcp(&response_uri, hint.position).await; + let label = match hint.label { + InlayHintLabel::String(s) => s, + InlayHintLabel::LabelParts(parts) => parts + .into_iter() + .map(|p| p.value) + .collect::>() + .concat(), + }; + let tooltip = hint.tooltip.map(|t| match t { + lsp_types::InlayHintTooltip::String(s) => s, + lsp_types::InlayHintTooltip::MarkupContent(m) => m.value, + }); + hints.push(InlayHintEntry { + position, + label, + kind: hint.kind.and_then(|k| { + serde_json::to_value(k) + .ok() + .and_then(|v| v.as_i64()) + .and_then(|n| u8::try_from(n).ok()) + }), + padding_left: hint.padding_left, + padding_right: hint.padding_right, + tooltip, + }); + } + + Ok(InlayHintsResult { hints }) + } +} diff --git a/crates/mcpls-core/src/bridge/translator/call_hierarchy.rs b/crates/mcpls-core/src/bridge/translator/call_hierarchy.rs new file mode 100644 index 00000000..60b58391 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/call_hierarchy.rs @@ -0,0 +1,623 @@ +//! Call hierarchy prepare/incoming/outgoing handlers. + +use lsp_types::{ + CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem, + CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams, + CallHierarchyPrepareParams as LspCallHierarchyPrepareParams, PartialResultParams, + TextDocumentIdentifier, TextDocumentPositionParams, WorkDoneProgressParams, +}; + +use super::Translator; +use super::dto::{ + CallHierarchyItemResult, CallHierarchyPrepareResult, IncomingCall, IncomingCallsResult, + OutgoingCall, OutgoingCallsResult, +}; +use super::encoding_ctx::EncodingCtx; +use super::routing::MAX_POSITION_VALUE; +use crate::config::ToolKind; +use crate::error::{Error, Result}; + +/// Whether a server's capabilities advertise `callHierarchyProvider` support. +/// +/// Shared by `handle_call_hierarchy_prepare`, `handle_incoming_calls`, and +/// `handle_outgoing_calls`, which all gate on the same capability field. +const fn call_hierarchy_provider_supported(caps: &lsp_types::ServerCapabilities) -> bool { + matches!( + caps.call_hierarchy_provider, + Some( + lsp_types::CallHierarchyServerCapability::Simple(true) + | lsp_types::CallHierarchyServerCapability::Options(_) + ) + ) +} + +/// Parsed form of an MCP-facing `CallHierarchyItemResult` JSON value (1-based +/// coordinates), before its ranges are converted back to the routed server's +/// negotiated encoding -- which requires resolving that server first (from +/// [`Self::uri`]), so that step is left to callers via +/// [`call_hierarchy_item_to_lsp`]. +struct ParsedCallHierarchyItem { + uri: lsp_types::Uri, + mcp: CallHierarchyItemResult, +} + +/// Deserialize an MCP-facing `CallHierarchyItemResult` JSON value and parse +/// its URI. +/// +/// MCP clients receive `CallHierarchyItemResult` from `prepare_call_hierarchy` +/// and pass it back opaquely to `get_incoming_calls` / `get_outgoing_calls`. +fn parse_mcp_call_hierarchy_item(item: serde_json::Value) -> Result { + let mcp: CallHierarchyItemResult = serde_json::from_value(item) + .map_err(|e| Error::InvalidToolParams(format!("Invalid call hierarchy item: {e}")))?; + + let uri = mcp.uri.parse::().map_err(|e| { + Error::InvalidToolParams(format!("Invalid URI in call hierarchy item: {e}")) + })?; + + Ok(ParsedCallHierarchyItem { uri, mcp }) +} + +/// Convert a parsed MCP call hierarchy item (1-based coordinates) into a +/// `lsp_types::CallHierarchyItem` (0-based, in `ctx`'s negotiated encoding). +async fn call_hierarchy_item_to_lsp( + parsed: ParsedCallHierarchyItem, + ctx: &EncodingCtx, +) -> CallHierarchyItem { + let ParsedCallHierarchyItem { uri, mcp } = parsed; + + // Round-trip via serde: `convert_call_hierarchy_item` stored the kind as a u32 + // by serialising `SymbolKind`; we reverse this to reconstruct the same value. + let kind: lsp_types::SymbolKind = serde_json::from_value(serde_json::json!(mcp.kind)) + .unwrap_or(lsp_types::SymbolKind::FUNCTION); + let range = ctx.denormalize_range(&uri, &mcp.range).await; + let selection_range = ctx.denormalize_range(&uri, &mcp.selection_range).await; + + CallHierarchyItem { + name: mcp.name, + kind, + tags: None, + detail: mcp.detail, + uri, + range, + selection_range, + data: mcp.data, + } +} + +/// Convert LSP call hierarchy item to MCP call hierarchy item. +async fn convert_call_hierarchy_item( + item: CallHierarchyItem, + ctx: &EncodingCtx, +) -> CallHierarchyItemResult { + let range = ctx.normalize_range(&item.uri, item.range).await; + let selection_range = ctx.normalize_range(&item.uri, item.selection_range).await; + + CallHierarchyItemResult { + name: item.name, + kind: serde_json::to_value(item.kind) + .ok() + .and_then(|v| v.as_u64()) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(0), + detail: item.detail, + uri: item.uri.to_string(), + range, + selection_range, + data: item.data, + } +} + +impl Translator { + /// Handle call hierarchy prepare request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `callHierarchyProvider` support. + pub async fn handle_call_hierarchy_prepare( + &self, + file_path: String, + line: u32, + character: u32, + ) -> Result { + // Validate position bounds + if line < 1 || character < 1 { + return Err(Error::InvalidToolParams( + "Line and character positions must be >= 1".to_string(), + )); + } + + if line > MAX_POSITION_VALUE || character > MAX_POSITION_VALUE { + return Err(Error::InvalidToolParams(format!( + "Position values must be <= {MAX_POSITION_VALUE}" + ))); + } + + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::CallHierarchy, + "callHierarchyProvider", + call_hierarchy_provider_supported, + ) + .await?; + let ctx = self.encoding_ctx(&server_id); + let lsp_position = ctx.to_lsp(&uri, line, character).await; + + let params = LspCallHierarchyPrepareParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }; + + let response: Option> = client + .request( + "textDocument/prepareCallHierarchy", + params, + client.request_timeout(), + ) + .await?; + + // Pre-allocate and build result + let lsp_items = response.unwrap_or_default(); + let mut items = Vec::with_capacity(lsp_items.len()); + for item in lsp_items { + items.push(convert_call_hierarchy_item(item, &ctx).await); + } + + Ok(CallHierarchyPrepareResult { items }) + } + + /// Handle incoming calls request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the item is invalid, or the + /// routed server does not advertise `callHierarchyProvider` support. + pub async fn handle_incoming_calls( + &self, + item: serde_json::Value, + ) -> Result { + // Deserialize as our own type (1-based coords). + let parsed = parse_mcp_call_hierarchy_item(item)?; + + // Parse and validate the URI. Resolved with the same ToolKind as + // `handle_call_hierarchy_prepare` -- the opaque item this call + // receives is only meaningful to the server that produced it, and + // that server is guaranteed to be the same one `prepare` synced the + // document to since both resolve via the same (language, tool) route. + let path = self.parse_file_uri(&parsed.uri)?; + let (server_id, client) = self + .resolve_client_for_file(&path, ToolKind::CallHierarchy) + .await?; + self.require_capability( + &server_id, + "callHierarchyProvider", + call_hierarchy_provider_supported, + )?; + let ctx = self.encoding_ctx(&server_id); + let lsp_item = call_hierarchy_item_to_lsp(parsed, &ctx).await; + + let params = CallHierarchyIncomingCallsParams { + item: lsp_item, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + + let response: Option> = client + .request( + "callHierarchy/incomingCalls", + params, + client.request_timeout(), + ) + .await?; + + // Pre-allocate and build result + let lsp_calls = response.unwrap_or_default(); + let mut calls = Vec::with_capacity(lsp_calls.len()); + + for call in lsp_calls { + // Per the LSP spec, `fromRanges` are ranges within the *caller's* + // document (`call.from.uri`), not the queried item's document. + let from_uri = call.from.uri.clone(); + let from_ranges = { + let mut ranges = Vec::with_capacity(call.from_ranges.len()); + for range in call.from_ranges { + ranges.push(ctx.normalize_range(&from_uri, range).await); + } + ranges + }; + + calls.push(IncomingCall { + from: convert_call_hierarchy_item(call.from, &ctx).await, + from_ranges, + }); + } + + Ok(IncomingCallsResult { calls }) + } + + /// Handle outgoing calls request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the item is invalid, or the + /// routed server does not advertise `callHierarchyProvider` support. + pub async fn handle_outgoing_calls( + &self, + item: serde_json::Value, + ) -> Result { + // Deserialize as our own type (1-based coords). + let parsed = parse_mcp_call_hierarchy_item(item)?; + + // Parse and validate the URI. Same ToolKind/route as `prepare` and + // `handle_incoming_calls` -- see that function's comment. + let path = self.parse_file_uri(&parsed.uri)?; + let (server_id, client) = self + .resolve_client_for_file(&path, ToolKind::CallHierarchy) + .await?; + self.require_capability( + &server_id, + "callHierarchyProvider", + call_hierarchy_provider_supported, + )?; + let ctx = self.encoding_ctx(&server_id); + // Per the LSP spec, an outgoing call's `fromRanges` are ranges within + // the *queried* item's own document, not the callee's (`call.to.uri`). + let source_uri = parsed.uri.clone(); + let lsp_item = call_hierarchy_item_to_lsp(parsed, &ctx).await; + + let params = CallHierarchyOutgoingCallsParams { + item: lsp_item, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + + let response: Option> = client + .request( + "callHierarchy/outgoingCalls", + params, + client.request_timeout(), + ) + .await?; + + // Pre-allocate and build result + let lsp_calls = response.unwrap_or_default(); + let mut calls = Vec::with_capacity(lsp_calls.len()); + + for call in lsp_calls { + let from_ranges = { + let mut ranges = Vec::with_capacity(call.from_ranges.len()); + for range in call.from_ranges { + ranges.push(ctx.normalize_range(&source_uri, range).await); + } + ranges + }; + + calls.push(OutgoingCall { + to: convert_call_hierarchy_item(call.to, &ctx).await, + from_ranges, + }); + } + + Ok(OutgoingCallsResult { calls }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::fs; + use std::sync::Arc; + use std::time::Duration; + + use tempfile::TempDir; + use tokio::io::BufReader; + use tokio::time::timeout; + use url::Url; + + use super::*; + use crate::bridge::translator::dto::{Position2D, Range}; + use crate::bridge::translator::testing::*; + use crate::config::ServerId; + + #[tokio::test] + async fn test_handle_call_hierarchy_prepare_invalid_position_zero() { + let translator = Translator::new(); + let result = translator + .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 0, 1) + .await; + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + + let result = translator + .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 0) + .await; + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_handle_call_hierarchy_prepare_invalid_position_too_large() { + let translator = Translator::new(); + let result = translator + .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1_000_001, 1) + .await; + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + + let result = translator + .handle_call_hierarchy_prepare("/tmp/test.rs".to_string(), 1, 1_000_001) + .await; + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_handle_incoming_calls_invalid_json() { + let translator = Translator::new(); + let invalid_item = serde_json::json!({"invalid": "structure"}); + let result = translator.handle_incoming_calls(invalid_item).await; + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_handle_outgoing_calls_invalid_json() { + let translator = Translator::new(); + let invalid_item = serde_json::json!({"invalid": "structure"}); + let result = translator.handle_outgoing_calls(invalid_item).await; + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_convert_call_hierarchy_item_kind_is_numeric() { + let item = lsp_types::CallHierarchyItem { + name: "my_fn".to_string(), + kind: lsp_types::SymbolKind::FUNCTION, + tags: None, + detail: None, + uri: "file:///tmp/test.rs".parse().unwrap(), + range: lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 0, + }, + end: lsp_types::Position { + line: 0, + character: 5, + }, + }, + selection_range: lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 0, + }, + end: lsp_types::Position { + line: 0, + character: 5, + }, + }, + data: None, + }; + let result = convert_call_hierarchy_item(item, &test_ctx()).await; + // SymbolKind::FUNCTION is LSP integer 12 + assert_eq!(result.kind, 12u32); + assert_eq!(result.name, "my_fn"); + } + + /// Per the LSP spec, an incoming call's `fromRanges` are ranges within + /// the *caller's* document (`call.from.uri`), not the queried item's + /// document -- `handle_incoming_calls` must convert them against + /// `caller.rs`'s own content, not `queried.rs`'s. Uses a UTF-8-negotiated + /// server and two files with different multibyte content, so converting + /// against the wrong file's line text produces a different, wrong + /// answer: `"aöb"` (caller) puts LSP byte offset 3 at UTF-16 column 3 + /// (`ö` is 2 UTF-8 bytes / 1 UTF-16 unit), while the ASCII `"abc"` + /// (queried item) would put the same byte offset at column 4. + #[tokio::test] + async fn test_handle_incoming_calls_from_ranges_convert_against_callers_own_uri() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let caps = lsp_types::ServerCapabilities { + call_hierarchy_provider: Some(lsp_types::CallHierarchyServerCapability::Simple(true)), + ..Default::default() + }; + let (translator, mut server) = translator_with_capabilities_and_encoding( + &dir, + &server_id, + caps, + lsp_types::PositionEncodingKind::UTF8, + ); + + let queried_path = dir.path().join("queried.rs"); + fs::write(&queried_path, "abc").unwrap(); + let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string(); + + let caller_path = dir.path().join("caller.rs"); + fs::write(&caller_path, "aöb").unwrap(); + let caller_uri = Url::from_file_path(&caller_path).unwrap().to_string(); + + let item = CallHierarchyItemResult { + name: "queried_fn".to_string(), + kind: 12, + detail: None, + uri: queried_uri, + range: Range { + start: Position2D { + line: 1, + character: 1, + }, + end: Position2D { + line: 1, + character: 4, + }, + }, + selection_range: Range { + start: Position2D { + line: 1, + character: 1, + }, + end: Position2D { + line: 1, + character: 4, + }, + }, + data: None, + }; + + let translator = Arc::new(translator); + let handle = { + let translator = Arc::clone(&translator); + let item = serde_json::to_value(item).unwrap(); + tokio::spawn(async move { translator.handle_incoming_calls(item).await }) + }; + + let mut wire = BufReader::new(&mut server.write_stdout); + let request = read_framed_message(&mut wire).await; + assert_eq!(request["method"], "callHierarchy/incomingCalls"); + + write_response( + &mut server.read_half_stdin, + &request["id"], + serde_json::json!([{ + "from": { + "name": "caller_fn", + "kind": 12, + "uri": caller_uri, + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 1} + }, + "selectionRange": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 1} + } + }, + "fromRanges": [{ + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 3} + }] + }]), + ) + .await; + + let result = timeout(Duration::from_secs(2), handle) + .await + .expect("handler call should not hang") + .unwrap() + .unwrap(); + + assert_eq!(result.calls.len(), 1); + let from_range = &result.calls[0].from_ranges[0]; + assert_eq!( + from_range.end.character, 3, + "fromRanges must convert against the caller's own file (\"aöb\"), not the queried \ + item's (\"abc\") -- a byte offset of 3 is UTF-16 column 3 in the former, 4 in the \ + latter" + ); + } + + /// Per the LSP spec, an outgoing call's `fromRanges` are ranges within + /// the *queried* item's own document, not the callee's (`call.to.uri`) -- + /// the inverse directional convention from incoming calls, tested above. + #[tokio::test] + async fn test_handle_outgoing_calls_from_ranges_convert_against_queried_uri() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let caps = lsp_types::ServerCapabilities { + call_hierarchy_provider: Some(lsp_types::CallHierarchyServerCapability::Simple(true)), + ..Default::default() + }; + let (translator, mut server) = translator_with_capabilities_and_encoding( + &dir, + &server_id, + caps, + lsp_types::PositionEncodingKind::UTF8, + ); + + let queried_path = dir.path().join("queried.rs"); + fs::write(&queried_path, "aöb").unwrap(); + let queried_uri = Url::from_file_path(&queried_path).unwrap().to_string(); + + let callee_path = dir.path().join("callee.rs"); + fs::write(&callee_path, "abc").unwrap(); + let callee_uri = Url::from_file_path(&callee_path).unwrap().to_string(); + + let item = CallHierarchyItemResult { + name: "queried_fn".to_string(), + kind: 12, + detail: None, + uri: queried_uri, + range: Range { + start: Position2D { + line: 1, + character: 1, + }, + end: Position2D { + line: 1, + character: 4, + }, + }, + selection_range: Range { + start: Position2D { + line: 1, + character: 1, + }, + end: Position2D { + line: 1, + character: 4, + }, + }, + data: None, + }; + + let translator = Arc::new(translator); + let handle = { + let translator = Arc::clone(&translator); + let item = serde_json::to_value(item).unwrap(); + tokio::spawn(async move { translator.handle_outgoing_calls(item).await }) + }; + + let mut wire = BufReader::new(&mut server.write_stdout); + let request = read_framed_message(&mut wire).await; + assert_eq!(request["method"], "callHierarchy/outgoingCalls"); + + write_response( + &mut server.read_half_stdin, + &request["id"], + serde_json::json!([{ + "to": { + "name": "callee_fn", + "kind": 12, + "uri": callee_uri, + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 1} + }, + "selectionRange": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 1} + } + }, + "fromRanges": [{ + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 3} + }] + }]), + ) + .await; + + let result = timeout(Duration::from_secs(2), handle) + .await + .expect("handler call should not hang") + .unwrap() + .unwrap(); + + assert_eq!(result.calls.len(), 1); + let from_range = &result.calls[0].from_ranges[0]; + assert_eq!( + from_range.end.character, 3, + "fromRanges must convert against the queried item's own file (\"aöb\"), not the \ + callee's (\"abc\") -- a byte offset of 3 is UTF-16 column 3 in the former, 4 in \ + the latter" + ); + } +} diff --git a/crates/mcpls-core/src/bridge/translator/clock.rs b/crates/mcpls-core/src/bridge/translator/clock.rs new file mode 100644 index 00000000..49f6bf4e --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/clock.rs @@ -0,0 +1,56 @@ +//! Injectable time source for the respawn-backoff logic in [`super::respawn`]. +//! +//! Production always uses [`SystemClock`]; tests use [`FakeClock`] to +//! advance time deterministically instead of sleeping in real time. + +use std::time::Instant; + +/// A source of the current instant, abstracting over [`Instant::now`] so +/// respawn-backoff tests can advance time deterministically instead of +/// sleeping in real time. +pub(super) trait Clock: std::fmt::Debug + Send + Sync { + /// The current instant, per this clock's notion of time. + fn now(&self) -> Instant; +} + +/// Production [`Clock`]: delegates directly to [`Instant::now`]. +#[derive(Debug, Clone, Copy, Default)] +pub(super) struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> Instant { + Instant::now() + } +} + +/// Test-only [`Clock`] with a settable, advanceable [`Instant`], so +/// respawn-backoff tests can assert on elapsed-time behavior without +/// sleeping in real time. +#[cfg(test)] +#[derive(Debug)] +pub(super) struct FakeClock { + now: std::sync::Mutex, +} + +#[cfg(test)] +impl FakeClock { + /// A `FakeClock` initialized to the current real instant. + pub(super) fn new() -> Self { + Self { + now: std::sync::Mutex::new(Instant::now()), + } + } + + /// Advance this clock's reported time by `duration`. + pub(super) fn advance(&self, duration: std::time::Duration) { + let mut now = crate::bridge::lock_std(&self.now); + *now += duration; + } +} + +#[cfg(test)] +impl Clock for FakeClock { + fn now(&self) -> Instant { + *crate::bridge::lock_std(&self.now) + } +} diff --git a/crates/mcpls-core/src/bridge/translator/diagnostics.rs b/crates/mcpls-core/src/bridge/translator/diagnostics.rs new file mode 100644 index 00000000..8fa22e84 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/diagnostics.rs @@ -0,0 +1,1335 @@ +//! Diagnostics pull/push merging, cache-derived diagnostics, and server +//! log/message retrieval. + +use std::path::PathBuf; +use std::sync::Arc; + +use lsp_types::{PartialResultParams, TextDocumentIdentifier, WorkDoneProgressParams}; +use serde::Serialize; +use tokio::sync::Mutex; + +use super::Translator; +use super::dto::{ + Diagnostic, DiagnosticSeverity, DiagnosticsResult, Position2D, Range, ServerLogsResult, + ServerMessagesResult, +}; +use super::encoding_ctx::EncodingCtx; +use super::routing::validate_path_against_roots; +use crate::bridge::encoding::PositionEncoding; +use crate::bridge::{DiagnosticInfo, DocumentTracker, NotificationCache, path_to_uri}; +use crate::config::ToolKind; +use crate::error::{Error, Result}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DiagnosticRequestParams { + text_document: TextDocumentIdentifier, + #[serde(skip_serializing_if = "Option::is_none")] + identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + previous_result_id: Option, + #[serde(flatten)] + work_done_progress_params: WorkDoneProgressParams, + #[serde(flatten)] + partial_result_params: PartialResultParams, +} + +fn diagnostic_request_params(text_document: TextDocumentIdentifier) -> DiagnosticRequestParams { + DiagnosticRequestParams { + text_document, + identifier: None, + previous_result_id: None, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + } +} + +/// Convert an LSP diagnostic into the MCP-facing `Diagnostic` shape. +/// +/// Shared by both the pull-model (`handle_diagnostics`) and cache-derived +/// (`diagnostics_from_cache_entry`) diagnostic paths, so their output never +/// diverges in formatting — `merge_diagnostics`'s dedup logic depends on +/// both sides mapping severity/code identically. +pub(super) async fn diagnostic_to_mcp( + diag: &lsp_types::Diagnostic, + ctx: &EncodingCtx, + uri: &lsp_types::Uri, +) -> Diagnostic { + Diagnostic { + range: ctx.normalize_range(uri, diag.range).await, + severity: match diag.severity { + Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error, + Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning, + Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint, + // INFORMATION and None (no severity reported) both fall here. + _ => DiagnosticSeverity::Information, + }, + message: diag.message.clone(), + code: diag.code.as_ref().map(|c| match c { + lsp_types::NumberOrString::Number(n) => n.to_string(), + lsp_types::NumberOrString::String(s) => s.clone(), + }), + } +} + +impl Translator { + /// Resolve the LSP-side cache key (URI string) for a cached-diagnostics lookup. + /// + /// Split out from the cache read itself so callers (e.g. the + /// `get_cached_diagnostics` MCP tool) can do the path `canonicalize()` and + /// workspace-boundary check *before* taking the `NotificationCache` lock — + /// that lock is also needed by `diagnostics_pump` to store incoming + /// notifications, so nothing that isn't a plain map lookup should run + /// while it's held. + /// + /// # Errors + /// + /// Returns an error if the path is invalid or outside workspace boundaries. + pub fn cached_diagnostics_uri(workspace_roots: &[PathBuf], file_path: &str) -> Result { + let path = PathBuf::from(file_path); + let validated_path = validate_path_against_roots(&path, workspace_roots)?; + + // Use path_to_uri (strips \\?\ on Windows) so the key matches what + // rust-analyzer stores in publishDiagnostics notifications. + Ok(path_to_uri(&validated_path)?.to_string()) + } + + /// Handle diagnostics request. + /// + /// Merges the LSP pull-model response (`textDocument/diagnostic`) with + /// whatever is already cached from `textDocument/publishDiagnostics` push + /// notifications for the same file, so this returns the same diagnostics + /// `get_cached_diagnostics` would for the file at the same point in time + /// (see #244 — rust-analyzer's pull endpoint omits flycheck/clippy-sourced + /// diagnostics, and empirically also some native ones, that are only ever + /// delivered via the push path). If the pull request itself fails (e.g. a + /// push-only server answering `-32601`, or a timeout), a non-empty cache + /// entry is returned as a cache-only result instead of propagating the + /// error, since the cache is not required to be fresher than the pull + /// response to be useful here. + /// + /// The cache is read only after the pull request settles (success or + /// failure) and held only for the lookup itself — never across the LSP + /// round-trip — matching the lock-ordering discipline documented on + /// `cached_diagnostics_uri`. Like `get_cached_diagnostics`, the cache is + /// treated as eventually consistent: a cached entry may reflect a + /// slightly older document version than the fresh pull result if an edit + /// landed inside the server's flycheck debounce window. + /// + /// # Errors + /// + /// Returns an error if the LSP pull request fails and the cache holds no + /// diagnostics for the file either, or if the file cannot be opened. + pub async fn handle_diagnostics( + &self, + file_path: String, + notification_cache: &Mutex, + ) -> Result { + let (server_id, client, uri) = self + .prepare_document(&file_path, ToolKind::Diagnostics) + .await?; + let ctx = self.encoding_ctx(&server_id); + + let params = diagnostic_request_params(TextDocumentIdentifier { uri: uri.clone() }); + + let pull_response: Result = client + .request("textDocument/diagnostic", params, client.request_timeout()) + .await; + + let diag_info = { + let cache = notification_cache.lock().await; + cache.get_diagnostics(uri.as_str()).cloned() + }; + + match pull_response { + Ok(response) => { + let items = match response { + lsp_types::DocumentDiagnosticReportResult::Report(report) => match report { + lsp_types::DocumentDiagnosticReport::Full(full) => { + full.full_document_diagnostic_report.items + } + lsp_types::DocumentDiagnosticReport::Unchanged(_) => vec![], + }, + lsp_types::DocumentDiagnosticReportResult::Partial(_) => vec![], + }; + let mut diagnostics = Vec::with_capacity(items.len()); + for d in &items { + diagnostics.push(diagnostic_to_mcp(d, &ctx, &uri).await); + } + let pull = DiagnosticsResult { diagnostics }; + Ok(Self::merge_diagnostics( + pull, + diag_info.as_ref(), + ctx.encoding, + &self.document_tracker, + ) + .await) + } + Err(e) => { + let cache_only = Self::diagnostics_from_cache_entry( + diag_info.as_ref(), + ctx.encoding, + &self.document_tracker, + ) + .await; + if cache_only.diagnostics.is_empty() { + Err(e) + } else { + Ok(cache_only) + } + } + } + } + + /// Convert a cached diagnostics entry into the MCP-facing result shape. + /// + /// Takes an already-cloned `Option<&DiagnosticInfo>` (out of the + /// `NotificationCache` lock) rather than the cache itself, so this + /// mapping — which is not a bounded operation for a large diagnostics set + /// — never runs while the cache is locked. + /// + /// `encoding` is the negotiated encoding of the server that published + /// these diagnostics; pass `PositionEncoding::Utf16` when no live server + /// context is available (e.g. a cache-only read with no resolved owner). + #[must_use] + pub async fn diagnostics_from_cache_entry( + diag_info: Option<&DiagnosticInfo>, + encoding: PositionEncoding, + tracker: &Arc, + ) -> DiagnosticsResult { + let diagnostics = match diag_info { + Some(diag_info) => { + let ctx = EncodingCtx { + encoding, + tracker: tracker.clone(), + }; + let mut result = Vec::with_capacity(diag_info.diagnostics.len()); + for d in &diag_info.diagnostics { + result.push(diagnostic_to_mcp(d, &ctx, &diag_info.uri).await); + } + result + } + None => Vec::new(), + }; + + DiagnosticsResult { diagnostics } + } + + /// Merge push-model diagnostics from the notification cache into a + /// pull-model (`textDocument/diagnostic`) result. + /// + /// rust-analyzer's pull endpoint omits diagnostics that are only ever + /// delivered via `textDocument/publishDiagnostics` push notifications — + /// not just flycheck/clippy lints, but empirically (verified against a + /// live rust-analyzer 1.97.1 session, see #244) some native diagnostics + /// too. Those are cached separately in `NotificationCache`. + /// + /// Where the *same* logical problem is reported through both paths, the + /// two representations were observed to differ in both `range` and + /// rendered `message`. Captured example, a "not all trait items + /// implemented" (E0046) error for one `impl` block: pull reported range + /// `(96,7)-(96,12)` (the trait name) with message "not all trait items + /// implemented, missing: `fn hello`"; the push notification for the same + /// error reported range `(95,1)-(95,32)` (the impl block) with message + /// "not all trait items implemented, missing: `hello`\nmissing `hello` + /// in implementation" — same `code`/`severity`, adjacent but distinct + /// ranges, different message text. Exact field equality never dedups + /// cases like that. + /// + /// Given that, a cache entry is treated as a duplicate of a pull entry + /// when both carry a `code`, the `(severity, code)` pair matches, *and* + /// the two ranges are either overlapping or start within + /// `DUPLICATE_RANGE_PROXIMITY_LINES` lines of each other — close + /// enough to be the same underlying model divergence, not two distinct + /// occurrences of the same error class (e.g. two unrelated `E0308` + /// mismatches at different call sites in one file, one caught only + /// natively and one only by flycheck). Diagnostics with no `code` fall + /// back to full-field equality, since there is no cheaper stable + /// identity available for them. + /// + /// Output is sorted by `(start.line, start.character)` so merged + /// cache-only entries don't land out of document order after the + /// pull-model ones. + #[must_use] + pub async fn merge_diagnostics( + mut pull: DiagnosticsResult, + diag_info: Option<&DiagnosticInfo>, + encoding: PositionEncoding, + tracker: &Arc, + ) -> DiagnosticsResult { + /// Start-line distance within which same-code, same-severity + /// diagnostics from the two models are still considered the same + /// underlying problem. Derived from the captured E0046 case above + /// (1 line apart); wide enough to absorb span drift between + /// rust-analyzer's own spans and rustc's, narrow enough that two + /// genuinely distinct same-code errors elsewhere in a file are not + /// collapsed into one. + const DUPLICATE_RANGE_PROXIMITY_LINES: u32 = 3; + + fn position_le(a: &Position2D, b: &Position2D) -> bool { + (a.line, a.character) <= (b.line, b.character) + } + + fn ranges_close(a: &Range, b: &Range) -> bool { + let overlaps = position_le(&a.start, &b.end) && position_le(&b.start, &a.end); + overlaps || a.start.line.abs_diff(b.start.line) <= DUPLICATE_RANGE_PROXIMITY_LINES + } + + fn is_duplicate(pull: &[Diagnostic], candidate: &Diagnostic) -> bool { + pull.iter().any(|p| match (&candidate.code, &p.code) { + (Some(c), Some(pc)) if c == pc && p.severity == candidate.severity => { + ranges_close(&p.range, &candidate.range) + } + _ => p == candidate, + }) + } + + let cached = Self::diagnostics_from_cache_entry(diag_info, encoding, tracker) + .await + .diagnostics; + let new_diagnostics: Vec<_> = cached + .into_iter() + .filter(|c| !is_duplicate(&pull.diagnostics, c)) + .collect(); + pull.diagnostics.extend(new_diagnostics); + pull.diagnostics + .sort_by_key(|d| (d.range.start.line, d.range.start.character)); + pull + } + + /// Handle server logs request. + /// + /// # Errors + /// + /// Returns an error if the `min_level` parameter is invalid. + pub fn handle_server_logs( + cache: &NotificationCache, + limit: usize, + min_level: Option, + ) -> Result { + use crate::bridge::notifications::LogLevel; + + let min_level_filter = if let Some(level_str) = min_level { + let level = match level_str.to_lowercase().as_str() { + "error" => LogLevel::Error, + "warning" => LogLevel::Warning, + "info" => LogLevel::Info, + "debug" => LogLevel::Debug, + _ => { + return Err(Error::InvalidToolParams(format!( + "Invalid min_level: '{level_str}'. Valid values: error, warning, info, debug" + ))); + } + }; + Some(level) + } else { + None + }; + + let all_logs = cache.logs(); + + let logs: Vec<_> = all_logs + .iter() + .filter(|log| { + min_level_filter.is_none_or(|min| match min { + LogLevel::Error => matches!(log.level, LogLevel::Error), + LogLevel::Warning => matches!(log.level, LogLevel::Error | LogLevel::Warning), + LogLevel::Info => !matches!(log.level, LogLevel::Debug), + LogLevel::Debug => true, + }) + }) + .take(limit) + .cloned() + .collect(); + + Ok(ServerLogsResult { logs }) + } + + /// Handle server messages request. + /// + /// # Errors + /// + /// This method does not return errors. + pub fn handle_server_messages( + cache: &NotificationCache, + limit: usize, + ) -> Result { + let all_messages = cache.messages(); + let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect(); + Ok(ServerMessagesResult { messages }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::collections::HashMap; + use std::fs; + use std::sync::Arc; + use std::time::Duration; + + use tempfile::TempDir; + use tokio::io::BufReader; + use tokio::time::timeout; + use url::Url; + + use super::*; + use crate::bridge::translator::testing::*; + use crate::config::{ServerId, ToolRouter}; + + #[test] + fn test_diagnostic_request_params_omit_optional_null_fields() { + let uri = "file:///test.ts".parse().unwrap(); + let params = diagnostic_request_params(TextDocumentIdentifier { uri }); + let value = serde_json::to_value(params).unwrap(); + + assert_eq!(value["textDocument"]["uri"], "file:///test.ts"); + assert!(value.get("identifier").is_none()); + assert!(value.get("previousResultId").is_none()); + } + + #[tokio::test] + async fn test_handle_cached_diagnostics_empty() { + let cache = NotificationCache::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let cache_key = + Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap(); + let diag_info = cache.get_diagnostics(&cache_key).cloned(); + let diags = Translator::diagnostics_from_cache_entry( + diag_info.as_ref(), + PositionEncoding::Utf16, + &test_tracker(), + ) + .await; + assert_eq!(diags.diagnostics.len(), 0); + } + + #[test] + fn test_handle_server_logs_with_filter() { + use crate::bridge::notifications::LogLevel; + + let mut cache = NotificationCache::new(); + + // Add some logs + cache.store_log(LogLevel::Error, "error msg".to_string()); + cache.store_log(LogLevel::Warning, "warning msg".to_string()); + cache.store_log(LogLevel::Info, "info msg".to_string()); + cache.store_log(LogLevel::Debug, "debug msg".to_string()); + + // Test with error filter + let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string())); + assert!(result.is_ok()); + let logs = result.unwrap(); + assert_eq!(logs.logs.len(), 1); + assert_eq!(logs.logs[0].message, "error msg"); + + // Test with warning filter (includes error and warning) + let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string())); + assert!(result.is_ok()); + let logs = result.unwrap(); + assert_eq!(logs.logs.len(), 2); + + // Test with info filter (excludes debug) + let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string())); + assert!(result.is_ok()); + let logs = result.unwrap(); + assert_eq!(logs.logs.len(), 3); + + // Test with debug filter (includes all) + let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string())); + assert!(result.is_ok()); + let logs = result.unwrap(); + assert_eq!(logs.logs.len(), 4); + + // Test with invalid filter + let result = Translator::handle_server_logs(&cache, 10, Some("invalid".to_string())); + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[test] + fn test_handle_server_messages_limit() { + use crate::bridge::notifications::MessageType; + + let mut cache = NotificationCache::new(); + + // Add some messages + for i in 0..10 { + cache.store_message(MessageType::Info, format!("message {i}")); + } + + // Test limit + let result = Translator::handle_server_messages(&cache, 5); + assert!(result.is_ok()); + let messages = result.unwrap(); + assert_eq!(messages.messages.len(), 5); + assert_eq!(messages.messages[0].message, "message 0"); + assert_eq!(messages.messages[4].message, "message 4"); + + // Test limit larger than available + let result = Translator::handle_server_messages(&cache, 100); + assert!(result.is_ok()); + let messages = result.unwrap(); + assert_eq!(messages.messages.len(), 10); + } + + #[tokio::test] + async fn test_handle_cached_diagnostics_with_data() { + let mut cache = NotificationCache::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let canonical_path = test_file.canonicalize().unwrap(); + let uri: lsp_types::Uri = Url::from_file_path(&canonical_path) + .unwrap() + .as_str() + .parse() + .unwrap(); + let diagnostic = lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 0, + }, + end: lsp_types::Position { + line: 0, + character: 5, + }, + }, + severity: Some(lsp_types::DiagnosticSeverity::ERROR), + message: "test error".to_string(), + code: Some(lsp_types::NumberOrString::String("E001".to_string())), + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + }; + + cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]); + + let cache_key = + Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap(); + let diag_info = cache.get_diagnostics(&cache_key).cloned(); + let diags = Translator::diagnostics_from_cache_entry( + diag_info.as_ref(), + PositionEncoding::Utf16, + &test_tracker(), + ) + .await; + assert_eq!(diags.diagnostics.len(), 1); + assert_eq!(diags.diagnostics[0].message, "test error"); + assert_eq!(diags.diagnostics[0].code, Some("E001".to_string())); + assert!(matches!( + diags.diagnostics[0].severity, + DiagnosticSeverity::Error + )); + assert_eq!(diags.diagnostics[0].range.start.line, 1); + assert_eq!(diags.diagnostics[0].range.start.character, 1); + } + + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn test_handle_cached_diagnostics_multiple_severities() { + let mut cache = NotificationCache::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let canonical_path = test_file.canonicalize().unwrap(); + let uri: lsp_types::Uri = Url::from_file_path(&canonical_path) + .unwrap() + .as_str() + .parse() + .unwrap(); + let diagnostics = vec![ + lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 0, + }, + end: lsp_types::Position { + line: 0, + character: 5, + }, + }, + severity: Some(lsp_types::DiagnosticSeverity::ERROR), + message: "error".to_string(), + code: None, + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + }, + lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { + line: 1, + character: 0, + }, + end: lsp_types::Position { + line: 1, + character: 5, + }, + }, + severity: Some(lsp_types::DiagnosticSeverity::WARNING), + message: "warning".to_string(), + code: None, + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + }, + lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { + line: 2, + character: 0, + }, + end: lsp_types::Position { + line: 2, + character: 5, + }, + }, + severity: Some(lsp_types::DiagnosticSeverity::INFORMATION), + message: "info".to_string(), + code: None, + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + }, + lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { + line: 3, + character: 0, + }, + end: lsp_types::Position { + line: 3, + character: 5, + }, + }, + severity: Some(lsp_types::DiagnosticSeverity::HINT), + message: "hint".to_string(), + code: None, + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + }, + ]; + + cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), diagnostics); + + let cache_key = + Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap(); + let diag_info = cache.get_diagnostics(&cache_key).cloned(); + let diags = Translator::diagnostics_from_cache_entry( + diag_info.as_ref(), + PositionEncoding::Utf16, + &test_tracker(), + ) + .await; + assert_eq!(diags.diagnostics.len(), 4); + assert!(matches!( + diags.diagnostics[0].severity, + DiagnosticSeverity::Error + )); + assert!(matches!( + diags.diagnostics[1].severity, + DiagnosticSeverity::Warning + )); + assert!(matches!( + diags.diagnostics[2].severity, + DiagnosticSeverity::Information + )); + assert!(matches!( + diags.diagnostics[3].severity, + DiagnosticSeverity::Hint + )); + } + + #[tokio::test] + async fn test_handle_cached_diagnostics_with_numeric_code() { + let mut cache = NotificationCache::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let canonical_path = test_file.canonicalize().unwrap(); + let uri: lsp_types::Uri = Url::from_file_path(&canonical_path) + .unwrap() + .as_str() + .parse() + .unwrap(); + let diagnostic = lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 0, + }, + end: lsp_types::Position { + line: 0, + character: 5, + }, + }, + severity: Some(lsp_types::DiagnosticSeverity::ERROR), + message: "test error".to_string(), + code: Some(lsp_types::NumberOrString::Number(42)), + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + }; + + cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]); + + let cache_key = + Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap(); + let diag_info = cache.get_diagnostics(&cache_key).cloned(); + let diags = Translator::diagnostics_from_cache_entry( + diag_info.as_ref(), + PositionEncoding::Utf16, + &test_tracker(), + ) + .await; + assert_eq!(diags.diagnostics.len(), 1); + assert_eq!(diags.diagnostics[0].code, Some("42".to_string())); + } + + #[test] + fn test_handle_cached_diagnostics_invalid_path() { + let result = Translator::cached_diagnostics_uri(&[], "/nonexistent/path/file.rs"); + assert!(matches!(result, Err(Error::FileIo { .. }))); + } + + #[tokio::test] + async fn test_merge_diagnostics_cache_only_appends_to_empty_pull() { + let pull = DiagnosticsResult { + diagnostics: vec![], + }; + let cache = diag_info(vec![lsp_diag( + 0, + 10, + lsp_types::DiagnosticSeverity::WARNING, + "unused import: `std::fmt`", + None, + )]); + + let merged = Translator::merge_diagnostics( + pull, + Some(&cache), + PositionEncoding::Utf16, + &test_tracker(), + ) + .await; + + assert_eq!(merged.diagnostics.len(), 1); + assert_eq!(merged.diagnostics[0].message, "unused import: `std::fmt`"); + assert!(matches!( + merged.diagnostics[0].severity, + DiagnosticSeverity::Warning + )); + } + + #[tokio::test] + async fn test_merge_diagnostics_exact_duplicate_not_repeated() { + // Same range/severity/message/code as the cache entry below, expressed + // in the 1-based MCP shape `diagnostics_from_cache_entry` would produce. + let pull_diag = Diagnostic { + range: Range { + start: Position2D { + line: 1, + character: 1, + }, + end: Position2D { + line: 1, + character: 11, + }, + }, + severity: DiagnosticSeverity::Error, + message: "mismatched types".to_string(), + code: Some("E0308".to_string()), + }; + let pull = DiagnosticsResult { + diagnostics: vec![pull_diag.clone()], + }; + let cache = diag_info(vec![lsp_diag( + 0, + 10, + lsp_types::DiagnosticSeverity::ERROR, + "mismatched types", + Some("E0308"), + )]); + + let merged = Translator::merge_diagnostics( + pull, + Some(&cache), + PositionEncoding::Utf16, + &test_tracker(), + ) + .await; + + assert_eq!(merged.diagnostics.len(), 1); + assert_eq!(merged.diagnostics[0], pull_diag); + } + + #[tokio::test] + async fn test_merge_diagnostics_no_cache_entry_returns_pull_unchanged() { + let pull_diag = Diagnostic { + range: Range { + start: Position2D { + line: 1, + character: 1, + }, + end: Position2D { + line: 1, + character: 5, + }, + }, + severity: DiagnosticSeverity::Error, + message: "syntax error".to_string(), + code: None, + }; + let pull = DiagnosticsResult { + diagnostics: vec![pull_diag.clone()], + }; + + let merged = + Translator::merge_diagnostics(pull, None, PositionEncoding::Utf16, &test_tracker()) + .await; + + assert_eq!(merged.diagnostics, vec![pull_diag]); + } + + #[tokio::test] + async fn test_merge_diagnostics_multiple_distinct_cache_entries_all_appear() { + let pull = DiagnosticsResult { + diagnostics: vec![], + }; + let cache = diag_info(vec![ + lsp_diag( + 0, + 10, + lsp_types::DiagnosticSeverity::WARNING, + "unused import: `std::fmt`", + None, + ), + lsp_diag( + 5, + 8, + lsp_types::DiagnosticSeverity::WARNING, + "function `helper` is never used", + None, + ), + ]); + + let merged = Translator::merge_diagnostics( + pull, + Some(&cache), + PositionEncoding::Utf16, + &test_tracker(), + ) + .await; + + assert_eq!(merged.diagnostics.len(), 2); + assert!( + merged + .diagnostics + .iter() + .any(|d| d.message == "unused import: `std::fmt`") + ); + assert!( + merged + .diagnostics + .iter() + .any(|d| d.message == "function `helper` is never used") + ); + } + + #[tokio::test] + async fn test_merge_diagnostics_same_range_different_message_not_deduped() { + let pull_diag = Diagnostic { + range: Range { + start: Position2D { + line: 1, + character: 1, + }, + end: Position2D { + line: 1, + character: 11, + }, + }, + severity: DiagnosticSeverity::Error, + message: "mismatched types".to_string(), + code: None, + }; + let pull = DiagnosticsResult { + diagnostics: vec![pull_diag], + }; + // Same range and severity as the pull diagnostic, but a different + // message — must be treated as a distinct diagnostic, not a duplicate. + let cache = diag_info(vec![lsp_diag( + 0, + 10, + lsp_types::DiagnosticSeverity::ERROR, + "expected `i32`, found `&str`", + None, + )]); + + let merged = Translator::merge_diagnostics( + pull, + Some(&cache), + PositionEncoding::Utf16, + &test_tracker(), + ) + .await; + + assert_eq!(merged.diagnostics.len(), 2); + } + + /// Pins a cross-model duplicate shape verified empirically against a live + /// rust-analyzer 1.97.1 session (#244): the pull and push diagnostics for + /// the *same* "not all trait items implemented" (E0046) error had + /// different ranges (trait name vs. impl block) and different messages + /// (terse vs. rustc's full rendering), but shared `code` and `severity`. + /// Exact-field dedup would report this twice; the `(severity, code)` + /// fingerprint must collapse it to one entry. + #[tokio::test] + async fn test_merge_diagnostics_same_code_different_range_and_message_deduped() { + let pull_diag = Diagnostic { + range: Range { + start: Position2D { + line: 96, + character: 7, + }, + end: Position2D { + line: 96, + character: 12, + }, + }, + severity: DiagnosticSeverity::Error, + message: "not all trait items implemented, missing: `fn hello`".to_string(), + code: Some("E0046".to_string()), + }; + let pull = DiagnosticsResult { + diagnostics: vec![pull_diag.clone()], + }; + // Same code and severity, but a different range and a longer, + // differently-worded message -- the rustc-rendered push side of the + // same underlying error. + let cache = diag_info(vec![lsp_diag( + 94, + 31, + lsp_types::DiagnosticSeverity::ERROR, + "not all trait items implemented, missing: `hello`\nmissing `hello` in implementation", + Some("E0046"), + )]); + + let merged = Translator::merge_diagnostics( + pull, + Some(&cache), + PositionEncoding::Utf16, + &test_tracker(), + ) + .await; + + assert_eq!(merged.diagnostics.len(), 1); + assert_eq!(merged.diagnostics[0], pull_diag); + } + + /// Regression: `merge_diagnostics`'s `(severity, code)` fingerprint alone + /// is coarser than full-field equality and cannot tell apart two + /// genuinely distinct diagnostics that happen to share `code` and + /// `severity` -- e.g. two separate `E0308` mismatched-type errors at + /// different locations in the same file, one caught only by native + /// (pull) analysis and a second, unrelated one caught only by + /// flycheck/cargo check (cache), such as an error inside macro-expanded + /// code the native pass did not evaluate. This previously caused the + /// cache-only entry to be silently dropped -- reproducing #244's exact + /// failure mode, just relocated from "no merge" to "over-eager dedup". + /// + /// The range-proximity check on `is_duplicate` (see `merge_diagnostics`) + /// closes this: these two diagnostics are 45 lines apart, far outside + /// `DUPLICATE_RANGE_PROXIMITY_LINES`, so both must survive the merge. + #[tokio::test] + async fn test_merge_diagnostics_same_code_distinct_diagnostics_at_different_locations_both_kept() + { + let pull_diag = Diagnostic { + range: Range { + start: Position2D { + line: 5, + character: 9, + }, + end: Position2D { + line: 5, + character: 20, + }, + }, + severity: DiagnosticSeverity::Error, + message: "mismatched types: expected `i32`, found `&str`".to_string(), + code: Some("E0308".to_string()), + }; + let pull = DiagnosticsResult { + diagnostics: vec![pull_diag.clone()], + }; + // A second, unrelated E0308 at a completely different location with + // a completely different message -- a real, distinct diagnostic, + // not a duplicate of pull_diag. + let cache = diag_info(vec![lsp_diag( + 49, + 22, + lsp_types::DiagnosticSeverity::ERROR, + "mismatched types: expected `String`, found `Vec`", + Some("E0308"), + )]); + + let merged = Translator::merge_diagnostics( + pull, + Some(&cache), + PositionEncoding::Utf16, + &test_tracker(), + ) + .await; + + assert_eq!(merged.diagnostics.len(), 2); + assert_eq!(merged.diagnostics[0], pull_diag); + assert_eq!( + merged.diagnostics[1].message, + "mismatched types: expected `String`, found `Vec`" + ); + } + + #[test] + fn test_handle_server_logs_no_filter() { + use crate::bridge::notifications::LogLevel; + + let mut cache = NotificationCache::new(); + + cache.store_log(LogLevel::Error, "error msg".to_string()); + cache.store_log(LogLevel::Warning, "warning msg".to_string()); + cache.store_log(LogLevel::Info, "info msg".to_string()); + cache.store_log(LogLevel::Debug, "debug msg".to_string()); + + let result = Translator::handle_server_logs(&cache, 10, None); + assert!(result.is_ok()); + let logs = result.unwrap(); + assert_eq!(logs.logs.len(), 4); + } + + #[test] + fn test_handle_server_logs_error_filter_strict() { + use crate::bridge::notifications::LogLevel; + + let mut cache = NotificationCache::new(); + + cache.store_log(LogLevel::Error, "error msg".to_string()); + cache.store_log(LogLevel::Warning, "warning msg".to_string()); + cache.store_log(LogLevel::Info, "info msg".to_string()); + + let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string())); + assert!(result.is_ok()); + let logs = result.unwrap(); + assert_eq!(logs.logs.len(), 1); + assert_eq!(logs.logs[0].message, "error msg"); + } + + #[test] + fn test_handle_server_logs_warning_filter_includes_errors() { + use crate::bridge::notifications::LogLevel; + + let mut cache = NotificationCache::new(); + + cache.store_log(LogLevel::Error, "error msg".to_string()); + cache.store_log(LogLevel::Warning, "warning msg".to_string()); + cache.store_log(LogLevel::Info, "info msg".to_string()); + + let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string())); + assert!(result.is_ok()); + let logs = result.unwrap(); + assert_eq!(logs.logs.len(), 2); + } + + #[test] + fn test_handle_server_logs_info_filter_excludes_debug() { + use crate::bridge::notifications::LogLevel; + + let mut cache = NotificationCache::new(); + + cache.store_log(LogLevel::Error, "error msg".to_string()); + cache.store_log(LogLevel::Info, "info msg".to_string()); + cache.store_log(LogLevel::Debug, "debug msg".to_string()); + + let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string())); + assert!(result.is_ok()); + let logs = result.unwrap(); + assert_eq!(logs.logs.len(), 2); + } + + #[test] + fn test_handle_server_logs_debug_filter_includes_all() { + use crate::bridge::notifications::LogLevel; + + let mut cache = NotificationCache::new(); + + cache.store_log(LogLevel::Error, "error msg".to_string()); + cache.store_log(LogLevel::Warning, "warning msg".to_string()); + cache.store_log(LogLevel::Info, "info msg".to_string()); + cache.store_log(LogLevel::Debug, "debug msg".to_string()); + + let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string())); + assert!(result.is_ok()); + let logs = result.unwrap(); + assert_eq!(logs.logs.len(), 4); + } + + #[test] + fn test_handle_server_logs_limit_applies_after_filter() { + use crate::bridge::notifications::LogLevel; + + let mut cache = NotificationCache::new(); + + for i in 0..10 { + cache.store_log(LogLevel::Error, format!("error {i}")); + } + + let result = Translator::handle_server_logs(&cache, 5, Some("error".to_string())); + assert!(result.is_ok()); + let logs = result.unwrap(); + assert_eq!(logs.logs.len(), 5); + assert_eq!(logs.logs[0].message, "error 0"); + assert_eq!(logs.logs[4].message, "error 4"); + } + + #[test] + fn test_handle_server_logs_case_insensitive_level() { + use crate::bridge::notifications::LogLevel; + + let mut cache = NotificationCache::new(); + + cache.store_log(LogLevel::Error, "error msg".to_string()); + + let result = Translator::handle_server_logs(&cache, 10, Some("ERROR".to_string())); + assert!(result.is_ok()); + + let result = Translator::handle_server_logs(&cache, 10, Some("Error".to_string())); + assert!(result.is_ok()); + + let result = Translator::handle_server_logs(&cache, 10, Some("eRrOr".to_string())); + assert!(result.is_ok()); + } + + #[test] + fn test_handle_server_messages_empty() { + let cache = NotificationCache::new(); + + let result = Translator::handle_server_messages(&cache, 10); + assert!(result.is_ok()); + let messages = result.unwrap(); + assert_eq!(messages.messages.len(), 0); + } + + #[test] + fn test_handle_server_messages_with_different_types() { + use crate::bridge::notifications::MessageType; + + let mut cache = NotificationCache::new(); + + cache.store_message(MessageType::Error, "error".to_string()); + cache.store_message(MessageType::Warning, "warning".to_string()); + cache.store_message(MessageType::Info, "info".to_string()); + cache.store_message(MessageType::Log, "log".to_string()); + + let result = Translator::handle_server_messages(&cache, 10); + assert!(result.is_ok()); + let messages = result.unwrap(); + assert_eq!(messages.messages.len(), 4); + assert_eq!(messages.messages[0].message, "error"); + assert_eq!(messages.messages[1].message, "warning"); + assert_eq!(messages.messages[2].message, "info"); + assert_eq!(messages.messages[3].message, "log"); + } + + #[test] + fn test_handle_server_messages_zero_limit() { + use crate::bridge::notifications::MessageType; + + let mut cache = NotificationCache::new(); + + cache.store_message(MessageType::Info, "test".to_string()); + + let result = Translator::handle_server_messages(&cache, 0); + assert!(result.is_ok()); + let messages = result.unwrap(); + assert_eq!(messages.messages.len(), 0); + } + + #[test] + fn test_handle_cached_diagnostics_path_outside_workspace() { + let temp_dir1 = TempDir::new().unwrap(); + let temp_dir2 = TempDir::new().unwrap(); + + let workspace_roots = vec![temp_dir1.path().to_path_buf()]; + + let test_file = temp_dir2.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let result = + Translator::cached_diagnostics_uri(&workspace_roots, test_file.to_str().unwrap()); + assert!(matches!(result, Err(Error::PathOutsideWorkspace(_)))); + } + + /// S1 regression (#244): a push-only server (or one that times out) + /// answering `textDocument/diagnostic` with an LSP error must not + /// discard diagnostics `handle_diagnostics` already knows about from the + /// cache -- it should return the cache-only result instead of `Err`. + #[tokio::test] + async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() { + let dir = TempDir::new().unwrap(); + let mut extensions = HashMap::new(); + extensions.insert("rs".to_string(), "rust".to_string()); + + let mut translator = + Translator::new() + .with_extensions(extensions) + .with_router(ToolRouter::catch_all([( + ServerId::from("rust"), + "rust".to_string(), + )])); + translator.set_workspace_roots(vec![dir.path().to_path_buf()]); + + let (client, mut server) = fake_lsp_client(); + translator.register_client("rust".to_string(), client); + + let path = dir.path().join("lib.rs"); + fs::write(&path, "fn main() {}").unwrap(); + let path_str = path.to_string_lossy().to_string(); + + // Prime the cache under the exact URI handle_diagnostics will look + // up (path_to_uri over the canonicalized path, same as + // document_tracker uses to open the document). + let canonical = path.canonicalize().unwrap(); + let uri = path_to_uri(&canonical).unwrap(); + let notification_cache = Mutex::new(NotificationCache::new()); + { + let mut cache = notification_cache.lock().await; + cache.store_diagnostics( + &ServerId::from("rust"), + &uri, + Some(1), + vec![lsp_diag( + 0, + 4, + lsp_types::DiagnosticSeverity::WARNING, + "unused import: `std::fmt`", + None, + )], + ); + } + + let translator = Arc::new(translator); + let handle = { + let translator = Arc::clone(&translator); + tokio::spawn(async move { + translator + .handle_diagnostics(path_str, ¬ification_cache) + .await + }) + }; + + let mut wire = BufReader::new(&mut server.write_stdout); + let opened = read_framed_message(&mut wire).await; + assert_eq!(opened["method"], "textDocument/didOpen"); + let diag_request = read_framed_message(&mut wire).await; + assert_eq!(diag_request["method"], "textDocument/diagnostic"); + write_error_response( + &mut server.read_half_stdin, + &diag_request["id"], + -32601, + "method not found", + ) + .await; + + let result = timeout(Duration::from_secs(2), handle) + .await + .expect("handler call should not hang") + .unwrap(); + + let diagnostics = result.expect("cache-only fallback should succeed despite pull error"); + assert_eq!(diagnostics.diagnostics.len(), 1); + assert_eq!( + diagnostics.diagnostics[0].message, + "unused import: `std::fmt`" + ); + } + + /// S1 counterpart: when the cache is also empty, the pull error must + /// still propagate -- there is nothing to fall back to. + #[tokio::test] + async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() { + let dir = TempDir::new().unwrap(); + let mut extensions = HashMap::new(); + extensions.insert("rs".to_string(), "rust".to_string()); + + let mut translator = + Translator::new() + .with_extensions(extensions) + .with_router(ToolRouter::catch_all([( + ServerId::from("rust"), + "rust".to_string(), + )])); + translator.set_workspace_roots(vec![dir.path().to_path_buf()]); + + let (client, mut server) = fake_lsp_client(); + translator.register_client("rust".to_string(), client); + + let path = dir.path().join("lib.rs"); + fs::write(&path, "fn main() {}").unwrap(); + let path_str = path.to_string_lossy().to_string(); + + let notification_cache = Mutex::new(NotificationCache::new()); + + let translator = Arc::new(translator); + let handle = { + let translator = Arc::clone(&translator); + tokio::spawn(async move { + translator + .handle_diagnostics(path_str, ¬ification_cache) + .await + }) + }; + + let mut wire = BufReader::new(&mut server.write_stdout); + let opened = read_framed_message(&mut wire).await; + assert_eq!(opened["method"], "textDocument/didOpen"); + let diag_request = read_framed_message(&mut wire).await; + assert_eq!(diag_request["method"], "textDocument/diagnostic"); + write_error_response( + &mut server.read_half_stdin, + &diag_request["id"], + -32601, + "method not found", + ) + .await; + + let result = timeout(Duration::from_secs(2), handle) + .await + .expect("handler call should not hang") + .unwrap(); + + assert!( + result.is_err(), + "pull error with no cache data must propagate, got {result:?}" + ); + } +} diff --git a/crates/mcpls-core/src/bridge/translator/dto.rs b/crates/mcpls-core/src/bridge/translator/dto.rs new file mode 100644 index 00000000..120b3598 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/dto.rs @@ -0,0 +1,382 @@ +//! Public MCP-facing result/data-transfer types returned by the tool-call +//! handlers in the sibling domain modules. + +use serde::{Deserialize, Serialize}; + +/// Position in a document (1-based for MCP). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Position2D { + /// Line number (1-based). + pub line: u32, + /// Character offset (1-based). + pub character: u32, +} + +/// Range in a document (1-based for MCP). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Range { + /// Start position. + pub start: Position2D, + /// End position. + pub end: Position2D, +} + +/// Location in a document. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Location { + /// URI of the document. + pub uri: String, + /// Range within the document. + pub range: Range, +} + +/// Result of a hover request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HoverResult { + /// Hover contents as markdown string. + pub contents: String, + /// Optional range the hover applies to. + pub range: Option, +} + +/// Result of a definition request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DefinitionResult { + /// Locations of the definition. + pub locations: Vec, +} + +/// Result of a references request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReferencesResult { + /// Locations of all references. + pub locations: Vec, +} + +/// Diagnostic severity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DiagnosticSeverity { + /// Error diagnostic. + Error, + /// Warning diagnostic. + Warning, + /// Informational diagnostic. + Information, + /// Hint diagnostic. + Hint, +} + +/// A single diagnostic. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diagnostic { + /// Range where the diagnostic applies. + pub range: Range, + /// Severity of the diagnostic. + pub severity: DiagnosticSeverity, + /// Diagnostic message. + pub message: String, + /// Optional diagnostic code. + pub code: Option, +} + +/// Result of a diagnostics request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiagnosticsResult { + /// List of diagnostics for the document. + pub diagnostics: Vec, +} + +/// A text edit operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextEdit { + /// Range to replace. + pub range: Range, + /// New text. + pub new_text: String, +} + +/// Changes to a document. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentChanges { + /// URI of the document. + pub uri: String, + /// List of edits to apply. + pub edits: Vec, +} + +/// Result of a rename request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenameResult { + /// Changes to apply across documents. + pub changes: Vec, +} + +/// A completion item. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Completion { + /// Label of the completion. + pub label: String, + /// Kind of completion. + pub kind: Option, + /// Detail information. + pub detail: Option, + /// Documentation. + pub documentation: Option, +} + +/// Result of a completions request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionsResult { + /// List of completion items. + pub items: Vec, +} + +/// A document symbol. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Symbol { + /// Name of the symbol. + pub name: String, + /// Kind of symbol. + pub kind: String, + /// Range of the symbol. + pub range: Range, + /// Selection range (identifier location). + pub selection_range: Range, + /// Child symbols. + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, +} + +/// Result of a document symbols request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentSymbolsResult { + /// List of symbols in the document. + pub symbols: Vec, +} + +/// Result of a format document request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormatDocumentResult { + /// List of edits to format the document. + pub edits: Vec, +} + +/// A workspace symbol. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceSymbol { + /// Name of the symbol. + pub name: String, + /// Kind of symbol. + pub kind: String, + /// Location of the symbol. + pub location: Location, + /// Optional container name (parent scope). + #[serde(skip_serializing_if = "Option::is_none")] + pub container_name: Option, +} + +/// Result of workspace symbol search. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceSymbolResult { + /// List of symbols found. + pub symbols: Vec, +} + +/// A single code action. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodeAction { + /// Title of the code action. + pub title: String, + /// Kind of code action (quickfix, refactor, etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Diagnostics that this action resolves. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub diagnostics: Vec, + /// Workspace edit to apply. + #[serde(skip_serializing_if = "Option::is_none")] + pub edit: Option, + /// Command to execute. + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + /// Whether this is the preferred action. + #[serde(default)] + pub is_preferred: bool, +} + +/// Description of a workspace edit. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceEditDescription { + /// Changes to apply to documents. + pub changes: Vec, +} + +/// Description of a command. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandDescription { + /// Title of the command. + pub title: String, + /// Command identifier. + pub command: String, + /// Command arguments. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub arguments: Vec, +} + +/// Result of code actions request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodeActionsResult { + /// Available code actions. + pub actions: Vec, +} + +/// A call hierarchy item. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallHierarchyItemResult { + /// Name of the symbol. + pub name: String, + /// LSP numeric symbol kind (e.g. 12 for Function). + pub kind: u32, + /// More detail for this item. + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// URI of the document. + pub uri: String, + /// Range of the symbol. + pub range: Range, + /// Selection range (identifier location). + /// + /// Serialized as `selectionRange` (camelCase) so that the value returned by + /// `prepare_call_hierarchy` round-trips correctly when the MCP client passes + /// it back to `get_incoming_calls` / `get_outgoing_calls`, which deserialize + /// it as `lsp_types::CallHierarchyItem` (camelCase). + #[serde(rename = "selectionRange")] + pub selection_range: Range, + /// Opaque data to pass to incoming/outgoing calls. + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// Result of call hierarchy prepare request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallHierarchyPrepareResult { + /// List of callable items at the position. + pub items: Vec, +} + +/// An incoming call (caller of the current item). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IncomingCall { + /// The item that calls the current item. + pub from: CallHierarchyItemResult, + /// Ranges where the call occurs. + pub from_ranges: Vec, +} + +/// Result of incoming calls request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IncomingCallsResult { + /// List of incoming calls. + pub calls: Vec, +} + +/// An outgoing call (callee from the current item). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutgoingCall { + /// The item being called. + pub to: CallHierarchyItemResult, + /// Ranges where the call occurs. + pub from_ranges: Vec, +} + +/// Result of outgoing calls request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutgoingCallsResult { + /// List of outgoing calls. + pub calls: Vec, +} + +/// Result of server logs request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerLogsResult { + /// List of log entries. + pub logs: Vec, +} + +/// Result of server messages request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerMessagesResult { + /// List of server messages. + pub messages: Vec, +} + +/// A single parameter in a signature. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureParameter { + /// Label of the parameter. + pub label: String, + /// Optional documentation for the parameter. + #[serde(skip_serializing_if = "Option::is_none")] + pub documentation: Option, +} + +/// A single signature overload. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureInfo { + /// Full label of the signature. + pub label: String, + /// Optional documentation for the signature. + #[serde(skip_serializing_if = "Option::is_none")] + pub documentation: Option, + /// Parameters of the signature. + pub parameters: Vec, +} + +/// Result of a signature help request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureHelpResult { + /// Available signatures. + pub signatures: Vec, + /// Index of the active signature. + #[serde(skip_serializing_if = "Option::is_none")] + pub active_signature: Option, + /// Index of the active parameter within the active signature. + #[serde(skip_serializing_if = "Option::is_none")] + pub active_parameter: Option, +} + +/// Result of a go-to-implementation or go-to-type-definition request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocationsResult { + /// Locations found. + pub locations: Vec, +} + +/// A single inlay hint entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InlayHintEntry { + /// Position of the hint (1-based MCP). + pub position: Position2D, + /// Label text for the hint. + pub label: String, + /// Hint kind (1 = Type, 2 = Parameter). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Whether to add a space before the hint. + #[serde(skip_serializing_if = "Option::is_none")] + pub padding_left: Option, + /// Whether to add a space after the hint. + #[serde(skip_serializing_if = "Option::is_none")] + pub padding_right: Option, + /// Tooltip text. + #[serde(skip_serializing_if = "Option::is_none")] + pub tooltip: Option, +} + +/// Result of an inlay hints request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InlayHintsResult { + /// List of inlay hints. + pub hints: Vec, +} diff --git a/crates/mcpls-core/src/bridge/translator/edits.rs b/crates/mcpls-core/src/bridge/translator/edits.rs new file mode 100644 index 00000000..8c34ad94 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/edits.rs @@ -0,0 +1,775 @@ +//! Rename, format-document, and code-actions handlers. + +use lsp_types::{ + DocumentFormattingParams, FormattingOptions, PartialResultParams, + RenameParams as LspRenameParams, TextDocumentIdentifier, TextDocumentPositionParams, + WorkDoneProgressParams, WorkspaceEdit, +}; + +use super::Translator; +use super::diagnostics::diagnostic_to_mcp; +use super::dto::{ + CodeAction, CodeActionsResult, CommandDescription, DocumentChanges, FormatDocumentResult, + RenameResult, TextEdit, WorkspaceEditDescription, +}; +use super::encoding_ctx::EncodingCtx; +use super::routing::{MAX_POSITION_VALUE, MAX_RANGE_LINES}; +use crate::config::ToolKind; +use crate::error::{Error, Result}; + +/// Convert LSP range to MCP range (0-based to 1-based). +/// Validate parameters for `handle_code_actions`. +fn validate_code_action_params( + start_line: u32, + start_character: u32, + end_line: u32, + end_character: u32, + kind_filter: Option<&str>, +) -> Result<()> { + const VALID_ACTION_KINDS: &[&str] = &[ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ]; + + if let Some(kind) = kind_filter + && !VALID_ACTION_KINDS + .iter() + .any(|k| k.eq_ignore_ascii_case(kind)) + { + return Err(Error::InvalidToolParams(format!( + "Invalid kind_filter: '{kind}'. Valid values: {VALID_ACTION_KINDS:?}" + ))); + } + + if start_line < 1 || start_character < 1 || end_line < 1 || end_character < 1 { + return Err(Error::InvalidToolParams( + "Line and character positions must be >= 1".to_string(), + )); + } + + if start_line > MAX_POSITION_VALUE + || start_character > MAX_POSITION_VALUE + || end_line > MAX_POSITION_VALUE + || end_character > MAX_POSITION_VALUE + { + return Err(Error::InvalidToolParams(format!( + "Position values must be <= {MAX_POSITION_VALUE}" + ))); + } + + if end_line.saturating_sub(start_line) > MAX_RANGE_LINES { + return Err(Error::InvalidToolParams(format!( + "Range size must be <= {MAX_RANGE_LINES} lines" + ))); + } + + if start_line > end_line || (start_line == end_line && start_character > end_character) { + return Err(Error::InvalidToolParams( + "Start position must be before or equal to end position".to_string(), + )); + } + + Ok(()) +} + +/// Convert LSP code action to MCP code action. `uri` is the queried +/// document's own URI, used for the action's `diagnostics` (always scoped to +/// the requested document); `edit.changes` carries its own per-file URIs. +async fn convert_code_action( + action: lsp_types::CodeAction, + ctx: &EncodingCtx, + uri: &lsp_types::Uri, +) -> CodeAction { + let diagnostics = match action.diagnostics { + Some(diags) => { + let mut result = Vec::with_capacity(diags.len()); + for d in &diags { + result.push(diagnostic_to_mcp(d, ctx, uri).await); + } + result + } + None => Vec::new(), + }; + + let edit = match action.edit { + Some(edit) => { + let changes = match edit.changes { + Some(changes_map) => { + let mut result = Vec::with_capacity(changes_map.len()); + for (uri, edits) in changes_map { + let mut text_edits = Vec::with_capacity(edits.len()); + for e in edits { + text_edits.push(TextEdit { + range: ctx.normalize_range(&uri, e.range).await, + new_text: e.new_text, + }); + } + result.push(DocumentChanges { + uri: uri.to_string(), + edits: text_edits, + }); + } + result + } + None => Vec::new(), + }; + Some(WorkspaceEditDescription { changes }) + } + None => None, + }; + + let command = action.command.map(|cmd| { + let arguments = cmd.arguments.unwrap_or_else(Vec::new); + CommandDescription { + title: cmd.title, + command: cmd.command, + arguments, + } + }); + + CodeAction { + title: action.title, + kind: action.kind.map(|k| k.as_str().to_string()), + diagnostics, + edit, + command, + is_preferred: action.is_preferred.unwrap_or(false), + } +} + +impl Translator { + /// Handle rename request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `renameProvider` support. + pub async fn handle_rename( + &self, + file_path: String, + line: u32, + character: u32, + new_name: String, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document(&file_path, ToolKind::Rename, "renameProvider", |caps| { + matches!( + caps.rename_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ) + }) + .await?; + let ctx = self.encoding_ctx(&server_id); + let lsp_position = ctx.to_lsp(&uri, line, character).await; + + let params = LspRenameParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_position, + }, + new_name, + work_done_progress_params: WorkDoneProgressParams::default(), + }; + + let response: Option = client + .request("textDocument/rename", params, client.request_timeout()) + .await?; + + let changes = if let Some(edit) = response { + let mut result_changes = Vec::new(); + + // Prefer the legacy `changes` map (HashMap>). + if let Some(changes_map) = edit.changes { + for (uri, edits) in changes_map { + let mut text_edits = Vec::with_capacity(edits.len()); + for e in edits { + text_edits.push(TextEdit { + range: ctx.normalize_range(&uri, e.range).await, + new_text: e.new_text, + }); + } + result_changes.push(DocumentChanges { + uri: uri.to_string(), + edits: text_edits, + }); + } + } + + // Also handle `documentChanges` (array format returned by rust-analyzer). + if result_changes.is_empty() { + let text_doc_edits = match edit.document_changes { + Some(lsp_types::DocumentChanges::Edits(edits)) => edits, + Some(lsp_types::DocumentChanges::Operations(ops)) => ops + .into_iter() + .filter_map(|op| match op { + lsp_types::DocumentChangeOperation::Edit(e) => Some(e), + lsp_types::DocumentChangeOperation::Op(_) => None, + }) + .collect(), + None => vec![], + }; + for tde in text_doc_edits { + let edit_uri = &tde.text_document.uri; + let mut text_edits = Vec::with_capacity(tde.edits.len()); + for one_of in tde.edits { + text_edits.push(match one_of { + lsp_types::OneOf::Left(te) => TextEdit { + range: ctx.normalize_range(edit_uri, te.range).await, + new_text: te.new_text, + }, + lsp_types::OneOf::Right(ate) => TextEdit { + range: ctx.normalize_range(edit_uri, ate.text_edit.range).await, + new_text: ate.text_edit.new_text, + }, + }); + } + result_changes.push(DocumentChanges { + uri: edit_uri.to_string(), + edits: text_edits, + }); + } + } + + result_changes + } else { + vec![] + }; + + Ok(RenameResult { changes }) + } + + /// Handle format document request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `documentFormattingProvider` support. + pub async fn handle_format_document( + &self, + file_path: String, + tab_size: u32, + insert_spaces: bool, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::FormatDocument, + "documentFormattingProvider", + |caps| { + matches!( + caps.document_formatting_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ) + }, + ) + .await?; + let ctx = self.encoding_ctx(&server_id); + let response_uri = uri.clone(); + + let params = DocumentFormattingParams { + text_document: TextDocumentIdentifier { uri }, + options: FormattingOptions { + tab_size, + insert_spaces, + ..Default::default() + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }; + + let response: Option> = client + .request("textDocument/formatting", params, client.request_timeout()) + .await?; + + let edits = response.unwrap_or_default(); + + let mut result_edits = Vec::with_capacity(edits.len()); + for edit in edits { + result_edits.push(TextEdit { + range: ctx.normalize_range(&response_uri, edit.range).await, + new_text: edit.new_text, + }); + } + let result = FormatDocumentResult { + edits: result_edits, + }; + + Ok(result) + } + + /// Handle code actions request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `codeActionProvider` support. + pub async fn handle_code_actions( + &self, + file_path: String, + start_line: u32, + start_character: u32, + end_line: u32, + end_character: u32, + kind_filter: Option, + ) -> Result { + validate_code_action_params( + start_line, + start_character, + end_line, + end_character, + kind_filter.as_deref(), + )?; + + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::CodeActions, + "codeActionProvider", + |caps| { + matches!( + caps.code_action_provider, + Some( + lsp_types::CodeActionProviderCapability::Simple(true) + | lsp_types::CodeActionProviderCapability::Options(_) + ) + ) + }, + ) + .await?; + let ctx = self.encoding_ctx(&server_id); + let response_uri = uri.clone(); + + let range = lsp_types::Range { + start: ctx.to_lsp(&uri, start_line, start_character).await, + end: ctx.to_lsp(&uri, end_line, end_character).await, + }; + + // Build context with optional kind filter + let only = kind_filter.map(|k| vec![lsp_types::CodeActionKind::from(k)]); + + // Pass empty diagnostics context — rust-analyzer generates code actions + // based on cursor position and its internal analysis state, not on the + // passed diagnostics. Passing stale cached diagnostics (which may lack + // the internal `data` field ra uses for fix mapping) suppresses results. + let context_diagnostics: Vec = vec![]; + + let params = lsp_types::CodeActionParams { + text_document: TextDocumentIdentifier { uri }, + range, + context: lsp_types::CodeActionContext { + diagnostics: context_diagnostics, + only, + trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + + let response: Option = client + .request("textDocument/codeAction", params, client.request_timeout()) + .await?; + let response_vec = response.unwrap_or_default(); + let mut actions = Vec::with_capacity(response_vec.len()); + + for action_or_command in response_vec { + let action = match action_or_command { + lsp_types::CodeActionOrCommand::CodeAction(action) => { + convert_code_action(action, &ctx, &response_uri).await + } + lsp_types::CodeActionOrCommand::Command(cmd) => { + let arguments = cmd.arguments.unwrap_or_else(Vec::new); + CodeAction { + title: cmd.title.clone(), + kind: None, + diagnostics: Vec::new(), + edit: None, + command: Some(CommandDescription { + title: cmd.title, + command: cmd.command, + arguments, + }), + is_preferred: false, + } + } + }; + actions.push(action); + } + + Ok(CodeActionsResult { actions }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::fs; + + use super::*; + use crate::bridge::translator::dto::DiagnosticSeverity; + use crate::bridge::translator::testing::*; + + #[tokio::test] + async fn test_handle_code_actions_invalid_kind() { + let translator = Translator::new(); + let result = translator + .handle_code_actions( + "/tmp/test.rs".to_string(), + 1, + 1, + 1, + 10, + Some("invalid_kind".to_string()), + ) + .await; + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_handle_code_actions_valid_kind_quickfix() { + use tempfile::TempDir; + + let translator = Translator::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let result = translator + .handle_code_actions( + test_file.to_str().unwrap().to_string(), + 1, + 1, + 1, + 10, + Some("quickfix".to_string()), + ) + .await; + // Will fail due to no LSP server, but validates kind is accepted + assert!(result.is_err()); + assert!(!matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_handle_code_actions_valid_kind_refactor() { + use tempfile::TempDir; + + let translator = Translator::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let result = translator + .handle_code_actions( + test_file.to_str().unwrap().to_string(), + 1, + 1, + 1, + 10, + Some("refactor".to_string()), + ) + .await; + assert!(result.is_err()); + assert!(!matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_handle_code_actions_valid_kind_refactor_extract() { + use tempfile::TempDir; + + let translator = Translator::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let result = translator + .handle_code_actions( + test_file.to_str().unwrap().to_string(), + 1, + 1, + 1, + 10, + Some("refactor.extract".to_string()), + ) + .await; + assert!(result.is_err()); + assert!(!matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_handle_code_actions_valid_kind_source() { + use tempfile::TempDir; + + let translator = Translator::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let result = translator + .handle_code_actions( + test_file.to_str().unwrap().to_string(), + 1, + 1, + 1, + 10, + Some("source.organizeImports".to_string()), + ) + .await; + assert!(result.is_err()); + assert!(!matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_handle_code_actions_invalid_range_zero() { + let translator = Translator::new(); + let result = translator + .handle_code_actions("/tmp/test.rs".to_string(), 0, 1, 1, 10, None) + .await; + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_handle_code_actions_invalid_range_order() { + let translator = Translator::new(); + let result = translator + .handle_code_actions("/tmp/test.rs".to_string(), 10, 5, 5, 1, None) + .await; + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_handle_code_actions_empty_range() { + use tempfile::TempDir; + + let translator = Translator::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + // Empty range (same position) should be valid + let result = translator + .handle_code_actions(test_file.to_str().unwrap().to_string(), 1, 5, 1, 5, None) + .await; + // Will fail due to no LSP server, but validates range is accepted + assert!(result.is_err()); + assert!(!matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_convert_code_action_minimal() { + let lsp_action = lsp_types::CodeAction { + title: "Fix issue".to_string(), + kind: None, + diagnostics: None, + edit: None, + command: None, + is_preferred: None, + disabled: None, + data: None, + }; + + let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await; + assert_eq!(result.title, "Fix issue"); + assert!(result.kind.is_none()); + assert!(result.diagnostics.is_empty()); + assert!(result.edit.is_none()); + assert!(result.command.is_none()); + assert!(!result.is_preferred); + } + + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn test_convert_code_action_with_diagnostics_all_severities() { + let lsp_diagnostics = vec![ + lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 0, + }, + end: lsp_types::Position { + line: 0, + character: 5, + }, + }, + severity: Some(lsp_types::DiagnosticSeverity::ERROR), + message: "Error message".to_string(), + code: Some(lsp_types::NumberOrString::Number(1)), + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + }, + lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { + line: 1, + character: 0, + }, + end: lsp_types::Position { + line: 1, + character: 5, + }, + }, + severity: Some(lsp_types::DiagnosticSeverity::WARNING), + message: "Warning message".to_string(), + code: Some(lsp_types::NumberOrString::String("W001".to_string())), + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + }, + lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { + line: 2, + character: 0, + }, + end: lsp_types::Position { + line: 2, + character: 5, + }, + }, + severity: Some(lsp_types::DiagnosticSeverity::INFORMATION), + message: "Info message".to_string(), + code: None, + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + }, + lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { + line: 3, + character: 0, + }, + end: lsp_types::Position { + line: 3, + character: 5, + }, + }, + severity: Some(lsp_types::DiagnosticSeverity::HINT), + message: "Hint message".to_string(), + code: None, + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + }, + ]; + + let lsp_action = lsp_types::CodeAction { + title: "Fix all issues".to_string(), + kind: Some(lsp_types::CodeActionKind::QUICKFIX), + diagnostics: Some(lsp_diagnostics), + edit: None, + command: None, + is_preferred: None, + disabled: None, + data: None, + }; + + let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await; + assert_eq!(result.diagnostics.len(), 4); + assert!(matches!( + result.diagnostics[0].severity, + DiagnosticSeverity::Error + )); + assert!(matches!( + result.diagnostics[1].severity, + DiagnosticSeverity::Warning + )); + assert!(matches!( + result.diagnostics[2].severity, + DiagnosticSeverity::Information + )); + assert!(matches!( + result.diagnostics[3].severity, + DiagnosticSeverity::Hint + )); + assert_eq!(result.diagnostics[0].code, Some("1".to_string())); + assert_eq!(result.diagnostics[1].code, Some("W001".to_string())); + } + + #[tokio::test] + #[allow(clippy::mutable_key_type)] + async fn test_convert_code_action_with_workspace_edit() { + use std::collections::HashMap; + use std::str::FromStr; + + let uri = lsp_types::Uri::from_str("file:///test.rs").unwrap(); + let mut changes_map = HashMap::new(); + changes_map.insert( + uri, + vec![lsp_types::TextEdit { + range: lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 0, + }, + end: lsp_types::Position { + line: 0, + character: 5, + }, + }, + new_text: "fixed".to_string(), + }], + ); + + let lsp_action = lsp_types::CodeAction { + title: "Apply fix".to_string(), + kind: Some(lsp_types::CodeActionKind::QUICKFIX), + diagnostics: None, + edit: Some(lsp_types::WorkspaceEdit { + changes: Some(changes_map), + document_changes: None, + change_annotations: None, + }), + command: None, + is_preferred: Some(true), + disabled: None, + data: None, + }; + + let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await; + assert!(result.edit.is_some()); + let edit = result.edit.unwrap(); + assert_eq!(edit.changes.len(), 1); + assert_eq!(edit.changes[0].uri, "file:///test.rs"); + assert_eq!(edit.changes[0].edits.len(), 1); + assert_eq!(edit.changes[0].edits[0].new_text, "fixed"); + assert!(result.is_preferred); + } + + #[tokio::test] + async fn test_convert_code_action_with_command() { + let lsp_action = lsp_types::CodeAction { + title: "Run command".to_string(), + kind: Some(lsp_types::CodeActionKind::REFACTOR), + diagnostics: None, + edit: None, + command: Some(lsp_types::Command { + title: "Execute refactor".to_string(), + command: "refactor.extract".to_string(), + arguments: Some(vec![serde_json::json!("arg1"), serde_json::json!(42)]), + }), + is_preferred: None, + disabled: None, + data: None, + }; + + let result = convert_code_action(lsp_action, &test_ctx(), &test_uri()).await; + assert!(result.command.is_some()); + let cmd = result.command.unwrap(); + assert_eq!(cmd.title, "Execute refactor"); + assert_eq!(cmd.command, "refactor.extract"); + assert_eq!(cmd.arguments.len(), 2); + } +} diff --git a/crates/mcpls-core/src/bridge/translator/encoding_ctx.rs b/crates/mcpls-core/src/bridge/translator/encoding_ctx.rs new file mode 100644 index 00000000..1887519f --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/encoding_ctx.rs @@ -0,0 +1,267 @@ +//! Per-response position/range encoding conversion between MCP's 1-based +//! UTF-16 columns and an LSP server's negotiated encoding. + +use std::sync::Arc; + +use super::dto::{Position2D, Range}; +use crate::bridge::DocumentTracker; +use crate::bridge::encoding::{PositionEncoding, lsp_to_mcp_position, mcp_to_lsp_position}; +use crate::bridge::state::uri_to_path; + +/// Per-response encoding context: the negotiated [`PositionEncoding`] of the +/// LSP server that produced a response, used to convert every +/// position/range in that response between MCP's 1-based UTF-16 columns and +/// the server's own 0-based columns. +/// +/// A single MCP tool call is always answered by exactly one LSP server, so +/// one context covers every location in its response -- even when +/// individual locations point into other files (e.g. `references` results +/// spanning multiple documents): each conversion resolves the *referenced* +/// file's line text independently rather than assuming it matches the +/// originally queried document. +#[derive(Debug, Clone)] +pub(super) struct EncodingCtx { + pub(super) encoding: PositionEncoding, + /// Source of a tracked document's in-memory content -- the text mcpls + /// actually sent the server via `didOpen`/`didChange` -- consulted + /// before falling back to disk. See [`read_line_text`]. + pub(super) tracker: Arc, +} + +/// Text of the 0-based `line`'th line of the file at `uri`, or `None` if it +/// cannot be resolved to a path, read, or has no such line. +/// +/// Only ever consulted when the negotiated encoding is not UTF-16 (see +/// [`EncodingCtx::to_lsp`]/[`EncodingCtx::to_mcp`]). Checks `tracker` first +/// (in-memory, no I/O) -- this is by construction both cheaper and more +/// correct than disk for any document mcpls has opened, since it is exactly +/// the text the server was told about, so it can't diverge from the +/// server's own view even if the file has since been edited on disk (see +/// #290 S1). Only a document `tracker` has never seen falls through to an +/// async disk read, matching `state.rs`'s `tokio::fs` convention so this +/// never blocks the executor thread. +async fn read_line_text( + uri: &lsp_types::Uri, + line: u32, + tracker: &DocumentTracker, +) -> Option { + let path = uri_to_path(uri)?; + if let Some(text) = tracker.line_text(&path, line) { + return Some(text); + } + let content = tokio::fs::read_to_string(&path).await.ok()?; + content.lines().nth(line as usize).map(str::to_string) +} + +impl EncodingCtx { + /// Convert an MCP position for the document at `uri` into an LSP + /// position in this context's negotiated encoding. + pub(super) async fn to_lsp( + &self, + uri: &lsp_types::Uri, + line: u32, + character: u32, + ) -> lsp_types::Position { + let line_text = if self.encoding == PositionEncoding::Utf16 { + None + } else { + let text = read_line_text(uri, line.saturating_sub(1), &self.tracker).await; + if text.is_none() { + tracing::warn!( + uri = uri.as_str(), + line, + encoding = self.encoding.to_lsp(), + "could not resolve line text for position conversion; passing MCP column \ + through unconverted, which is wrong for a non-UTF-16 server" + ); + } + text + }; + mcp_to_lsp_position(line, character, line_text.as_deref(), self.encoding) + } + + /// Convert an LSP position (in this context's negotiated encoding) from + /// the document at `uri` into an MCP position. + pub(super) async fn to_mcp( + &self, + uri: &lsp_types::Uri, + pos: lsp_types::Position, + ) -> Position2D { + let line_text = if self.encoding == PositionEncoding::Utf16 { + None + } else { + let text = read_line_text(uri, pos.line, &self.tracker).await; + if text.is_none() { + tracing::warn!( + uri = uri.as_str(), + line = pos.line, + encoding = self.encoding.to_lsp(), + "could not resolve line text for position conversion; passing server \ + column through unconverted, which is wrong for a non-UTF-16 server" + ); + } + text + }; + let (line, character) = lsp_to_mcp_position(pos, line_text.as_deref(), self.encoding); + Position2D { line, character } + } + + /// Convert an LSP range (in this context's negotiated encoding) from the + /// document at `uri` into an MCP range. + pub(super) async fn normalize_range( + &self, + uri: &lsp_types::Uri, + range: lsp_types::Range, + ) -> Range { + Range { + start: self.to_mcp(uri, range.start).await, + end: self.to_mcp(uri, range.end).await, + } + } + + /// Convert an MCP range for the document at `uri` back into an LSP range + /// in this context's negotiated encoding -- the inverse of + /// [`Self::normalize_range`]. + pub(super) async fn denormalize_range( + &self, + uri: &lsp_types::Uri, + range: &Range, + ) -> lsp_types::Range { + lsp_types::Range { + start: self + .to_lsp(uri, range.start.line, range.start.character) + .await, + end: self.to_lsp(uri, range.end.line, range.end.character).await, + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::collections::HashMap; + use std::fs; + + use tempfile::TempDir; + + use super::*; + use crate::bridge::path_to_uri; + use crate::bridge::state::ResourceLimits; + use crate::bridge::translator::testing::*; + + #[tokio::test] + async fn test_normalize_range() { + let lsp_range = lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 0, + }, + end: lsp_types::Position { + line: 2, + character: 5, + }, + }; + + let mcp_range = test_ctx().normalize_range(&test_uri(), lsp_range).await; + assert_eq!(mcp_range.start.line, 1); + assert_eq!(mcp_range.start.character, 1); + assert_eq!(mcp_range.end.line, 3); + assert_eq!(mcp_range.end.character, 6); + } + + /// End-to-end proof that a non-UTF-16 `EncodingCtx` is actually wired to + /// `read_line_text`/disk, not just correct in isolation at the + /// `encoding.rs` function level: a real temp file with a multibyte line + /// ("héllo"), converted through `EncodingCtx::to_lsp` for a document the + /// tracker has never seen (forcing the disk-read fallback). + #[tokio::test] + async fn test_encoding_ctx_utf8_reads_disk_line_text_for_untracked_document() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("multibyte.rs"); + fs::write(&path, "héllo").unwrap(); + let uri = path_to_uri(&path).unwrap(); + + let ctx = test_ctx_with(PositionEncoding::Utf8); + let lsp_pos = ctx.to_lsp(&uri, 1, 3).await; + // "hé" is 3 bytes in UTF-8 (h=1, é=2); MCP column 3 (UTF-16, after + // "hé") must re-derive to that byte offset via the disk-read line + // text, matching the `encoding.rs`-level math for the same input. + assert_eq!(lsp_pos.character, 3); + } + + /// C3/S1: when a document is tracked, `EncodingCtx` must prefer its + /// in-memory content over disk -- both cheaper (no I/O) and more correct + /// when they've diverged. Here disk holds stale ASCII ("hello", no + /// accent) while the tracker holds the live multibyte content + /// ("héllo"); if conversion used disk instead, MCP column 3 would + /// re-derive to LSP byte offset 2 (ASCII, no multibyte char) instead of + /// 3 (multibyte-correct) -- so this distinguishes the two sources rather + /// than merely tolerating either. + #[tokio::test] + async fn test_encoding_ctx_utf8_prefers_tracked_content_over_stale_disk() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("tracked.rs"); + fs::write(&path, "hello").unwrap(); // stale: no accent + + let tracker = Arc::new(DocumentTracker::new( + ResourceLimits::default(), + HashMap::new(), + )); + let uri = tracker.open(path.clone(), "héllo".to_string()).unwrap(); // live: accent + + let ctx = EncodingCtx { + encoding: PositionEncoding::Utf8, + tracker, + }; + let lsp_pos = ctx.to_lsp(&uri, 1, 3).await; + assert_eq!( + lsp_pos.character, 3, + "must convert against the tracker's live content (\"héllo\" -> byte 3), not disk's \ + stale content (\"hello\" -> byte 2)" + ); + } + + /// A single `EncodingCtx` answering one MCP tool call may still need to + /// convert positions in several different files (e.g. `references` + /// results spanning multiple documents) -- each conversion must resolve + /// *that* location's own file, never reuse or leak another file's line + /// text. Two untracked files with different content at the same + /// byte offset make a wrong-file conversion produce a visibly different + /// (wrong) answer: byte offset 3 is UTF-16 column 3 in "héllo" but + /// column 4 in the all-ASCII "hello". + #[tokio::test] + async fn test_normalize_range_multi_file_converts_each_location_against_its_own_uri() { + let dir = TempDir::new().unwrap(); + let path_a = dir.path().join("a.rs"); + fs::write(&path_a, "héllo").unwrap(); + let uri_a = path_to_uri(&path_a).unwrap(); + + let path_b = dir.path().join("b.rs"); + fs::write(&path_b, "hello").unwrap(); + let uri_b = path_to_uri(&path_b).unwrap(); + + let lsp_range = lsp_types::Range { + start: lsp_types::Position { + line: 0, + character: 0, + }, + end: lsp_types::Position { + line: 0, + character: 3, + }, + }; + + let ctx = test_ctx_with(PositionEncoding::Utf8); + let range_a = ctx.normalize_range(&uri_a, lsp_range).await; + let range_b = ctx.normalize_range(&uri_b, lsp_range).await; + + assert_eq!( + range_a.end.character, 3, + "must convert against a.rs's own content" + ); + assert_eq!( + range_b.end.character, 4, + "must convert against b.rs's own content" + ); + } +} diff --git a/crates/mcpls-core/src/bridge/translator/mod.rs b/crates/mcpls-core/src/bridge/translator/mod.rs new file mode 100644 index 00000000..f70c4ad2 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/mod.rs @@ -0,0 +1,608 @@ +//! MCP to LSP translation layer. +//! +//! `Translator` owns the LSP client/server registries and dispatches MCP +//! tool calls to per-domain handler modules. This module defines the +//! `Translator` struct itself plus setup/lifecycle methods (construction, +//! registration, shutdown); actual tool-call handling lives in the sibling +//! modules below, grouped by domain. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex as StdMutex}; + +use tokio::sync::Mutex; + +use self::clock::{Clock, SystemClock}; +use self::encoding_ctx::EncodingCtx; +use self::respawn::RespawnBackoff; +use crate::bridge::encoding::PositionEncoding; +use crate::bridge::state::ResourceLimits; +use crate::bridge::{DocumentTracker, NotificationCache, lock_std}; +use crate::config::{ServerId, ToolKind, ToolRouter}; +use crate::lsp::{LspClient, LspServer, ServerInitConfig}; + +mod assist; +mod call_hierarchy; +mod clock; +mod diagnostics; +mod dto; +mod edits; +mod encoding_ctx; +mod navigation; +mod respawn; +mod routing; +mod symbols; +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod testing; + +pub use dto::*; +pub use routing::validate_path_against_roots; + +/// Translator handles MCP tool calls by converting them to LSP requests. +/// +/// All fields use interior mutability so `Translator` can be shared via a +/// plain `Arc` with no outer lock: every LSP tool call would +/// otherwise serialize behind a single mutex for its entire round trip +/// (including the LSP request timeout), which is the root cause fixed here. +/// Each field is locked independently and only for the short, synchronous +/// section that touches it. In particular, the actual LSP request/response +/// round trip (`client.request(...)`) always runs with no lock held. +/// +/// `document_tracker` is no exception: `DocumentTracker` locks its own state +/// per-path internally (see its docs), so `prepare_document`'s call into +/// `ensure_open` never holds a lock shared across unrelated paths or +/// languages while it does that document's disk I/O and +/// `textDocument/didOpen`/`didChange` notify. +#[derive(Debug)] +pub struct Translator { + /// LSP clients indexed by routing identity. Locked only for the map + /// lookup/insert itself, never across an LSP request. + lsp_clients: Arc>>, + /// LSP servers indexed by routing identity (held for lifetime management). + lsp_servers: Arc>>, + /// Document state tracker. Locks its own state internally, per path. + document_tracker: Arc, + /// Resource limits `document_tracker` was last built with. Kept + /// alongside `document_tracker` so [`Self::with_extensions`] and + /// [`Self::with_resource_limits`] can each rebuild the tracker from + /// whichever of (limits, extension map) the other has already set, + /// regardless of call order -- see [`Self::with_resource_limits`]. + resource_limits: ResourceLimits, + /// Allowed workspace roots for path validation. Read-only after `serve()` + /// setup, so no lock is needed. + workspace_roots: Arc>, + /// Custom file extension to language ID mappings. Read-only after + /// `serve()` setup, so no lock is needed. + extension_map: Arc>, + /// Servers that are configured + applicable but may not have finished + /// initializing yet (background init). Used to return a clear "still + /// initializing" error instead of "no server configured". + expected_servers: Arc>>, + /// Per-tool routing table: resolves `(language, tool)` to a `ServerId`. + /// Locked independently so `rebind_router` (called from a background + /// task once registration completes) never contends with an in-flight + /// LSP round trip. + router: Arc>, + /// Configs needed to respawn a server if its process dies later, keyed + /// by routing identity. Populated once per server right after a + /// successful spawn (see [`Self::register_server_config`]); the respawn + /// path ([`Self::respawn_if_dead`]) is the only reader. + server_configs: Arc>>, + /// Per-server single-flight lock so concurrent callers that both observe + /// a dead process don't race to respawn it independently -- the loser + /// waits for the winner's attempt to finish (success or failure) and + /// then re-reads whatever ended up registered. See + /// [`Self::respawn_if_dead`]. + respawn_locks: Arc>>>>, + /// Consecutive respawn failures and last-attempt time per server, so a + /// crash-looping server backs off instead of eating a fresh + /// `timeout_seconds` on every tool call that arrives while it is down. + /// See [`Self::respawn_if_dead`]. + respawn_backoffs: Arc>>, + /// Diagnostics cache, shared with `serve_with`'s notification pump. + /// + /// `None` for a `Translator` built without [`Self::with_notification_cache`] + /// (e.g. most unit tests). When present, [`Self::respawn_if_dead`] uses + /// it to invalidate a respawned server's stale cached diagnostics -- + /// see that method's docs for why that matters. + notification_cache: Option>>, + /// Time source for respawn-backoff bookkeeping ([`respawn`](self::respawn)). + /// Always [`SystemClock`] in production; overridden via + /// [`Self::with_clock`] in tests so backoff-window tests can advance + /// time deterministically instead of sleeping in real time. + clock: Arc, +} + +/// Upper bound on how long [`Translator::shutdown_servers`] waits for a +/// single LSP server's graceful `shutdown`/`exit` handshake before giving up +/// and letting `kill_on_drop` terminate it instead. +const SERVER_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +impl Translator { + /// Create a new translator. + /// + /// Starts with an empty router: nothing is routable until [`Self::with_router`] + /// installs one, which matches having no servers registered. + #[must_use] + pub fn new() -> Self { + Self { + lsp_clients: Arc::new(StdMutex::new(HashMap::new())), + lsp_servers: Arc::new(StdMutex::new(HashMap::new())), + document_tracker: Arc::new(DocumentTracker::new( + ResourceLimits::default(), + HashMap::new(), + )), + resource_limits: ResourceLimits::default(), + workspace_roots: Arc::new(Vec::new()), + extension_map: Arc::new(HashMap::new()), + expected_servers: Arc::new(StdMutex::new(HashSet::new())), + router: Arc::new(StdMutex::new(ToolRouter::default())), + server_configs: Arc::new(StdMutex::new(HashMap::new())), + respawn_locks: Arc::new(StdMutex::new(HashMap::new())), + respawn_backoffs: Arc::new(StdMutex::new(HashMap::new())), + notification_cache: None, + clock: Arc::new(SystemClock), + } + } + + /// Override the time source used by respawn-backoff bookkeeping. + /// + /// Test-only: production always uses [`SystemClock`]. Lets + /// backoff-window tests advance a `FakeClock` deterministically instead + /// of sleeping in real time. + #[cfg(test)] + #[must_use] + fn with_clock(mut self, clock: Arc) -> Self { + self.clock = clock; + self + } + + /// Set the workspace roots for path validation. + /// + /// Only called during single-owner setup, before the translator is + /// shared, so this replaces the `Arc` wholesale rather than locking. + pub fn set_workspace_roots(&mut self, roots: Vec) { + self.workspace_roots = Arc::new(roots); + } + + /// Give the translator a handle to the shared diagnostics cache, so the + /// respawn path can invalidate a respawned server's stale entries. + /// + /// Only called during single-owner setup (mirrors [`Self::with_router`]), + /// before the translator is shared -- `serve_with` passes the same + /// `Arc>` used by the notification pump tasks. + #[must_use] + pub fn with_notification_cache(mut self, cache: Arc>) -> Self { + self.notification_cache = Some(cache); + self + } + + /// Mark the set of servers that are expected (configured + applicable) + /// but may still be initializing in the background. + pub fn set_expected_servers(&self, servers: HashSet) { + *lock_std(&self.expected_servers) = servers; + } + + /// Clear the expected-servers set (e.g. after background init failed). + pub fn clear_expected_servers(&self) { + lock_std(&self.expected_servers).clear(); + } + + /// Install the per-tool routing table built from the applicable configs. + /// + /// Only called during single-owner setup, before the translator is + /// shared, so this replaces the `Arc`-wrapped router wholesale. + #[must_use] + pub fn with_router(mut self, router: ToolRouter) -> Self { + self.router = Arc::new(StdMutex::new(router)); + self + } + + /// Rebind the routing table to the set of servers that actually + /// registered, dropping or redirecting routes to servers that failed to + /// spawn. See `ToolRouter::rebind_to_registered` for the full semantics. + pub fn rebind_router(&self, registered: &HashSet) { + lock_std(&self.router).rebind_to_registered(registered); + } + + /// Whether `id` is the server the router currently resolves + /// `ToolKind::Diagnostics` to for `language_id`. + /// + /// Purpose-built for `register_servers`, which needs this to compute the + /// diagnostics-cache filter passed into each pump task, without exposing + /// the router's lock guard outside this module. + #[must_use] + pub fn is_diagnostics_route(&self, language_id: &str, id: &ServerId) -> bool { + lock_std(&self.router).resolve(language_id, ToolKind::Diagnostics) == Some(id) + } + + /// Negotiated [`PositionEncoding`] of the registered server `id`, or the + /// LSP spec's own default (UTF-16) if `id` is not currently registered. + /// + /// Note this falls back to UTF-16, not [`PositionEncoding::default`] + /// (UTF-8): UTF-16 is what an absent/unrecognized negotiation means per + /// the LSP spec and what [`crate::lsp::LspServer::spawn`] itself falls + /// back to, so this must match rather than use the bridge type's own + /// default, which exists only for `PositionEncoding`'s own internal use. + #[must_use] + pub(crate) fn position_encoding_for(&self, server_id: &ServerId) -> PositionEncoding { + lock_std(&self.lsp_servers) + .get(server_id) + .and_then(|server| PositionEncoding::from_lsp(server.position_encoding().as_str())) + .unwrap_or(PositionEncoding::Utf16) + } + + /// Build the [`EncodingCtx`] for converting positions/ranges in + /// responses from the registered server `id`. + fn encoding_ctx(&self, server_id: &ServerId) -> EncodingCtx { + EncodingCtx { + encoding: self.position_encoding_for(server_id), + tracker: self.document_tracker.clone(), + } + } + + /// Rebuilds `document_tracker` from `self.resource_limits` and + /// `self.extension_map`, whatever the two are currently set to. + /// + /// Called by every builder that touches either input ([`Self::with_extensions`], + /// [`Self::with_resource_limits`]), so each one only needs to set its own + /// field and call this -- it always reads *both* current values, so the + /// builders remain order-independent (see [`Self::with_resource_limits`]) + /// without each one needing to know the other's field. A future builder + /// that adds a third tracker input should follow the same pattern: + /// update its own field, then call this. + fn rebuild_document_tracker(&mut self) { + self.document_tracker = Arc::new(DocumentTracker::new( + self.resource_limits, + (*self.extension_map).clone(), + )); + } + + /// Configure custom file extension mappings. + /// + /// This method sets the extension map and updates the document tracker + /// to use the same mappings for language detection. + /// + /// Only called during single-owner setup, before the translator is + /// shared, so this replaces the `Arc`-wrapped fields wholesale. + #[must_use] + pub fn with_extensions(mut self, extension_map: HashMap) -> Self { + self.extension_map = Arc::new(extension_map); + self.rebuild_document_tracker(); + self + } + + /// Configure resource limits (max open documents, max file size) for the + /// document tracker. + /// + /// Only called during single-owner setup, before the translator is + /// shared. This builder and [`Self::with_extensions`] may be called in + /// either order -- each rebuilds `document_tracker` from *both* of + /// `self.resource_limits`/`self.extension_map`'s current values, + /// instead of one of them starting fresh from + /// `ResourceLimits::default()`/an empty extension map, which previously + /// meant whichever builder ran last silently discarded the other's + /// effect. + #[must_use] + pub fn with_resource_limits(mut self, limits: ResourceLimits) -> Self { + self.resource_limits = limits; + self.rebuild_document_tracker(); + self + } + + /// Register an LSP client under its routing identity. + /// + /// Only called once per server, from `register_servers` during initial + /// background init. The respawn path does not reuse this method: it + /// needs the previous client back (to fail its pending requests) and + /// must also reset `document_tracker` for the swapped-in server, neither + /// of which this method does. + pub fn register_client(&self, id: impl Into, client: LspClient) { + lock_std(&self.lsp_clients).insert(id.into(), client); + } + + /// Register an LSP server under its routing identity. + pub fn register_server(&self, id: impl Into, server: LspServer) { + lock_std(&self.lsp_servers).insert(id.into(), server); + } + + /// Store the config needed to respawn `id` if its process dies later. + /// + /// Called once per server, right after a successful spawn (see the + /// crate-root `register_servers`); [`Self::respawn_if_dead`] is the only + /// reader. + pub(crate) fn register_server_config(&self, id: impl Into, config: ServerInitConfig) { + lock_std(&self.server_configs).insert(id.into(), config); + } + + /// Number of currently registered LSP servers. + /// + /// Test-only: `lsp_servers` is private, so this is the one way a test + /// outside this module (e.g. `crate::tests`, exercising + /// [`Translator::shutdown_servers`] indirectly through `serve_with`'s + /// shutdown sequence) can observe that a registered server was actually + /// drained. + #[cfg(test)] + pub(crate) fn registered_server_count(&self) -> usize { + lock_std(&self.lsp_servers).len() + } + + /// Snapshot of currently open document paths, used for MCP resource listing. + #[must_use] + pub fn open_document_paths(&self) -> Vec { + self.document_tracker.open_paths() + } + + /// Whether a document is currently tracked as open. + #[must_use] + pub fn is_document_open(&self, path: &Path) -> bool { + self.document_tracker.is_open(path) + } + + /// The document tracker, shared with [`EncodingCtx`] so a cache-only + /// caller (e.g. `get_cached_diagnostics`) can still prefer tracked + /// in-memory content over a disk read when converting positions. + #[must_use] + pub(crate) const fn document_tracker(&self) -> &Arc { + &self.document_tracker + } + + /// Gracefully shut down every registered LSP server. + /// + /// Drains the registered LSP servers and, for each one concurrently, + /// sends the LSP `shutdown` request and `exit` notification via + /// [`LspServer::shutdown`], bounded by a fixed per-server timeout. A + /// server that errors or fails to respond in time is simply dropped + /// instead: its child process handle is `kill_on_drop(true)`, so the + /// process is killed rather than left running. Call this once, from the + /// top-level shutdown path, after the MCP transport has stopped + /// accepting new requests. + /// + /// # Limitations + /// + /// This only runs on the normal shutdown path (stdio EOF, `SIGTERM`/ + /// `SIGINT`, or the HTTP transport's own graceful shutdown). This crate's + /// workspace `[profile.release]` builds with `panic = "abort"`, so a + /// panic reachable from a request handler or background pump task in a + /// release build still terminates the process without unwinding — this + /// method never runs, and spawned LSP children are orphaned exactly as + /// before this fix. Making that path safe would need process-group + /// isolation (`kill_on_drop` alone doesn't help, since no `Drop` runs + /// either); tracked separately, out of scope here. + /// + /// `pub(crate)` rather than `pub`: this is meant for exactly one call + /// site (`serve_with`'s post-transport shutdown sequence), after the MCP + /// transport is already down. An external caller invoking it mid-session + /// would drain `lsp_servers` while `lsp_clients` (routing table) still + /// points at the now-shut-down servers, so in-flight tool calls would + /// resolve to a client whose server is gone. + pub(crate) async fn shutdown_servers(&self) { + let servers: Vec<(ServerId, LspServer)> = lock_std(&self.lsp_servers).drain().collect(); + if servers.is_empty() { + return; + } + + let mut tasks = tokio::task::JoinSet::new(); + for (id, server) in servers { + tasks.spawn(async move { + match tokio::time::timeout(SERVER_SHUTDOWN_TIMEOUT, server.shutdown()).await { + Ok(Ok(())) => tracing::debug!(%id, "LSP server shut down gracefully"), + Ok(Err(e)) => tracing::warn!( + %id, error = %e, + "LSP server shutdown handshake failed, killing process instead" + ), + Err(_) => tracing::warn!( + %id, timeout = ?SERVER_SHUTDOWN_TIMEOUT, + "LSP server did not shut down in time, killing process instead" + ), + } + }); + } + tasks.join_all().await; + } +} + +impl Default for Translator { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::collections::{HashMap, HashSet}; + use std::path::PathBuf; + + use tokio::time::Duration; + + use super::*; + use crate::bridge::state::detect_language; + use crate::config::{ServerId, ToolKind, ToolRouter}; + use crate::error::Error; + + #[test] + fn test_translator_new() { + let translator = Translator::new(); + assert_eq!(translator.workspace_roots.len(), 0); + assert_eq!(lock_std(&translator.lsp_clients).len(), 0); + assert_eq!(lock_std(&translator.lsp_servers).len(), 0); + } + + #[test] + fn test_set_workspace_roots() { + let mut translator = Translator::new(); + let roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")]; + translator.set_workspace_roots(roots.clone()); + assert_eq!(*translator.workspace_roots, roots); + } + + #[test] + fn test_register_server() { + let translator = Translator::new(); + + // Initial state: no servers registered + assert_eq!(lock_std(&translator.lsp_servers).len(), 0); + + // The register_server method exists and is callable + // Full integration testing with real LspServer is done in integration tests + // This unit test verifies the method signature and basic functionality + + // Note: We can't easily construct an LspServer in a unit test without async + // and a real LSP server process. The actual registration functionality is + // tested in integration tests (see rust_analyzer_tests.rs). + // This test verifies the data structure is properly initialized. + } + + /// #241: `shutdown_servers` on an empty registry must return immediately + /// rather than blocking (e.g. on a `JoinSet` that's never populated). + #[tokio::test] + async fn test_shutdown_servers_empty_registry_returns_promptly() { + let translator = Translator::new(); + + let result = + tokio::time::timeout(Duration::from_secs(1), translator.shutdown_servers()).await; + + assert!( + result.is_ok(), + "shutdown_servers must return promptly when no servers are registered" + ); + } + + /// #241: `shutdown_servers` must drain every registered `LspServer` — + /// this is the core behavior the issue is about (orphaned LSP children + /// on shutdown). Uses `fake_lsp_server()` (mock `echo`/`cat` child + /// processes, real `LspServer`, see `lsp::lifecycle`), which won't + /// answer the LSP `shutdown` handshake — proving the drain completes, + /// via the timeout/error fallback path, without hanging on + /// non-responsive servers. + #[tokio::test] + async fn test_shutdown_servers_drains_registered_servers() { + let translator = Translator::new(); + translator.register_server("server-a", crate::lsp::fake_lsp_server()); + translator.register_server("server-b", crate::lsp::fake_lsp_server()); + assert_eq!(lock_std(&translator.lsp_servers).len(), 2); + + // Bounded well above `SERVER_SHUTDOWN_TIMEOUT` (10s) so a genuine + // regression (a hang) still fails the test instead of the harness + // itself timing out ambiguously. + let result = + tokio::time::timeout(Duration::from_secs(20), translator.shutdown_servers()).await; + + assert!( + result.is_ok(), + "shutdown_servers must not hang against non-responsive mock servers" + ); + assert_eq!( + lock_std(&translator.lsp_servers).len(), + 0, + "all registered servers must be drained" + ); + } + + #[test] + fn test_clear_expected_servers_reverts_to_no_server_after_all_routes_dropped() { + // Mirrors the real `serve_with` flow: `rebind_router` (called from + // `register_servers`/the all-failed path) drops routes to servers + // that never registered, then `clear_expected_servers` runs under + // the same lock. Subsequent lookups must fall back to + // NoServerForLanguage rather than keep implying the server is still + // on its way. + let path = PathBuf::from("/ws/Assets/Scripts/Player.cs"); + let lang = detect_language(&path, &HashMap::new()); + let id = ServerId::from(lang.clone()); + + let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)])); + let mut expected = HashSet::new(); + expected.insert(id); + translator.set_expected_servers(expected); + + translator.rebind_router(&HashSet::new()); + translator.clear_expected_servers(); + + let err = translator + .get_client_for_file(&path, ToolKind::Hover) + .unwrap_err(); + assert!(matches!(err, Error::NoServerForLanguage(_))); + } + + #[test] + fn test_translator_with_custom_extensions() { + let mut extension_map = HashMap::new(); + extension_map.insert("nu".to_string(), "nushell".to_string()); + extension_map.insert("customext".to_string(), "customlang".to_string()); + + let translator = Translator::new().with_extensions(extension_map.clone()); + + assert_eq!(translator.extension_map.len(), 2); + assert_eq!( + translator.extension_map.get("nu"), + Some(&"nushell".to_string()) + ); + assert_eq!( + translator.extension_map.get("customext"), + Some(&"customlang".to_string()) + ); + } + + /// `with_resource_limits` called before `with_extensions` (the order + /// `serve()` uses) must reach `document_tracker`. + #[test] + fn test_with_resource_limits_applies_before_with_extensions() { + let limits = ResourceLimits { + max_documents: 1, + max_file_size: 0, + }; + let translator = Translator::new() + .with_resource_limits(limits) + .with_extensions(HashMap::new()); + + translator + .document_tracker + .open(PathBuf::from("/tmp/a.rs"), "a".to_string()) + .unwrap(); + let err = translator + .document_tracker + .open(PathBuf::from("/tmp/b.rs"), "b".to_string()) + .unwrap_err(); + assert!(matches!(err, Error::DocumentLimitExceeded { max: 1, .. })); + } + + /// `with_resource_limits` called *after* `with_extensions` (the reverse + /// of `serve()`'s order) must still reach `document_tracker` -- the two + /// builders must not clobber each other regardless of call order. See + /// `Translator::with_resource_limits`'s docs. + /// + /// Uses a non-empty extension map (unlike the "before" test above) and + /// asserts it survived `with_resource_limits`'s rebuild by checking the + /// tracked document's resolved `language_id` -- a bug that dropped the + /// extension map (e.g. rebuilding from `HashMap::new()` instead of + /// `self.extension_map`) would leave `max_documents` correct but the + /// extension map silently empty, which the "before" test alone cannot + /// detect. + #[test] + fn test_with_resource_limits_applies_after_with_extensions() { + let limits = ResourceLimits { + max_documents: 1, + max_file_size: 0, + }; + let translator = Translator::new() + .with_extensions(HashMap::from([("rs".to_string(), "rust".to_string())])) + .with_resource_limits(limits); + + let path = PathBuf::from("/tmp/a.rs"); + translator + .document_tracker + .open(path.clone(), "a".to_string()) + .unwrap(); + let err = translator + .document_tracker + .open(PathBuf::from("/tmp/b.rs"), "b".to_string()) + .unwrap_err(); + assert!(matches!(err, Error::DocumentLimitExceeded { max: 1, .. })); + + let state = translator.document_tracker.close(&path).unwrap(); + assert_eq!(state.language_id(), "rust"); + } +} diff --git a/crates/mcpls-core/src/bridge/translator/navigation.rs b/crates/mcpls-core/src/bridge/translator/navigation.rs new file mode 100644 index 00000000..727294d2 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/navigation.rs @@ -0,0 +1,377 @@ +//! Hover, go-to-definition/implementation/type-definition, and references +//! handlers. + +use lsp_types::{ + GotoDefinitionParams, Hover, HoverContents, HoverParams as LspHoverParams, MarkedString, + PartialResultParams, ReferenceContext, ReferenceParams, TextDocumentIdentifier, + TextDocumentPositionParams, WorkDoneProgressParams, +}; + +use super::Translator; +use super::dto::{DefinitionResult, HoverResult, Location, LocationsResult, ReferencesResult}; +use super::encoding_ctx::EncodingCtx; +use crate::config::ToolKind; +use crate::error::Result; + +/// Normalize a `GotoDefinitionResponse` into a flat list of MCP `Location` values. +async fn goto_response_to_locations( + response: Option, + ctx: &EncodingCtx, +) -> Vec { + let lsp_locs: Vec = match response { + Some(lsp_types::GotoDefinitionResponse::Scalar(loc)) => vec![loc], + Some(lsp_types::GotoDefinitionResponse::Array(locs)) => locs, + Some(lsp_types::GotoDefinitionResponse::Link(links)) => links + .into_iter() + .map(|link| lsp_types::Location { + uri: link.target_uri, + range: link.target_selection_range, + }) + .collect(), + None => vec![], + }; + + let mut locations = Vec::with_capacity(lsp_locs.len()); + for loc in lsp_locs { + locations.push(Location { + uri: loc.uri.to_string(), + range: ctx.normalize_range(&loc.uri, loc.range).await, + }); + } + locations +} + +fn extract_hover_contents(contents: HoverContents) -> String { + match contents { + HoverContents::Scalar(marked_string) => marked_string_to_string(marked_string), + HoverContents::Array(marked_strings) => marked_strings + .into_iter() + .map(marked_string_to_string) + .collect::>() + .join("\n\n"), + HoverContents::Markup(markup) => markup.value, + } +} + +/// Convert a marked string to a plain string. +fn marked_string_to_string(marked: MarkedString) -> String { + match marked { + MarkedString::String(s) => s, + MarkedString::LanguageString(ls) => format!("```{}\n{}\n```", ls.language, ls.value), + } +} + +impl Translator { + /// Handle hover request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `hoverProvider` support. + pub async fn handle_hover( + &self, + file_path: String, + line: u32, + character: u32, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document(&file_path, ToolKind::Hover, "hoverProvider", |caps| { + matches!( + caps.hover_provider, + Some( + lsp_types::HoverProviderCapability::Simple(true) + | lsp_types::HoverProviderCapability::Options(_) + ) + ) + }) + .await?; + let ctx = self.encoding_ctx(&server_id); + let lsp_position = ctx.to_lsp(&uri, line, character).await; + let response_uri = uri.clone(); + + let params = LspHoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }; + + let response: Option = client + .request("textDocument/hover", params, client.request_timeout()) + .await?; + + let result = match response { + Some(hover) => { + let contents = extract_hover_contents(hover.contents); + let range = match hover.range { + Some(r) => Some(ctx.normalize_range(&response_uri, r).await), + None => None, + }; + HoverResult { contents, range } + } + None => HoverResult { + contents: "No hover information available".to_string(), + range: None, + }, + }; + + Ok(result) + } + + /// Handle definition request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `definitionProvider` support. + pub async fn handle_definition( + &self, + file_path: String, + line: u32, + character: u32, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::Definition, + "definitionProvider", + |caps| { + matches!( + caps.definition_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ) + }, + ) + .await?; + let ctx = self.encoding_ctx(&server_id); + let lsp_position = ctx.to_lsp(&uri, line, character).await; + + let params = GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + + let response: Option = client + .request("textDocument/definition", params, client.request_timeout()) + .await?; + + let result = DefinitionResult { + locations: goto_response_to_locations(response, &ctx).await, + }; + + Ok(result) + } + + /// Handle references request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `referencesProvider` support. + pub async fn handle_references( + &self, + file_path: String, + line: u32, + character: u32, + include_declaration: bool, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::References, + "referencesProvider", + |caps| { + matches!( + caps.references_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ) + }, + ) + .await?; + let ctx = self.encoding_ctx(&server_id); + let lsp_position = ctx.to_lsp(&uri, line, character).await; + + let params = ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: ReferenceContext { + include_declaration, + }, + }; + + let response: Option> = client + .request("textDocument/references", params, client.request_timeout()) + .await?; + + let locations = response.unwrap_or_default(); + + let mut result_locations = Vec::with_capacity(locations.len()); + for loc in locations { + result_locations.push(Location { + uri: loc.uri.to_string(), + range: ctx.normalize_range(&loc.uri, loc.range).await, + }); + } + let result = ReferencesResult { + locations: result_locations, + }; + + Ok(result) + } + + /// Handle go-to-implementation request (`textDocument/implementation`). + /// + /// Returns the locations of trait method or interface member implementations. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `implementationProvider` support. + pub async fn handle_implementation( + &self, + file_path: String, + line: u32, + character: u32, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::Implementation, + "implementationProvider", + |caps| { + matches!( + caps.implementation_provider, + Some( + lsp_types::ImplementationProviderCapability::Simple(true) + | lsp_types::ImplementationProviderCapability::Options(_) + ) + ) + }, + ) + .await?; + let ctx = self.encoding_ctx(&server_id); + let lsp_position = ctx.to_lsp(&uri, line, character).await; + + let params = GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + + let response: Option = client + .request( + "textDocument/implementation", + params, + client.request_timeout(), + ) + .await?; + + Ok(LocationsResult { + locations: goto_response_to_locations(response, &ctx).await, + }) + } + + /// Handle go-to-type-definition request (`textDocument/typeDefinition`). + /// + /// Returns the type definition location of the expression at position. Distinct + /// from go-to-definition for variable bindings where definition and type differ. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `typeDefinitionProvider` support. + pub async fn handle_type_definition( + &self, + file_path: String, + line: u32, + character: u32, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::TypeDefinition, + "typeDefinitionProvider", + |caps| { + matches!( + caps.type_definition_provider, + Some( + lsp_types::TypeDefinitionProviderCapability::Simple(true) + | lsp_types::TypeDefinitionProviderCapability::Options(_) + ) + ) + }, + ) + .await?; + let ctx = self.encoding_ctx(&server_id); + let lsp_position = ctx.to_lsp(&uri, line, character).await; + + let params = GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: lsp_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + + let response: Option = client + .request( + "textDocument/typeDefinition", + params, + client.request_timeout(), + ) + .await?; + + Ok(LocationsResult { + locations: goto_response_to_locations(response, &ctx).await, + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn test_extract_hover_contents_string() { + let marked_string = lsp_types::MarkedString::String("Test hover".to_string()); + let contents = lsp_types::HoverContents::Scalar(marked_string); + let result = extract_hover_contents(contents); + assert_eq!(result, "Test hover"); + } + + #[test] + fn test_extract_hover_contents_language_string() { + let marked_string = lsp_types::MarkedString::LanguageString(lsp_types::LanguageString { + language: "rust".to_string(), + value: "fn main() {}".to_string(), + }); + let contents = lsp_types::HoverContents::Scalar(marked_string); + let result = extract_hover_contents(contents); + assert_eq!(result, "```rust\nfn main() {}\n```"); + } + + #[test] + fn test_extract_hover_contents_markup() { + let markup = lsp_types::MarkupContent { + kind: lsp_types::MarkupKind::Markdown, + value: "# Documentation".to_string(), + }; + let contents = lsp_types::HoverContents::Markup(markup); + let result = extract_hover_contents(contents); + assert_eq!(result, "# Documentation"); + } +} diff --git a/crates/mcpls-core/src/bridge/translator/respawn.rs b/crates/mcpls-core/src/bridge/translator/respawn.rs new file mode 100644 index 00000000..2e27ffa0 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/respawn.rs @@ -0,0 +1,967 @@ +//! Dead-server detection and respawn-backoff bookkeeping. +//! +//! Tracks consecutive respawn failures per server so a crash-looping +//! process backs off exponentially instead of eating a fresh +//! `timeout_seconds` on every tool call that arrives while it is down. + +use std::sync::Arc; +use std::time::Instant; + +use tokio::sync::Mutex; +use tokio::time::Duration; + +use super::Translator; +use crate::bridge::lock_std; +use crate::config::ServerId; +use crate::error::{Error, Result}; +use crate::lsp::LspServer; + +/// Tracks respawn attempts for one server, so [`Translator::respawn_if_dead`] +/// can back off a crash-looping process instead of retrying it on every +/// single tool call. +#[derive(Debug, Clone, Copy)] +pub(super) struct RespawnBackoff { + /// Number of consecutive attempts that have not produced a server which + /// stayed alive for at least [`RESPAWN_BACKOFF_BASE`]. A spawn failure + /// counts immediately; a spawn that succeeds but is found dead again + /// within that window counts too, once that is discovered -- see + /// [`Translator::reconcile_respawn_stability`]. Without this, a server + /// that starts, completes `initialize`, and then crashes a second later + /// (a common real crash-loop shape) would bypass backoff entirely: each + /// "success" would otherwise look like a fresh, unbacked-off start. + consecutive_failures: u32, + /// When the most recent attempt was made, or (if `last_attempt_succeeded`) + /// when that success was last found to have not held up. + last_attempt: Instant, + /// Whether the most recent attempt completed `initialize` successfully. + /// `false` for an outright spawn failure. Also reset to `false` once a + /// "successful" respawn is found to have died again within the + /// stability window, so that discovery is applied only once. + last_attempt_succeeded: bool, +} + +/// Base delay before the first backed-off retry after a respawn failure. +const RESPAWN_BACKOFF_BASE: Duration = Duration::from_secs(1); + +/// Upper bound on the exponential backoff delay between respawn attempts. +const RESPAWN_BACKOFF_MAX: Duration = Duration::from_secs(30); + +impl Translator { + /// Whether the server tracked under `id` is registered and has exited. + /// + /// Returns `false` ("not dead") for an `id` that isn't registered at + /// all -- that's the separate `ServerInitializing`/`NoServerForTool` + /// concern callers already handle, not something the respawn path + /// should react to -- and for any `try_wait` error, on the conservative + /// assumption that a health check that itself failed should not trigger + /// a respawn. + fn is_server_dead(&self, id: &ServerId) -> bool { + lock_std(&self.lsp_servers) + .get_mut(id) + .and_then(|server| server.has_exited().ok()) + .unwrap_or(false) + } + + /// Return the shared single-flight lock for `id`, creating it on first + /// use. + /// + /// Two concurrent callers racing to respawn the same server both get a + /// clone of the *same* underlying `Mutex`, so awaiting it actually + /// serializes them instead of letting both proceed independently. + fn respawn_lock(&self, id: &ServerId) -> Arc> { + Arc::clone( + lock_std(&self.respawn_locks) + .entry(id.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) + } + + /// Remaining backoff delay before `id` may be respawned again, or + /// `None` if it may be attempted right now. + /// + /// Only consults recorded *failures* -- a server with no recorded + /// attempt is never backed off. A server whose last attempt "succeeded" + /// is reconciled by [`Self::reconcile_respawn_stability`] (called by + /// [`Self::respawn_if_dead`] before this) into either a failure (died + /// again too soon) or removed entirely (proven stable), so by the time + /// this runs, a lingering "succeeded" entry never reaches here. + fn respawn_backoff_remaining(&self, id: &ServerId) -> Option { + let (consecutive_failures, last_attempt) = { + let entry = lock_std(&self.respawn_backoffs).get(id).copied()?; + (entry.consecutive_failures, entry.last_attempt) + }; + if consecutive_failures == 0 { + return None; + } + let shift = consecutive_failures.saturating_sub(1).min(5); + let delay = RESPAWN_BACKOFF_BASE + .saturating_mul(1 << shift) + .min(RESPAWN_BACKOFF_MAX); + let elapsed = self.clock.now().saturating_duration_since(last_attempt); + (elapsed < delay).then(|| delay.saturating_sub(elapsed)) + } + + /// Records a failed respawn attempt for `id`, extending its backoff. + fn record_respawn_failure(&self, id: &ServerId) { + let mut backoffs = lock_std(&self.respawn_backoffs); + let entry = backoffs + .entry(id.clone()) + .or_insert_with(|| RespawnBackoff { + consecutive_failures: 0, + last_attempt: self.clock.now(), + last_attempt_succeeded: false, + }); + entry.consecutive_failures = entry.consecutive_failures.saturating_add(1); + entry.last_attempt = self.clock.now(); + entry.last_attempt_succeeded = false; + drop(backoffs); + } + + /// Records that a respawn attempt for `id` completed `initialize` + /// successfully. + /// + /// Does *not* clear `consecutive_failures`: whether this attempt + /// actually broke the crash loop is only known once the server either + /// stays alive for a while or is found dead again -- see + /// [`Self::reconcile_respawn_stability`], which is what acts on this + /// entry. + fn record_respawn_success(&self, id: &ServerId) { + let mut backoffs = lock_std(&self.respawn_backoffs); + let entry = backoffs + .entry(id.clone()) + .or_insert_with(|| RespawnBackoff { + consecutive_failures: 0, + last_attempt: self.clock.now(), + last_attempt_succeeded: true, + }); + entry.last_attempt = self.clock.now(); + entry.last_attempt_succeeded = true; + drop(backoffs); + } + + /// Reconciles `id`'s backoff state against a *newly observed* death, + /// before deciding whether to back off this respawn attempt. + /// + /// A no-op unless the last recorded attempt "succeeded" ([`Self::record_respawn_success`]): + /// - If it has since survived at least [`RESPAWN_BACKOFF_BASE`], it is + /// treated as proven stable and its backoff state is cleared -- a + /// later, unrelated crash starts a fresh backoff sequence rather than + /// inheriting history from a long-resolved incident. + /// - Otherwise, the server died again before proving itself: this + /// counts as a failure (extending `consecutive_failures`) instead of + /// being silently forgotten. Without this, a server that starts, + /// completes `initialize`, and crashes again a moment later would + /// bypass backoff entirely -- every such cycle would look like a + /// fresh, unbacked-off start, spawning one child process per tool + /// call forever. + fn reconcile_respawn_stability(&self, id: &ServerId) { + let Some(entry) = lock_std(&self.respawn_backoffs).get(id).copied() else { + return; + }; + if !entry.last_attempt_succeeded { + return; + } + if self + .clock + .now() + .saturating_duration_since(entry.last_attempt) + >= RESPAWN_BACKOFF_BASE + { + lock_std(&self.respawn_backoffs).remove(id); + } else { + let mut backoffs = lock_std(&self.respawn_backoffs); + if let Some(current) = backoffs.get_mut(id) { + current.consecutive_failures = current.consecutive_failures.saturating_add(1); + current.last_attempt = self.clock.now(); + current.last_attempt_succeeded = false; + } + } + } + + /// Detect whether the server routed to `id` has crashed and, if so, + /// eagerly respawn and re-initialize it before returning. + /// + /// A no-op if `id` names a server that was never registered (routing + /// resolved to it, but it hasn't started yet or never will) or is still + /// alive. + /// + /// # Concurrency + /// + /// Multiple callers can race in here for the same `id` -- e.g. two tool + /// calls landing back-to-back right after the process dies. They + /// single-flight on [`Self::respawn_lock`]: the first to acquire it + /// performs the actual respawn; everyone else waits for that attempt to + /// finish (or fail), rechecks, and finds nothing left to do. + /// + /// Requests still parked in the dead client's `pending_requests` are + /// failed immediately via [`LspClient::fail_pending_requests`] instead + /// of being left to time out on their own. + /// + /// The respawned process has no memory of any document the old one had + /// open, so this also clears `document_tracker`'s per-server sync + /// history for `id` -- otherwise `ensure_open` would send `didChange` + /// instead of `didOpen` for a document the new process never saw. Any + /// diagnostics cached from the old connection are invalidated (see + /// [`Self::with_notification_cache`]) rather than left to be merged into + /// fresh pulls as if still current. + /// + /// Diagnostics and other push notifications from the new process itself + /// are drained and discarded rather than wired into the existing pump + /// task: the pump's remaining dependencies (resource subscriptions, peer + /// handle) live in `serve_with`'s scope, not the translator's, so + /// reconnecting live push for a respawned server is out of scope for + /// this fix -- it does not resume until the whole mcpls process + /// restarts, but stale data is no longer served as current. + /// + /// A crash-looping server (repeated respawn failures) backs off + /// exponentially (`RESPAWN_BACKOFF_BASE` up to `RESPAWN_BACKOFF_MAX`) + /// instead of retrying on every single tool call, each of which would + /// otherwise cost up to a full `timeout_seconds` inside `initialize`. + /// + /// # Errors + /// + /// Returns [`Error::ServerUnavailable`] if no respawn config was ever + /// registered for `id`, or if it is currently within its backoff + /// window. Returns whatever error `LspServer::spawn` produced (e.g. its + /// command is no longer on `PATH`, or `initialize` fails again) if an + /// actual respawn attempt failed. + pub(super) async fn respawn_if_dead(&self, id: &ServerId) -> Result<()> { + if !self.is_server_dead(id) { + return Ok(()); + } + + let lock = self.respawn_lock(id); + let _guard = lock.lock().await; + + // Another caller may have already respawned it while we waited. + if !self.is_server_dead(id) { + return Ok(()); + } + + self.reconcile_respawn_stability(id); + + if let Some(remaining) = self.respawn_backoff_remaining(id) { + tracing::warn!( + "LSP server '{id}' is crash-looping, backing off for {remaining:?} \ + before the next respawn attempt" + ); + return Err(Error::ServerUnavailable { + server_id: id.clone(), + reason: format!("crash-looping, retry in {remaining:?}"), + }); + } + + let Some(config) = lock_std(&self.server_configs).get(id).cloned() else { + return Err(Error::ServerUnavailable { + server_id: id.clone(), + reason: "no respawn config registered for this server".to_string(), + }); + }; + let language_id = config.server_config.language_id.clone(); + + tracing::warn!("LSP server '{id}' has crashed, respawning"); + let mut new_server = match LspServer::spawn(config).await { + Ok(server) => { + self.record_respawn_success(id); + server + } + Err(err) => { + self.record_respawn_failure(id); + return Err(err); + } + }; + let new_client = new_server.client().clone(); + let mut notification_rx = new_server.take_notification_rx(); + tokio::spawn(async move { while notification_rx.recv().await.is_some() {} }); + + let old_client = lock_std(&self.lsp_clients).insert(id.clone(), new_client); + let old_server = lock_std(&self.lsp_servers).insert(id.clone(), new_server); + drop(old_server); // dropped after the `lsp_servers` guard, not under it + + self.document_tracker.forget_server(id); + + // Only the diagnostics-route server for this language ever writes + // to the cache (see `diagnostics_pump`'s `caches_diagnostics` gate + // in the crate root) -- clearing a non-route server's synced URIs + // would delete the *healthy* diagnostics server's valid entries for + // those same files instead. And the route server's own cache + // entries are not limited to documents mcpls ever opened (it + // publishes workspace-wide, e.g. `cargo check` diagnostics), so a + // per-URI clear scoped to synced documents would miss most of what + // needs invalidating. + // + // `clear_server_diagnostics` scopes the clear to just this server's + // own entries, tracked via `NotificationCache`'s per-server + // ownership map (#266) -- a crashed rust-analyzer no longer wipes a + // healthy pyright's cached diagnostics for Python files in the same + // workspace. + // + // This clear is not atomic with the swap above: a caller that reads + // `lsp_clients` between the swap and this point sees the new client + // and could read a not-yet-cleared cache entry. In practice + // `handle_diagnostics` only reads the cache after a full LSP pull + // round-trip, so this window is negligible. + if self.is_diagnostics_route(&language_id, id) + && let Some(cache) = &self.notification_cache + { + cache.lock().await.clear_server_diagnostics(id); + } + + if let Some(old_client) = old_client { + old_client.fail_pending_requests().await; + } + + tracing::info!("LSP server '{id}' respawned successfully"); + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::bridge::translator::clock::{Clock, FakeClock}; + use crate::config::ServerId; + + #[test] + fn test_respawn_backoff_remaining_returns_none_once_delay_elapsed() { + let clock = Arc::new(FakeClock::new()); + let translator = Translator::new().with_clock(Arc::clone(&clock) as Arc); + let id = ServerId::from("rust"); + + translator.record_respawn_failure(&id); + assert!( + translator.respawn_backoff_remaining(&id).is_some(), + "immediately after a failure, the backoff window must still be active" + ); + + clock.advance(RESPAWN_BACKOFF_MAX); + assert!( + translator.respawn_backoff_remaining(&id).is_none(), + "once the fake clock has advanced past the computed delay, \ + the backoff window must be reported as elapsed" + ); + } + + #[test] + fn test_reconcile_respawn_stability_clears_backoff_after_proven_stable() { + let clock = Arc::new(FakeClock::new()); + let translator = Translator::new().with_clock(Arc::clone(&clock) as Arc); + let id = ServerId::from("rust"); + + translator.record_respawn_failure(&id); + translator.record_respawn_success(&id); + assert!( + lock_std(&translator.respawn_backoffs).contains_key(&id), + "a recorded success must still leave a backoff entry pending reconciliation" + ); + + clock.advance(RESPAWN_BACKOFF_BASE); + translator.reconcile_respawn_stability(&id); + + assert!( + !lock_std(&translator.respawn_backoffs).contains_key(&id), + "once proven stable (survived at least RESPAWN_BACKOFF_BASE), \ + the backoff entry must be cleared entirely" + ); + } + + // These three are pure logic (no process spawning), so they run on + // every platform rather than being swept under `respawn_tests`'s + // `#[cfg(unix)]` gate below -- otherwise Windows CI would have zero + // #249 coverage at all. + #[test] + fn test_respawn_lock_is_shared_across_lookups_for_same_id() { + let translator = Translator::new(); + let id = ServerId::from("rust"); + + let first = translator.respawn_lock(&id); + let second = translator.respawn_lock(&id); + + assert!( + Arc::ptr_eq(&first, &second), + "two lookups for the same id must return the same underlying lock, \ + otherwise concurrent respawns would not actually be serialized" + ); + } + + #[test] + fn test_respawn_lock_differs_across_ids() { + let translator = Translator::new(); + + let rust_lock = translator.respawn_lock(&ServerId::from("rust")); + let python_lock = translator.respawn_lock(&ServerId::from("python")); + + assert!(!Arc::ptr_eq(&rust_lock, &python_lock)); + } + + #[test] + fn test_is_server_dead_false_when_not_registered() { + let translator = Translator::new(); + assert!(!translator.is_server_dead(&ServerId::from("rust"))); + } + + // Gated `#[cfg(unix)]`: this module's fake-LSP-server test double is a + // hand-written `sh` script (POSIX parameter expansion, `printf`-framed + // LSP responses, file-based invocation counters), which has no + // equivalent on Windows. CI's "Test (unit)" job matrix includes + // `windows-latest`. + #[cfg(unix)] + mod respawn_tests { + use std::collections::HashMap; + use std::fs; + use std::path::{Path, PathBuf}; + + use tempfile::TempDir; + use tokio::time::Duration; + + use super::*; + use crate::config::{LspServerConfig, ToolKind, ToolRouter}; + use crate::lsp::ServerInitConfig; + + /// Writes a `sh` script that answers the LSP `initialize` handshake + /// with a canned response -- request id `1`, since a freshly spawned + /// `LspClient`'s request counter always starts there -- and then + /// exits shortly after, so `LspServer::spawn` succeeds but the + /// process is already dead moments later. Stands in for "the server + /// was alive, then crashed" without needing a real language server + /// binary. + /// + /// The brief sleep before exiting matters: `LspServer::spawn` sends + /// the `initialized` notification right after the `initialize` + /// response arrives, and without it the process can (racily) have + /// already exited by the time that notification is written to its + /// stdin, failing the spawn itself instead of the respawn this is + /// meant to seed. + fn write_crash_after_init_script(dir: &Path) -> PathBuf { + let script_path = dir.join("crash_after_init.sh"); + let body = r#"body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}' +printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body" +sleep 0.3 +"#; + fs::write(&script_path, body).unwrap(); + script_path + } + + /// Like [`write_crash_after_init_script`], but stays alive for + /// `sleep_secs` after responding instead of exiting immediately. + fn write_responder_script(dir: &Path, sleep_secs: u64) -> PathBuf { + let script_path = dir.join("responder.sh"); + let template = r#"body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}' +printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body" +sleep __SLEEP__ +"#; + fs::write( + &script_path, + template.replace("__SLEEP__", &sleep_secs.to_string()), + ) + .unwrap(); + script_path + } + + fn stub_server_config(id: &str, script: &Path) -> ServerInitConfig { + ServerInitConfig { + server_config: LspServerConfig { + language_id: id.to_string(), + command: "sh".to_string(), + args: vec![script.to_string_lossy().to_string()], + env: HashMap::new(), + file_patterns: vec![], + initialization_options: None, + timeout_seconds: 5, + request_timeout_seconds: 5, + heuristics: None, + name: Some(id.to_string()), + handles: None, + }, + workspace_roots: vec![], + initialization_options: None, + position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()], + notification_tx: None, + } + } + + /// Polls `is_server_dead` until it reports `true`, bounding the wait + /// so a broken script fails the test instead of hanging it. + async fn wait_until_dead(translator: &Translator, id: &ServerId) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if translator.is_server_dead(id) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("seed server never reported as exited"); + } + + #[tokio::test] + async fn test_respawn_if_dead_noop_when_server_alive() { + let dir = TempDir::new().unwrap(); + let script = write_responder_script(dir.path(), 1); + let id = ServerId::from("rust"); + let config = stub_server_config("rust", &script); + + let server = LspServer::spawn(config).await.unwrap(); + let translator = Translator::new(); + translator.register_client(id.clone(), server.client().clone()); + translator.register_server(id.clone(), server); + // Deliberately no `register_server_config`: if a respawn were + // (wrongly) attempted despite the server being alive, the + // missing config would surface as `Error::ServerUnavailable` + // instead of quietly succeeding -- so `Ok(())` here is proof + // the alive fast path skipped respawning entirely. + + assert!(translator.respawn_if_dead(&id).await.is_ok()); + } + + #[tokio::test] + async fn test_respawn_if_dead_errors_when_no_config_registered() { + let dir = TempDir::new().unwrap(); + let script = write_crash_after_init_script(dir.path()); + let id = ServerId::from("rust"); + let config = stub_server_config("rust", &script); + + let server = LspServer::spawn(config).await.unwrap(); + let translator = Translator::new(); + translator.register_client(id.clone(), server.client().clone()); + translator.register_server(id.clone(), server); + wait_until_dead(&translator, &id).await; + + let err = translator.respawn_if_dead(&id).await.unwrap_err(); + assert!( + matches!(err, Error::ServerUnavailable { .. }), + "got {err:?}" + ); + } + + #[tokio::test] + async fn test_respawn_if_dead_propagates_spawn_failure() { + let dir = TempDir::new().unwrap(); + let script = write_crash_after_init_script(dir.path()); + let id = ServerId::from("rust"); + let seed_config = stub_server_config("rust", &script); + + let server = LspServer::spawn(seed_config).await.unwrap(); + let translator = Translator::new(); + translator.register_client(id.clone(), server.client().clone()); + translator.register_server(id.clone(), server); + wait_until_dead(&translator, &id).await; + + let mut broken = stub_server_config("rust", &script); + broken.server_config.command = "nonexistent-lsp-cmd-xyz".to_string(); + translator.register_server_config(id.clone(), broken); + + let err = translator.respawn_if_dead(&id).await.unwrap_err(); + assert!( + matches!(err, Error::ServerSpawnFailed { .. }), + "got {err:?}" + ); + } + + /// #249: two concurrent tool calls that both observe the same dead + /// server must not each perform their own respawn -- only one + /// replacement process should ever be spawned, and both callers + /// must still resolve successfully. + /// + /// The fake server script counts every invocation and, on its + /// first run only, exits right after answering `initialize` + /// (simulating "was alive, then crashed"); every later invocation + /// answers and then sleeps, standing in for a healthy replacement. + /// If single-flighting were broken, both concurrent callers would + /// spawn their own replacement and the invocation count would be + /// 3 (seed + two independent respawns) instead of 2 (seed + one + /// shared respawn). + #[tokio::test] + async fn test_respawn_if_dead_single_flights_concurrent_callers() { + let dir = TempDir::new().unwrap(); + let marker = dir.path().join("marker"); + let counter = dir.path().join("invocations"); + let script_path = dir.path().join("flaky.sh"); + let template = r#"echo x >> "__COUNTER__" +if [ -f "__MARKER__" ]; then + body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}' + printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body" + sleep 1 +else + touch "__MARKER__" + body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}' + printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body" + sleep 0.3 +fi +"#; + let script_body = template + .replace("__COUNTER__", &counter.display().to_string()) + .replace("__MARKER__", &marker.display().to_string()); + fs::write(&script_path, script_body).unwrap(); + + let id = ServerId::from("rust"); + let config = stub_server_config("rust", &script_path); + + let seed = LspServer::spawn(config.clone()).await.unwrap(); + let translator = Arc::new(Translator::new()); + translator.register_client(id.clone(), seed.client().clone()); + translator.register_server(id.clone(), seed); + translator.register_server_config(id.clone(), config); + wait_until_dead(&translator, &id).await; + + let (t1, id1) = (Arc::clone(&translator), id.clone()); + let (t2, id2) = (Arc::clone(&translator), id.clone()); + let (r1, r2) = tokio::join!( + tokio::spawn(async move { t1.respawn_if_dead(&id1).await }), + tokio::spawn(async move { t2.respawn_if_dead(&id2).await }), + ); + assert!(r1.unwrap().is_ok()); + assert!(r2.unwrap().is_ok()); + + let invocations = fs::read_to_string(&counter).unwrap(); + assert_eq!( + invocations.lines().count(), + 2, + "expected exactly one seed spawn + one single-flighted \ + respawn, got:\n{invocations}" + ); + } + + /// #249 S2 regression: a second `respawn_if_dead` call within the + /// backoff window must fail fast via `Error::ServerUnavailable` + /// instead of repeating a real spawn attempt -- proven by the + /// *kind* of error changing between the two calls, not by timing: + /// the first call's failure is the genuine `LspServer::spawn` error + /// (`Error::ServerSpawnFailed`, from a command that does not + /// exist), and the second, immediately following, is the distinct + /// backoff error. + #[tokio::test] + async fn test_respawn_if_dead_backs_off_after_repeated_failure() { + let dir = TempDir::new().unwrap(); + let seed_script = write_crash_after_init_script(dir.path()); + let id = ServerId::from("rust"); + let seed_config = stub_server_config("rust", &seed_script); + + let seed = LspServer::spawn(seed_config).await.unwrap(); + let clock = Arc::new(FakeClock::new()); + let translator = Translator::new().with_clock(Arc::clone(&clock) as Arc); + translator.register_client(id.clone(), seed.client().clone()); + translator.register_server(id.clone(), seed); + wait_until_dead(&translator, &id).await; + + let mut broken = stub_server_config("rust", &seed_script); + broken.server_config.command = "nonexistent-lsp-cmd-xyz".to_string(); + translator.register_server_config(id.clone(), broken); + + let err1 = translator.respawn_if_dead(&id).await.unwrap_err(); + assert!( + matches!(err1, Error::ServerSpawnFailed { .. }), + "first attempt should be a real (failed) spawn, got {err1:?}" + ); + + let err2 = translator.respawn_if_dead(&id).await.unwrap_err(); + assert!( + matches!(err2, Error::ServerUnavailable { .. }), + "second call within the backoff window must fail fast \ + without attempting another real spawn, got {err2:?}" + ); + } + + /// #292 regression: once the backoff window has elapsed, the next + /// `respawn_if_dead` call must actually attempt a fresh respawn + /// instead of continuing to fail fast -- proven by swapping in a + /// config that succeeds and observing `Ok(())`, not merely a + /// different error kind. + #[tokio::test] + async fn test_respawn_if_dead_reattempts_once_backoff_window_elapses() { + let dir = TempDir::new().unwrap(); + let seed_script = write_crash_after_init_script(dir.path()); + let id = ServerId::from("rust"); + let seed_config = stub_server_config("rust", &seed_script); + + let seed = LspServer::spawn(seed_config).await.unwrap(); + let clock = Arc::new(FakeClock::new()); + let translator = Translator::new().with_clock(Arc::clone(&clock) as Arc); + translator.register_client(id.clone(), seed.client().clone()); + translator.register_server(id.clone(), seed); + wait_until_dead(&translator, &id).await; + + let mut broken = stub_server_config("rust", &seed_script); + broken.server_config.command = "nonexistent-lsp-cmd-xyz".to_string(); + translator.register_server_config(id.clone(), broken); + + let err1 = translator.respawn_if_dead(&id).await.unwrap_err(); + assert!( + matches!(err1, Error::ServerSpawnFailed { .. }), + "first attempt should be a real (failed) spawn, got {err1:?}" + ); + + let err2 = translator.respawn_if_dead(&id).await.unwrap_err(); + assert!( + matches!(err2, Error::ServerUnavailable { .. }), + "second call within the backoff window must still fail fast, got {err2:?}" + ); + + // Advance well past the computed backoff delay and swap in a + // config that will actually succeed this time. + clock.advance(RESPAWN_BACKOFF_MAX); + let working_script = write_crash_after_init_script(dir.path()); + translator + .register_server_config(id.clone(), stub_server_config("rust", &working_script)); + + let result = translator.respawn_if_dead(&id).await; + assert!( + result.is_ok(), + "once the backoff window has elapsed, respawn_if_dead must actually \ + reattempt a respawn instead of continuing to short-circuit, got {result:?}" + ); + } + + /// #249 R3 regression: a respawn that *succeeds* (completes + /// `initialize`) but dies again almost immediately must still + /// engage backoff -- this is the more realistic crash-loop shape + /// (start, initialize, then OOM-die a second later) than an + /// outright spawn failure, and without this fix every such cycle + /// looked like a fresh, unbacked-off start, spawning one child + /// process per tool call forever. + #[tokio::test] + async fn test_respawn_if_dead_backs_off_after_quick_recrash_following_success() { + let dir = TempDir::new().unwrap(); + let seed_script = write_crash_after_init_script(dir.path()); + let id = ServerId::from("rust"); + let seed_config = stub_server_config("rust", &seed_script); + + let seed = LspServer::spawn(seed_config).await.unwrap(); + let clock = Arc::new(FakeClock::new()); + let translator = Translator::new().with_clock(Arc::clone(&clock) as Arc); + translator.register_client(id.clone(), seed.client().clone()); + translator.register_server(id.clone(), seed); + wait_until_dead(&translator, &id).await; + + // Reuse the same crash-after-init script as the respawn target: + // every attempt completes `initialize` successfully, then dies + // ~0.3s later -- a post-init crash loop, not a spawn failure. + translator.register_server_config(id.clone(), stub_server_config("rust", &seed_script)); + + translator + .respawn_if_dead(&id) + .await + .expect("the replacement completes initialize, so this attempt succeeds"); + wait_until_dead(&translator, &id).await; + + let err = translator.respawn_if_dead(&id).await.unwrap_err(); + assert!( + matches!(err, Error::ServerUnavailable { .. }), + "a respawn that dies again within the stability window must \ + back off instead of being treated as a fresh attempt, got {err:?}" + ); + } + + /// #249 C1 regression: respawning the *diagnostics-route* server + /// for a language must invalidate that server's diagnostics cache + /// entries, rather than leaving stale entries to be merged into + /// fresh pull results as if still current -- the crashed process's + /// pump is gone and will never update or clear them itself. + /// + /// Covers the "under-clear" failure mode a scoped-to-synced-URIs + /// clear has: a real diagnostics-route server (e.g. rust-analyzer) + /// publishes workspace-wide (`cargo check` results for files never + /// opened through mcpls), so `never_opened_uri` below stands in for + /// an entry that must still be cleared despite never having gone + /// through `ensure_open`. + /// + /// #266 S2 regression (over-clear direction, multi-language case): + /// `other_language_uri` is owned by a *different* diagnostics-route + /// server (e.g. pyright for Python, in the same workspace as the + /// rust-analyzer under test here) and must survive -- `clear_server_diagnostics` + /// replaced a workspace-wide `clear_all_diagnostics` that used to + /// wipe every language's cache on any single server's respawn. + #[tokio::test] + async fn test_respawn_if_dead_clears_diagnostics_cache_when_diagnostics_route() { + let dir = TempDir::new().unwrap(); + let seed_script = write_crash_after_init_script(dir.path()); + let id = ServerId::from("rust"); + let seed_config = stub_server_config("rust", &seed_script); + + let seed = LspServer::spawn(seed_config).await.unwrap(); + + let cache = Arc::new(Mutex::new(crate::bridge::NotificationCache::new())); + let translator = Translator::new() + .with_router(ToolRouter::catch_all([(id.clone(), "rust".to_string())])) + .with_notification_cache(Arc::clone(&cache)); + translator.register_client(id.clone(), seed.client().clone()); + translator.register_server(id.clone(), seed); + + let synced_uri: lsp_types::Uri = "file:///workspace/opened.rs".parse().unwrap(); + let never_opened_uri: lsp_types::Uri = + "file:///workspace/never_opened.rs".parse().unwrap(); + let other_language_uri: lsp_types::Uri = "file:///workspace/main.py".parse().unwrap(); + cache + .lock() + .await + .store_diagnostics(&id, &synced_uri, None, vec![]); + cache + .lock() + .await + .store_diagnostics(&id, &never_opened_uri, None, vec![]); + cache.lock().await.store_diagnostics( + &ServerId::from("python"), + &other_language_uri, + None, + vec![], + ); + + wait_until_dead(&translator, &id).await; + + let respawn_script = write_responder_script(dir.path(), 1); + translator + .register_server_config(id.clone(), stub_server_config("rust", &respawn_script)); + + translator.respawn_if_dead(&id).await.unwrap(); + + let guard = cache.lock().await; + assert!( + guard.get_diagnostics(synced_uri.as_str()).is_none(), + "diagnostics attributed to the crashed connection must be \ + invalidated on respawn, not served as current" + ); + assert!( + guard.get_diagnostics(never_opened_uri.as_str()).is_none(), + "workspace-wide diagnostics for a file mcpls never opened \ + must also be invalidated, not just synced documents" + ); + assert!( + guard.get_diagnostics(other_language_uri.as_str()).is_some(), + "a different diagnostics-route server's entries must survive \ + an unrelated server's respawn-triggered cache clear" + ); + drop(guard); + } + + /// #249 C1 regression (over-clear direction): respawning a server + /// that is *not* the diagnostics route for its language must not + /// touch the cache at all -- otherwise a crashed hover-only server + /// would wipe out a healthy, still-running diagnostics server's + /// valid entries for the same files. + #[tokio::test] + async fn test_respawn_if_dead_does_not_clear_cache_when_not_diagnostics_route() { + use crate::config::LspServerConfig; + + let dir = TempDir::new().unwrap(); + let seed_script = write_crash_after_init_script(dir.path()); + let hover_id = ServerId::from("hover-only"); + let hover_seed_config = stub_server_config("hover-only", &seed_script); + + let seed = LspServer::spawn(hover_seed_config).await.unwrap(); + + // `hover_id` handles only Hover; a separate (never-registered + // here, purely routing-table) server is the catch-all and thus + // the diagnostics route. + let configs = [ + LspServerConfig { + language_id: "rust".to_string(), + command: "sh".to_string(), + args: vec![], + env: HashMap::new(), + file_patterns: vec![], + initialization_options: None, + timeout_seconds: 5, + request_timeout_seconds: 5, + heuristics: None, + name: Some("hover-only".to_string()), + handles: Some(vec![ToolKind::Hover]), + }, + LspServerConfig { + language_id: "rust".to_string(), + command: "sh".to_string(), + args: vec![], + env: HashMap::new(), + file_patterns: vec![], + initialization_options: None, + timeout_seconds: 5, + request_timeout_seconds: 5, + heuristics: None, + name: Some("diag-catchall".to_string()), + handles: None, + }, + ]; + let router = ToolRouter::from_configs(configs.iter()).unwrap(); + + let cache = Arc::new(Mutex::new(crate::bridge::NotificationCache::new())); + let translator = Translator::new() + .with_router(router) + .with_notification_cache(Arc::clone(&cache)); + translator.register_client(hover_id.clone(), seed.client().clone()); + translator.register_server(hover_id.clone(), seed); + + let owned_by_healthy_server: lsp_types::Uri = + "file:///workspace/still_healthy.rs".parse().unwrap(); + cache + .lock() + .await + .store_diagnostics(&hover_id, &owned_by_healthy_server, None, vec![]); + + wait_until_dead(&translator, &hover_id).await; + + let respawn_script = write_responder_script(dir.path(), 1); + // `language_id` must match the router's ("rust"), not the + // routing identity ("hover-only"): otherwise `is_diagnostics_route` + // returns `false` because of a language mismatch rather than + // because of the `handles: Some([Hover])` restriction this test + // means to exercise, which would pass for the wrong reason. + let mut respawn_config = stub_server_config("hover-only", &respawn_script); + respawn_config.server_config.language_id = "rust".to_string(); + translator.register_server_config(hover_id.clone(), respawn_config); + + translator.respawn_if_dead(&hover_id).await.unwrap(); + + assert!( + cache + .lock() + .await + .get_diagnostics(owned_by_healthy_server.as_str()) + .is_some(), + "respawning a non-diagnostics-route server must not clear \ + the diagnostics-route server's cache entries" + ); + } + + /// #249 test-gap closure: proves `resolve_client_for_file`'s + /// dead-server branch is actually reached through the shared + /// entry point every public tool handler (`handle_hover`, + /// `handle_definition`, ...) funnels through -- not just through + /// the private `respawn_if_dead`/`is_server_dead` calls the other + /// tests in this module make directly. + #[tokio::test] + async fn test_prepare_document_respawns_dead_server_through_shared_entry_point() { + let dir = TempDir::new().unwrap(); + let workspace = dir.path(); + let file_path = workspace.join("main.rs"); + fs::write(&file_path, "fn main() {}").unwrap(); + + let seed_script = write_crash_after_init_script(dir.path()); + let id = ServerId::from("rust"); + let seed_config = stub_server_config("rust", &seed_script); + + let seed = LspServer::spawn(seed_config).await.unwrap(); + let mut translator = Translator::new() + .with_router(ToolRouter::catch_all([(id.clone(), "rust".to_string())])) + .with_extensions(HashMap::from([("rs".to_string(), "rust".to_string())])); + translator.set_workspace_roots(vec![workspace.to_path_buf()]); + translator.register_client(id.clone(), seed.client().clone()); + translator.register_server(id.clone(), seed); + wait_until_dead(&translator, &id).await; + + let respawn_script = write_responder_script(dir.path(), 1); + translator + .register_server_config(id.clone(), stub_server_config("rust", &respawn_script)); + + let result = translator + .prepare_document(&file_path.to_string_lossy(), ToolKind::Hover) + .await; + assert!(result.is_ok(), "got {result:?}"); + + assert!( + !translator.is_server_dead(&id), + "the respawned replacement should be alive" + ); + } + } +} diff --git a/crates/mcpls-core/src/bridge/translator/routing.rs b/crates/mcpls-core/src/bridge/translator/routing.rs new file mode 100644 index 00000000..245a6569 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/routing.rs @@ -0,0 +1,1475 @@ +//! Client/server routing, document-open preparation, and capability gating +//! shared by every LSP-round-trip tool-call handler. + +use std::path::{Path, PathBuf}; + +use super::Translator; +use crate::bridge::lock_std; +use crate::bridge::state::detect_language; +use crate::config::{ServerId, ToolKind, base_language_id}; +use crate::error::{Error, Result}; +use crate::lsp::LspClient; + +/// Maximum allowed position value for validation. +pub(super) const MAX_POSITION_VALUE: u32 = 1_000_000; + +/// Maximum allowed range size in lines. +pub(super) const MAX_RANGE_LINES: u32 = 10_000; + +/// Validate that `path` is within one of `workspace_roots`. +/// +/// Free function (rather than a `Translator` method) so callers that only need +/// path validation — e.g. cache-only MCP handlers — can validate against a +/// cloned, lock-free snapshot of the workspace roots instead of locking the +/// full `Arc>`, which may be held elsewhere across a slow +/// in-flight LSP round-trip. +/// +/// # Errors +/// +/// Returns `Error::PathOutsideWorkspace` if the path is outside all workspace roots. +pub fn validate_path_against_roots(path: &Path, workspace_roots: &[PathBuf]) -> Result { + let canonical = path.canonicalize().map_err(|e| Error::FileIo { + path: path.to_path_buf(), + source: e, + })?; + + // If no workspace roots configured, allow any path (backward compatibility) + if workspace_roots.is_empty() { + return Ok(canonical); + } + + // Check if path is within any workspace root + for root in workspace_roots { + if let Ok(canonical_root) = root.canonicalize() + && canonical.starts_with(&canonical_root) + { + return Ok(canonical); + } + } + + Err(Error::PathOutsideWorkspace(path.to_path_buf())) +} + +impl Translator { + /// Validate that a path is within allowed workspace boundaries. + /// + /// # Errors + /// + /// Returns `Error::PathOutsideWorkspace` if the path is outside all workspace roots. + pub(crate) fn validate_path(&self, path: &Path) -> Result { + validate_path_against_roots(path, &self.workspace_roots) + } + + /// Resolve the client and routing identity for `path`/`tool`, giving the + /// resolved server a chance to be respawned first if its process has + /// died. + /// + /// Thin async wrapper around [`Self::get_client_for_file`] (kept + /// synchronous so its existing unit tests don't need a runtime): this is + /// the entry point async handlers call instead, so a dead server is + /// transparently replaced before its stale client is handed back. + pub(super) async fn resolve_client_for_file( + &self, + path: &Path, + tool: ToolKind, + ) -> Result<(ServerId, LspClient)> { + let (id, client) = self.get_client_for_file(path, tool)?; + self.respawn_if_dead(&id).await?; + let client = lock_std(&self.lsp_clients) + .get(&id) + .cloned() + .unwrap_or(client); + Ok((id, client)) + } + + /// Resolve the server that should handle `tool` for the file at `path`, + /// returning both its routing identity and a cloned client. + /// + /// Tries the file's detected language first, then (if that has no route) + /// its React base language (`.tsx` falling back from `typescriptreact` to + /// `typescript`, and similarly for `.jsx`) -- in that order, so an + /// explicit `typescriptreact` server still wins over the `typescript` + /// fallback when both are configured. + /// + /// Locks `router`, `lsp_clients`, and (on the not-yet-registered path) + /// `expected_servers` only for their respective lookups — every guard is + /// dropped before this method returns. + pub(super) fn get_client_for_file( + &self, + path: &Path, + tool: ToolKind, + ) -> Result<(ServerId, LspClient)> { + let language = detect_language(path, &self.extension_map); + let mut candidates: Vec<&str> = vec![language.as_str()]; + if let Some(base) = base_language_id(&language) { + candidates.push(base); + } + + for lang in &candidates { + let resolved = lock_std(&self.router).resolve(lang, tool).cloned(); + let Some(id) = resolved else { continue }; + + let found = lock_std(&self.lsp_clients).get(&id).cloned(); + if let Some(client) = found { + return Ok((id, client)); + } + // A route naming a server that is still initializing (e.g. a + // large Unity solution loading via OmniSharp) -- tell the caller + // to wait and retry rather than implying no server is configured. + if lock_std(&self.expected_servers).contains(&id) { + return Err(Error::ServerInitializing { server_id: id }); + } + // Unreachable once registration has rebound the router + // (`Translator::rebind_router`) -- a route can only name a + // registered server after that point. Logged rather than + // `debug_assert!`-panicked: this method is reachable by any + // library consumer calling `with_router` without registering + // matching clients, not just internal misuse. + tracing::error!( + "router route names server '{id}' for tool '{tool}' that is neither \ + registered nor expected" + ); + return Err(Error::NoServerForTool { + language_id: (*lang).to_string(), + tool, + }); + } + + let has_language = { + let router = lock_std(&self.router); + candidates.iter().any(|lang| router.has_language(lang)) + }; + if has_language { + Err(Error::NoServerForTool { + language_id: language, + tool, + }) + } else { + Err(Error::NoServerForLanguage(language)) + } + } + + /// Validate `file_path`, then resolve its routed client via + /// [`Self::resolve_client_for_file`] (respawn-aware), without opening + /// the document. + /// + /// Split out from [`Self::prepare_document`] so [`Self::prepare_gated_document`] + /// can check the routed server's capabilities *before* `ensure_open` sends + /// `textDocument/didOpen` -- a server rejected by the gate should never + /// observe an open notification for a request it can't service. Also + /// used directly by handlers that already have a resolved `PathBuf` + /// (from `parse_file_uri`) but still need capability gating, e.g. + /// `handle_incoming_calls`/`handle_outgoing_calls`. + async fn resolve_validated_client_for_file( + &self, + file_path: &str, + tool: ToolKind, + ) -> Result<(ServerId, LspClient, PathBuf)> { + let path = PathBuf::from(file_path); + let validated_path = self.validate_path(&path)?; + let (server_id, client) = self.resolve_client_for_file(&validated_path, tool).await?; + Ok((server_id, client, validated_path)) + } + + /// Resolve the LSP client and ensure the document is open. + /// + /// This is the "prepare" phase shared by every LSP-round-trip handler: + /// it validates the path, selects the client via + /// [`Self::resolve_validated_client_for_file`] (respawn-aware), and + /// calls `ensure_open`, which locks the document tracker's state only + /// for the given path. The returned client and URI are owned values, so + /// the caller can issue the actual LSP request (the "execute" phase) + /// without holding any lock across the network round trip. + /// + /// `ensure_open`'s own awaits (a `stat`, optionally a re-read of the + /// file, and the `textDocument/didOpen`/`didChange` notify) run under a + /// lock scoped to `validated_path` alone — see [`DocumentTracker::ensure_open`] + /// — so a slow or wedged language server cannot stall `prepare_document` + /// calls for unrelated files. (Per-tool routing, #228, means the same + /// file can be routed to more than one server; a wedged server-A notify + /// still holds this path's lock and can therefore delay a healthy + /// server-B call for that *same* file.) + pub(super) async fn prepare_document( + &self, + file_path: &str, + tool: ToolKind, + ) -> Result<(ServerId, LspClient, lsp_types::Uri)> { + let (server_id, client, validated_path) = self + .resolve_validated_client_for_file(file_path, tool) + .await?; + let uri = self + .document_tracker + .ensure_open(&validated_path, &server_id, &client) + .await?; + Ok((server_id, client, uri)) + } + + /// Like [`Self::prepare_document`], but checks `capability` against the + /// routed server's `ServerCapabilities` *before* opening the document -- + /// see [`Self::resolve_client_for_file`]'s doc comment for why the + /// ordering matters. + /// + /// # Errors + /// + /// Returns [`Error::CapabilityNotSupported`] if the routed server's + /// `ServerCapabilities` explicitly does not advertise `capability`. + pub(super) async fn prepare_gated_document( + &self, + file_path: &str, + tool: ToolKind, + capability: &'static str, + supported: impl FnOnce(&lsp_types::ServerCapabilities) -> bool, + ) -> Result<(ServerId, LspClient, lsp_types::Uri)> { + let (server_id, client, validated_path) = self + .resolve_validated_client_for_file(file_path, tool) + .await?; + self.require_capability(&server_id, capability, supported)?; + let uri = self + .document_tracker + .ensure_open(&validated_path, &server_id, &client) + .await?; + Ok((server_id, client, uri)) + } + + /// Verify the routed server advertises support for a capability before + /// dispatching a capability-gated LSP request. + /// + /// Production always registers an [`LspServer`] alongside its + /// [`LspClient`] in the same `register_servers` step (see `lib.rs`), so in + /// practice a registered client always has known capabilities. If no + /// `LspServer` is registered for `server_id` regardless -- a client + /// registered without its server, which only happens in tests, or a + /// narrow window during registration where the two maps are inserted + /// under separate locks -- the capability is assumed supported rather + /// than blocking the request: this mirrors the graceful-degradation + /// stance used elsewhere in `Translator` when capability information is + /// unavailable rather than known-absent. + /// + /// Note: this checks the `ServerCapabilities` snapshot captured at + /// `initialize` time. A server that advertises a capability later via + /// `client/registerCapability` (dynamic registration) is not reflected + /// here and will be incorrectly rejected; mcpls does not currently apply + /// dynamic registrations back onto the stored capabilities. + /// + /// # Errors + /// + /// Returns [`Error::CapabilityNotSupported`] if the registered server's + /// `ServerCapabilities` explicitly does not advertise `capability`. + pub(super) fn require_capability( + &self, + server_id: &ServerId, + capability: &'static str, + supported: impl FnOnce(&lsp_types::ServerCapabilities) -> bool, + ) -> Result<()> { + let servers = lock_std(&self.lsp_servers); + match servers.get(server_id) { + Some(server) if !supported(server.capabilities()) => { + Err(Error::CapabilityNotSupported { + server_id: server_id.clone(), + capability, + }) + } + _ => Ok(()), + } + } + + /// Parse and validate a file URI, returning the validated path. + /// + /// # Errors + /// + /// Returns an error if: + /// - The URI doesn't have a file:// scheme + /// - The path is outside workspace boundaries + pub(super) fn parse_file_uri(&self, uri: &lsp_types::Uri) -> Result { + let uri_str = uri.as_str(); + + // Validate file:// scheme + if !uri_str.starts_with("file://") { + return Err(Error::InvalidToolParams(format!( + "Invalid URI scheme, expected file:// but got: {uri_str}" + ))); + } + + // Extract path after file:// + let path_str = &uri_str["file://".len()..]; + + // Handle Windows paths: file:///C:/path -> /C:/path -> C:/path + // On Windows, URIs have format file:///C:/path, so we need to strip the leading / + #[cfg(windows)] + let path_str = if path_str.len() >= 3 + && path_str.starts_with('/') + && path_str.chars().nth(2) == Some(':') + { + &path_str[1..] + } else { + path_str + }; + + let path = PathBuf::from(path_str); + + // Validate path is within workspace + self.validate_path(&path) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::collections::{HashMap, HashSet}; + use std::fs; + use std::sync::Arc; + + use tempfile::TempDir; + use tokio::io::BufReader; + use tokio::sync::Mutex; + use tokio::time::{Duration, timeout}; + use url::Url; + + use super::*; + use crate::bridge::NotificationCache; + use crate::bridge::translator::testing::*; + use crate::config::{LspServerConfig, ToolRouter}; + use crate::error::Error; + use crate::lsp::LspServer; + + type JsonValue = serde_json::Value; + + #[test] + fn test_get_client_for_file_server_initializing_when_expected() { + // A configured/applicable language whose LSP client has not registered + // yet (large solution still loading via OmniSharp) must surface + // ServerInitializing — "wait and retry" — not NoServerForLanguage. + let path = PathBuf::from("/ws/Assets/Scripts/Player.cs"); + let lang = detect_language(&path, &HashMap::new()); + let id = ServerId::from(lang.clone()); + + let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)])); + let mut expected = HashSet::new(); + expected.insert(id.clone()); + translator.set_expected_servers(expected); + + let err = translator + .get_client_for_file(&path, ToolKind::Hover) + .unwrap_err(); + assert!(matches!(err, Error::ServerInitializing { server_id } if server_id == id)); + } + + #[test] + fn test_get_client_for_file_no_server_when_not_expected() { + // When no route is configured for the language at all, the error + // stays NoServerForLanguage. + let translator = Translator::new(); + let path = PathBuf::from("/ws/Assets/Scripts/Player.cs"); + let lang = detect_language(&path, &translator.extension_map); + + let err = translator + .get_client_for_file(&path, ToolKind::Hover) + .unwrap_err(); + assert!(matches!(err, Error::NoServerForLanguage(ref l) if *l == lang)); + } + + #[test] + fn test_validate_path_no_workspace_roots() { + let translator = Translator::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + // With no workspace roots, any valid path should be accepted + let result = translator.validate_path(&test_file); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_path_within_workspace() { + let mut translator = Translator::new(); + let temp_dir = TempDir::new().unwrap(); + let workspace_root = temp_dir.path().to_path_buf(); + translator.set_workspace_roots(vec![workspace_root]); + + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let result = translator.validate_path(&test_file); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_path_outside_workspace() { + let mut translator = Translator::new(); + let temp_dir1 = TempDir::new().unwrap(); + let temp_dir2 = TempDir::new().unwrap(); + + // Set workspace root to temp_dir1 + translator.set_workspace_roots(vec![temp_dir1.path().to_path_buf()]); + + // Create file in temp_dir2 (outside workspace) + let test_file = temp_dir2.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + let result = translator.validate_path(&test_file); + assert!(matches!(result, Err(Error::PathOutsideWorkspace(_)))); + } + + #[tokio::test] + async fn test_parse_file_uri_invalid_scheme() { + let translator = Translator::new(); + let uri: lsp_types::Uri = "http://example.com/file.rs".parse().unwrap(); + let result = translator.parse_file_uri(&uri); + assert!(matches!(result, Err(Error::InvalidToolParams(_)))); + } + + #[tokio::test] + async fn test_parse_file_uri_valid_scheme() { + let translator = Translator::new(); + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.rs"); + fs::write(&test_file, "fn main() {}").unwrap(); + + // Use url crate for cross-platform file URI creation + let file_url = Url::from_file_path(&test_file).unwrap(); + let uri: lsp_types::Uri = file_url.as_str().parse().unwrap(); + let result = translator.parse_file_uri(&uri); + assert!(result.is_ok()); + } + + #[test] + fn test_get_client_for_file_uses_custom_extension() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("script.nu"); + fs::write(&test_file, "echo hello").unwrap(); + + let mut extension_map = HashMap::new(); + extension_map.insert("nu".to_string(), "nushell".to_string()); + + let translator = Translator::new().with_extensions(extension_map); + + let result = translator.get_client_for_file(&test_file, ToolKind::Hover); + + assert!(result.is_err()); + if let Err(Error::NoServerForLanguage(lang)) = result { + assert_eq!(lang, "nushell"); + } else { + panic!("Expected NoServerForLanguage(nushell) error"); + } + } + + #[test] + fn test_get_client_for_file_falls_back_to_default() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("unknown.xyz"); + fs::write(&test_file, "content").unwrap(); + + let mut extension_map = HashMap::new(); + extension_map.insert("rs".to_string(), "rust".to_string()); + + let translator = Translator::new().with_extensions(extension_map); + + let result = translator.get_client_for_file(&test_file, ToolKind::Hover); + + assert!(result.is_err()); + if let Err(Error::NoServerForLanguage(lang)) = result { + assert_eq!(lang, "plaintext"); + } else { + panic!("Expected NoServerForLanguage(plaintext) error"); + } + } + + #[test] + fn test_get_client_for_file_routes_tsx_to_typescript_server() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("component.tsx"); + fs::write(&test_file, "export const Component = () =>
").unwrap(); + + let mut extension_map = HashMap::new(); + extension_map.insert("tsx".to_string(), "typescriptreact".to_string()); + + let translator = Translator::new() + .with_extensions(extension_map) + .with_router(ToolRouter::catch_all([( + ServerId::from("typescript"), + "typescript".to_string(), + )])); + translator.register_client( + "typescript".to_string(), + LspClient::new(crate::config::LspServerConfig::typescript()), + ); + + let (_id, client) = translator + .get_client_for_file(&test_file, ToolKind::Hover) + .unwrap(); + assert_eq!(client.language_id(), "typescript"); + } + + #[test] + fn test_get_client_for_file_prefers_exact_react_server() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("component.tsx"); + fs::write(&test_file, "export const Component = () =>
").unwrap(); + + let mut extension_map = HashMap::new(); + extension_map.insert("tsx".to_string(), "typescriptreact".to_string()); + + let typescript_react_config = crate::config::LspServerConfig { + language_id: "typescriptreact".to_string(), + command: "typescript-language-server".to_string(), + args: vec!["--stdio".to_string()], + env: HashMap::new(), + file_patterns: vec!["**/*.tsx".to_string()], + initialization_options: None, + timeout_seconds: 30, + request_timeout_seconds: 30, + heuristics: None, + name: None, + handles: None, + }; + + let translator = Translator::new() + .with_extensions(extension_map) + .with_router(ToolRouter::catch_all([ + (ServerId::from("typescript"), "typescript".to_string()), + ( + ServerId::from("typescriptreact"), + "typescriptreact".to_string(), + ), + ])); + translator.register_client( + "typescript".to_string(), + LspClient::new(crate::config::LspServerConfig::typescript()), + ); + translator.register_client( + "typescriptreact".to_string(), + LspClient::new(typescript_react_config), + ); + + let (_id, client) = translator + .get_client_for_file(&test_file, ToolKind::Hover) + .unwrap(); + assert_eq!(client.language_id(), "typescriptreact"); + } + + #[test] + fn test_get_client_for_file_routes_jsx_to_javascript_server() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("component.jsx"); + fs::write(&test_file, "export const Component = () =>
").unwrap(); + + let mut extension_map = HashMap::new(); + extension_map.insert("jsx".to_string(), "javascriptreact".to_string()); + + let javascript_config = crate::config::LspServerConfig { + language_id: "javascript".to_string(), + command: "typescript-language-server".to_string(), + args: vec!["--stdio".to_string()], + env: HashMap::new(), + file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()], + initialization_options: None, + timeout_seconds: 30, + request_timeout_seconds: 30, + heuristics: None, + name: None, + handles: None, + }; + let translator = Translator::new() + .with_extensions(extension_map) + .with_router(ToolRouter::catch_all([( + ServerId::from("javascript"), + "javascript".to_string(), + )])); + translator.register_client("javascript".to_string(), LspClient::new(javascript_config)); + + let (_id, client) = translator + .get_client_for_file(&test_file, ToolKind::Hover) + .unwrap(); + assert_eq!(client.language_id(), "javascript"); + } + + #[tokio::test] + async fn test_serve_initializes_translator_with_extensions() { + use crate::bridge::state::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE}; + use crate::config::{LanguageExtensionMapping, WorkspaceConfig}; + + let language_extensions = vec![ + LanguageExtensionMapping { + extensions: vec!["nu".to_string()], + language_id: "nushell".to_string(), + }, + LanguageExtensionMapping { + extensions: vec!["rs".to_string()], + language_id: "rust".to_string(), + }, + ]; + + let config = crate::config::ServerConfig { + workspace: WorkspaceConfig { + roots: vec![PathBuf::from("/tmp/test-workspace")], + position_encodings: vec!["utf-8".to_string()], + language_extensions: language_extensions.clone(), + heuristics_max_depth: 10, + max_documents: DEFAULT_MAX_DOCUMENTS, + max_file_size: DEFAULT_MAX_FILE_SIZE, + }, + lsp_servers: vec![], + project_config_ignored: false, + }; + + let extension_map = config.build_effective_extension_map(); + assert_eq!(extension_map.get("nu"), Some(&"nushell".to_string())); + assert_eq!(extension_map.get("rs"), Some(&"rust".to_string())); + + // serve() starts in protocol-only mode when no LSP servers are configured; + // it may return a transport error but must not return NoServersAvailable. + let result = crate::serve(config).await; + if let Err(ref err) = result { + assert!( + !matches!(err, crate::error::Error::NoServersAvailable(_)), + "serve() must not return NoServersAvailable for empty lsp_servers config" + ); + } + } + + #[tokio::test] + async fn test_concurrent_handlers_on_different_files_do_not_serialize() { + // Before the fix, Translator was shared as Arc>, so + // handling one LSP request held that lock across the `.await` on the + // response -- blocking every other tool call, even for a completely + // different file and language server, until the first request + // completed or timed out (up to 30s). With interior mutability, a + // concurrent call for a different file must complete without waiting + // on an unrelated in-flight request. + let dir = TempDir::new().unwrap(); + let mut extensions = HashMap::new(); + extensions.insert("aa".to_string(), "lang_a".to_string()); + extensions.insert("bb".to_string(), "lang_b".to_string()); + + let mut translator = + Translator::new() + .with_extensions(extensions) + .with_router(ToolRouter::catch_all([ + (ServerId::from("lang_a"), "lang_a".to_string()), + (ServerId::from("lang_b"), "lang_b".to_string()), + ])); + translator.set_workspace_roots(vec![dir.path().to_path_buf()]); + + let (client_a, mut server_a) = fake_lsp_client(); + let (client_b, mut server_b) = fake_lsp_client(); + translator.register_client("lang_a".to_string(), client_a); + translator.register_client("lang_b".to_string(), client_b); + + let path_a = dir.path().join("file.aa"); + let path_b = dir.path().join("file.bb"); + fs::write(&path_a, "content a").unwrap(); + fs::write(&path_b, "content b").unwrap(); + + let translator = Arc::new(translator); + + // `server_a` is never given a response, simulating a slow server. If + // any translator-held lock still spanned the LSP round trip, this + // task blocking forever would also block the "fast" call below. + let slow = { + let translator = Arc::clone(&translator); + let path = path_a.to_string_lossy().to_string(); + tokio::spawn(async move { translator.handle_hover(path, 1, 1).await }) + }; + + // Wait for the slow task to actually reach its LSP request (i.e. the + // request bytes were written to the wire) before treating it as + // "in-flight", so the test doesn't race the spawned task's startup. + let mut wire_a = BufReader::new(&mut server_a.write_stdout); + let opened_a = read_framed_message(&mut wire_a).await; + assert_eq!(opened_a["method"], "textDocument/didOpen"); + let hover_request_a = read_framed_message(&mut wire_a).await; + assert_eq!(hover_request_a["method"], "textDocument/hover"); + + // The fast path: a concurrent call for a different file/server. + let fast = { + let translator = Arc::clone(&translator); + let path = path_b.to_string_lossy().to_string(); + tokio::spawn(async move { translator.handle_hover(path, 1, 1).await }) + }; + + let mut wire_b = BufReader::new(&mut server_b.write_stdout); + let opened_b = read_framed_message(&mut wire_b).await; + assert_eq!(opened_b["method"], "textDocument/didOpen"); + let hover_request_b = read_framed_message(&mut wire_b).await; + assert_eq!(hover_request_b["method"], "textDocument/hover"); + write_response( + &mut server_b.read_half_stdin, + &hover_request_b["id"], + JsonValue::Null, + ) + .await; + + let fast_result = timeout(Duration::from_secs(2), fast) + .await + .expect("fast call must not be blocked by the slow in-flight request") + .unwrap(); + assert!(fast_result.is_ok()); + + assert!( + !slow.is_finished(), + "slow call should still be waiting on its (never-sent) response" + ); + slow.abort(); + } + + #[tokio::test] + async fn test_concurrent_ensure_open_same_path_sends_single_did_open() { + // Regression test: concurrent handler calls for the SAME path must + // serialize on that path's `ensure_open` lock (see `DocumentTracker::lock_path`) + // so they can't both observe "not open yet" and both send didOpen. + let dir = TempDir::new().unwrap(); + let mut extensions = HashMap::new(); + extensions.insert("aa".to_string(), "lang_a".to_string()); + + let mut translator = + Translator::new() + .with_extensions(extensions) + .with_router(ToolRouter::catch_all([( + ServerId::from("lang_a"), + "lang_a".to_string(), + )])); + translator.set_workspace_roots(vec![dir.path().to_path_buf()]); + + let (client, mut server) = fake_lsp_client(); + translator.register_client("lang_a".to_string(), client); + + let path = dir.path().join("file.aa"); + fs::write(&path, "content").unwrap(); + + let concurrent_calls = 4; + + let translator = Arc::new(translator); + let path_str = path.to_string_lossy().to_string(); + + let handles: Vec<_> = (0..concurrent_calls) + .map(|_| { + let translator = Arc::clone(&translator); + let path_str = path_str.clone(); + tokio::spawn(async move { translator.handle_hover(path_str, 1, 1).await }) + }) + .collect(); + + let mut wire = BufReader::new(&mut server.write_stdout); + let opened = read_framed_message(&mut wire).await; + assert_eq!(opened["method"], "textDocument/didOpen"); + + for _ in 0..concurrent_calls { + let request = read_framed_message(&mut wire).await; + assert_eq!( + request["method"], "textDocument/hover", + "no second didOpen must appear ahead of the hover requests" + ); + write_response(&mut server.read_half_stdin, &request["id"], JsonValue::Null).await; + } + + for handle in handles { + let result = timeout(Duration::from_secs(2), handle) + .await + .expect("handler call should not hang") + .unwrap(); + assert!(result.is_ok()); + } + } + + /// #174 §12's own headline dispatch scenario: "pyright/pylsp fixture -- + /// hover -> pyright, diagnostics -> pylsp, rename (unclaimed) -> + /// `NoServerForTool`", exercised through `Translator`'s public handlers + /// end to end rather than through `ToolRouter`'s unit tests alone. + #[tokio::test] + async fn test_dispatch_routes_hover_and_diagnostics_to_different_servers() { + let dir = TempDir::new().unwrap(); + let mut extensions = HashMap::new(); + extensions.insert("py".to_string(), "python".to_string()); + + let pyright_id = ServerId::from("pyright"); + let pylsp_id = ServerId::from("pylsp"); + let configs = vec![ + LspServerConfig { + language_id: "python".to_string(), + command: "pyright-langserver".to_string(), + args: vec![], + env: HashMap::new(), + file_patterns: vec![], + initialization_options: None, + timeout_seconds: 30, + request_timeout_seconds: 30, + heuristics: None, + name: Some("pyright".to_string()), + handles: Some(vec![ToolKind::Hover]), + }, + LspServerConfig { + language_id: "python".to_string(), + command: "pylsp".to_string(), + args: vec![], + env: HashMap::new(), + file_patterns: vec![], + initialization_options: None, + timeout_seconds: 30, + request_timeout_seconds: 30, + heuristics: None, + name: Some("pylsp".to_string()), + handles: Some(vec![ToolKind::Diagnostics]), + }, + ]; + let router = ToolRouter::from_configs(&configs).unwrap(); + + let mut translator = Translator::new() + .with_extensions(extensions) + .with_router(router); + translator.set_workspace_roots(vec![dir.path().to_path_buf()]); + + let (client_pyright, mut server_pyright) = fake_lsp_client(); + let (client_pylsp, mut server_pylsp) = fake_lsp_client(); + translator.register_client(pyright_id, client_pyright); + translator.register_client(pylsp_id, client_pylsp); + + let path = dir.path().join("main.py"); + fs::write(&path, "x = 1").unwrap(); + let path_str = path.to_string_lossy().to_string(); + + let translator = Arc::new(translator); + + // rename is claimed by neither server -> NoServerForTool, checked + // first so it can't be masked by either server's wire state. + let rename_result = translator + .handle_rename(path_str.clone(), 1, 1, "renamed".to_string()) + .await; + assert!( + matches!( + rename_result, + Err(Error::NoServerForTool { + tool: ToolKind::Rename, + .. + }) + ), + "expected NoServerForTool for rename, got {rename_result:?}" + ); + + // hover must route to pyright: didOpen + hover request on its wire. + let hover = { + let translator = Arc::clone(&translator); + let path_str = path_str.clone(); + tokio::spawn(async move { translator.handle_hover(path_str, 1, 1).await }) + }; + let mut wire_pyright = BufReader::new(&mut server_pyright.write_stdout); + let opened = read_framed_message(&mut wire_pyright).await; + assert_eq!(opened["method"], "textDocument/didOpen"); + let hover_request = read_framed_message(&mut wire_pyright).await; + assert_eq!(hover_request["method"], "textDocument/hover"); + write_response( + &mut server_pyright.read_half_stdin, + &hover_request["id"], + JsonValue::Null, + ) + .await; + hover + .await + .unwrap() + .expect("hover routed to pyright must succeed"); + + // diagnostics must route to pylsp, independently of pyright: its own + // didOpen (a second server's first sync of the same path) followed + // by the diagnostic request on pylsp's wire, never pyright's. + let diagnostics = { + let translator = Arc::clone(&translator); + let notification_cache = Arc::new(Mutex::new(NotificationCache::new())); + tokio::spawn(async move { + translator + .handle_diagnostics(path_str, ¬ification_cache) + .await + }) + }; + let mut wire_pylsp = BufReader::new(&mut server_pylsp.write_stdout); + let opened = read_framed_message(&mut wire_pylsp).await; + assert_eq!(opened["method"], "textDocument/didOpen"); + let diag_request = read_framed_message(&mut wire_pylsp).await; + assert_eq!(diag_request["method"], "textDocument/diagnostic"); + // Routing is proven by the request landing on pylsp's wire; abort + // rather than crafting a well-formed DocumentDiagnosticReportResult. + diagnostics.abort(); + } + + /// No `LspServer` registered for `server_id` (only a raw `LspClient`, as + /// most tests in this module do) -- capability is unknown, so the gate + /// must not block the request. + #[test] + fn test_require_capability_ok_when_server_not_registered() { + let translator = Translator::new(); + let result = + translator.require_capability(&ServerId::from("rust"), "renameProvider", |_| false); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_require_capability_ok_when_capability_present() { + let translator = Translator::new(); + let server_id = ServerId::from("rust"); + let caps = lsp_types::ServerCapabilities { + rename_provider: Some(lsp_types::OneOf::Left(true)), + ..Default::default() + }; + translator.register_server(server_id.clone(), LspServer::new_for_test(caps)); + + let result = translator.require_capability(&server_id, "renameProvider", |c| { + matches!( + c.rename_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ) + }); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_require_capability_err_when_capability_absent() { + let translator = Translator::new(); + let server_id = ServerId::from("rust"); + let caps = lsp_types::ServerCapabilities::default(); + translator.register_server(server_id.clone(), LspServer::new_for_test(caps)); + + let result = translator.require_capability(&server_id, "renameProvider", |c| { + matches!( + c.rename_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ) + }); + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "renameProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_rename_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_rename( + path.to_string_lossy().to_string(), + 1, + 1, + "renamed".to_string(), + ) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "renameProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_code_actions_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_code_actions(path.to_string_lossy().to_string(), 1, 1, 1, 5, None) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "codeActionProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_signature_help_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_signature_help(path.to_string_lossy().to_string(), 1, 1) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "signatureHelpProvider", + .. + }) + )); + } + + /// `handle_incoming_calls` resolves its server via `get_client_for_file` + /// directly (not `prepare_document`), a separate code path from the other + /// gated handlers -- exercise it explicitly. + #[tokio::test] + async fn test_handle_incoming_calls_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + let uri = Url::from_file_path(&path).unwrap().to_string(); + + let item = serde_json::json!({ + "name": "test_function", + "kind": 12, + "uri": uri, + "range": { + "start": {"line": 1, "character": 1}, + "end": {"line": 1, "character": 10} + }, + "selectionRange": { + "start": {"line": 1, "character": 1}, + "end": {"line": 1, "character": 10} + } + }); + + let result = translator.handle_incoming_calls(item).await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "callHierarchyProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_outgoing_calls_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + let uri = Url::from_file_path(&path).unwrap().to_string(); + + let item = serde_json::json!({ + "name": "test_function", + "kind": 12, + "uri": uri, + "range": { + "start": {"line": 1, "character": 1}, + "end": {"line": 1, "character": 10} + }, + "selectionRange": { + "start": {"line": 1, "character": 1}, + "end": {"line": 1, "character": 10} + } + }); + + let result = translator.handle_outgoing_calls(item).await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "callHierarchyProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_format_document_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_format_document(path.to_string_lossy().to_string(), 4, true) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "documentFormattingProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_call_hierarchy_prepare_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_call_hierarchy_prepare(path.to_string_lossy().to_string(), 1, 1) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "callHierarchyProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_inlay_hints_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_inlay_hints(path.to_string_lossy().to_string(), 1, 1, 10, 1) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "inlayHintProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_hover_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_hover(path.to_string_lossy().to_string(), 1, 1) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "hoverProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_definition_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_definition(path.to_string_lossy().to_string(), 1, 1) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "definitionProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_references_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_references(path.to_string_lossy().to_string(), 1, 1, false) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "referencesProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_completions_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_completions(path.to_string_lossy().to_string(), 1, 1, None) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "completionProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_document_symbols_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_document_symbols(path.to_string_lossy().to_string()) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "documentSymbolProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_workspace_symbol_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let result = translator + .handle_workspace_symbol("main".to_string(), None, 100) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "workspaceSymbolProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_implementation_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_implementation(path.to_string_lossy().to_string(), 1, 1) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "implementationProvider", + .. + }) + )); + } + + #[tokio::test] + async fn test_handle_type_definition_blocked_when_capability_not_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let (translator, _server) = translator_with_capabilities( + &dir, + &server_id, + lsp_types::ServerCapabilities::default(), + ); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + + let result = translator + .handle_type_definition(path.to_string_lossy().to_string(), 1, 1) + .await; + + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "typeDefinitionProvider", + .. + }) + )); + } + + /// Explicit `Some(OneOf::Left(false))` -- as distinct from an absent + /// (`None`) field -- must also be rejected: some servers advertise a + /// provider field with an explicit `false` rather than omitting it. + #[tokio::test] + async fn test_require_capability_err_when_capability_explicitly_false() { + let translator = Translator::new(); + let server_id = ServerId::from("rust"); + let caps = lsp_types::ServerCapabilities { + rename_provider: Some(lsp_types::OneOf::Left(false)), + ..Default::default() + }; + translator.register_server(server_id.clone(), LspServer::new_for_test(caps)); + + let result = translator.require_capability(&server_id, "renameProvider", |c| { + matches!( + c.rename_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ) + }); + assert!(matches!( + result, + Err(Error::CapabilityNotSupported { + capability: "renameProvider", + .. + }) + )); + } + + /// Positive path: when the routed server *does* advertise the gated + /// capability, the gate must let the request proceed into dispatch rather + /// than short-circuiting with `CapabilityNotSupported`. Drives the fake + /// wire to answer the request so the call completes quickly instead of + /// idling out its internal 30s request timeout. + #[tokio::test] + async fn test_handle_rename_proceeds_when_capability_supported() { + let dir = TempDir::new().unwrap(); + let server_id = ServerId::from("rust"); + let caps = lsp_types::ServerCapabilities { + rename_provider: Some(lsp_types::OneOf::Left(true)), + ..Default::default() + }; + let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps); + + let path = dir.path().join("main.rs"); + fs::write(&path, "fn main() {}").unwrap(); + let path_str = path.to_string_lossy().to_string(); + + let translator = Arc::new(translator); + let handle = { + let translator = Arc::clone(&translator); + tokio::spawn(async move { + translator + .handle_rename(path_str, 1, 1, "renamed".to_string()) + .await + }) + }; + + let mut wire = BufReader::new(&mut server.write_stdout); + let opened = read_framed_message(&mut wire).await; + assert_eq!(opened["method"], "textDocument/didOpen"); + let rename_request = read_framed_message(&mut wire).await; + assert_eq!(rename_request["method"], "textDocument/rename"); + write_response( + &mut server.read_half_stdin, + &rename_request["id"], + JsonValue::Null, + ) + .await; + + let result = timeout(Duration::from_secs(2), handle) + .await + .expect("handler call should not hang") + .unwrap(); + + assert!( + !matches!(result, Err(Error::CapabilityNotSupported { .. })), + "capability is supported, gate must not block dispatch, got {result:?}" + ); + assert!( + result.is_ok(), + "fake server answered, expected Ok: {result:?}" + ); + } +} diff --git a/crates/mcpls-core/src/bridge/translator/symbols.rs b/crates/mcpls-core/src/bridge/translator/symbols.rs new file mode 100644 index 00000000..41d62ce7 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/symbols.rs @@ -0,0 +1,337 @@ +//! Document symbols and workspace symbol search handlers. + +use lsp_types::{ + DocumentSymbol, DocumentSymbolParams, PartialResultParams, TextDocumentIdentifier, + WorkDoneProgressParams, WorkspaceSymbolParams as LspWorkspaceSymbolParams, +}; + +use super::Translator; +use super::dto::{DocumentSymbolsResult, Location, Symbol, WorkspaceSymbol, WorkspaceSymbolResult}; +use super::encoding_ctx::EncodingCtx; +use crate::bridge::lock_std; +use crate::config::{NoServerReason, ToolKind}; +use crate::error::{Error, Result}; + +/// Validate parameters for `handle_workspace_symbol`. +fn validate_workspace_symbol_params(query: &str, kind_filter: Option<&str>) -> Result<()> { + const MAX_QUERY_LENGTH: usize = 1000; + const VALID_SYMBOL_KINDS: &[&str] = &[ + "File", + "Module", + "Namespace", + "Package", + "Class", + "Method", + "Property", + "Field", + "Constructor", + "Enum", + "Interface", + "Function", + "Variable", + "Constant", + "String", + "Number", + "Boolean", + "Array", + "Object", + "Key", + "Null", + "EnumMember", + "Struct", + "Event", + "Operator", + "TypeParameter", + ]; + + if query.len() > MAX_QUERY_LENGTH { + return Err(Error::InvalidToolParams(format!( + "Query too long: {} chars (max {MAX_QUERY_LENGTH})", + query.len() + ))); + } + + if let Some(kind) = kind_filter + && !VALID_SYMBOL_KINDS + .iter() + .any(|k| k.eq_ignore_ascii_case(kind)) + { + return Err(Error::InvalidToolParams(format!( + "Invalid kind_filter: '{kind}'. Valid values: {VALID_SYMBOL_KINDS:?}" + ))); + } + + Ok(()) +} + +/// Convert LSP document symbol to MCP symbol. `uri` is the queried +/// document's own URI: nested `DocumentSymbol` entries have no URI of their +/// own, since `textDocument/documentSymbol` is always scoped to one file. +/// +/// Boxed because it recurses through `children` and an `async fn` cannot +/// call itself directly (its future would have unbounded size). +fn convert_document_symbol<'a>( + symbol: DocumentSymbol, + ctx: &'a EncodingCtx, + uri: &'a lsp_types::Uri, +) -> futures::future::BoxFuture<'a, Symbol> { + Box::pin(async move { + let range = ctx.normalize_range(uri, symbol.range).await; + let selection_range = ctx.normalize_range(uri, symbol.selection_range).await; + let children = match symbol.children { + Some(children) => { + let mut result = Vec::with_capacity(children.len()); + for child in children { + result.push(convert_document_symbol(child, ctx, uri).await); + } + Some(result) + } + None => None, + }; + + Symbol { + name: symbol.name, + kind: format!("{:?}", symbol.kind), + range, + selection_range, + children, + } + }) +} + +impl Translator { + /// Handle document symbols request. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, the file cannot be opened, + /// or the routed server does not advertise `documentSymbolProvider` support. + pub async fn handle_document_symbols( + &self, + file_path: String, + ) -> Result { + let (server_id, client, uri) = self + .prepare_gated_document( + &file_path, + ToolKind::DocumentSymbols, + "documentSymbolProvider", + |caps| { + matches!( + caps.document_symbol_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ) + }, + ) + .await?; + let ctx = self.encoding_ctx(&server_id); + let response_uri = uri.clone(); + + let params = DocumentSymbolParams { + text_document: TextDocumentIdentifier { uri }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + + let response: Option = client + .request( + "textDocument/documentSymbol", + params, + client.request_timeout(), + ) + .await?; + + let symbols = match response { + Some(lsp_types::DocumentSymbolResponse::Flat(symbols)) => { + let mut result = Vec::with_capacity(symbols.len()); + for sym in symbols { + let range = ctx + .normalize_range(&sym.location.uri, sym.location.range) + .await; + let selection_range = ctx + .normalize_range(&sym.location.uri, sym.location.range) + .await; + result.push(Symbol { + name: sym.name, + kind: format!("{:?}", sym.kind), + range, + selection_range, + children: None, + }); + } + result + } + Some(lsp_types::DocumentSymbolResponse::Nested(symbols)) => { + let mut result = Vec::with_capacity(symbols.len()); + for sym in symbols { + result.push(convert_document_symbol(sym, &ctx, &response_uri).await); + } + result + } + None => vec![], + }; + + Ok(DocumentSymbolsResult { symbols }) + } + + /// Handle workspace symbol search. + /// + /// # Errors + /// + /// Returns an error if the LSP request fails, no server is configured, or + /// the routed server does not advertise `workspaceSymbolProvider` support. + pub async fn handle_workspace_symbol( + &self, + query: String, + kind_filter: Option, + limit: u32, + ) -> Result { + validate_workspace_symbol_params(&query, kind_filter.as_deref())?; + + // Workspace search has no document, so it resolves via `resolve_any` + // rather than a per-language route. If the resolved server is not + // registered yet but is expected, tell the caller to wait and retry + // rather than implying nothing is configured. + let server_id = lock_std(&self.router) + .resolve_any(ToolKind::WorkspaceSymbols) + .cloned() + .map_err(|reason| match reason { + // `resolve_any` reports "nothing registered", which also + // covers a server that is configured but has not finished + // spawning yet -- check `expected_servers` (unavailable to + // `ToolRouter` itself) to tell the two apart, mirroring + // `get_client_for_file`'s `ServerInitializing` check below. + NoServerReason::NothingRegistered => { + if lock_std(&self.expected_servers).is_empty() { + Error::NoServerConfigured + } else { + Error::WorkspaceServersInitializing + } + } + NoServerReason::NoClaimant => Error::NoServerForWorkspaceTool { + tool: ToolKind::WorkspaceSymbols, + }, + })?; + self.respawn_if_dead(&server_id).await?; + let client = lock_std(&self.lsp_clients).get(&server_id).cloned(); + let client = client.ok_or_else(|| { + if lock_std(&self.expected_servers).contains(&server_id) { + Error::ServerInitializing { + server_id: server_id.clone(), + } + } else { + Error::NoServerConfigured + } + })?; + self.require_capability(&server_id, "workspaceSymbolProvider", |caps| { + matches!( + caps.workspace_symbol_provider, + Some(lsp_types::OneOf::Left(true) | lsp_types::OneOf::Right(_)) + ) + })?; + + let params = LspWorkspaceSymbolParams { + query, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + + let response: Option> = client + .request("workspace/symbol", params, client.request_timeout()) + .await?; + + let ctx = self.encoding_ctx(&server_id); + let mut symbols: Vec = Vec::new(); + for sym in response.unwrap_or_default() { + let range = ctx + .normalize_range(&sym.location.uri, sym.location.range) + .await; + symbols.push(WorkspaceSymbol { + name: sym.name, + kind: format!("{:?}", sym.kind), + location: Location { + uri: sym.location.uri.to_string(), + range, + }, + container_name: sym.container_name, + }); + } + + // Apply kind filter if specified + if let Some(kind) = kind_filter { + symbols.retain(|s| s.kind.eq_ignore_ascii_case(&kind)); + } + + // Limit results + symbols.truncate(limit as usize); + + Ok(WorkspaceSymbolResult { symbols }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use super::*; + use crate::config::{ServerId, ToolRouter}; + + #[tokio::test] + async fn test_handle_workspace_symbol_no_server() { + let translator = Translator::new(); + let result = translator + .handle_workspace_symbol("test".to_string(), None, 100) + .await; + assert!(matches!(result, Err(Error::NoServerConfigured))); + } + + /// #242/S4 regression: a server is configured and still spawning (large + /// project load) rather than never having existed -- the router alone + /// cannot tell these apart (both look like "nothing registered"), so + /// `handle_workspace_symbol` must consult `expected_servers` to report + /// "still initializing" instead of the misleading "no server configured". + #[tokio::test] + async fn test_handle_workspace_symbol_reports_initializing_when_expected_but_not_registered() { + let translator = Translator::new(); + translator.set_expected_servers(HashSet::from([ServerId::from("pyright")])); + + let result = translator + .handle_workspace_symbol("test".to_string(), None, 100) + .await; + assert!(matches!(result, Err(Error::WorkspaceServersInitializing))); + } + + /// #242 regression: a server *is* configured and running, it just + /// doesn't claim `workspace_symbols` and there is no catch-all -- the + /// error must name the tool rather than collapse into the generic + /// "no LSP server configured" message a client would also see if + /// nothing were running at all. + #[tokio::test] + async fn test_handle_workspace_symbol_no_claimant_names_tool() { + let configs = vec![crate::config::LspServerConfig { + language_id: "python".to_string(), + command: "pyright-langserver".to_string(), + args: vec![], + env: HashMap::new(), + file_patterns: vec![], + initialization_options: None, + timeout_seconds: 30, + request_timeout_seconds: 30, + heuristics: None, + name: Some("pyright".to_string()), + handles: Some(vec![ToolKind::Hover]), + }]; + let router = ToolRouter::from_configs(&configs).unwrap(); + let translator = Translator::new().with_router(router); + + let result = translator + .handle_workspace_symbol("test".to_string(), None, 100) + .await; + assert!(matches!( + result, + Err(Error::NoServerForWorkspaceTool { + tool: ToolKind::WorkspaceSymbols + }) + )); + } +} diff --git a/crates/mcpls-core/src/bridge/translator/testing.rs b/crates/mcpls-core/src/bridge/translator/testing.rs new file mode 100644 index 00000000..b85fdab4 --- /dev/null +++ b/crates/mcpls-core/src/bridge/translator/testing.rs @@ -0,0 +1,249 @@ +//! Shared test fixtures for the `translator` module's sibling `tests` +//! submodules: an `EncodingCtx` builder, a fake in-process LSP server driven +//! over `cat` pipes, and JSON-RPC framing helpers. + +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::Arc; + +use tempfile::TempDir; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; + +use super::Translator; +use super::encoding_ctx::EncodingCtx; +use crate::bridge::encoding::PositionEncoding; +use crate::bridge::state::ResourceLimits; +use crate::bridge::{DiagnosticInfo, DocumentTracker}; +use crate::config::{LspServerConfig, ServerId, ToolRouter}; +use crate::lsp::{LspClient, LspServer, LspTransport}; + +type JsonValue = serde_json::Value; + +/// A UTF-16 `EncodingCtx`, matching the pre-negotiation behavior: no +/// disk reads, pure line/column offsetting. +pub(super) fn test_ctx() -> EncodingCtx { + test_ctx_with(PositionEncoding::Utf16) +} + +/// An `EncodingCtx` with a fresh, empty `DocumentTracker` -- suitable for +/// tests that need a non-UTF-16 encoding and don't care about the +/// tracker fast path (e.g. exercising the disk-read fallback directly). +pub(super) fn test_ctx_with(encoding: PositionEncoding) -> EncodingCtx { + EncodingCtx { + encoding, + tracker: Arc::new(DocumentTracker::new( + ResourceLimits::default(), + HashMap::new(), + )), + } +} + +pub(super) fn test_uri() -> lsp_types::Uri { + "file:///test.rs".parse().unwrap() +} + +/// A fresh, empty `DocumentTracker` for tests that call +/// `diagnostics_from_cache_entry`/`merge_diagnostics` directly and don't +/// care about the tracker fast path. +pub(super) fn test_tracker() -> Arc { + Arc::new(DocumentTracker::new( + ResourceLimits::default(), + HashMap::new(), + )) +} + +/// Builds an LSP-side diagnostic for `merge_diagnostics` cache fixtures. +pub(super) fn lsp_diag( + line: u32, + end_character: u32, + severity: lsp_types::DiagnosticSeverity, + message: &str, + code: Option<&str>, +) -> lsp_types::Diagnostic { + lsp_types::Diagnostic { + range: lsp_types::Range { + start: lsp_types::Position { line, character: 0 }, + end: lsp_types::Position { + line, + character: end_character, + }, + }, + severity: Some(severity), + message: message.to_string(), + code: code.map(|c| lsp_types::NumberOrString::String(c.to_string())), + source: None, + code_description: None, + related_information: None, + tags: None, + data: None, + } +} + +pub(super) fn diag_info(diagnostics: Vec) -> DiagnosticInfo { + DiagnosticInfo { + uri: "file:///test.rs".parse().unwrap(), + version: Some(1), + diagnostics, + } +} + +pub(super) struct FakeServer { + _write_half: Child, + _read_half: Child, + pub(super) read_half_stdin: ChildStdin, + pub(super) write_stdout: ChildStdout, +} + +pub(super) fn fake_lsp_client() -> (LspClient, FakeServer) { + let mut write_half = Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let write_stdin = write_half.stdin.take().unwrap(); + let write_stdout = write_half.stdout.take().unwrap(); + + let mut read_half = Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let read_stdout = read_half.stdout.take().unwrap(); + let read_stdin = read_half.stdin.take().unwrap(); + + let transport = LspTransport::new(write_stdin, read_stdout); + let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport); + + ( + client, + FakeServer { + _write_half: write_half, + _read_half: read_half, + read_half_stdin: read_stdin, + write_stdout, + }, + ) +} + +/// Reads one `Content-Length`-framed JSON-RPC message off `reader`. +/// +/// `reader` must be reused across calls, not recreated per message: a +/// fresh `BufReader` would silently drop any bytes of a later message it +/// over-read into its internal buffer while parsing an earlier one. +pub(super) async fn read_framed_message(reader: &mut BufReader<&mut ChildStdout>) -> JsonValue { + let mut content_length = None; + let mut line = String::new(); + loop { + line.clear(); + reader.read_line(&mut line).await.unwrap(); + if line == "\r\n" || line == "\n" { + break; + } + if let Some((key, value)) = line.trim_end().split_once(':') + && key.trim().eq_ignore_ascii_case("content-length") + { + content_length = Some(value.trim().parse::().unwrap()); + } + } + let mut buf = vec![0u8; content_length.unwrap()]; + reader.read_exact(&mut buf).await.unwrap(); + serde_json::from_slice(&buf).unwrap() +} + +/// Writes a framed JSON-RPC success response, as a real LSP server would. +pub(super) async fn write_response(stdin: &mut ChildStdin, id: &JsonValue, result: JsonValue) { + let message = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }); + let content = serde_json::to_string(&message).unwrap(); + let header = format!("Content-Length: {}\r\n\r\n", content.len()); + stdin.write_all(header.as_bytes()).await.unwrap(); + stdin.write_all(content.as_bytes()).await.unwrap(); + stdin.flush().await.unwrap(); +} + +/// Writes a framed JSON-RPC error response, e.g. to simulate a push-only +/// server answering `textDocument/diagnostic` with method-not-found. +pub(super) async fn write_error_response( + stdin: &mut ChildStdin, + id: &JsonValue, + code: i64, + message: &str, +) { + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message, + }, + }); + let content = serde_json::to_string(&response).unwrap(); + let header = format!("Content-Length: {}\r\n\r\n", content.len()); + stdin.write_all(header.as_bytes()).await.unwrap(); + stdin.write_all(content.as_bytes()).await.unwrap(); + stdin.flush().await.unwrap(); +} + +/// Builds a single-server translator routed to `server_id` for every tool, +/// with a registered `LspServer` fixture carrying `capabilities` (default +/// capabilities advertise nothing). +pub(super) fn translator_with_capabilities( + dir: &TempDir, + server_id: &ServerId, + capabilities: lsp_types::ServerCapabilities, +) -> (Translator, FakeServer) { + let mut extensions = HashMap::new(); + extensions.insert("rs".to_string(), "rust".to_string()); + + let mut translator = + Translator::new() + .with_extensions(extensions) + .with_router(ToolRouter::catch_all([( + server_id.clone(), + "rust".to_string(), + )])); + translator.set_workspace_roots(vec![dir.path().to_path_buf()]); + + let (client, server) = fake_lsp_client(); + translator.register_client(server_id.clone(), client); + translator.register_server(server_id.clone(), LspServer::new_for_test(capabilities)); + + (translator, server) +} + +/// As [`translator_with_capabilities`], but with a caller-chosen +/// negotiated `position_encoding` -- for tests exercising a non-UTF-16 +/// `EncodingCtx` conversion path through a full mocked LSP round trip. +pub(super) fn translator_with_capabilities_and_encoding( + dir: &TempDir, + server_id: &ServerId, + capabilities: lsp_types::ServerCapabilities, + position_encoding: lsp_types::PositionEncodingKind, +) -> (Translator, FakeServer) { + let mut extensions = HashMap::new(); + extensions.insert("rs".to_string(), "rust".to_string()); + + let mut translator = + Translator::new() + .with_extensions(extensions) + .with_router(ToolRouter::catch_all([( + server_id.clone(), + "rust".to_string(), + )])); + translator.set_workspace_roots(vec![dir.path().to_path_buf()]); + + let (client, server) = fake_lsp_client(); + translator.register_client(server_id.clone(), client); + translator.register_server( + server_id.clone(), + LspServer::new_for_test_with_encoding(capabilities, position_encoding), + ); + + (translator, server) +}