Skip to main content

mcpls_core/lsp/
lifecycle.rs

1//! LSP server lifecycle management.
2//!
3//! This module handles the complete lifecycle of an LSP server:
4//! 1. Spawn server process
5//! 2. Initialize → initialized handshake
6//! 3. Capability negotiation
7//! 4. Active request handling
8//! 5. Graceful shutdown sequence
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::process::Stdio;
13
14use lsp_types::{
15    ClientCapabilities, ClientInfo, ExitNotification, GeneralClientCapabilities, InitializeParams,
16    InitializeRequest, InitializeResult, InitializedNotification, InitializedParams,
17    PositionEncodingKind, Request, ServerCapabilities, ShutdownRequest, StaleRequestSupportOptions,
18    SymbolKind, WorkspaceFolder,
19};
20use tokio::process::Command;
21use tokio::sync::mpsc;
22use tokio::time::Duration;
23use tracing::{debug, info, warn};
24
25use crate::bridge::try_path_to_uri;
26use crate::config::{LspServerConfig, ServerId};
27use crate::error::{Error, Result, ServerSpawnFailure};
28use crate::lsp::CONTENT_MODIFIED_RETRY_METHODS;
29use crate::lsp::client::LspClient;
30use crate::lsp::transport::LspTransport;
31use crate::lsp::types::LspNotification;
32
33/// Environment variables passed through to a spawned LSP server even though
34/// its environment is otherwise cleared.
35///
36/// `PATH` lets the server resolve its own toolchain (e.g. rustup shims, venv
37/// binaries); `HOME`/`USERPROFILE` and `TMPDIR`/`TEMP`/`TMP` let it find user
38/// config/cache and scratch directories.
39///
40/// This list is not exhaustive: session-specific values that cannot be
41/// hardcoded into a static [`LspServerConfig::env`] table (e.g.
42/// `SSH_AUTH_SOCK`, which changes every login session) have no way through
43/// today. See [`LspServerConfig::env`] for the config-level override/addition
44/// mechanism this list feeds into.
45const ENV_PASSTHROUGH: &[&str] = &["PATH", "HOME", "USERPROFILE", "TMPDIR", "TEMP", "TMP"];
46
47/// Upper bound [`LspServer::shutdown`] waits for the child process to exit on
48/// its own after sending the LSP `exit` notification, before falling back to
49/// `kill_on_drop`.
50const CHILD_EXIT_GRACE: Duration = Duration::from_secs(3);
51
52/// Capacity of the diagnostics/log/showMessage notification channel (P3).
53///
54/// Raised from the pre-#422 value of 64: P1 makes mcpls advertise
55/// `window.workDoneProgress`, so a server may now attach progress reporting
56/// to ordinary request capabilities too, raising overall notification
57/// volume generally even though progress itself moved to its own
58/// [`LIFECYCLE_CHANNEL_CAPACITY`] lane.
59const NOTIFICATION_CHANNEL_CAPACITY: usize = 256;
60
61/// Capacity of the lifecycle channel (P3): `$/progress` `begin`/`end`
62/// frames plus `Other` (rust-analyzer's `experimental/serverStatus`).
63///
64/// O(phases), not O(reports): a `$/progress` `report` frame is filtered out
65/// before it ever reaches this channel (see
66/// `lsp::client::LspClient::message_loop_inner`), so this only needs to hold
67/// a handful of `begin`/`end` transitions even for a server with several
68/// concurrent operations, not a `report`-per-package stream like gopls can
69/// produce.
70const LIFECYCLE_CHANNEL_CAPACITY: usize = 128;
71
72/// Every symbol kind defined by LSP 3.17 and understood by mcpls.
73///
74/// Single source of truth for both the `initialize` request's
75/// `value_set` and the `workspace/symbol` `kind_filter` validation in
76/// [`crate::bridge::translator::symbols`] — the latter derives its accepted
77/// string names from this array via `format!("{:?}", kind)`.
78pub const SUPPORTED_SYMBOL_KINDS: [SymbolKind; 26] = [
79    SymbolKind::File,
80    SymbolKind::Module,
81    SymbolKind::Namespace,
82    SymbolKind::Package,
83    SymbolKind::Class,
84    SymbolKind::Method,
85    SymbolKind::Property,
86    SymbolKind::Field,
87    SymbolKind::Constructor,
88    SymbolKind::Enum,
89    SymbolKind::Interface,
90    SymbolKind::Function,
91    SymbolKind::Variable,
92    SymbolKind::Constant,
93    SymbolKind::String,
94    SymbolKind::Number,
95    SymbolKind::Boolean,
96    SymbolKind::Array,
97    SymbolKind::Object,
98    SymbolKind::Key,
99    SymbolKind::Null,
100    SymbolKind::EnumMember,
101    SymbolKind::Struct,
102    SymbolKind::Event,
103    SymbolKind::Operator,
104    SymbolKind::TypeParameter,
105];
106
107/// Windows-only additions to [`ENV_PASSTHROUGH`].
108///
109/// `SystemRoot`/`SystemDrive`/`windir` are required by the Windows process
110/// loader itself; `APPDATA`/`LOCALAPPDATA` are read by the Node-based default
111/// servers (pyright, typescript-language-server) for global config and
112/// cache; the rest are conventionally expected by Windows child processes.
113#[cfg(windows)]
114const ENV_PASSTHROUGH_WINDOWS: &[&str] = &[
115    "SystemRoot",
116    "SystemDrive",
117    "windir",
118    "APPDATA",
119    "LOCALAPPDATA",
120    "ProgramData",
121    "ProgramFiles",
122    "COMSPEC",
123    "PATHEXT",
124    "NUMBER_OF_PROCESSORS",
125    "USERNAME",
126];
127
128/// State of an LSP server connection.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum ServerState {
131    /// Server has not been initialized.
132    Uninitialized,
133    /// Server is currently initializing.
134    Initializing,
135    /// Server is ready to handle requests.
136    Ready,
137    /// Server is shutting down.
138    ShuttingDown,
139    /// Server has been shut down.
140    Shutdown,
141}
142
143impl ServerState {
144    /// Check if the server is ready to handle requests.
145    #[must_use]
146    pub const fn is_ready(&self) -> bool {
147        matches!(self, Self::Ready)
148    }
149
150    /// Check if the server can accept new requests.
151    #[must_use]
152    pub const fn can_accept_requests(&self) -> bool {
153        matches!(self, Self::Ready)
154    }
155}
156
157/// Configuration for LSP server initialization.
158#[derive(Debug, Clone)]
159pub struct ServerInitConfig {
160    /// LSP server configuration.
161    pub server_config: LspServerConfig,
162    /// Workspace root paths.
163    pub workspace_roots: Vec<PathBuf>,
164    /// Initialization options (server-specific JSON).
165    pub initialization_options: Option<serde_json::Value>,
166    /// Position encoding preference order from
167    /// [`crate::config::WorkspaceConfig::position_encodings`].
168    ///
169    /// Sent as `capabilities.general.positionEncodings` during [`LspServer::spawn`]'s
170    /// `initialize` handshake, in the configured order. Values that don't parse
171    /// as a valid [`PositionEncodingKind`] are skipped with a warning rather than
172    /// failing the handshake: `serve`/`serve_with` validate the top-level
173    /// `ServerConfig` via [`crate::config::ServerConfig::validate`] before this
174    /// is ever built, but `LspServer::spawn`/`spawn_batch` are `pub` and
175    /// reachable directly by a library embedder bypassing that validation
176    /// entirely (same reasoning as the `initialize` timeout clamp below), so
177    /// this can't assume the value was already checked. If nothing parses,
178    /// falls back to `config::default_position_encodings()`'s default.
179    pub position_encodings: Vec<String>,
180    /// Optional channel for forwarding LSP notifications to the notification cache.
181    ///
182    /// When `Some`, the spawned LSP client sends every notification it receives
183    /// (publishDiagnostics, logMessage, showMessage, …) through this sender.
184    /// The caller is responsible for draining the corresponding receiver and
185    /// storing entries in [`crate::bridge::NotificationCache`].
186    pub notification_tx: Option<mpsc::Sender<LspNotification>>,
187}
188
189/// Result of attempting to spawn multiple LSP servers.
190///
191/// This type enables graceful degradation by collecting both
192/// successful initializations and failures. Use the helper methods
193/// to inspect the outcome and make decisions about how to proceed.
194///
195/// # Examples
196///
197/// ```
198/// use mcpls_core::lsp::ServerInitResult;
199/// use mcpls_core::error::ServerSpawnFailure;
200///
201/// let mut result = ServerInitResult::new();
202///
203/// // Check for different scenarios
204/// if result.all_failed() {
205///     eprintln!("All servers failed to initialize");
206/// } else if result.partial_success() {
207///     println!("Some servers succeeded, some failed");
208/// } else if result.has_servers() {
209///     println!("All servers initialized successfully");
210/// }
211/// ```
212#[derive(Debug)]
213pub struct ServerInitResult {
214    /// Successfully initialized servers, keyed by routing identity.
215    pub servers: HashMap<ServerId, LspServer>,
216    /// Failures that occurred during spawn attempts.
217    pub failures: Vec<ServerSpawnFailure>,
218}
219
220impl ServerInitResult {
221    /// Create a new empty result.
222    #[must_use]
223    pub fn new() -> Self {
224        Self {
225            servers: HashMap::new(),
226            failures: Vec::new(),
227        }
228    }
229
230    /// Check if any servers were successfully initialized.
231    ///
232    /// Returns `true` if at least one server is available for use.
233    #[must_use]
234    pub fn has_servers(&self) -> bool {
235        !self.servers.is_empty()
236    }
237
238    /// Check if all attempted servers failed.
239    ///
240    /// Returns `true` only if there were failures and no servers succeeded.
241    /// Returns `false` for empty results (no servers configured).
242    #[must_use]
243    pub fn all_failed(&self) -> bool {
244        self.servers.is_empty() && !self.failures.is_empty()
245    }
246
247    /// Check if some but not all servers failed.
248    ///
249    /// Returns `true` if there are both successful servers and failures.
250    #[must_use]
251    pub fn partial_success(&self) -> bool {
252        !self.servers.is_empty() && !self.failures.is_empty()
253    }
254
255    /// Get the number of successfully initialized servers.
256    #[must_use]
257    pub fn server_count(&self) -> usize {
258        self.servers.len()
259    }
260
261    /// Get the number of failures.
262    #[must_use]
263    pub const fn failure_count(&self) -> usize {
264        self.failures.len()
265    }
266
267    /// Add a successful server.
268    ///
269    /// If a server with the same [`ServerId`] already exists, it will be replaced.
270    pub fn add_server(&mut self, id: impl Into<ServerId>, server: LspServer) {
271        self.servers.insert(id.into(), server);
272    }
273
274    /// Add a failure.
275    pub fn add_failure(&mut self, failure: ServerSpawnFailure) {
276        self.failures.push(failure);
277    }
278}
279
280impl Default for ServerInitResult {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286/// Managed LSP server instance with capabilities and encoding.
287pub struct LspServer {
288    client: LspClient,
289    capabilities: ServerCapabilities,
290    position_encoding: PositionEncodingKind,
291    /// Receiver for push notifications from the LSP server: diagnostics,
292    /// log messages, and show-message requests.
293    ///
294    /// Extract this before registering the server to receive real-time
295    /// notifications (e.g., `textDocument/publishDiagnostics`).
296    pub notification_rx: mpsc::Receiver<LspNotification>,
297    /// Receiver for the lifecycle lane (P3): `$/progress` `begin`/`end`
298    /// frames and unrecognized notifications (which carry e.g.
299    /// rust-analyzer's `experimental/serverStatus`), kept separate from
300    /// [`Self::notification_rx`] so a high-volume diagnostics publisher can
301    /// never starve out a workspace-readiness signal, or vice versa.
302    ///
303    /// Extract this before registering the server, the same way as
304    /// [`Self::notification_rx`] -- see [`Self::take_lifecycle_rx`].
305    pub lifecycle_rx: mpsc::Receiver<LspNotification>,
306    /// Child process handle. Kept alive for process lifetime management and
307    /// queried by [`Self::has_exited`] to detect a crash. [`LspServer::shutdown`]
308    /// waits for it to exit after sending `exit`; otherwise, or if that wait
309    /// times out, dropping it terminates the process via SIGKILL
310    /// (`kill_on_drop`).
311    ///
312    /// `None` only for test fixtures that never spawn a real server process
313    /// (see `crate::test_lsp`, `Self::new_for_test_with_encoding`) --
314    /// [`Self::spawn`] always populates this with `Some`.
315    child: Option<tokio::process::Child>,
316}
317
318impl std::fmt::Debug for LspServer {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        f.debug_struct("LspServer")
321            .field("client", &self.client)
322            .field("capabilities", &self.capabilities)
323            .field("position_encoding", &self.position_encoding)
324            .field("notification_rx", &"<channel>")
325            .field("lifecycle_rx", &"<channel>")
326            .field("child", &"<process>")
327            .finish()
328    }
329}
330
331impl LspServer {
332    /// Take the notification receiver out of this server, replacing it with a dummy channel.
333    ///
334    /// Use this to extract the receiver for a background pump task before registering
335    /// the server with the translator. After this call, the server's `notification_rx`
336    /// will never receive messages.
337    pub fn take_notification_rx(&mut self) -> tokio::sync::mpsc::Receiver<LspNotification> {
338        let (_, dummy) = tokio::sync::mpsc::channel(1);
339        std::mem::replace(&mut self.notification_rx, dummy)
340    }
341
342    /// Take the lifecycle receiver out of this server, replacing it with a
343    /// dummy channel -- the lifecycle-lane counterpart to
344    /// [`Self::take_notification_rx`]. Extract this before registering the
345    /// server for a background pump task to drain, the same way as
346    /// [`Self::notification_rx`].
347    pub fn take_lifecycle_rx(&mut self) -> tokio::sync::mpsc::Receiver<LspNotification> {
348        let (_, dummy) = tokio::sync::mpsc::channel(1);
349        std::mem::replace(&mut self.lifecycle_rx, dummy)
350    }
351
352    /// Spawn and initialize LSP server.
353    ///
354    /// This performs the complete initialization sequence:
355    /// 1. Spawns the LSP server as a child process
356    /// 2. Sends initialize request with client capabilities
357    /// 3. Receives server capabilities from initialize response
358    /// 4. Sends initialized notification
359    ///
360    /// # Errors
361    ///
362    /// Returns an error if:
363    /// - Server process fails to spawn
364    /// - Initialize request fails or times out
365    /// - Server returns error during initialization
366    pub async fn spawn(config: ServerInitConfig) -> Result<Self> {
367        info!(
368            "Spawning LSP server: {} {:?}",
369            config.server_config.command, config.server_config.args
370        );
371
372        let mut command = Self::build_command(&config.server_config, |key| std::env::var_os(key));
373
374        // Log allowlist presence and an override count only — never the
375        // configured keys themselves, since `config.server_config.env` may
376        // hold secret-bearing names (e.g. `AWS_SECRET_ACCESS_KEY`) whose
377        // mere presence in a debug log would be its own disclosure.
378        let passthrough_present = {
379            let base = ENV_PASSTHROUGH
380                .iter()
381                .filter(|key| std::env::var_os(key).is_some())
382                .count();
383            #[cfg(windows)]
384            let windows = ENV_PASSTHROUGH_WINDOWS
385                .iter()
386                .filter(|key| std::env::var_os(key).is_some())
387                .count();
388            #[cfg(not(windows))]
389            let windows = 0;
390            base + windows
391        };
392        debug!(
393            "Effective LSP server env: {passthrough_present} allowlisted key(s) present, \
394             {} configured override(s) applied",
395            config.server_config.env.len()
396        );
397
398        let mut child = command.spawn().map_err(|e| Error::ServerSpawnFailed {
399            command: config.server_config.command.clone(),
400            source: e,
401        })?;
402
403        let stdin = child
404            .stdin
405            .take()
406            .ok_or_else(|| Error::Transport("Failed to capture stdin".to_string()))?;
407        let stdout = child
408            .stdout
409            .take()
410            .ok_or_else(|| Error::Transport("Failed to capture stdout".to_string()))?;
411
412        let transport = LspTransport::new(stdin, stdout);
413        let (notification_tx, notification_rx) = mpsc::channel(NOTIFICATION_CHANNEL_CAPACITY);
414        let (lifecycle_tx, lifecycle_rx) = mpsc::channel(LIFECYCLE_CHANNEL_CAPACITY);
415        let client = LspClient::from_transport_with_notifications(
416            config.server_config.clone(),
417            transport,
418            notification_tx,
419            lifecycle_tx,
420        );
421
422        let (capabilities, position_encoding) = Self::initialize(&client, &config).await?;
423
424        info!("LSP server initialized successfully");
425
426        Ok(Self {
427            client,
428            capabilities,
429            position_encoding,
430            notification_rx,
431            lifecycle_rx,
432            child: Some(child),
433        })
434    }
435
436    /// Build the child `Command` for a spawned LSP server, without spawning it.
437    ///
438    /// The child's environment is cleared, then [`ENV_PASSTHROUGH`] (plus
439    /// [`ENV_PASSTHROUGH_WINDOWS`] under `cfg(windows)`) is copied in from
440    /// `parent_env` for whichever of those keys it returns `Some` for, then
441    /// `config.env` is applied last so it can override any passthrough
442    /// value. `parent_env` is injected (production passes
443    /// `std::env::var_os`) so tests can supply a fixed environment without
444    /// racing on real process-global state.
445    fn build_command(
446        config: &LspServerConfig,
447        parent_env: impl Fn(&str) -> Option<std::ffi::OsString>,
448    ) -> Command {
449        let mut command = Command::new(&config.command);
450        command.args(&config.args).env_clear();
451
452        for key in ENV_PASSTHROUGH {
453            if let Some(value) = parent_env(key) {
454                command.env(key, value);
455            }
456        }
457        #[cfg(windows)]
458        for key in ENV_PASSTHROUGH_WINDOWS {
459            if let Some(value) = parent_env(key) {
460                command.env(key, value);
461            }
462        }
463
464        command
465            .envs(&config.env)
466            .stdin(Stdio::piped())
467            .stdout(Stdio::piped())
468            .stderr(Stdio::null())
469            .kill_on_drop(true);
470
471        command
472    }
473
474    /// Build the `capabilities` mcpls advertises in the `initialize`
475    /// request, given the configured position-encoding preference order.
476    ///
477    /// Extracted from [`Self::initialize`] (S2) so a unit test can assert
478    /// directly on the returned value -- in particular that
479    /// `window.work_done_progress == Some(true)` (P1), without which no
480    /// spec-compliant LSP server may ever initiate `$/progress` at all (see
481    /// the matching `window/workDoneProgress/create` allowlist entry in
482    /// [`crate::lsp::client::LspClient::server_request_result`]). Before
483    /// this existed, deleting those capability lines would silently no-op
484    /// the whole feature with an otherwise-green test suite.
485    #[allow(clippy::too_many_lines)]
486    fn client_capabilities(position_encodings: &[String]) -> ClientCapabilities {
487        ClientCapabilities {
488            general: Some(GeneralClientCapabilities {
489                position_encodings: Some(resolve_position_encodings(position_encodings)),
490                stale_request_support: Some(StaleRequestSupportOptions {
491                    // mcpls does not implement active in-flight request
492                    // cancellation.
493                    cancel: false,
494                    retry_on_content_modified: CONTENT_MODIFIED_RETRY_METHODS
495                        .iter()
496                        .map(ToString::to_string)
497                        .collect(),
498                }),
499                ..Default::default()
500            }),
501            text_document: Some(lsp_types::TextDocumentClientCapabilities {
502                document_symbol: Some(lsp_types::DocumentSymbolClientCapabilities {
503                    dynamic_registration: Some(false),
504                    symbol_kind: Some(lsp_types::ClientSymbolKindOptions {
505                        value_set: Some(SUPPORTED_SYMBOL_KINDS.to_vec()),
506                    }),
507                    hierarchical_document_symbol_support: Some(true),
508                    ..Default::default()
509                }),
510                hover: Some(lsp_types::HoverClientCapabilities {
511                    dynamic_registration: Some(false),
512                    content_format: Some(vec![
513                        lsp_types::MarkupKind::Markdown,
514                        lsp_types::MarkupKind::PlainText,
515                    ]),
516                }),
517                definition: Some(lsp_types::DefinitionClientCapabilities {
518                    dynamic_registration: Some(false),
519                    link_support: Some(true),
520                }),
521                references: Some(lsp_types::ReferenceClientCapabilities {
522                    dynamic_registration: Some(false),
523                }),
524                code_action: Some(lsp_types::CodeActionClientCapabilities {
525                    dynamic_registration: Some(false),
526                    data_support: Some(true),
527                    resolve_support: Some(lsp_types::ClientCodeActionResolveOptions {
528                        properties: vec!["edit".to_string()],
529                    }),
530                    // Declare supported action kinds so the server returns
531                    // CodeAction objects (not just legacy Command objects).
532                    code_action_literal_support: Some(lsp_types::ClientCodeActionLiteralOptions {
533                        code_action_kind: lsp_types::ClientCodeActionKindOptions {
534                            value_set: vec![
535                                lsp_types::CodeActionKind::Empty,
536                                lsp_types::CodeActionKind::QuickFix,
537                                lsp_types::CodeActionKind::Refactor,
538                                lsp_types::CodeActionKind::RefactorExtract,
539                                lsp_types::CodeActionKind::RefactorInline,
540                                lsp_types::CodeActionKind::RefactorRewrite,
541                                lsp_types::CodeActionKind::Source,
542                                lsp_types::CodeActionKind::SourceOrganizeImports,
543                            ],
544                        },
545                    }),
546                    ..Default::default()
547                }),
548                ..Default::default()
549            }),
550            workspace: Some(lsp_types::WorkspaceClientCapabilities {
551                workspace_folders: Some(true),
552                ..Default::default()
553            }),
554            // Required per LSP 3.17 before a server may send $/progress at all -- see this fn's doc.
555            window: Some(lsp_types::WindowClientCapabilities {
556                work_done_progress: Some(true),
557                ..Default::default()
558            }),
559            // Required for rust-analyzer to ever emit experimental/serverStatus; other servers ignore this unrecognized key.
560            experimental: Some(serde_json::json!({ "serverStatusNotification": true })),
561            ..Default::default()
562        }
563    }
564
565    /// Perform LSP initialization handshake.
566    ///
567    /// Sends initialize request and waits for response, then sends initialized notification.
568    async fn initialize(
569        client: &LspClient,
570        config: &ServerInitConfig,
571    ) -> Result<(ServerCapabilities, PositionEncodingKind)> {
572        debug!("Sending initialize request");
573
574        let workspace_folders: Vec<WorkspaceFolder> = config
575            .workspace_roots
576            .iter()
577            .map(|root| workspace_folder(root))
578            .collect::<Result<Vec<_>>>()?;
579
580        let params = InitializeParams {
581            process_id: Some(i32::try_from(std::process::id()).unwrap_or(i32::MAX)),
582            #[allow(deprecated)]
583            root_uri: None,
584            initialization_options: config.initialization_options.clone(),
585            capabilities: Self::client_capabilities(&config.position_encodings),
586            client_info: Some(ClientInfo {
587                name: "mcpls".to_string(),
588                version: Some(env!("CARGO_PKG_VERSION").to_string()),
589            }),
590            workspace_folders_initialize_params: lsp_types::WorkspaceFoldersInitializeParams {
591                workspace_folders: Some(lsp_types::WorkspaceFolders::WorkspaceFolderList(
592                    workspace_folders,
593                )),
594            },
595            ..Default::default()
596        };
597
598        // Use the server's configured timeout for the initialize handshake too,
599        // not a hardcoded 30s: large solutions (e.g. a 130-project Unity .sln via
600        // OmniSharp) take minutes to respond to `initialize`.
601        let result: InitializeResult = client
602            .request_typed::<InitializeRequest>(
603                params,
604                // Clamped for the same reason as `LspClient::request_timeout`:
605                // `serve()`/`serve_with()` now validate the top-level
606                // `ServerConfig` via `ServerConfig::validate()`, but this call
607                // operates on the per-server `config.server_config` reached
608                // through `LspServer::spawn`/`spawn_batch`, which bypass that
609                // top-level validation entirely, so an out-of-range value (0,
610                // or an unbounded one that would silently disable the timeout
611                // via tokio's `Instant::far_future()` fallback) is still
612                // reachable here and needs a last-line-of-defense clamp.
613                Duration::from_secs(
614                    config
615                        .server_config
616                        .timeout_seconds
617                        .clamp(1, crate::config::MAX_TIMEOUT_SECONDS),
618                ),
619            )
620            .await
621            .map_err(|e| Error::LspInitFailed {
622                message: format!("Initialize request failed: {e}"),
623            })?;
624
625        let position_encoding = result
626            .capabilities
627            .position_encoding
628            .clone()
629            .unwrap_or(PositionEncodingKind::UTF16);
630
631        debug!(
632            "Server capabilities received, encoding: {:?}",
633            position_encoding
634        );
635
636        client
637            .notify_typed::<InitializedNotification>(InitializedParams {})
638            .await
639            .map_err(|e| Error::LspInitFailed {
640                message: format!("Initialized notification failed: {e}"),
641            })?;
642
643        Ok((result.capabilities, position_encoding))
644    }
645
646    /// Get server capabilities.
647    #[must_use]
648    pub const fn capabilities(&self) -> &ServerCapabilities {
649        &self.capabilities
650    }
651
652    /// Get negotiated position encoding.
653    #[must_use]
654    pub fn position_encoding(&self) -> PositionEncodingKind {
655        self.position_encoding.clone()
656    }
657
658    /// Get client for making requests.
659    #[must_use]
660    pub const fn client(&self) -> &LspClient {
661        &self.client
662    }
663
664    /// Non-blocking check for whether the child process has already exited.
665    ///
666    /// Uses [`tokio::process::Child::try_wait`], which never blocks waiting
667    /// for the process: `true` means it is gone (crashed, killed, or exited
668    /// on its own), and any [`LspClient`] obtained from [`Self::client`] is
669    /// now permanently disconnected -- new requests through it fail with
670    /// [`crate::error::Error::ServerTerminated`]. Callers that want to
671    /// recover substitute a freshly [`Self::spawn`]ed replacement.
672    ///
673    /// Always returns `Ok(false)` for a test fixture with no real backing
674    /// process (`self.child` is `None`) -- there is nothing to have exited.
675    /// [`Self::spawn`] always populates `child`, so production code never
676    /// observes this case.
677    ///
678    /// # Errors
679    ///
680    /// Returns an error if the OS fails to report the process's status.
681    pub fn has_exited(&mut self) -> Result<bool> {
682        match &mut self.child {
683            Some(child) => Ok(child.try_wait()?.is_some()),
684            None => Ok(false),
685        }
686    }
687
688    /// Shutdown server gracefully.
689    ///
690    /// Sends the LSP `shutdown` request, waits for the response, sends the
691    /// `exit` notification, then waits up to a fixed grace period for the
692    /// child process to exit on its own. If it hasn't by then, or if the
693    /// `shutdown`/`exit` handshake itself fails, the child is simply dropped
694    /// here — `kill_on_drop` terminates it via SIGKILL (a no-op if it has
695    /// already exited). A test fixture with no real backing process (`child`
696    /// is `None`) skips this step entirely -- there is nothing to wait for or
697    /// kill.
698    ///
699    /// # Errors
700    ///
701    /// Returns an error if the `shutdown`/`exit` handshake fails. The child
702    /// process is still torn down (gracefully if it exits in time, killed
703    /// otherwise) regardless of whether this returns `Ok` or `Err`.
704    pub async fn shutdown(self) -> Result<()> {
705        debug!("Shutting down LSP server");
706
707        let handshake: Result<()> = async move {
708            let _: serde_json::Value = self
709                .client
710                .request(ShutdownRequest::METHOD.as_str(), (), Duration::from_secs(5))
711                .await?;
712            self.client.notify_typed::<ExitNotification>(()).await?;
713            self.client.shutdown().await
714        }
715        .await;
716
717        if let Some(mut child) = self.child {
718            match tokio::time::timeout(CHILD_EXIT_GRACE, child.wait()).await {
719                Ok(Ok(status)) => {
720                    debug!(
721                        ?status,
722                        "LSP server process exited after `exit` notification"
723                    );
724                }
725                Ok(Err(e)) => warn!(error = %e, "failed to wait for LSP server process exit"),
726                Err(_) => warn!(
727                    timeout = ?CHILD_EXIT_GRACE,
728                    "LSP server process did not exit within grace period after `exit` \
729                     notification, killing it"
730                ),
731            }
732            // `child` drops here: `kill_on_drop` kills it if still running, and is a
733            // no-op if `wait()` above already reaped it.
734        }
735
736        handshake?;
737        info!("LSP server shut down successfully");
738        Ok(())
739    }
740
741    /// Spawn multiple LSP servers in batch mode with graceful degradation.
742    ///
743    /// Attempts to spawn and initialize all configured servers. If some servers
744    /// fail to spawn, the successful servers are still returned. This enables
745    /// graceful degradation where the system can continue to operate with
746    /// partial functionality.
747    ///
748    /// # Behavior
749    ///
750    /// - Attempts to spawn each server sequentially
751    /// - Logs success (info) and failure (error) for each server
752    /// - Accumulates successful servers and failures
753    /// - Never panics or returns early - attempts all servers
754    ///
755    /// # Examples
756    ///
757    /// ```
758    /// use mcpls_core::lsp::{LspServer, ServerInitConfig};
759    /// use mcpls_core::config::LspServerConfig;
760    /// use std::path::PathBuf;
761    ///
762    /// # async fn example() {
763    /// let configs = vec![
764    ///     ServerInitConfig {
765    ///         server_config: LspServerConfig::rust_analyzer(),
766    ///         workspace_roots: vec![PathBuf::from("/workspace")],
767    ///         initialization_options: None,
768    ///         position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
769    ///         notification_tx: None,
770    ///     },
771    ///     ServerInitConfig {
772    ///         server_config: LspServerConfig::pyright(),
773    ///         workspace_roots: vec![PathBuf::from("/workspace")],
774    ///         initialization_options: None,
775    ///         position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
776    ///         notification_tx: None,
777    ///     },
778    /// ];
779    ///
780    /// let result = LspServer::spawn_batch(&configs).await;
781    ///
782    /// if result.has_servers() {
783    ///     println!("Successfully spawned {} servers", result.server_count());
784    /// }
785    ///
786    /// if result.partial_success() {
787    ///     eprintln!("Warning: {} servers failed", result.failure_count());
788    /// }
789    /// # }
790    /// ```
791    pub async fn spawn_batch(configs: &[ServerInitConfig]) -> ServerInitResult {
792        let mut result = ServerInitResult::new();
793
794        for config in configs {
795            let server_id = config.server_config.id();
796            let language_id = config.server_config.language_id.clone();
797            let command = config.server_config.command.clone();
798
799            match Self::spawn(config.clone()).await {
800                Ok(server) => {
801                    info!(
802                        "Successfully spawned LSP server: {} ({})",
803                        server_id, command
804                    );
805                    result.add_server(server_id, server);
806                }
807                Err(e) => {
808                    tracing::error!(
809                        "Failed to spawn LSP server: {} ({}): {}",
810                        server_id,
811                        command,
812                        e
813                    );
814                    result.add_failure(ServerSpawnFailure {
815                        server_id,
816                        language_id,
817                        command,
818                        message: e.to_string(),
819                    });
820                }
821            }
822        }
823
824        result
825    }
826}
827
828/// Convert configured position-encoding strings into the ordered
829/// [`PositionEncodingKind`] list offered during the `initialize` handshake.
830///
831/// Values that don't parse are skipped with a warning instead of failing the
832/// handshake (see [`ServerInitConfig::position_encodings`] for why this can't
833/// assume [`crate::config::ServerConfig::validate`] already ran). Falls back
834/// to `config::default_position_encodings()` -- the same default used when
835/// nothing is configured at all -- if no configured value parses.
836fn resolve_position_encodings(configured: &[String]) -> Vec<PositionEncodingKind> {
837    let encodings: Vec<PositionEncodingKind> = configured
838        .iter()
839        .filter_map(|value| {
840            let kind = crate::config::parse_position_encoding(value);
841            if kind.is_none() {
842                warn!(value = %value, "ignoring invalid configured position encoding");
843            }
844            kind
845        })
846        .collect();
847
848    if encodings.is_empty() {
849        crate::config::default_position_encodings()
850            .iter()
851            .filter_map(|value| crate::config::parse_position_encoding(value))
852            .collect()
853    } else {
854        encodings
855    }
856}
857
858/// Build the `workspace/workspaceFolders` entry for one configured root.
859///
860/// Reserved characters have to be percent-encoded here: an unencoded `#`
861/// would truncate the path into a URI fragment, and `[` / `]` are rejected
862/// outright by `Uri`.
863fn workspace_folder(root: &Path) -> Result<WorkspaceFolder> {
864    let uri = try_path_to_uri(root).ok_or_else(|| {
865        let root_display = root.display();
866        Error::InvalidUri(format!("Invalid workspace root: {root_display}"))
867    })?;
868    Ok(WorkspaceFolder {
869        uri,
870        name: root
871            .file_name()
872            .and_then(|n| n.to_str())
873            .unwrap_or("workspace")
874            .to_string(),
875    })
876}
877
878/// Builds an `LspServer` that can be registered without a real language
879/// server: an inert, in-memory transport (see `crate::test_lsp`) and no
880/// backing child process (`child: None`) -- neither is ever read from or
881/// written to.
882///
883/// `pub` rather than private to this module's own `tests` (`lifecycle` is a
884/// private module, so this stays crate-scoped in practice, per the
885/// `redundant_pub_crate` clippy lint): it constructs `LspServer` via a
886/// struct literal, which only code inside this module can do (all its
887/// fields are private), so this is the one place other modules'
888/// shutdown-path tests (`bridge::translator`, `lib.rs`) can get a real,
889/// registerable `LspServer` from.
890#[cfg(test)]
891pub fn fake_lsp_server() -> LspServer {
892    let transport = crate::test_lsp::inert_transport();
893    let client = LspClient::from_transport(LspServerConfig::pyright(), transport);
894    let (_, mock_notification_rx) = mpsc::channel(1);
895    let (_, mock_lifecycle_rx) = mpsc::channel(1);
896    LspServer {
897        client,
898        capabilities: lsp_types::ServerCapabilities::default(),
899        position_encoding: PositionEncodingKind::UTF8,
900        notification_rx: mock_notification_rx,
901        lifecycle_rx: mock_lifecycle_rx,
902        child: None,
903    }
904}
905
906#[cfg(test)]
907impl LspServer {
908    /// Construct an `LspServer` fixture carrying the given capabilities, for
909    /// tests elsewhere in the crate that need to drive capability-gated
910    /// dispatch paths in `Translator` without spawning a real language server.
911    ///
912    /// The underlying client and child process (`child: None`) are inert
913    /// placeholders — only `capabilities()` is meaningful on the returned
914    /// value.
915    ///
916    /// Uses `LspClient::new` (uninitialized, no background task) rather than
917    /// `LspClient::from_transport`, so this does not depend on the Tokio
918    /// message loop or a real process at all.
919    pub(crate) fn new_for_test(capabilities: ServerCapabilities) -> Self {
920        Self::new_for_test_with_encoding(capabilities, PositionEncodingKind::UTF16)
921    }
922
923    /// As [`Self::new_for_test`], but with a caller-chosen negotiated
924    /// encoding -- for tests exercising a non-UTF-16 conversion path (e.g.
925    /// `EncodingCtx`-driven range conversion) without spawning a real
926    /// process.
927    pub(crate) fn new_for_test_with_encoding(
928        capabilities: ServerCapabilities,
929        position_encoding: PositionEncodingKind,
930    ) -> Self {
931        let client = LspClient::new(LspServerConfig::rust_analyzer());
932        let (_, notification_rx) = mpsc::channel(1);
933        let (_, lifecycle_rx) = mpsc::channel(1);
934
935        Self {
936            client,
937            capabilities,
938            position_encoding,
939            notification_rx,
940            lifecycle_rx,
941            child: None,
942        }
943    }
944}
945
946#[cfg(test)]
947#[allow(clippy::unwrap_used)]
948mod tests {
949    use super::*;
950
951    #[test]
952    fn test_resolve_position_encodings_preserves_configured_order() {
953        let result = resolve_position_encodings(&["utf-32".to_string(), "utf-8".to_string()]);
954        assert_eq!(
955            result,
956            vec![PositionEncodingKind::UTF32, PositionEncodingKind::UTF8]
957        );
958    }
959
960    #[test]
961    fn test_resolve_position_encodings_skips_invalid_and_keeps_valid() {
962        let result = resolve_position_encodings(&["utf-7".to_string(), "utf-16".to_string()]);
963        assert_eq!(result, vec![PositionEncodingKind::UTF16]);
964    }
965
966    #[test]
967    fn test_resolve_position_encodings_falls_back_when_all_invalid() {
968        let result = resolve_position_encodings(&["utf-7".to_string(), "bogus".to_string()]);
969        assert_eq!(
970            result,
971            vec![PositionEncodingKind::UTF8, PositionEncodingKind::UTF16]
972        );
973    }
974
975    #[test]
976    fn test_resolve_position_encodings_falls_back_when_empty() {
977        let result = resolve_position_encodings(&[]);
978        assert_eq!(
979            result,
980            vec![PositionEncodingKind::UTF8, PositionEncodingKind::UTF16]
981        );
982    }
983
984    /// P1/S2: without `window.work_done_progress == Some(true)`, no
985    /// spec-compliant LSP server may ever initiate `$/progress` at all.
986    /// Regression guard: deleting the capability lines in
987    /// `client_capabilities` must fail this test, not silently no-op the
988    /// whole feature with an otherwise-green suite.
989    #[test]
990    fn test_client_capabilities_advertises_work_done_progress() {
991        let capabilities =
992            LspServer::client_capabilities(&["utf-8".to_string(), "utf-16".to_string()]);
993
994        assert_eq!(
995            capabilities.window.and_then(|w| w.work_done_progress),
996            Some(true)
997        );
998    }
999
1000    #[test]
1001    fn test_server_state_ready() {
1002        assert!(ServerState::Ready.is_ready());
1003        assert!(ServerState::Ready.can_accept_requests());
1004    }
1005
1006    #[test]
1007    fn test_server_state_uninitialized() {
1008        assert!(!ServerState::Uninitialized.is_ready());
1009        assert!(!ServerState::Uninitialized.can_accept_requests());
1010    }
1011
1012    #[test]
1013    fn test_server_state_initializing() {
1014        assert!(!ServerState::Initializing.is_ready());
1015        assert!(!ServerState::Initializing.can_accept_requests());
1016    }
1017
1018    #[test]
1019    fn test_workspace_folder_encodes_fragment_char() {
1020        // An unencoded `#` parses as a fragment, silently handing the server
1021        // the parent directory as its root.
1022        #[cfg(windows)]
1023        let (root, expected) = (
1024            Path::new(r"C:\home\me\dev\#work"),
1025            "file:///C:/home/me/dev/%23work",
1026        );
1027        #[cfg(not(windows))]
1028        let (root, expected) = (
1029            Path::new("/home/me/dev/#work"),
1030            "file:///home/me/dev/%23work",
1031        );
1032
1033        let folder = workspace_folder(root).unwrap();
1034
1035        assert_eq!(folder.uri.as_ref(), expected);
1036        assert_eq!(folder.name, "#work");
1037    }
1038
1039    #[test]
1040    fn test_workspace_folder_encodes_bracket_chars() {
1041        #[cfg(windows)]
1042        let (root, expected) = (
1043            Path::new(r"C:\home\me\dev\[env]"),
1044            "file:///C:/home/me/dev/%5Benv%5D",
1045        );
1046        #[cfg(not(windows))]
1047        let (root, expected) = (
1048            Path::new("/home/me/dev/[env]"),
1049            "file:///home/me/dev/%5Benv%5D",
1050        );
1051
1052        let folder = workspace_folder(root).unwrap();
1053
1054        assert_eq!(folder.uri.as_ref(), expected);
1055        assert_eq!(folder.name, "[env]");
1056    }
1057
1058    #[test]
1059    fn test_workspace_folder_rejects_relative_root() {
1060        let err = workspace_folder(Path::new("relative/root")).unwrap_err();
1061        assert!(matches!(err, Error::InvalidUri(_)), "got {err:?}");
1062    }
1063
1064    #[test]
1065    fn test_server_state_shutting_down() {
1066        assert!(!ServerState::ShuttingDown.is_ready());
1067        assert!(!ServerState::ShuttingDown.can_accept_requests());
1068    }
1069
1070    #[test]
1071    fn test_server_state_shutdown() {
1072        assert!(!ServerState::Shutdown.is_ready());
1073        assert!(!ServerState::Shutdown.can_accept_requests());
1074    }
1075
1076    #[test]
1077    fn test_server_state_equality() {
1078        assert_eq!(ServerState::Ready, ServerState::Ready);
1079        assert_ne!(ServerState::Ready, ServerState::Uninitialized);
1080        assert_eq!(ServerState::Shutdown, ServerState::Shutdown);
1081    }
1082
1083    #[test]
1084    fn test_server_state_clone() {
1085        let state = ServerState::Ready;
1086        let cloned = state;
1087        assert_eq!(state, cloned);
1088    }
1089
1090    #[test]
1091    fn test_server_state_debug() {
1092        let state = ServerState::Ready;
1093        let debug_str = format!("{state:?}");
1094        assert!(debug_str.contains("Ready"));
1095    }
1096
1097    #[test]
1098    fn test_server_init_config_clone() {
1099        let config = ServerInitConfig {
1100            server_config: LspServerConfig::rust_analyzer(),
1101            workspace_roots: vec![PathBuf::from("/tmp/workspace")],
1102            initialization_options: Some(serde_json::json!({"key": "value"})),
1103            position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1104            notification_tx: None,
1105        };
1106
1107        #[allow(clippy::redundant_clone)]
1108        let cloned = config.clone();
1109        assert_eq!(cloned.server_config.language_id, "rust");
1110        assert_eq!(cloned.workspace_roots.len(), 1);
1111    }
1112
1113    #[test]
1114    fn test_server_init_config_debug() {
1115        let config = ServerInitConfig {
1116            server_config: LspServerConfig::pyright(),
1117            workspace_roots: vec![],
1118            initialization_options: None,
1119            position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1120            notification_tx: None,
1121        };
1122
1123        let debug_str = format!("{config:?}");
1124        assert!(debug_str.contains("python"));
1125        assert!(debug_str.contains("pyright"));
1126    }
1127
1128    #[test]
1129    fn test_server_init_config_with_options() {
1130        use std::collections::HashMap;
1131
1132        let init_opts = serde_json::json!({
1133            "settings": {
1134                "python": {
1135                    "analysis": {
1136                        "typeCheckingMode": "strict"
1137                    }
1138                }
1139            }
1140        });
1141
1142        let mut env = HashMap::new();
1143        env.insert("PYTHONPATH".to_string(), "/usr/lib".to_string());
1144
1145        let config = ServerInitConfig {
1146            server_config: LspServerConfig {
1147                language_id: "python".to_string(),
1148                command: "pyright-langserver".to_string(),
1149                args: vec!["--stdio".to_string()],
1150                env,
1151                file_patterns: vec!["**/*.py".to_string()],
1152                initialization_options: Some(init_opts.clone()),
1153                timeout_seconds: 10,
1154                request_timeout_seconds: 10,
1155                heuristics: None,
1156                name: None,
1157                handles: None,
1158                indexing: crate::bridge::IndexingPolicy::Auto,
1159            },
1160            workspace_roots: vec![PathBuf::from("/workspace")],
1161            initialization_options: Some(init_opts),
1162            position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1163            notification_tx: None,
1164        };
1165
1166        assert!(config.initialization_options.is_some());
1167        assert_eq!(config.workspace_roots.len(), 1);
1168    }
1169
1170    #[test]
1171    fn test_server_init_config_empty_workspace() {
1172        let config = ServerInitConfig {
1173            server_config: LspServerConfig::typescript(),
1174            workspace_roots: vec![],
1175            initialization_options: None,
1176            position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1177            notification_tx: None,
1178        };
1179
1180        assert!(config.workspace_roots.is_empty());
1181    }
1182
1183    #[test]
1184    fn test_server_init_config_multiple_workspaces() {
1185        let config = ServerInitConfig {
1186            server_config: LspServerConfig::rust_analyzer(),
1187            workspace_roots: vec![
1188                PathBuf::from("/workspace1"),
1189                PathBuf::from("/workspace2"),
1190                PathBuf::from("/workspace3"),
1191            ],
1192            initialization_options: None,
1193            position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1194            notification_tx: None,
1195        };
1196
1197        assert_eq!(config.workspace_roots.len(), 3);
1198    }
1199
1200    /// #249: `has_exited` must distinguish a live child from one that has
1201    /// already exited, since this is the signal the respawn path relies on
1202    /// to detect a crashed LSP server.
1203    ///
1204    /// Unix-only: spawns a real `sleep` subprocess, which is unavailable on
1205    /// the Windows CI runner.
1206    #[cfg(unix)]
1207    #[tokio::test]
1208    async fn test_has_exited_reflects_child_process_state() {
1209        use lsp_types::ServerCapabilities;
1210
1211        let mut mock_child = tokio::process::Command::new("sleep")
1212            .arg("2")
1213            .stdin(Stdio::piped())
1214            .stdout(Stdio::piped())
1215            .kill_on_drop(true)
1216            .spawn()
1217            .unwrap();
1218
1219        let mock_stdin = mock_child.stdin.take().unwrap();
1220        let mock_stdout = mock_child.stdout.take().unwrap();
1221
1222        let transport = LspTransport::new(mock_stdin, mock_stdout);
1223        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1224        let (_, mock_notification_rx) = mpsc::channel(1);
1225        let (_, mock_lifecycle_rx) = mpsc::channel(1);
1226
1227        let mut server = LspServer {
1228            client,
1229            capabilities: ServerCapabilities::default(),
1230            position_encoding: PositionEncodingKind::UTF8,
1231            notification_rx: mock_notification_rx,
1232            lifecycle_rx: mock_lifecycle_rx,
1233            child: Some(mock_child),
1234        };
1235
1236        assert!(
1237            !server.has_exited().unwrap(),
1238            "freshly spawned `sleep 2` should still be running"
1239        );
1240
1241        server.child.as_mut().unwrap().kill().await.unwrap();
1242        // `kill().await` waits for the process to actually exit, so the
1243        // very next `try_wait` reliably observes it as gone.
1244        assert!(
1245            server.has_exited().unwrap(),
1246            "killed child must report as exited"
1247        );
1248    }
1249
1250    #[tokio::test]
1251    async fn test_lsp_server_getters() {
1252        use lsp_types::ServerCapabilities;
1253
1254        let transport = crate::test_lsp::inert_transport();
1255        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1256        let (_, mock_notification_rx) = mpsc::channel(1);
1257        let (_, mock_lifecycle_rx) = mpsc::channel(1);
1258
1259        let server = LspServer {
1260            client,
1261            capabilities: ServerCapabilities::default(),
1262            position_encoding: PositionEncodingKind::UTF8,
1263            notification_rx: mock_notification_rx,
1264            lifecycle_rx: mock_lifecycle_rx,
1265            child: None,
1266        };
1267
1268        assert_eq!(server.position_encoding(), PositionEncodingKind::UTF8);
1269        assert!(server.capabilities().text_document_sync.is_none());
1270
1271        let debug_str = format!("{server:?}");
1272        assert!(debug_str.contains("LspServer"));
1273        assert!(debug_str.contains("<process>"));
1274    }
1275
1276    #[test]
1277    fn test_server_init_result_new_empty() {
1278        let result = ServerInitResult::new();
1279        assert!(!result.has_servers());
1280        assert!(!result.all_failed());
1281        assert!(!result.partial_success());
1282        assert_eq!(result.server_count(), 0);
1283        assert_eq!(result.failure_count(), 0);
1284    }
1285
1286    #[test]
1287    fn test_server_init_result_default() {
1288        let result = ServerInitResult::default();
1289        assert!(!result.has_servers());
1290        assert_eq!(result.server_count(), 0);
1291        assert_eq!(result.failure_count(), 0);
1292    }
1293
1294    #[test]
1295    fn test_server_init_result_all_failures() {
1296        let mut result = ServerInitResult::new();
1297
1298        result.add_failure(ServerSpawnFailure {
1299            server_id: ServerId::from("rust"),
1300            language_id: "rust".to_string(),
1301            command: "rust-analyzer".to_string(),
1302            message: "not found".to_string(),
1303        });
1304
1305        result.add_failure(ServerSpawnFailure {
1306            server_id: ServerId::from("python"),
1307            language_id: "python".to_string(),
1308            command: "pyright".to_string(),
1309            message: "permission denied".to_string(),
1310        });
1311
1312        assert!(!result.has_servers());
1313        assert!(result.all_failed());
1314        assert!(!result.partial_success());
1315        assert_eq!(result.server_count(), 0);
1316        assert_eq!(result.failure_count(), 2);
1317    }
1318
1319    #[tokio::test]
1320    async fn test_server_init_result_all_success() {
1321        let mut result = ServerInitResult::new();
1322
1323        let transport1 = crate::test_lsp::inert_transport();
1324        let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
1325        let (_, mock_notification_rx1) = mpsc::channel(1);
1326        let (_, mock_lifecycle_rx1) = mpsc::channel(1);
1327
1328        let server1 = LspServer {
1329            client: client1,
1330            capabilities: lsp_types::ServerCapabilities::default(),
1331            position_encoding: PositionEncodingKind::UTF8,
1332            notification_rx: mock_notification_rx1,
1333            lifecycle_rx: mock_lifecycle_rx1,
1334            child: None,
1335        };
1336
1337        result.add_server("rust".to_string(), server1);
1338
1339        assert!(result.has_servers());
1340        assert!(!result.all_failed());
1341        assert!(!result.partial_success());
1342        assert_eq!(result.server_count(), 1);
1343        assert_eq!(result.failure_count(), 0);
1344    }
1345
1346    #[tokio::test]
1347    async fn test_server_init_result_partial_success() {
1348        let mut result = ServerInitResult::new();
1349
1350        let transport = crate::test_lsp::inert_transport();
1351        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1352        let (_, mock_notification_rx) = mpsc::channel(1);
1353        let (_, mock_lifecycle_rx) = mpsc::channel(1);
1354
1355        let server = LspServer {
1356            client,
1357            capabilities: lsp_types::ServerCapabilities::default(),
1358            position_encoding: PositionEncodingKind::UTF8,
1359            notification_rx: mock_notification_rx,
1360            lifecycle_rx: mock_lifecycle_rx,
1361            child: None,
1362        };
1363
1364        result.add_server("rust".to_string(), server);
1365
1366        result.add_failure(ServerSpawnFailure {
1367            server_id: ServerId::from("python"),
1368            language_id: "python".to_string(),
1369            command: "pyright".to_string(),
1370            message: "not found".to_string(),
1371        });
1372
1373        assert!(result.has_servers());
1374        assert!(!result.all_failed());
1375        assert!(result.partial_success());
1376        assert_eq!(result.server_count(), 1);
1377        assert_eq!(result.failure_count(), 1);
1378    }
1379
1380    #[tokio::test]
1381    async fn test_server_init_result_multiple_servers() {
1382        let mut result = ServerInitResult::new();
1383
1384        for i in 0..3 {
1385            let transport = crate::test_lsp::inert_transport();
1386            let config = if i == 0 {
1387                LspServerConfig::rust_analyzer()
1388            } else if i == 1 {
1389                LspServerConfig::pyright()
1390            } else {
1391                LspServerConfig::typescript()
1392            };
1393            let client = LspClient::from_transport(config.clone(), transport);
1394            let (_, mock_notification_rx) = mpsc::channel(1);
1395            let (_, mock_lifecycle_rx) = mpsc::channel(1);
1396
1397            let server = LspServer {
1398                client,
1399                capabilities: lsp_types::ServerCapabilities::default(),
1400                position_encoding: PositionEncodingKind::UTF8,
1401                notification_rx: mock_notification_rx,
1402                lifecycle_rx: mock_lifecycle_rx,
1403                child: None,
1404            };
1405
1406            result.add_server(config.language_id, server);
1407        }
1408
1409        assert!(result.has_servers());
1410        assert!(!result.all_failed());
1411        assert!(!result.partial_success());
1412        assert_eq!(result.server_count(), 3);
1413        assert_eq!(result.failure_count(), 0);
1414    }
1415
1416    #[tokio::test]
1417    async fn test_server_init_result_replace_server() {
1418        let mut result = ServerInitResult::new();
1419
1420        let transport1 = crate::test_lsp::inert_transport();
1421        let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
1422        let (_, mock_notification_rx1) = mpsc::channel(1);
1423        let (_, mock_lifecycle_rx1) = mpsc::channel(1);
1424
1425        let server1 = LspServer {
1426            client: client1,
1427            capabilities: lsp_types::ServerCapabilities::default(),
1428            position_encoding: PositionEncodingKind::UTF8,
1429            notification_rx: mock_notification_rx1,
1430            lifecycle_rx: mock_lifecycle_rx1,
1431            child: None,
1432        };
1433
1434        result.add_server("rust".to_string(), server1);
1435        assert_eq!(result.server_count(), 1);
1436
1437        let transport2 = crate::test_lsp::inert_transport();
1438        let client2 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport2);
1439        let (_, mock_notification_rx2) = mpsc::channel(1);
1440        let (_, mock_lifecycle_rx2) = mpsc::channel(1);
1441
1442        let server2 = LspServer {
1443            client: client2,
1444            capabilities: lsp_types::ServerCapabilities::default(),
1445            position_encoding: PositionEncodingKind::UTF16,
1446            notification_rx: mock_notification_rx2,
1447            lifecycle_rx: mock_lifecycle_rx2,
1448            child: None,
1449        };
1450
1451        result.add_server("rust".to_string(), server2);
1452        assert_eq!(result.server_count(), 1);
1453    }
1454
1455    #[test]
1456    fn test_server_init_result_debug() {
1457        let mut result = ServerInitResult::new();
1458
1459        result.add_failure(ServerSpawnFailure {
1460            server_id: ServerId::from("rust"),
1461            language_id: "rust".to_string(),
1462            command: "rust-analyzer".to_string(),
1463            message: "not found".to_string(),
1464        });
1465
1466        let debug_str = format!("{result:?}");
1467        assert!(debug_str.contains("ServerInitResult"));
1468    }
1469
1470    #[test]
1471    fn test_server_init_result_multiple_failures() {
1472        let mut result = ServerInitResult::new();
1473
1474        result.add_failure(ServerSpawnFailure {
1475            server_id: ServerId::from("python"),
1476            language_id: "python".to_string(),
1477            command: "pyright".to_string(),
1478            message: "not found".to_string(),
1479        });
1480
1481        result.add_failure(ServerSpawnFailure {
1482            server_id: ServerId::from("typescript"),
1483            language_id: "typescript".to_string(),
1484            command: "tsserver".to_string(),
1485            message: "command not found".to_string(),
1486        });
1487
1488        assert_eq!(result.failure_count(), 2);
1489        assert_eq!(result.server_count(), 0);
1490        assert!(result.all_failed());
1491        assert!(!result.partial_success());
1492    }
1493
1494    #[tokio::test]
1495    async fn test_spawn_batch_empty_configs() {
1496        let configs: &[ServerInitConfig] = &[];
1497        let result = LspServer::spawn_batch(configs).await;
1498
1499        assert!(!result.has_servers());
1500        assert!(!result.all_failed());
1501        assert!(!result.partial_success());
1502        assert_eq!(result.server_count(), 0);
1503        assert_eq!(result.failure_count(), 0);
1504    }
1505
1506    #[tokio::test]
1507    async fn test_spawn_batch_single_invalid_config() {
1508        let configs = vec![ServerInitConfig {
1509            server_config: LspServerConfig {
1510                language_id: "rust".to_string(),
1511                command: "nonexistent-command-12345".to_string(),
1512                args: vec![],
1513                env: std::collections::HashMap::new(),
1514                file_patterns: vec!["**/*.rs".to_string()],
1515                initialization_options: None,
1516                timeout_seconds: 10,
1517                request_timeout_seconds: 10,
1518                heuristics: None,
1519                name: None,
1520                handles: None,
1521                indexing: crate::bridge::IndexingPolicy::Auto,
1522            },
1523            workspace_roots: vec![],
1524            initialization_options: None,
1525            position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1526            notification_tx: None,
1527        }];
1528
1529        let result = LspServer::spawn_batch(&configs).await;
1530
1531        assert!(!result.has_servers());
1532        assert!(result.all_failed());
1533        assert!(!result.partial_success());
1534        assert_eq!(result.server_count(), 0);
1535        assert_eq!(result.failure_count(), 1);
1536
1537        let failure = &result.failures[0];
1538        assert_eq!(failure.language_id, "rust");
1539        assert_eq!(failure.command, "nonexistent-command-12345");
1540        assert!(failure.message.contains("spawn"));
1541    }
1542
1543    #[tokio::test]
1544    async fn test_spawn_batch_all_invalid_configs() {
1545        let configs = vec![
1546            ServerInitConfig {
1547                server_config: LspServerConfig {
1548                    language_id: "rust".to_string(),
1549                    command: "nonexistent-rust-analyzer".to_string(),
1550                    args: vec![],
1551                    env: std::collections::HashMap::new(),
1552                    file_patterns: vec!["**/*.rs".to_string()],
1553                    initialization_options: None,
1554                    timeout_seconds: 10,
1555                    request_timeout_seconds: 10,
1556                    heuristics: None,
1557                    name: None,
1558                    handles: None,
1559                    indexing: crate::bridge::IndexingPolicy::Auto,
1560                },
1561                workspace_roots: vec![],
1562                initialization_options: None,
1563                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1564                notification_tx: None,
1565            },
1566            ServerInitConfig {
1567                server_config: LspServerConfig {
1568                    language_id: "python".to_string(),
1569                    command: "nonexistent-pyright".to_string(),
1570                    args: vec![],
1571                    env: std::collections::HashMap::new(),
1572                    file_patterns: vec!["**/*.py".to_string()],
1573                    initialization_options: None,
1574                    timeout_seconds: 10,
1575                    request_timeout_seconds: 10,
1576                    heuristics: None,
1577                    name: None,
1578                    handles: None,
1579                    indexing: crate::bridge::IndexingPolicy::Auto,
1580                },
1581                workspace_roots: vec![],
1582                initialization_options: None,
1583                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1584                notification_tx: None,
1585            },
1586            ServerInitConfig {
1587                server_config: LspServerConfig {
1588                    language_id: "typescript".to_string(),
1589                    command: "nonexistent-tsserver".to_string(),
1590                    args: vec![],
1591                    env: std::collections::HashMap::new(),
1592                    file_patterns: vec!["**/*.ts".to_string()],
1593                    initialization_options: None,
1594                    timeout_seconds: 10,
1595                    request_timeout_seconds: 10,
1596                    heuristics: None,
1597                    name: None,
1598                    handles: None,
1599                    indexing: crate::bridge::IndexingPolicy::Auto,
1600                },
1601                workspace_roots: vec![],
1602                initialization_options: None,
1603                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1604                notification_tx: None,
1605            },
1606        ];
1607
1608        let result = LspServer::spawn_batch(&configs).await;
1609
1610        assert!(!result.has_servers());
1611        assert!(result.all_failed());
1612        assert!(!result.partial_success());
1613        assert_eq!(result.server_count(), 0);
1614        assert_eq!(result.failure_count(), 3);
1615
1616        let failure_languages: Vec<_> = result
1617            .failures
1618            .iter()
1619            .map(|f| f.language_id.as_str())
1620            .collect();
1621        assert!(failure_languages.contains(&"rust"));
1622        assert!(failure_languages.contains(&"python"));
1623        assert!(failure_languages.contains(&"typescript"));
1624    }
1625
1626    #[tokio::test]
1627    async fn test_spawn_batch_multiple_invalid_configs_ordering() {
1628        let configs = vec![
1629            ServerInitConfig {
1630                server_config: LspServerConfig {
1631                    language_id: "lang1".to_string(),
1632                    command: "cmd1-nonexistent".to_string(),
1633                    args: vec![],
1634                    env: std::collections::HashMap::new(),
1635                    file_patterns: vec![],
1636                    initialization_options: None,
1637                    timeout_seconds: 10,
1638                    request_timeout_seconds: 10,
1639                    heuristics: None,
1640                    name: None,
1641                    handles: None,
1642                    indexing: crate::bridge::IndexingPolicy::Auto,
1643                },
1644                workspace_roots: vec![],
1645                initialization_options: None,
1646                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1647                notification_tx: None,
1648            },
1649            ServerInitConfig {
1650                server_config: LspServerConfig {
1651                    language_id: "lang2".to_string(),
1652                    command: "cmd2-nonexistent".to_string(),
1653                    args: vec![],
1654                    env: std::collections::HashMap::new(),
1655                    file_patterns: vec![],
1656                    initialization_options: None,
1657                    timeout_seconds: 10,
1658                    request_timeout_seconds: 10,
1659                    heuristics: None,
1660                    name: None,
1661                    handles: None,
1662                    indexing: crate::bridge::IndexingPolicy::Auto,
1663                },
1664                workspace_roots: vec![],
1665                initialization_options: None,
1666                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1667                notification_tx: None,
1668            },
1669        ];
1670
1671        let result = LspServer::spawn_batch(&configs).await;
1672
1673        assert_eq!(result.failure_count(), 2);
1674
1675        assert_eq!(result.failures[0].language_id, "lang1");
1676        assert_eq!(result.failures[0].command, "cmd1-nonexistent");
1677
1678        assert_eq!(result.failures[1].language_id, "lang2");
1679        assert_eq!(result.failures[1].command, "cmd2-nonexistent");
1680    }
1681
1682    /// Wire-level regressions for the `initialize` request. The tests
1683    /// capture the real bytes `LspServer::initialize` writes over an
1684    /// in-memory duplex pipe standing in for the LSP server. Mirrors the
1685    /// `fake_lsp_client`/`FakeServer` pattern in
1686    /// `client.rs::tests::retry_behavior`.
1687    mod initialize_wire {
1688        use tempfile::TempDir;
1689        use tokio::io::BufReader;
1690
1691        use super::*;
1692        use crate::test_lsp::{
1693            fake_lsp_client, read_framed_message, write_response as write_success_response,
1694        };
1695
1696        #[tokio::test]
1697        async fn test_initialize_sends_configured_position_encodings() {
1698            let (client, mut server) = fake_lsp_client();
1699
1700            let config = ServerInitConfig {
1701                server_config: LspServerConfig::rust_analyzer(),
1702                workspace_roots: vec![],
1703                initialization_options: None,
1704                position_encodings: vec!["utf-32".to_string(), "utf-8".to_string()],
1705                notification_tx: None,
1706            };
1707
1708            let init_task =
1709                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1710
1711            let mut reader = BufReader::new(&mut server.write_stdout);
1712            let request = read_framed_message(&mut reader).await;
1713
1714            assert_eq!(request["method"], "initialize");
1715            assert_eq!(
1716                request["params"]["capabilities"]["general"]["positionEncodings"],
1717                serde_json::json!(["utf-32", "utf-8"]),
1718                "initialize request must carry the configured encoding order, not the \
1719                 hardcoded [UTF8, UTF16] default"
1720            );
1721
1722            write_success_response(
1723                &mut server.read_half_stdin,
1724                &request["id"].clone(),
1725                serde_json::json!({ "capabilities": {} }),
1726            )
1727            .await;
1728
1729            // The response written above must let `initialize` complete successfully.
1730            init_task.await.unwrap().unwrap();
1731        }
1732
1733        #[tokio::test]
1734        async fn test_initialize_advertises_stale_request_support() {
1735            let (client, mut server) = fake_lsp_client();
1736
1737            let config = ServerInitConfig {
1738                server_config: LspServerConfig::rust_analyzer(),
1739                workspace_roots: vec![],
1740                initialization_options: None,
1741                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1742                notification_tx: None,
1743            };
1744
1745            let init_task =
1746                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1747
1748            let mut reader = BufReader::new(&mut server.write_stdout);
1749            let request = read_framed_message(&mut reader).await;
1750            let params: InitializeParams =
1751                serde_json::from_value(request["params"].clone()).unwrap();
1752            let stale_request_support = params
1753                .capabilities
1754                .general
1755                .unwrap()
1756                .stale_request_support
1757                .unwrap();
1758
1759            assert_eq!(request["method"], "initialize");
1760            assert!(
1761                !stale_request_support.cancel,
1762                "mcpls does not implement active in-flight request cancellation"
1763            );
1764            assert_eq!(
1765                stale_request_support.retry_on_content_modified,
1766                CONTENT_MODIFIED_RETRY_METHODS
1767                    .iter()
1768                    .map(ToString::to_string)
1769                    .collect::<Vec<_>>(),
1770                "the wire-advertised capability must match the methods LspClient::request \
1771                 actually retries -32801 for, not drift from it"
1772            );
1773
1774            write_success_response(
1775                &mut server.read_half_stdin,
1776                &request["id"].clone(),
1777                serde_json::json!({ "capabilities": {} }),
1778            )
1779            .await;
1780
1781            init_task.await.unwrap().unwrap();
1782        }
1783
1784        #[tokio::test]
1785        async fn test_initialize_advertises_server_status_notification_support() {
1786            let (client, mut server) = fake_lsp_client();
1787
1788            let config = ServerInitConfig {
1789                server_config: LspServerConfig::rust_analyzer(),
1790                workspace_roots: vec![],
1791                initialization_options: None,
1792                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1793                notification_tx: None,
1794            };
1795
1796            let init_task =
1797                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1798
1799            let mut reader = BufReader::new(&mut server.write_stdout);
1800            let request = read_framed_message(&mut reader).await;
1801
1802            assert_eq!(request["method"], "initialize");
1803            assert_eq!(
1804                request["params"]["capabilities"]["experimental"]["serverStatusNotification"],
1805                serde_json::json!(true),
1806                "without this, rust-analyzer never emits experimental/serverStatus and the \
1807                 indexing-readiness gate in Translator::wait_for_indexing_ready is a \
1808                 permanent no-op"
1809            );
1810
1811            write_success_response(
1812                &mut server.read_half_stdin,
1813                &request["id"].clone(),
1814                serde_json::json!({ "capabilities": {} }),
1815            )
1816            .await;
1817
1818            init_task.await.unwrap().unwrap();
1819        }
1820
1821        #[tokio::test]
1822        async fn test_initialize_advertises_hierarchical_document_symbols() {
1823            let (client, mut server) = fake_lsp_client();
1824
1825            let config = ServerInitConfig {
1826                server_config: LspServerConfig::rust_analyzer(),
1827                workspace_roots: vec![],
1828                initialization_options: None,
1829                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1830                notification_tx: None,
1831            };
1832
1833            let init_task =
1834                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1835
1836            let mut reader = BufReader::new(&mut server.write_stdout);
1837            let request = read_framed_message(&mut reader).await;
1838            let params: InitializeParams =
1839                serde_json::from_value(request["params"].clone()).unwrap();
1840            let document_symbol = params
1841                .capabilities
1842                .text_document
1843                .unwrap()
1844                .document_symbol
1845                .unwrap();
1846
1847            assert_eq!(request["method"], "initialize");
1848            assert_eq!(document_symbol.dynamic_registration, Some(false));
1849            assert_eq!(
1850                document_symbol.hierarchical_document_symbol_support,
1851                Some(true)
1852            );
1853            assert_eq!(
1854                document_symbol.symbol_kind.unwrap().value_set,
1855                Some(SUPPORTED_SYMBOL_KINDS.to_vec())
1856            );
1857
1858            write_success_response(
1859                &mut server.read_half_stdin,
1860                &request["id"].clone(),
1861                serde_json::json!({ "capabilities": {} }),
1862            )
1863            .await;
1864
1865            init_task.await.unwrap().unwrap();
1866        }
1867
1868        #[tokio::test]
1869        async fn test_initialize_accepts_resolved_dot_workspace_root() {
1870            let temp_dir = TempDir::new().unwrap();
1871            let base = dunce::canonicalize(temp_dir.path()).unwrap();
1872            let workspace_roots =
1873                crate::resolve_workspace_roots(&[PathBuf::from(".")], &base).unwrap();
1874            assert_eq!(workspace_roots, vec![base.clone()]);
1875
1876            let (client, mut server) = fake_lsp_client();
1877            let config = ServerInitConfig {
1878                server_config: LspServerConfig::rust_analyzer(),
1879                workspace_roots,
1880                initialization_options: None,
1881                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1882                notification_tx: None,
1883            };
1884
1885            let init_task =
1886                tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1887
1888            let mut reader = BufReader::new(&mut server.write_stdout);
1889            let request = read_framed_message(&mut reader).await;
1890            let expected_uri = try_path_to_uri(&base).unwrap();
1891            assert_eq!(
1892                request["params"]["workspaceFolders"][0]["uri"],
1893                expected_uri.as_ref()
1894            );
1895
1896            write_success_response(
1897                &mut server.read_half_stdin,
1898                &request["id"].clone(),
1899                serde_json::json!({ "capabilities": {} }),
1900            )
1901            .await;
1902
1903            init_task.await.unwrap().unwrap();
1904        }
1905    }
1906
1907    #[tokio::test]
1908    async fn test_spawn_batch_logs_each_failure() {
1909        let configs = vec![
1910            ServerInitConfig {
1911                server_config: LspServerConfig {
1912                    language_id: "test1".to_string(),
1913                    command: "nonexistent-test1".to_string(),
1914                    args: vec![],
1915                    env: std::collections::HashMap::new(),
1916                    file_patterns: vec![],
1917                    initialization_options: None,
1918                    timeout_seconds: 10,
1919                    request_timeout_seconds: 10,
1920                    heuristics: None,
1921                    name: None,
1922                    handles: None,
1923                    indexing: crate::bridge::IndexingPolicy::Auto,
1924                },
1925                workspace_roots: vec![],
1926                initialization_options: None,
1927                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1928                notification_tx: None,
1929            },
1930            ServerInitConfig {
1931                server_config: LspServerConfig {
1932                    language_id: "test2".to_string(),
1933                    command: "nonexistent-test2".to_string(),
1934                    args: vec![],
1935                    env: std::collections::HashMap::new(),
1936                    file_patterns: vec![],
1937                    initialization_options: None,
1938                    timeout_seconds: 10,
1939                    request_timeout_seconds: 10,
1940                    heuristics: None,
1941                    name: None,
1942                    handles: None,
1943                    indexing: crate::bridge::IndexingPolicy::Auto,
1944                },
1945                workspace_roots: vec![],
1946                initialization_options: None,
1947                position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1948                notification_tx: None,
1949            },
1950        ];
1951
1952        let result = LspServer::spawn_batch(&configs).await;
1953
1954        assert_eq!(result.failure_count(), 2);
1955        assert_eq!(result.failures[0].language_id, "test1");
1956        assert_eq!(result.failures[1].language_id, "test2");
1957    }
1958
1959    /// Minimal [`LspServerConfig`] for `build_command` tests, where only
1960    /// `command`/`args`/`env` matter.
1961    fn bare_server_config(env: HashMap<String, String>) -> LspServerConfig {
1962        LspServerConfig {
1963            language_id: "test".to_string(),
1964            command: "irrelevant-for-build-command".to_string(),
1965            args: vec![],
1966            env,
1967            file_patterns: vec![],
1968            initialization_options: None,
1969            timeout_seconds: 5,
1970            request_timeout_seconds: 5,
1971            heuristics: None,
1972            name: None,
1973            handles: None,
1974            indexing: crate::bridge::IndexingPolicy::Auto,
1975        }
1976    }
1977
1978    /// Collects the env vars a `Command` would set, resolving `env_clear`
1979    /// removals (`None` values from `get_envs`) away so the map reflects
1980    /// what the child process would actually see.
1981    fn effective_envs(command: &Command) -> HashMap<String, String> {
1982        command
1983            .as_std()
1984            .get_envs()
1985            .filter_map(|(k, v)| {
1986                v.map(|v| {
1987                    (
1988                        k.to_string_lossy().into_owned(),
1989                        v.to_string_lossy().into_owned(),
1990                    )
1991                })
1992            })
1993            .collect()
1994    }
1995
1996    /// Regression test for #236/#246: a spawned LSP server used to inherit
1997    /// mcpls's entire environment. `build_command` must only pass through
1998    /// `ENV_PASSTHROUGH` keys from `parent_env`, not arbitrary ones.
1999    #[test]
2000    fn test_build_command_excludes_non_allowlisted_parent_env_vars() {
2001        let config = bare_server_config(HashMap::new());
2002        let command = LspServer::build_command(&config, |key| match key {
2003            "PATH" => Some("/parent/bin".into()),
2004            "MCPLS_TEST_LEAK_CANARY" => Some("should-not-reach-child".into()),
2005            _ => None,
2006        });
2007
2008        let envs = effective_envs(&command);
2009
2010        assert!(
2011            !envs.contains_key("MCPLS_TEST_LEAK_CANARY"),
2012            "non-allowlisted parent env var leaked into child command: {envs:?}"
2013        );
2014
2015        // The assertion above is provably vacuous on its own:
2016        // `Command::get_envs()` only reports explicit `.env()`/`.envs()`
2017        // modifications and is blind to whether `.env_clear()` was called,
2018        // and `build_command`'s passthrough loop never even queries
2019        // `parent_env` for a key outside `ENV_PASSTHROUGH`, so it would
2020        // pass unchanged even if `.env_clear()` were deleted from
2021        // `build_command` entirely. `std::process::Command`'s `Debug` impl
2022        // does encode clearing, prefixing the formatted command with
2023        // `env -i ` on Unix once `.env_clear()` has run; assert on that to
2024        // actually guard against the clear being removed.
2025        #[cfg(unix)]
2026        assert!(
2027            format!("{:?}", command.as_std()).starts_with("env -i "),
2028            "build_command must call .env_clear() so the child doesn't inherit the full parent environment"
2029        );
2030    }
2031
2032    /// Regression test for #236/#246: allowlisted vars present in the parent
2033    /// (e.g. `PATH`) must still reach the child.
2034    #[test]
2035    fn test_build_command_passes_through_allowlisted_env_vars() {
2036        let config = bare_server_config(HashMap::new());
2037        let command =
2038            LspServer::build_command(&config, |key| (key == "PATH").then(|| "/parent/bin".into()));
2039
2040        let envs = effective_envs(&command);
2041
2042        assert_eq!(envs.get("PATH"), Some(&"/parent/bin".to_string()));
2043    }
2044
2045    /// Regression test for #247: `LspServerConfig::env` entries must reach
2046    /// the spawned child (previously dead configuration).
2047    #[test]
2048    fn test_build_command_includes_configured_env_vars() {
2049        let mut env = HashMap::new();
2050        env.insert(
2051            "MCPLS_TEST_CONFIGURED".to_string(),
2052            "from-server-config".to_string(),
2053        );
2054        let config = bare_server_config(env);
2055        let command = LspServer::build_command(&config, |_| None);
2056
2057        let envs = effective_envs(&command);
2058
2059        assert_eq!(
2060            envs.get("MCPLS_TEST_CONFIGURED"),
2061            Some(&"from-server-config".to_string())
2062        );
2063    }
2064
2065    /// Regression test for #247: a `LspServerConfig::env` entry must be able
2066    /// to override an allowlisted passthrough value, since `config.env` is
2067    /// applied after the passthrough loop in `build_command`.
2068    #[test]
2069    fn test_build_command_configured_env_overrides_allowlisted_var() {
2070        let mut env = HashMap::new();
2071        env.insert("PATH".to_string(), "/configured/override/path".to_string());
2072        let config = bare_server_config(env);
2073        let command =
2074            LspServer::build_command(&config, |key| (key == "PATH").then(|| "/parent/bin".into()));
2075
2076        let envs = effective_envs(&command);
2077
2078        assert_eq!(
2079            envs.get("PATH"),
2080            Some(&"/configured/override/path".to_string())
2081        );
2082    }
2083
2084    /// #174 §8/S2 regression: `register_servers`'s diagnostics-cache flags
2085    /// must be computed from the *rebound* router, not the pre-rebind view.
2086    /// Sets up a `python` config where a narrow "diagnostics-only" server
2087    /// (`pyright-diag`) is configured but never actually registers (as if
2088    /// it failed to spawn), leaving only a catch-all (`pylsp`) live. Before
2089    /// the fix, computing the flags from the pre-rebind router would resolve
2090    /// `Diagnostics` to the dead `pyright-diag` for every survivor, so
2091    /// `pylsp` would be flagged `false` and the diagnostics cache would go
2092    /// silently dark for `python` despite a live server being available.
2093    #[tokio::test]
2094    async fn test_register_servers_computes_diagnostics_flags_from_rebound_router() {
2095        use crate::bridge::Translator;
2096        use crate::config::{ServerId, ToolKind, ToolRouter};
2097
2098        let pylsp_id = ServerId::from("pylsp");
2099        let configs = vec![
2100            LspServerConfig {
2101                language_id: "python".to_string(),
2102                command: "pyright-langserver".to_string(),
2103                args: vec![],
2104                env: std::collections::HashMap::new(),
2105                file_patterns: vec![],
2106                initialization_options: None,
2107                timeout_seconds: 30,
2108                request_timeout_seconds: 30,
2109                heuristics: None,
2110                name: Some("pyright-diag".to_string()),
2111                handles: Some(vec![ToolKind::Diagnostics]),
2112                indexing: crate::bridge::IndexingPolicy::Auto,
2113            },
2114            LspServerConfig {
2115                language_id: "python".to_string(),
2116                command: "pylsp".to_string(),
2117                args: vec![],
2118                env: std::collections::HashMap::new(),
2119                file_patterns: vec![],
2120                initialization_options: None,
2121                timeout_seconds: 30,
2122                request_timeout_seconds: 30,
2123                heuristics: None,
2124                name: Some("pylsp".to_string()),
2125                handles: None,
2126                indexing: crate::bridge::IndexingPolicy::Auto,
2127            },
2128        ];
2129        let router = ToolRouter::from_configs(&configs).unwrap();
2130        let translator = Translator::new().with_router(router);
2131
2132        // Only pylsp actually registers; pyright-diag never spawned.
2133        let mut result = ServerInitResult::new();
2134        result.add_server(pylsp_id.clone(), fake_lsp_server());
2135
2136        let registered = crate::register_servers(result, &translator, &HashMap::new());
2137
2138        assert_eq!(
2139            registered.diagnostics_flags.get(&pylsp_id),
2140            Some(&true),
2141            "pylsp must inherit the diagnostics route once pyright-diag is \
2142             known dead, and the flag must reflect that post-rebind state"
2143        );
2144    }
2145}