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