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