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