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::PathBuf;
12use std::process::Stdio;
13use std::str::FromStr;
14
15use lsp_types::{
16    ClientCapabilities, ClientInfo, GeneralClientCapabilities, InitializeParams, InitializeResult,
17    InitializedParams, PositionEncodingKind, ServerCapabilities, Uri, WorkspaceFolder,
18};
19use tokio::process::Command;
20use tokio::sync::mpsc;
21use tokio::time::Duration;
22use tracing::{debug, info};
23
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/// State of an LSP server connection.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ServerState {
33    /// Server has not been initialized.
34    Uninitialized,
35    /// Server is currently initializing.
36    Initializing,
37    /// Server is ready to handle requests.
38    Ready,
39    /// Server is shutting down.
40    ShuttingDown,
41    /// Server has been shut down.
42    Shutdown,
43}
44
45impl ServerState {
46    /// Check if the server is ready to handle requests.
47    #[must_use]
48    pub const fn is_ready(&self) -> bool {
49        matches!(self, Self::Ready)
50    }
51
52    /// Check if the server can accept new requests.
53    #[must_use]
54    pub const fn can_accept_requests(&self) -> bool {
55        matches!(self, Self::Ready)
56    }
57}
58
59/// Configuration for LSP server initialization.
60#[derive(Debug, Clone)]
61pub struct ServerInitConfig {
62    /// LSP server configuration.
63    pub server_config: LspServerConfig,
64    /// Workspace root paths.
65    pub workspace_roots: Vec<PathBuf>,
66    /// Initialization options (server-specific JSON).
67    pub initialization_options: Option<serde_json::Value>,
68    /// Optional channel for forwarding LSP notifications to the notification cache.
69    ///
70    /// When `Some`, the spawned LSP client sends every notification it receives
71    /// (publishDiagnostics, logMessage, showMessage, …) through this sender.
72    /// The caller is responsible for draining the corresponding receiver and
73    /// storing entries in [`crate::bridge::NotificationCache`].
74    pub notification_tx: Option<mpsc::Sender<LspNotification>>,
75}
76
77/// Result of attempting to spawn multiple LSP servers.
78///
79/// This type enables graceful degradation by collecting both
80/// successful initializations and failures. Use the helper methods
81/// to inspect the outcome and make decisions about how to proceed.
82///
83/// # Examples
84///
85/// ```
86/// use mcpls_core::lsp::ServerInitResult;
87/// use mcpls_core::error::ServerSpawnFailure;
88///
89/// let mut result = ServerInitResult::new();
90///
91/// // Check for different scenarios
92/// if result.all_failed() {
93///     eprintln!("All servers failed to initialize");
94/// } else if result.partial_success() {
95///     println!("Some servers succeeded, some failed");
96/// } else if result.has_servers() {
97///     println!("All servers initialized successfully");
98/// }
99/// ```
100#[derive(Debug)]
101pub struct ServerInitResult {
102    /// Successfully initialized servers, keyed by routing identity.
103    pub servers: HashMap<ServerId, LspServer>,
104    /// Failures that occurred during spawn attempts.
105    pub failures: Vec<ServerSpawnFailure>,
106}
107
108impl ServerInitResult {
109    /// Create a new empty result.
110    #[must_use]
111    pub fn new() -> Self {
112        Self {
113            servers: HashMap::new(),
114            failures: Vec::new(),
115        }
116    }
117
118    /// Check if any servers were successfully initialized.
119    ///
120    /// Returns `true` if at least one server is available for use.
121    #[must_use]
122    pub fn has_servers(&self) -> bool {
123        !self.servers.is_empty()
124    }
125
126    /// Check if all attempted servers failed.
127    ///
128    /// Returns `true` only if there were failures and no servers succeeded.
129    /// Returns `false` for empty results (no servers configured).
130    #[must_use]
131    pub fn all_failed(&self) -> bool {
132        self.servers.is_empty() && !self.failures.is_empty()
133    }
134
135    /// Check if some but not all servers failed.
136    ///
137    /// Returns `true` if there are both successful servers and failures.
138    #[must_use]
139    pub fn partial_success(&self) -> bool {
140        !self.servers.is_empty() && !self.failures.is_empty()
141    }
142
143    /// Get the number of successfully initialized servers.
144    #[must_use]
145    pub fn server_count(&self) -> usize {
146        self.servers.len()
147    }
148
149    /// Get the number of failures.
150    #[must_use]
151    pub const fn failure_count(&self) -> usize {
152        self.failures.len()
153    }
154
155    /// Add a successful server.
156    ///
157    /// If a server with the same [`ServerId`] already exists, it will be replaced.
158    pub fn add_server(&mut self, id: impl Into<ServerId>, server: LspServer) {
159        self.servers.insert(id.into(), server);
160    }
161
162    /// Add a failure.
163    pub fn add_failure(&mut self, failure: ServerSpawnFailure) {
164        self.failures.push(failure);
165    }
166}
167
168impl Default for ServerInitResult {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174/// Managed LSP server instance with capabilities and encoding.
175pub struct LspServer {
176    client: LspClient,
177    capabilities: ServerCapabilities,
178    position_encoding: PositionEncodingKind,
179    /// Receiver for push notifications from the LSP server.
180    ///
181    /// Extract this before registering the server to receive real-time
182    /// notifications (e.g., `textDocument/publishDiagnostics`, `$/progress`).
183    pub notification_rx: mpsc::Receiver<LspNotification>,
184    /// Child process handle. Kept alive for process lifetime management.
185    /// When dropped, the process is terminated via SIGKILL (`kill_on_drop`).
186    _child: tokio::process::Child,
187}
188
189impl std::fmt::Debug for LspServer {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        f.debug_struct("LspServer")
192            .field("client", &self.client)
193            .field("capabilities", &self.capabilities)
194            .field("position_encoding", &self.position_encoding)
195            .field("notification_rx", &"<channel>")
196            .field("_child", &"<process>")
197            .finish()
198    }
199}
200
201impl LspServer {
202    /// Take the notification receiver out of this server, replacing it with a dummy channel.
203    ///
204    /// Use this to extract the receiver for a background pump task before registering
205    /// the server with the translator. After this call, the server's `notification_rx`
206    /// will never receive messages.
207    pub fn take_notification_rx(&mut self) -> tokio::sync::mpsc::Receiver<LspNotification> {
208        let (_, dummy) = tokio::sync::mpsc::channel(1);
209        std::mem::replace(&mut self.notification_rx, dummy)
210    }
211
212    /// Spawn and initialize LSP server.
213    ///
214    /// This performs the complete initialization sequence:
215    /// 1. Spawns the LSP server as a child process
216    /// 2. Sends initialize request with client capabilities
217    /// 3. Receives server capabilities from initialize response
218    /// 4. Sends initialized notification
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if:
223    /// - Server process fails to spawn
224    /// - Initialize request fails or times out
225    /// - Server returns error during initialization
226    pub async fn spawn(config: ServerInitConfig) -> Result<Self> {
227        info!(
228            "Spawning LSP server: {} {:?}",
229            config.server_config.command, config.server_config.args
230        );
231
232        let mut child = Command::new(&config.server_config.command)
233            .args(&config.server_config.args)
234            .stdin(Stdio::piped())
235            .stdout(Stdio::piped())
236            .stderr(Stdio::null())
237            .kill_on_drop(true)
238            .spawn()
239            .map_err(|e| Error::ServerSpawnFailed {
240                command: config.server_config.command.clone(),
241                source: e,
242            })?;
243
244        let stdin = child
245            .stdin
246            .take()
247            .ok_or_else(|| Error::Transport("Failed to capture stdin".to_string()))?;
248        let stdout = child
249            .stdout
250            .take()
251            .ok_or_else(|| Error::Transport("Failed to capture stdout".to_string()))?;
252
253        let transport = LspTransport::new(stdin, stdout);
254        let (notification_tx, notification_rx) = mpsc::channel(64);
255        let client = LspClient::from_transport_with_notifications(
256            config.server_config.clone(),
257            transport,
258            notification_tx,
259        );
260
261        let (capabilities, position_encoding) = Self::initialize(&client, &config).await?;
262
263        info!("LSP server initialized successfully");
264
265        Ok(Self {
266            client,
267            capabilities,
268            position_encoding,
269            notification_rx,
270            _child: child,
271        })
272    }
273
274    /// Perform LSP initialization handshake.
275    ///
276    /// Sends initialize request and waits for response, then sends initialized notification.
277    #[allow(clippy::too_many_lines)]
278    async fn initialize(
279        client: &LspClient,
280        config: &ServerInitConfig,
281    ) -> Result<(ServerCapabilities, PositionEncodingKind)> {
282        debug!("Sending initialize request");
283
284        let workspace_folders: Vec<WorkspaceFolder> = config
285            .workspace_roots
286            .iter()
287            .map(|root| {
288                let path_str = root.to_str().ok_or_else(|| {
289                    let root_display = root.display();
290                    Error::InvalidUri(format!("Invalid UTF-8 in path: {root_display}"))
291                })?;
292                let uri_str = if cfg!(windows) {
293                    // Strip \\?\ extended-path prefix that canonicalize() adds on Windows.
294                    let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(path_str);
295                    format!("file:///{}", stripped.replace('\\', "/"))
296                } else {
297                    format!("file://{path_str}")
298                };
299                let uri = Uri::from_str(&uri_str).map_err(|_| {
300                    let root_display = root.display();
301                    Error::InvalidUri(format!("Invalid workspace root: {root_display}"))
302                })?;
303                Ok(WorkspaceFolder {
304                    uri,
305                    name: root
306                        .file_name()
307                        .and_then(|n| n.to_str())
308                        .unwrap_or("workspace")
309                        .to_string(),
310                })
311            })
312            .collect::<Result<Vec<_>>>()?;
313
314        let params = InitializeParams {
315            process_id: Some(std::process::id()),
316            #[allow(deprecated)]
317            root_uri: None,
318            initialization_options: config.initialization_options.clone(),
319            capabilities: ClientCapabilities {
320                general: Some(GeneralClientCapabilities {
321                    position_encodings: Some(vec![
322                        PositionEncodingKind::UTF8,
323                        PositionEncodingKind::UTF16,
324                    ]),
325                    ..Default::default()
326                }),
327                text_document: Some(lsp_types::TextDocumentClientCapabilities {
328                    hover: Some(lsp_types::HoverClientCapabilities {
329                        dynamic_registration: Some(false),
330                        content_format: Some(vec![
331                            lsp_types::MarkupKind::Markdown,
332                            lsp_types::MarkupKind::PlainText,
333                        ]),
334                    }),
335                    definition: Some(lsp_types::GotoCapability {
336                        dynamic_registration: Some(false),
337                        link_support: Some(true),
338                    }),
339                    references: Some(lsp_types::ReferenceClientCapabilities {
340                        dynamic_registration: Some(false),
341                    }),
342                    code_action: Some(lsp_types::CodeActionClientCapabilities {
343                        dynamic_registration: Some(false),
344                        data_support: Some(true),
345                        resolve_support: Some(lsp_types::CodeActionCapabilityResolveSupport {
346                            properties: vec!["edit".to_string()],
347                        }),
348                        // Declare supported action kinds so the server returns
349                        // CodeAction objects (not just legacy Command objects).
350                        code_action_literal_support: Some(lsp_types::CodeActionLiteralSupport {
351                            code_action_kind: lsp_types::CodeActionKindLiteralSupport {
352                                value_set: [
353                                    lsp_types::CodeActionKind::EMPTY,
354                                    lsp_types::CodeActionKind::QUICKFIX,
355                                    lsp_types::CodeActionKind::REFACTOR,
356                                    lsp_types::CodeActionKind::REFACTOR_EXTRACT,
357                                    lsp_types::CodeActionKind::REFACTOR_INLINE,
358                                    lsp_types::CodeActionKind::REFACTOR_REWRITE,
359                                    lsp_types::CodeActionKind::SOURCE,
360                                    lsp_types::CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
361                                ]
362                                .iter()
363                                .map(|k| k.as_str().to_string())
364                                .collect(),
365                            },
366                        }),
367                        ..Default::default()
368                    }),
369                    ..Default::default()
370                }),
371                workspace: Some(lsp_types::WorkspaceClientCapabilities {
372                    workspace_folders: Some(true),
373                    ..Default::default()
374                }),
375                ..Default::default()
376            },
377            client_info: Some(ClientInfo {
378                name: "mcpls".to_string(),
379                version: Some(env!("CARGO_PKG_VERSION").to_string()),
380            }),
381            workspace_folders: Some(workspace_folders),
382            ..Default::default()
383        };
384
385        // Use the server's configured timeout for the initialize handshake too,
386        // not a hardcoded 30s: large solutions (e.g. a 130-project Unity .sln via
387        // OmniSharp) take minutes to respond to `initialize`.
388        let result: InitializeResult = client
389            .request(
390                "initialize",
391                params,
392                Duration::from_secs(config.server_config.timeout_seconds),
393            )
394            .await
395            .map_err(|e| Error::LspInitFailed {
396                message: format!("Initialize request failed: {e}"),
397            })?;
398
399        let position_encoding = result
400            .capabilities
401            .position_encoding
402            .clone()
403            .unwrap_or(PositionEncodingKind::UTF16);
404
405        debug!(
406            "Server capabilities received, encoding: {:?}",
407            position_encoding
408        );
409
410        client
411            .notify("initialized", InitializedParams {})
412            .await
413            .map_err(|e| Error::LspInitFailed {
414                message: format!("Initialized notification failed: {e}"),
415            })?;
416
417        Ok((result.capabilities, position_encoding))
418    }
419
420    /// Get server capabilities.
421    #[must_use]
422    pub const fn capabilities(&self) -> &ServerCapabilities {
423        &self.capabilities
424    }
425
426    /// Get negotiated position encoding.
427    #[must_use]
428    pub fn position_encoding(&self) -> PositionEncodingKind {
429        self.position_encoding.clone()
430    }
431
432    /// Get client for making requests.
433    #[must_use]
434    pub const fn client(&self) -> &LspClient {
435        &self.client
436    }
437
438    /// Shutdown server gracefully.
439    ///
440    /// Sends shutdown request, waits for response, then sends exit notification.
441    ///
442    /// # Errors
443    ///
444    /// Returns an error if shutdown sequence fails.
445    pub async fn shutdown(self) -> Result<()> {
446        debug!("Shutting down LSP server");
447
448        let _: serde_json::Value = self
449            .client
450            .request("shutdown", serde_json::Value::Null, Duration::from_secs(5))
451            .await?;
452
453        self.client.notify("exit", serde_json::Value::Null).await?;
454
455        self.client.shutdown().await?;
456
457        info!("LSP server shut down successfully");
458        Ok(())
459    }
460
461    /// Spawn multiple LSP servers in batch mode with graceful degradation.
462    ///
463    /// Attempts to spawn and initialize all configured servers. If some servers
464    /// fail to spawn, the successful servers are still returned. This enables
465    /// graceful degradation where the system can continue to operate with
466    /// partial functionality.
467    ///
468    /// # Behavior
469    ///
470    /// - Attempts to spawn each server sequentially
471    /// - Logs success (info) and failure (error) for each server
472    /// - Accumulates successful servers and failures
473    /// - Never panics or returns early - attempts all servers
474    ///
475    /// # Examples
476    ///
477    /// ```
478    /// use mcpls_core::lsp::{LspServer, ServerInitConfig};
479    /// use mcpls_core::config::LspServerConfig;
480    /// use std::path::PathBuf;
481    ///
482    /// # async fn example() {
483    /// let configs = vec![
484    ///     ServerInitConfig {
485    ///         server_config: LspServerConfig::rust_analyzer(),
486    ///         workspace_roots: vec![PathBuf::from("/workspace")],
487    ///         initialization_options: None,
488    ///         notification_tx: None,
489    ///     },
490    ///     ServerInitConfig {
491    ///         server_config: LspServerConfig::pyright(),
492    ///         workspace_roots: vec![PathBuf::from("/workspace")],
493    ///         initialization_options: None,
494    ///         notification_tx: None,
495    ///     },
496    /// ];
497    ///
498    /// let result = LspServer::spawn_batch(&configs).await;
499    ///
500    /// if result.has_servers() {
501    ///     println!("Successfully spawned {} servers", result.server_count());
502    /// }
503    ///
504    /// if result.partial_success() {
505    ///     eprintln!("Warning: {} servers failed", result.failure_count());
506    /// }
507    /// # }
508    /// ```
509    pub async fn spawn_batch(configs: &[ServerInitConfig]) -> ServerInitResult {
510        let mut result = ServerInitResult::new();
511
512        for config in configs {
513            let server_id = config.server_config.id();
514            let language_id = config.server_config.language_id.clone();
515            let command = config.server_config.command.clone();
516
517            match Self::spawn(config.clone()).await {
518                Ok(server) => {
519                    info!(
520                        "Successfully spawned LSP server: {} ({})",
521                        server_id, command
522                    );
523                    result.add_server(server_id, server);
524                }
525                Err(e) => {
526                    tracing::error!(
527                        "Failed to spawn LSP server: {} ({}): {}",
528                        server_id,
529                        command,
530                        e
531                    );
532                    result.add_failure(ServerSpawnFailure {
533                        server_id,
534                        language_id,
535                        command,
536                        message: e.to_string(),
537                    });
538                }
539            }
540        }
541
542        result
543    }
544}
545
546#[cfg(test)]
547#[allow(clippy::unwrap_used)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn test_server_state_ready() {
553        assert!(ServerState::Ready.is_ready());
554        assert!(ServerState::Ready.can_accept_requests());
555    }
556
557    #[test]
558    fn test_server_state_uninitialized() {
559        assert!(!ServerState::Uninitialized.is_ready());
560        assert!(!ServerState::Uninitialized.can_accept_requests());
561    }
562
563    #[test]
564    fn test_server_state_initializing() {
565        assert!(!ServerState::Initializing.is_ready());
566        assert!(!ServerState::Initializing.can_accept_requests());
567    }
568
569    #[test]
570    fn test_server_state_shutting_down() {
571        assert!(!ServerState::ShuttingDown.is_ready());
572        assert!(!ServerState::ShuttingDown.can_accept_requests());
573    }
574
575    #[test]
576    fn test_server_state_shutdown() {
577        assert!(!ServerState::Shutdown.is_ready());
578        assert!(!ServerState::Shutdown.can_accept_requests());
579    }
580
581    #[test]
582    fn test_server_state_equality() {
583        assert_eq!(ServerState::Ready, ServerState::Ready);
584        assert_ne!(ServerState::Ready, ServerState::Uninitialized);
585        assert_eq!(ServerState::Shutdown, ServerState::Shutdown);
586    }
587
588    #[test]
589    fn test_server_state_clone() {
590        let state = ServerState::Ready;
591        let cloned = state;
592        assert_eq!(state, cloned);
593    }
594
595    #[test]
596    fn test_server_state_debug() {
597        let state = ServerState::Ready;
598        let debug_str = format!("{state:?}");
599        assert!(debug_str.contains("Ready"));
600    }
601
602    #[test]
603    fn test_server_init_config_clone() {
604        let config = ServerInitConfig {
605            server_config: LspServerConfig::rust_analyzer(),
606            workspace_roots: vec![PathBuf::from("/tmp/workspace")],
607            initialization_options: Some(serde_json::json!({"key": "value"})),
608            notification_tx: None,
609        };
610
611        #[allow(clippy::redundant_clone)]
612        let cloned = config.clone();
613        assert_eq!(cloned.server_config.language_id, "rust");
614        assert_eq!(cloned.workspace_roots.len(), 1);
615    }
616
617    #[test]
618    fn test_server_init_config_debug() {
619        let config = ServerInitConfig {
620            server_config: LspServerConfig::pyright(),
621            workspace_roots: vec![],
622            initialization_options: None,
623            notification_tx: None,
624        };
625
626        let debug_str = format!("{config:?}");
627        assert!(debug_str.contains("python"));
628        assert!(debug_str.contains("pyright"));
629    }
630
631    #[test]
632    fn test_server_init_config_with_options() {
633        use std::collections::HashMap;
634
635        let init_opts = serde_json::json!({
636            "settings": {
637                "python": {
638                    "analysis": {
639                        "typeCheckingMode": "strict"
640                    }
641                }
642            }
643        });
644
645        let mut env = HashMap::new();
646        env.insert("PYTHONPATH".to_string(), "/usr/lib".to_string());
647
648        let config = ServerInitConfig {
649            server_config: LspServerConfig {
650                language_id: "python".to_string(),
651                command: "pyright-langserver".to_string(),
652                args: vec!["--stdio".to_string()],
653                env,
654                file_patterns: vec!["**/*.py".to_string()],
655                initialization_options: Some(init_opts.clone()),
656                timeout_seconds: 10,
657                heuristics: None,
658                name: None,
659                handles: None,
660            },
661            workspace_roots: vec![PathBuf::from("/workspace")],
662            initialization_options: Some(init_opts),
663            notification_tx: None,
664        };
665
666        assert!(config.initialization_options.is_some());
667        assert_eq!(config.workspace_roots.len(), 1);
668    }
669
670    #[test]
671    fn test_server_init_config_empty_workspace() {
672        let config = ServerInitConfig {
673            server_config: LspServerConfig::typescript(),
674            workspace_roots: vec![],
675            initialization_options: None,
676            notification_tx: None,
677        };
678
679        assert!(config.workspace_roots.is_empty());
680    }
681
682    #[test]
683    fn test_server_init_config_multiple_workspaces() {
684        let config = ServerInitConfig {
685            server_config: LspServerConfig::rust_analyzer(),
686            workspace_roots: vec![
687                PathBuf::from("/workspace1"),
688                PathBuf::from("/workspace2"),
689                PathBuf::from("/workspace3"),
690            ],
691            initialization_options: None,
692            notification_tx: None,
693        };
694
695        assert_eq!(config.workspace_roots.len(), 3);
696    }
697
698    #[tokio::test]
699    async fn test_lsp_server_getters() {
700        use lsp_types::ServerCapabilities;
701
702        let mock_child = tokio::process::Command::new("echo")
703            .stdin(Stdio::piped())
704            .stdout(Stdio::piped())
705            .kill_on_drop(true)
706            .spawn()
707            .unwrap();
708
709        let mock_stdin = tokio::process::Command::new("cat")
710            .stdin(Stdio::piped())
711            .spawn()
712            .unwrap()
713            .stdin
714            .take()
715            .unwrap();
716
717        let mock_stdout = tokio::process::Command::new("echo")
718            .stdout(Stdio::piped())
719            .spawn()
720            .unwrap()
721            .stdout
722            .take()
723            .unwrap();
724
725        let transport = LspTransport::new(mock_stdin, mock_stdout);
726        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
727        let (_, mock_notification_rx) = mpsc::channel(1);
728
729        let server = LspServer {
730            client,
731            capabilities: ServerCapabilities::default(),
732            position_encoding: PositionEncodingKind::UTF8,
733            notification_rx: mock_notification_rx,
734            _child: mock_child,
735        };
736
737        assert_eq!(server.position_encoding(), PositionEncodingKind::UTF8);
738        assert!(server.capabilities().text_document_sync.is_none());
739
740        let debug_str = format!("{server:?}");
741        assert!(debug_str.contains("LspServer"));
742        assert!(debug_str.contains("<process>"));
743    }
744
745    #[test]
746    fn test_server_init_result_new_empty() {
747        let result = ServerInitResult::new();
748        assert!(!result.has_servers());
749        assert!(!result.all_failed());
750        assert!(!result.partial_success());
751        assert_eq!(result.server_count(), 0);
752        assert_eq!(result.failure_count(), 0);
753    }
754
755    #[test]
756    fn test_server_init_result_default() {
757        let result = ServerInitResult::default();
758        assert!(!result.has_servers());
759        assert_eq!(result.server_count(), 0);
760        assert_eq!(result.failure_count(), 0);
761    }
762
763    #[test]
764    fn test_server_init_result_all_failures() {
765        let mut result = ServerInitResult::new();
766
767        result.add_failure(ServerSpawnFailure {
768            server_id: ServerId::from("rust"),
769            language_id: "rust".to_string(),
770            command: "rust-analyzer".to_string(),
771            message: "not found".to_string(),
772        });
773
774        result.add_failure(ServerSpawnFailure {
775            server_id: ServerId::from("python"),
776            language_id: "python".to_string(),
777            command: "pyright".to_string(),
778            message: "permission denied".to_string(),
779        });
780
781        assert!(!result.has_servers());
782        assert!(result.all_failed());
783        assert!(!result.partial_success());
784        assert_eq!(result.server_count(), 0);
785        assert_eq!(result.failure_count(), 2);
786    }
787
788    #[tokio::test]
789    async fn test_server_init_result_all_success() {
790        let mut result = ServerInitResult::new();
791
792        let mock_child1 = tokio::process::Command::new("echo")
793            .stdin(Stdio::piped())
794            .stdout(Stdio::piped())
795            .kill_on_drop(true)
796            .spawn()
797            .unwrap();
798
799        let mock_stdin1 = tokio::process::Command::new("cat")
800            .stdin(Stdio::piped())
801            .spawn()
802            .unwrap()
803            .stdin
804            .take()
805            .unwrap();
806
807        let mock_stdout1 = tokio::process::Command::new("echo")
808            .stdout(Stdio::piped())
809            .spawn()
810            .unwrap()
811            .stdout
812            .take()
813            .unwrap();
814
815        let transport1 = LspTransport::new(mock_stdin1, mock_stdout1);
816        let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
817        let (_, mock_notification_rx1) = mpsc::channel(1);
818
819        let server1 = LspServer {
820            client: client1,
821            capabilities: lsp_types::ServerCapabilities::default(),
822            position_encoding: PositionEncodingKind::UTF8,
823            notification_rx: mock_notification_rx1,
824            _child: mock_child1,
825        };
826
827        result.add_server("rust".to_string(), server1);
828
829        assert!(result.has_servers());
830        assert!(!result.all_failed());
831        assert!(!result.partial_success());
832        assert_eq!(result.server_count(), 1);
833        assert_eq!(result.failure_count(), 0);
834    }
835
836    #[tokio::test]
837    async fn test_server_init_result_partial_success() {
838        let mut result = ServerInitResult::new();
839
840        let mock_child = tokio::process::Command::new("echo")
841            .stdin(Stdio::piped())
842            .stdout(Stdio::piped())
843            .kill_on_drop(true)
844            .spawn()
845            .unwrap();
846
847        let mock_stdin = tokio::process::Command::new("cat")
848            .stdin(Stdio::piped())
849            .spawn()
850            .unwrap()
851            .stdin
852            .take()
853            .unwrap();
854
855        let mock_stdout = tokio::process::Command::new("echo")
856            .stdout(Stdio::piped())
857            .spawn()
858            .unwrap()
859            .stdout
860            .take()
861            .unwrap();
862
863        let transport = LspTransport::new(mock_stdin, mock_stdout);
864        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
865        let (_, mock_notification_rx) = mpsc::channel(1);
866
867        let server = LspServer {
868            client,
869            capabilities: lsp_types::ServerCapabilities::default(),
870            position_encoding: PositionEncodingKind::UTF8,
871            notification_rx: mock_notification_rx,
872            _child: mock_child,
873        };
874
875        result.add_server("rust".to_string(), server);
876
877        result.add_failure(ServerSpawnFailure {
878            server_id: ServerId::from("python"),
879            language_id: "python".to_string(),
880            command: "pyright".to_string(),
881            message: "not found".to_string(),
882        });
883
884        assert!(result.has_servers());
885        assert!(!result.all_failed());
886        assert!(result.partial_success());
887        assert_eq!(result.server_count(), 1);
888        assert_eq!(result.failure_count(), 1);
889    }
890
891    #[tokio::test]
892    async fn test_server_init_result_multiple_servers() {
893        let mut result = ServerInitResult::new();
894
895        for i in 0..3 {
896            let mock_child = tokio::process::Command::new("echo")
897                .stdin(Stdio::piped())
898                .stdout(Stdio::piped())
899                .kill_on_drop(true)
900                .spawn()
901                .unwrap();
902
903            let mock_stdin = tokio::process::Command::new("cat")
904                .stdin(Stdio::piped())
905                .spawn()
906                .unwrap()
907                .stdin
908                .take()
909                .unwrap();
910
911            let mock_stdout = tokio::process::Command::new("echo")
912                .stdout(Stdio::piped())
913                .spawn()
914                .unwrap()
915                .stdout
916                .take()
917                .unwrap();
918
919            let transport = LspTransport::new(mock_stdin, mock_stdout);
920            let config = if i == 0 {
921                LspServerConfig::rust_analyzer()
922            } else if i == 1 {
923                LspServerConfig::pyright()
924            } else {
925                LspServerConfig::typescript()
926            };
927            let client = LspClient::from_transport(config.clone(), transport);
928            let (_, mock_notification_rx) = mpsc::channel(1);
929
930            let server = LspServer {
931                client,
932                capabilities: lsp_types::ServerCapabilities::default(),
933                position_encoding: PositionEncodingKind::UTF8,
934                notification_rx: mock_notification_rx,
935                _child: mock_child,
936            };
937
938            result.add_server(config.language_id, server);
939        }
940
941        assert!(result.has_servers());
942        assert!(!result.all_failed());
943        assert!(!result.partial_success());
944        assert_eq!(result.server_count(), 3);
945        assert_eq!(result.failure_count(), 0);
946    }
947
948    #[tokio::test]
949    async fn test_server_init_result_replace_server() {
950        let mut result = ServerInitResult::new();
951
952        let mock_child1 = tokio::process::Command::new("echo")
953            .stdin(Stdio::piped())
954            .stdout(Stdio::piped())
955            .kill_on_drop(true)
956            .spawn()
957            .unwrap();
958
959        let mock_stdin1 = tokio::process::Command::new("cat")
960            .stdin(Stdio::piped())
961            .spawn()
962            .unwrap()
963            .stdin
964            .take()
965            .unwrap();
966
967        let mock_stdout1 = tokio::process::Command::new("echo")
968            .stdout(Stdio::piped())
969            .spawn()
970            .unwrap()
971            .stdout
972            .take()
973            .unwrap();
974
975        let transport1 = LspTransport::new(mock_stdin1, mock_stdout1);
976        let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
977        let (_, mock_notification_rx1) = mpsc::channel(1);
978
979        let server1 = LspServer {
980            client: client1,
981            capabilities: lsp_types::ServerCapabilities::default(),
982            position_encoding: PositionEncodingKind::UTF8,
983            notification_rx: mock_notification_rx1,
984            _child: mock_child1,
985        };
986
987        result.add_server("rust".to_string(), server1);
988        assert_eq!(result.server_count(), 1);
989
990        let mock_child2 = tokio::process::Command::new("echo")
991            .stdin(Stdio::piped())
992            .stdout(Stdio::piped())
993            .kill_on_drop(true)
994            .spawn()
995            .unwrap();
996
997        let mock_stdin2 = tokio::process::Command::new("cat")
998            .stdin(Stdio::piped())
999            .spawn()
1000            .unwrap()
1001            .stdin
1002            .take()
1003            .unwrap();
1004
1005        let mock_stdout2 = tokio::process::Command::new("echo")
1006            .stdout(Stdio::piped())
1007            .spawn()
1008            .unwrap()
1009            .stdout
1010            .take()
1011            .unwrap();
1012
1013        let transport2 = LspTransport::new(mock_stdin2, mock_stdout2);
1014        let client2 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport2);
1015        let (_, mock_notification_rx2) = mpsc::channel(1);
1016
1017        let server2 = LspServer {
1018            client: client2,
1019            capabilities: lsp_types::ServerCapabilities::default(),
1020            position_encoding: PositionEncodingKind::UTF16,
1021            notification_rx: mock_notification_rx2,
1022            _child: mock_child2,
1023        };
1024
1025        result.add_server("rust".to_string(), server2);
1026        assert_eq!(result.server_count(), 1);
1027    }
1028
1029    #[test]
1030    fn test_server_init_result_debug() {
1031        let mut result = ServerInitResult::new();
1032
1033        result.add_failure(ServerSpawnFailure {
1034            server_id: ServerId::from("rust"),
1035            language_id: "rust".to_string(),
1036            command: "rust-analyzer".to_string(),
1037            message: "not found".to_string(),
1038        });
1039
1040        let debug_str = format!("{result:?}");
1041        assert!(debug_str.contains("ServerInitResult"));
1042    }
1043
1044    #[test]
1045    fn test_server_init_result_multiple_failures() {
1046        let mut result = ServerInitResult::new();
1047
1048        result.add_failure(ServerSpawnFailure {
1049            server_id: ServerId::from("python"),
1050            language_id: "python".to_string(),
1051            command: "pyright".to_string(),
1052            message: "not found".to_string(),
1053        });
1054
1055        result.add_failure(ServerSpawnFailure {
1056            server_id: ServerId::from("typescript"),
1057            language_id: "typescript".to_string(),
1058            command: "tsserver".to_string(),
1059            message: "command not found".to_string(),
1060        });
1061
1062        assert_eq!(result.failure_count(), 2);
1063        assert_eq!(result.server_count(), 0);
1064        assert!(result.all_failed());
1065        assert!(!result.partial_success());
1066    }
1067
1068    #[tokio::test]
1069    async fn test_spawn_batch_empty_configs() {
1070        let configs: &[ServerInitConfig] = &[];
1071        let result = LspServer::spawn_batch(configs).await;
1072
1073        assert!(!result.has_servers());
1074        assert!(!result.all_failed());
1075        assert!(!result.partial_success());
1076        assert_eq!(result.server_count(), 0);
1077        assert_eq!(result.failure_count(), 0);
1078    }
1079
1080    #[tokio::test]
1081    async fn test_spawn_batch_single_invalid_config() {
1082        let configs = vec![ServerInitConfig {
1083            server_config: LspServerConfig {
1084                language_id: "rust".to_string(),
1085                command: "nonexistent-command-12345".to_string(),
1086                args: vec![],
1087                env: std::collections::HashMap::new(),
1088                file_patterns: vec!["**/*.rs".to_string()],
1089                initialization_options: None,
1090                timeout_seconds: 10,
1091                heuristics: None,
1092                name: None,
1093                handles: None,
1094            },
1095            workspace_roots: vec![],
1096            initialization_options: None,
1097            notification_tx: None,
1098        }];
1099
1100        let result = LspServer::spawn_batch(&configs).await;
1101
1102        assert!(!result.has_servers());
1103        assert!(result.all_failed());
1104        assert!(!result.partial_success());
1105        assert_eq!(result.server_count(), 0);
1106        assert_eq!(result.failure_count(), 1);
1107
1108        let failure = &result.failures[0];
1109        assert_eq!(failure.language_id, "rust");
1110        assert_eq!(failure.command, "nonexistent-command-12345");
1111        assert!(failure.message.contains("spawn"));
1112    }
1113
1114    #[tokio::test]
1115    async fn test_spawn_batch_all_invalid_configs() {
1116        let configs = vec![
1117            ServerInitConfig {
1118                server_config: LspServerConfig {
1119                    language_id: "rust".to_string(),
1120                    command: "nonexistent-rust-analyzer".to_string(),
1121                    args: vec![],
1122                    env: std::collections::HashMap::new(),
1123                    file_patterns: vec!["**/*.rs".to_string()],
1124                    initialization_options: None,
1125                    timeout_seconds: 10,
1126                    heuristics: None,
1127                    name: None,
1128                    handles: None,
1129                },
1130                workspace_roots: vec![],
1131                initialization_options: None,
1132                notification_tx: None,
1133            },
1134            ServerInitConfig {
1135                server_config: LspServerConfig {
1136                    language_id: "python".to_string(),
1137                    command: "nonexistent-pyright".to_string(),
1138                    args: vec![],
1139                    env: std::collections::HashMap::new(),
1140                    file_patterns: vec!["**/*.py".to_string()],
1141                    initialization_options: None,
1142                    timeout_seconds: 10,
1143                    heuristics: None,
1144                    name: None,
1145                    handles: None,
1146                },
1147                workspace_roots: vec![],
1148                initialization_options: None,
1149                notification_tx: None,
1150            },
1151            ServerInitConfig {
1152                server_config: LspServerConfig {
1153                    language_id: "typescript".to_string(),
1154                    command: "nonexistent-tsserver".to_string(),
1155                    args: vec![],
1156                    env: std::collections::HashMap::new(),
1157                    file_patterns: vec!["**/*.ts".to_string()],
1158                    initialization_options: None,
1159                    timeout_seconds: 10,
1160                    heuristics: None,
1161                    name: None,
1162                    handles: None,
1163                },
1164                workspace_roots: vec![],
1165                initialization_options: None,
1166                notification_tx: None,
1167            },
1168        ];
1169
1170        let result = LspServer::spawn_batch(&configs).await;
1171
1172        assert!(!result.has_servers());
1173        assert!(result.all_failed());
1174        assert!(!result.partial_success());
1175        assert_eq!(result.server_count(), 0);
1176        assert_eq!(result.failure_count(), 3);
1177
1178        let failure_languages: Vec<_> = result
1179            .failures
1180            .iter()
1181            .map(|f| f.language_id.as_str())
1182            .collect();
1183        assert!(failure_languages.contains(&"rust"));
1184        assert!(failure_languages.contains(&"python"));
1185        assert!(failure_languages.contains(&"typescript"));
1186    }
1187
1188    #[tokio::test]
1189    async fn test_spawn_batch_multiple_invalid_configs_ordering() {
1190        let configs = vec![
1191            ServerInitConfig {
1192                server_config: LspServerConfig {
1193                    language_id: "lang1".to_string(),
1194                    command: "cmd1-nonexistent".to_string(),
1195                    args: vec![],
1196                    env: std::collections::HashMap::new(),
1197                    file_patterns: vec![],
1198                    initialization_options: None,
1199                    timeout_seconds: 10,
1200                    heuristics: None,
1201                    name: None,
1202                    handles: None,
1203                },
1204                workspace_roots: vec![],
1205                initialization_options: None,
1206                notification_tx: None,
1207            },
1208            ServerInitConfig {
1209                server_config: LspServerConfig {
1210                    language_id: "lang2".to_string(),
1211                    command: "cmd2-nonexistent".to_string(),
1212                    args: vec![],
1213                    env: std::collections::HashMap::new(),
1214                    file_patterns: vec![],
1215                    initialization_options: None,
1216                    timeout_seconds: 10,
1217                    heuristics: None,
1218                    name: None,
1219                    handles: None,
1220                },
1221                workspace_roots: vec![],
1222                initialization_options: None,
1223                notification_tx: None,
1224            },
1225        ];
1226
1227        let result = LspServer::spawn_batch(&configs).await;
1228
1229        assert_eq!(result.failure_count(), 2);
1230
1231        assert_eq!(result.failures[0].language_id, "lang1");
1232        assert_eq!(result.failures[0].command, "cmd1-nonexistent");
1233
1234        assert_eq!(result.failures[1].language_id, "lang2");
1235        assert_eq!(result.failures[1].command, "cmd2-nonexistent");
1236    }
1237
1238    #[tokio::test]
1239    async fn test_spawn_batch_logs_each_failure() {
1240        let configs = vec![
1241            ServerInitConfig {
1242                server_config: LspServerConfig {
1243                    language_id: "test1".to_string(),
1244                    command: "nonexistent-test1".to_string(),
1245                    args: vec![],
1246                    env: std::collections::HashMap::new(),
1247                    file_patterns: vec![],
1248                    initialization_options: None,
1249                    timeout_seconds: 10,
1250                    heuristics: None,
1251                    name: None,
1252                    handles: None,
1253                },
1254                workspace_roots: vec![],
1255                initialization_options: None,
1256                notification_tx: None,
1257            },
1258            ServerInitConfig {
1259                server_config: LspServerConfig {
1260                    language_id: "test2".to_string(),
1261                    command: "nonexistent-test2".to_string(),
1262                    args: vec![],
1263                    env: std::collections::HashMap::new(),
1264                    file_patterns: vec![],
1265                    initialization_options: None,
1266                    timeout_seconds: 10,
1267                    heuristics: None,
1268                    name: None,
1269                    handles: None,
1270                },
1271                workspace_roots: vec![],
1272                initialization_options: None,
1273                notification_tx: None,
1274            },
1275        ];
1276
1277        let result = LspServer::spawn_batch(&configs).await;
1278
1279        assert_eq!(result.failure_count(), 2);
1280        assert_eq!(result.failures[0].language_id, "test1");
1281        assert_eq!(result.failures[1].language_id, "test2");
1282    }
1283
1284    /// Builds an `LspServer` backed by mock `echo`/`cat` child processes, so
1285    /// it can be registered without a real language server. Mirrors the
1286    /// pattern already used by this module's other `LspServer`-literal
1287    /// tests (e.g. `test_server_init_result_partial_success`).
1288    fn fake_lsp_server() -> LspServer {
1289        let mock_child = tokio::process::Command::new("echo")
1290            .stdin(Stdio::piped())
1291            .stdout(Stdio::piped())
1292            .kill_on_drop(true)
1293            .spawn()
1294            .unwrap();
1295        let mock_stdin = tokio::process::Command::new("cat")
1296            .stdin(Stdio::piped())
1297            .spawn()
1298            .unwrap()
1299            .stdin
1300            .take()
1301            .unwrap();
1302        let mock_stdout = tokio::process::Command::new("echo")
1303            .stdout(Stdio::piped())
1304            .spawn()
1305            .unwrap()
1306            .stdout
1307            .take()
1308            .unwrap();
1309        let transport = LspTransport::new(mock_stdin, mock_stdout);
1310        let client = LspClient::from_transport(LspServerConfig::pyright(), transport);
1311        let (_, mock_notification_rx) = mpsc::channel(1);
1312        LspServer {
1313            client,
1314            capabilities: lsp_types::ServerCapabilities::default(),
1315            position_encoding: PositionEncodingKind::UTF8,
1316            notification_rx: mock_notification_rx,
1317            _child: mock_child,
1318        }
1319    }
1320
1321    /// #174 §8/S2 regression: `register_servers`'s diagnostics-cache flags
1322    /// must be computed from the *rebound* router, not the pre-rebind view.
1323    /// Sets up a `python` config where a narrow "diagnostics-only" server
1324    /// (`pyright-diag`) is configured but never actually registers (as if
1325    /// it failed to spawn), leaving only a catch-all (`pylsp`) live. Before
1326    /// the fix, computing the flags from the pre-rebind router would resolve
1327    /// `Diagnostics` to the dead `pyright-diag` for every survivor, so
1328    /// `pylsp` would be flagged `false` and the diagnostics cache would go
1329    /// silently dark for `python` despite a live server being available.
1330    #[tokio::test]
1331    async fn test_register_servers_computes_diagnostics_flags_from_rebound_router() {
1332        use crate::bridge::Translator;
1333        use crate::config::{ServerId, ToolKind, ToolRouter};
1334
1335        let pylsp_id = ServerId::from("pylsp");
1336        let configs = vec![
1337            LspServerConfig {
1338                language_id: "python".to_string(),
1339                command: "pyright-langserver".to_string(),
1340                args: vec![],
1341                env: std::collections::HashMap::new(),
1342                file_patterns: vec![],
1343                initialization_options: None,
1344                timeout_seconds: 30,
1345                heuristics: None,
1346                name: Some("pyright-diag".to_string()),
1347                handles: Some(vec![ToolKind::Diagnostics]),
1348            },
1349            LspServerConfig {
1350                language_id: "python".to_string(),
1351                command: "pylsp".to_string(),
1352                args: vec![],
1353                env: std::collections::HashMap::new(),
1354                file_patterns: vec![],
1355                initialization_options: None,
1356                timeout_seconds: 30,
1357                heuristics: None,
1358                name: Some("pylsp".to_string()),
1359                handles: None,
1360            },
1361        ];
1362        let router = ToolRouter::from_configs(&configs).unwrap();
1363        let translator = Translator::new().with_router(router);
1364
1365        // Only pylsp actually registers; pyright-diag never spawned.
1366        let mut result = ServerInitResult::new();
1367        result.add_server(pylsp_id.clone(), fake_lsp_server());
1368
1369        let registered = crate::register_servers(result, &translator);
1370
1371        assert_eq!(
1372            registered.diagnostics_flags.get(&pylsp_id),
1373            Some(&true),
1374            "pylsp must inherit the diagnostics route once pyright-diag is \
1375             known dead, and the flag must reflect that post-rebind state"
1376        );
1377    }
1378}