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::time::Duration;
21use tracing::{debug, info};
22
23use crate::config::LspServerConfig;
24use crate::error::{Error, Result, ServerSpawnFailure};
25use crate::lsp::client::LspClient;
26use crate::lsp::transport::LspTransport;
27
28/// State of an LSP server connection.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ServerState {
31    /// Server has not been initialized.
32    Uninitialized,
33    /// Server is currently initializing.
34    Initializing,
35    /// Server is ready to handle requests.
36    Ready,
37    /// Server is shutting down.
38    ShuttingDown,
39    /// Server has been shut down.
40    Shutdown,
41}
42
43impl ServerState {
44    /// Check if the server is ready to handle requests.
45    #[must_use]
46    pub const fn is_ready(&self) -> bool {
47        matches!(self, Self::Ready)
48    }
49
50    /// Check if the server can accept new requests.
51    #[must_use]
52    pub const fn can_accept_requests(&self) -> bool {
53        matches!(self, Self::Ready)
54    }
55}
56
57/// Configuration for LSP server initialization.
58#[derive(Debug, Clone)]
59pub struct ServerInitConfig {
60    /// LSP server configuration.
61    pub server_config: LspServerConfig,
62    /// Workspace root paths.
63    pub workspace_roots: Vec<PathBuf>,
64    /// Initialization options (server-specific JSON).
65    pub initialization_options: Option<serde_json::Value>,
66}
67
68/// Result of attempting to spawn multiple LSP servers.
69///
70/// This type enables graceful degradation by collecting both
71/// successful initializations and failures. Use the helper methods
72/// to inspect the outcome and make decisions about how to proceed.
73///
74/// # Examples
75///
76/// ```
77/// use mcpls_core::lsp::ServerInitResult;
78/// use mcpls_core::error::ServerSpawnFailure;
79///
80/// let mut result = ServerInitResult::new();
81///
82/// // Check for different scenarios
83/// if result.all_failed() {
84///     eprintln!("All servers failed to initialize");
85/// } else if result.partial_success() {
86///     println!("Some servers succeeded, some failed");
87/// } else if result.has_servers() {
88///     println!("All servers initialized successfully");
89/// }
90/// ```
91#[derive(Debug)]
92pub struct ServerInitResult {
93    /// Successfully initialized servers (`language_id` -> server).
94    pub servers: HashMap<String, LspServer>,
95    /// Failures that occurred during spawn attempts.
96    pub failures: Vec<ServerSpawnFailure>,
97}
98
99impl ServerInitResult {
100    /// Create a new empty result.
101    #[must_use]
102    pub fn new() -> Self {
103        Self {
104            servers: HashMap::new(),
105            failures: Vec::new(),
106        }
107    }
108
109    /// Check if any servers were successfully initialized.
110    ///
111    /// Returns `true` if at least one server is available for use.
112    #[must_use]
113    pub fn has_servers(&self) -> bool {
114        !self.servers.is_empty()
115    }
116
117    /// Check if all attempted servers failed.
118    ///
119    /// Returns `true` only if there were failures and no servers succeeded.
120    /// Returns `false` for empty results (no servers configured).
121    #[must_use]
122    pub fn all_failed(&self) -> bool {
123        self.servers.is_empty() && !self.failures.is_empty()
124    }
125
126    /// Check if some but not all servers failed.
127    ///
128    /// Returns `true` if there are both successful servers and failures.
129    #[must_use]
130    pub fn partial_success(&self) -> bool {
131        !self.servers.is_empty() && !self.failures.is_empty()
132    }
133
134    /// Get the number of successfully initialized servers.
135    #[must_use]
136    pub fn server_count(&self) -> usize {
137        self.servers.len()
138    }
139
140    /// Get the number of failures.
141    #[must_use]
142    pub fn failure_count(&self) -> usize {
143        self.failures.len()
144    }
145
146    /// Add a successful server.
147    ///
148    /// If a server with the same `language_id` already exists, it will be replaced.
149    pub fn add_server(&mut self, language_id: String, server: LspServer) {
150        self.servers.insert(language_id, server);
151    }
152
153    /// Add a failure.
154    pub fn add_failure(&mut self, failure: ServerSpawnFailure) {
155        self.failures.push(failure);
156    }
157}
158
159impl Default for ServerInitResult {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165/// Managed LSP server instance with capabilities and encoding.
166pub struct LspServer {
167    client: LspClient,
168    capabilities: ServerCapabilities,
169    position_encoding: PositionEncodingKind,
170    /// Child process handle. Kept alive for process lifetime management.
171    /// When dropped, the process is terminated via SIGKILL (`kill_on_drop`).
172    _child: tokio::process::Child,
173}
174
175impl std::fmt::Debug for LspServer {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        f.debug_struct("LspServer")
178            .field("client", &self.client)
179            .field("capabilities", &self.capabilities)
180            .field("position_encoding", &self.position_encoding)
181            .field("_child", &"<process>")
182            .finish()
183    }
184}
185
186impl LspServer {
187    /// Spawn and initialize LSP server.
188    ///
189    /// This performs the complete initialization sequence:
190    /// 1. Spawns the LSP server as a child process
191    /// 2. Sends initialize request with client capabilities
192    /// 3. Receives server capabilities from initialize response
193    /// 4. Sends initialized notification
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if:
198    /// - Server process fails to spawn
199    /// - Initialize request fails or times out
200    /// - Server returns error during initialization
201    pub async fn spawn(config: ServerInitConfig) -> Result<Self> {
202        info!(
203            "Spawning LSP server: {} {:?}",
204            config.server_config.command, config.server_config.args
205        );
206
207        let mut child = Command::new(&config.server_config.command)
208            .args(&config.server_config.args)
209            .stdin(Stdio::piped())
210            .stdout(Stdio::piped())
211            .stderr(Stdio::null())
212            .kill_on_drop(true)
213            .spawn()
214            .map_err(|e| Error::ServerSpawnFailed {
215                command: config.server_config.command.clone(),
216                source: e,
217            })?;
218
219        let stdin = child
220            .stdin
221            .take()
222            .ok_or_else(|| Error::Transport("Failed to capture stdin".to_string()))?;
223        let stdout = child
224            .stdout
225            .take()
226            .ok_or_else(|| Error::Transport("Failed to capture stdout".to_string()))?;
227
228        let transport = LspTransport::new(stdin, stdout);
229        let client = LspClient::from_transport(config.server_config.clone(), transport);
230
231        let (capabilities, position_encoding) = Self::initialize(&client, &config).await?;
232
233        info!("LSP server initialized successfully");
234
235        Ok(Self {
236            client,
237            capabilities,
238            position_encoding,
239            _child: child,
240        })
241    }
242
243    /// Perform LSP initialization handshake.
244    ///
245    /// Sends initialize request and waits for response, then sends initialized notification.
246    async fn initialize(
247        client: &LspClient,
248        config: &ServerInitConfig,
249    ) -> Result<(ServerCapabilities, PositionEncodingKind)> {
250        debug!("Sending initialize request");
251
252        let workspace_folders: Vec<WorkspaceFolder> = config
253            .workspace_roots
254            .iter()
255            .map(|root| {
256                let path_str = root.to_str().ok_or_else(|| {
257                    let root_display = root.display();
258                    Error::InvalidUri(format!("Invalid UTF-8 in path: {root_display}"))
259                })?;
260                let uri_str = if cfg!(windows) {
261                    format!("file:///{}", path_str.replace('\\', "/"))
262                } else {
263                    format!("file://{path_str}")
264                };
265                let uri = Uri::from_str(&uri_str).map_err(|_| {
266                    let root_display = root.display();
267                    Error::InvalidUri(format!("Invalid workspace root: {root_display}"))
268                })?;
269                Ok(WorkspaceFolder {
270                    uri,
271                    name: root
272                        .file_name()
273                        .and_then(|n| n.to_str())
274                        .unwrap_or("workspace")
275                        .to_string(),
276                })
277            })
278            .collect::<Result<Vec<_>>>()?;
279
280        let params = InitializeParams {
281            process_id: Some(std::process::id()),
282            #[allow(deprecated)]
283            root_uri: None,
284            initialization_options: config.initialization_options.clone(),
285            capabilities: ClientCapabilities {
286                general: Some(GeneralClientCapabilities {
287                    position_encodings: Some(vec![
288                        PositionEncodingKind::UTF8,
289                        PositionEncodingKind::UTF16,
290                    ]),
291                    ..Default::default()
292                }),
293                text_document: Some(lsp_types::TextDocumentClientCapabilities {
294                    hover: Some(lsp_types::HoverClientCapabilities {
295                        dynamic_registration: Some(false),
296                        content_format: Some(vec![
297                            lsp_types::MarkupKind::Markdown,
298                            lsp_types::MarkupKind::PlainText,
299                        ]),
300                    }),
301                    definition: Some(lsp_types::GotoCapability {
302                        dynamic_registration: Some(false),
303                        link_support: Some(true),
304                    }),
305                    references: Some(lsp_types::ReferenceClientCapabilities {
306                        dynamic_registration: Some(false),
307                    }),
308                    ..Default::default()
309                }),
310                workspace: Some(lsp_types::WorkspaceClientCapabilities {
311                    workspace_folders: Some(true),
312                    ..Default::default()
313                }),
314                ..Default::default()
315            },
316            client_info: Some(ClientInfo {
317                name: "mcpls".to_string(),
318                version: Some(env!("CARGO_PKG_VERSION").to_string()),
319            }),
320            workspace_folders: Some(workspace_folders),
321            ..Default::default()
322        };
323
324        let result: InitializeResult = client
325            .request("initialize", params, Duration::from_secs(30))
326            .await
327            .map_err(|e| Error::LspInitFailed {
328                message: format!("Initialize request failed: {e}"),
329            })?;
330
331        let position_encoding = result
332            .capabilities
333            .position_encoding
334            .clone()
335            .unwrap_or(PositionEncodingKind::UTF16);
336
337        debug!(
338            "Server capabilities received, encoding: {:?}",
339            position_encoding
340        );
341
342        client
343            .notify("initialized", InitializedParams {})
344            .await
345            .map_err(|e| Error::LspInitFailed {
346                message: format!("Initialized notification failed: {e}"),
347            })?;
348
349        Ok((result.capabilities, position_encoding))
350    }
351
352    /// Get server capabilities.
353    #[must_use]
354    pub const fn capabilities(&self) -> &ServerCapabilities {
355        &self.capabilities
356    }
357
358    /// Get negotiated position encoding.
359    #[must_use]
360    pub fn position_encoding(&self) -> PositionEncodingKind {
361        self.position_encoding.clone()
362    }
363
364    /// Get client for making requests.
365    #[must_use]
366    pub const fn client(&self) -> &LspClient {
367        &self.client
368    }
369
370    /// Shutdown server gracefully.
371    ///
372    /// Sends shutdown request, waits for response, then sends exit notification.
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if shutdown sequence fails.
377    pub async fn shutdown(self) -> Result<()> {
378        debug!("Shutting down LSP server");
379
380        let _: serde_json::Value = self
381            .client
382            .request("shutdown", serde_json::Value::Null, Duration::from_secs(5))
383            .await?;
384
385        self.client.notify("exit", serde_json::Value::Null).await?;
386
387        self.client.shutdown().await?;
388
389        info!("LSP server shut down successfully");
390        Ok(())
391    }
392
393    /// Spawn multiple LSP servers in batch mode with graceful degradation.
394    ///
395    /// Attempts to spawn and initialize all configured servers. If some servers
396    /// fail to spawn, the successful servers are still returned. This enables
397    /// graceful degradation where the system can continue to operate with
398    /// partial functionality.
399    ///
400    /// # Behavior
401    ///
402    /// - Attempts to spawn each server sequentially
403    /// - Logs success (info) and failure (error) for each server
404    /// - Accumulates successful servers and failures
405    /// - Never panics or returns early - attempts all servers
406    ///
407    /// # Examples
408    ///
409    /// ```
410    /// use mcpls_core::lsp::{LspServer, ServerInitConfig};
411    /// use mcpls_core::config::LspServerConfig;
412    /// use std::path::PathBuf;
413    ///
414    /// # async fn example() {
415    /// let configs = vec![
416    ///     ServerInitConfig {
417    ///         server_config: LspServerConfig::rust_analyzer(),
418    ///         workspace_roots: vec![PathBuf::from("/workspace")],
419    ///         initialization_options: None,
420    ///     },
421    ///     ServerInitConfig {
422    ///         server_config: LspServerConfig::pyright(),
423    ///         workspace_roots: vec![PathBuf::from("/workspace")],
424    ///         initialization_options: None,
425    ///     },
426    /// ];
427    ///
428    /// let result = LspServer::spawn_batch(&configs).await;
429    ///
430    /// if result.has_servers() {
431    ///     println!("Successfully spawned {} servers", result.server_count());
432    /// }
433    ///
434    /// if result.partial_success() {
435    ///     eprintln!("Warning: {} servers failed", result.failure_count());
436    /// }
437    /// # }
438    /// ```
439    pub async fn spawn_batch(configs: &[ServerInitConfig]) -> ServerInitResult {
440        let mut result = ServerInitResult::new();
441
442        for config in configs {
443            let language_id = config.server_config.language_id.clone();
444            let command = config.server_config.command.clone();
445
446            match Self::spawn(config.clone()).await {
447                Ok(server) => {
448                    info!(
449                        "Successfully spawned LSP server: {} ({})",
450                        language_id, command
451                    );
452                    result.add_server(language_id, server);
453                }
454                Err(e) => {
455                    tracing::error!(
456                        "Failed to spawn LSP server: {} ({}): {}",
457                        language_id,
458                        command,
459                        e
460                    );
461                    result.add_failure(ServerSpawnFailure {
462                        language_id,
463                        command,
464                        message: e.to_string(),
465                    });
466                }
467            }
468        }
469
470        result
471    }
472}
473
474#[cfg(test)]
475#[allow(clippy::unwrap_used)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_server_state_ready() {
481        assert!(ServerState::Ready.is_ready());
482        assert!(ServerState::Ready.can_accept_requests());
483    }
484
485    #[test]
486    fn test_server_state_uninitialized() {
487        assert!(!ServerState::Uninitialized.is_ready());
488        assert!(!ServerState::Uninitialized.can_accept_requests());
489    }
490
491    #[test]
492    fn test_server_state_initializing() {
493        assert!(!ServerState::Initializing.is_ready());
494        assert!(!ServerState::Initializing.can_accept_requests());
495    }
496
497    #[test]
498    fn test_server_state_shutting_down() {
499        assert!(!ServerState::ShuttingDown.is_ready());
500        assert!(!ServerState::ShuttingDown.can_accept_requests());
501    }
502
503    #[test]
504    fn test_server_state_shutdown() {
505        assert!(!ServerState::Shutdown.is_ready());
506        assert!(!ServerState::Shutdown.can_accept_requests());
507    }
508
509    #[test]
510    fn test_server_state_equality() {
511        assert_eq!(ServerState::Ready, ServerState::Ready);
512        assert_ne!(ServerState::Ready, ServerState::Uninitialized);
513        assert_eq!(ServerState::Shutdown, ServerState::Shutdown);
514    }
515
516    #[test]
517    fn test_server_state_clone() {
518        let state = ServerState::Ready;
519        let cloned = state;
520        assert_eq!(state, cloned);
521    }
522
523    #[test]
524    fn test_server_state_debug() {
525        let state = ServerState::Ready;
526        let debug_str = format!("{state:?}");
527        assert!(debug_str.contains("Ready"));
528    }
529
530    #[test]
531    fn test_server_init_config_clone() {
532        let config = ServerInitConfig {
533            server_config: LspServerConfig::rust_analyzer(),
534            workspace_roots: vec![PathBuf::from("/tmp/workspace")],
535            initialization_options: Some(serde_json::json!({"key": "value"})),
536        };
537
538        #[allow(clippy::redundant_clone)]
539        let cloned = config.clone();
540        assert_eq!(cloned.server_config.language_id, "rust");
541        assert_eq!(cloned.workspace_roots.len(), 1);
542    }
543
544    #[test]
545    fn test_server_init_config_debug() {
546        let config = ServerInitConfig {
547            server_config: LspServerConfig::pyright(),
548            workspace_roots: vec![],
549            initialization_options: None,
550        };
551
552        let debug_str = format!("{config:?}");
553        assert!(debug_str.contains("python"));
554        assert!(debug_str.contains("pyright"));
555    }
556
557    #[test]
558    fn test_server_init_config_with_options() {
559        use std::collections::HashMap;
560
561        let init_opts = serde_json::json!({
562            "settings": {
563                "python": {
564                    "analysis": {
565                        "typeCheckingMode": "strict"
566                    }
567                }
568            }
569        });
570
571        let mut env = HashMap::new();
572        env.insert("PYTHONPATH".to_string(), "/usr/lib".to_string());
573
574        let config = ServerInitConfig {
575            server_config: LspServerConfig {
576                language_id: "python".to_string(),
577                command: "pyright-langserver".to_string(),
578                args: vec!["--stdio".to_string()],
579                env,
580                file_patterns: vec!["**/*.py".to_string()],
581                initialization_options: Some(init_opts.clone()),
582                timeout_seconds: 10,
583                heuristics: None,
584            },
585            workspace_roots: vec![PathBuf::from("/workspace")],
586            initialization_options: Some(init_opts),
587        };
588
589        assert!(config.initialization_options.is_some());
590        assert_eq!(config.workspace_roots.len(), 1);
591    }
592
593    #[test]
594    fn test_server_init_config_empty_workspace() {
595        let config = ServerInitConfig {
596            server_config: LspServerConfig::typescript(),
597            workspace_roots: vec![],
598            initialization_options: None,
599        };
600
601        assert!(config.workspace_roots.is_empty());
602    }
603
604    #[test]
605    fn test_server_init_config_multiple_workspaces() {
606        let config = ServerInitConfig {
607            server_config: LspServerConfig::rust_analyzer(),
608            workspace_roots: vec![
609                PathBuf::from("/workspace1"),
610                PathBuf::from("/workspace2"),
611                PathBuf::from("/workspace3"),
612            ],
613            initialization_options: None,
614        };
615
616        assert_eq!(config.workspace_roots.len(), 3);
617    }
618
619    #[tokio::test]
620    async fn test_lsp_server_getters() {
621        use lsp_types::ServerCapabilities;
622
623        let mock_child = tokio::process::Command::new("echo")
624            .stdin(Stdio::piped())
625            .stdout(Stdio::piped())
626            .kill_on_drop(true)
627            .spawn()
628            .unwrap();
629
630        let mock_stdin = tokio::process::Command::new("cat")
631            .stdin(Stdio::piped())
632            .spawn()
633            .unwrap()
634            .stdin
635            .take()
636            .unwrap();
637
638        let mock_stdout = tokio::process::Command::new("echo")
639            .stdout(Stdio::piped())
640            .spawn()
641            .unwrap()
642            .stdout
643            .take()
644            .unwrap();
645
646        let transport = LspTransport::new(mock_stdin, mock_stdout);
647        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
648
649        let server = LspServer {
650            client,
651            capabilities: ServerCapabilities::default(),
652            position_encoding: PositionEncodingKind::UTF8,
653            _child: mock_child,
654        };
655
656        assert_eq!(server.position_encoding(), PositionEncodingKind::UTF8);
657        assert!(server.capabilities().text_document_sync.is_none());
658
659        let debug_str = format!("{server:?}");
660        assert!(debug_str.contains("LspServer"));
661        assert!(debug_str.contains("<process>"));
662    }
663
664    #[test]
665    fn test_server_init_result_new_empty() {
666        let result = ServerInitResult::new();
667        assert!(!result.has_servers());
668        assert!(!result.all_failed());
669        assert!(!result.partial_success());
670        assert_eq!(result.server_count(), 0);
671        assert_eq!(result.failure_count(), 0);
672    }
673
674    #[test]
675    fn test_server_init_result_default() {
676        let result = ServerInitResult::default();
677        assert!(!result.has_servers());
678        assert_eq!(result.server_count(), 0);
679        assert_eq!(result.failure_count(), 0);
680    }
681
682    #[test]
683    fn test_server_init_result_all_failures() {
684        let mut result = ServerInitResult::new();
685
686        result.add_failure(ServerSpawnFailure {
687            language_id: "rust".to_string(),
688            command: "rust-analyzer".to_string(),
689            message: "not found".to_string(),
690        });
691
692        result.add_failure(ServerSpawnFailure {
693            language_id: "python".to_string(),
694            command: "pyright".to_string(),
695            message: "permission denied".to_string(),
696        });
697
698        assert!(!result.has_servers());
699        assert!(result.all_failed());
700        assert!(!result.partial_success());
701        assert_eq!(result.server_count(), 0);
702        assert_eq!(result.failure_count(), 2);
703    }
704
705    #[tokio::test]
706    async fn test_server_init_result_all_success() {
707        let mut result = ServerInitResult::new();
708
709        let mock_child1 = tokio::process::Command::new("echo")
710            .stdin(Stdio::piped())
711            .stdout(Stdio::piped())
712            .kill_on_drop(true)
713            .spawn()
714            .unwrap();
715
716        let mock_stdin1 = tokio::process::Command::new("cat")
717            .stdin(Stdio::piped())
718            .spawn()
719            .unwrap()
720            .stdin
721            .take()
722            .unwrap();
723
724        let mock_stdout1 = tokio::process::Command::new("echo")
725            .stdout(Stdio::piped())
726            .spawn()
727            .unwrap()
728            .stdout
729            .take()
730            .unwrap();
731
732        let transport1 = LspTransport::new(mock_stdin1, mock_stdout1);
733        let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
734
735        let server1 = LspServer {
736            client: client1,
737            capabilities: lsp_types::ServerCapabilities::default(),
738            position_encoding: PositionEncodingKind::UTF8,
739            _child: mock_child1,
740        };
741
742        result.add_server("rust".to_string(), server1);
743
744        assert!(result.has_servers());
745        assert!(!result.all_failed());
746        assert!(!result.partial_success());
747        assert_eq!(result.server_count(), 1);
748        assert_eq!(result.failure_count(), 0);
749    }
750
751    #[tokio::test]
752    async fn test_server_init_result_partial_success() {
753        let mut result = ServerInitResult::new();
754
755        let mock_child = tokio::process::Command::new("echo")
756            .stdin(Stdio::piped())
757            .stdout(Stdio::piped())
758            .kill_on_drop(true)
759            .spawn()
760            .unwrap();
761
762        let mock_stdin = tokio::process::Command::new("cat")
763            .stdin(Stdio::piped())
764            .spawn()
765            .unwrap()
766            .stdin
767            .take()
768            .unwrap();
769
770        let mock_stdout = tokio::process::Command::new("echo")
771            .stdout(Stdio::piped())
772            .spawn()
773            .unwrap()
774            .stdout
775            .take()
776            .unwrap();
777
778        let transport = LspTransport::new(mock_stdin, mock_stdout);
779        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
780
781        let server = LspServer {
782            client,
783            capabilities: lsp_types::ServerCapabilities::default(),
784            position_encoding: PositionEncodingKind::UTF8,
785            _child: mock_child,
786        };
787
788        result.add_server("rust".to_string(), server);
789
790        result.add_failure(ServerSpawnFailure {
791            language_id: "python".to_string(),
792            command: "pyright".to_string(),
793            message: "not found".to_string(),
794        });
795
796        assert!(result.has_servers());
797        assert!(!result.all_failed());
798        assert!(result.partial_success());
799        assert_eq!(result.server_count(), 1);
800        assert_eq!(result.failure_count(), 1);
801    }
802
803    #[tokio::test]
804    async fn test_server_init_result_multiple_servers() {
805        let mut result = ServerInitResult::new();
806
807        for i in 0..3 {
808            let mock_child = tokio::process::Command::new("echo")
809                .stdin(Stdio::piped())
810                .stdout(Stdio::piped())
811                .kill_on_drop(true)
812                .spawn()
813                .unwrap();
814
815            let mock_stdin = tokio::process::Command::new("cat")
816                .stdin(Stdio::piped())
817                .spawn()
818                .unwrap()
819                .stdin
820                .take()
821                .unwrap();
822
823            let mock_stdout = tokio::process::Command::new("echo")
824                .stdout(Stdio::piped())
825                .spawn()
826                .unwrap()
827                .stdout
828                .take()
829                .unwrap();
830
831            let transport = LspTransport::new(mock_stdin, mock_stdout);
832            let config = if i == 0 {
833                LspServerConfig::rust_analyzer()
834            } else if i == 1 {
835                LspServerConfig::pyright()
836            } else {
837                LspServerConfig::typescript()
838            };
839            let client = LspClient::from_transport(config.clone(), transport);
840
841            let server = LspServer {
842                client,
843                capabilities: lsp_types::ServerCapabilities::default(),
844                position_encoding: PositionEncodingKind::UTF8,
845                _child: mock_child,
846            };
847
848            result.add_server(config.language_id, server);
849        }
850
851        assert!(result.has_servers());
852        assert!(!result.all_failed());
853        assert!(!result.partial_success());
854        assert_eq!(result.server_count(), 3);
855        assert_eq!(result.failure_count(), 0);
856    }
857
858    #[tokio::test]
859    async fn test_server_init_result_replace_server() {
860        let mut result = ServerInitResult::new();
861
862        let mock_child1 = tokio::process::Command::new("echo")
863            .stdin(Stdio::piped())
864            .stdout(Stdio::piped())
865            .kill_on_drop(true)
866            .spawn()
867            .unwrap();
868
869        let mock_stdin1 = tokio::process::Command::new("cat")
870            .stdin(Stdio::piped())
871            .spawn()
872            .unwrap()
873            .stdin
874            .take()
875            .unwrap();
876
877        let mock_stdout1 = tokio::process::Command::new("echo")
878            .stdout(Stdio::piped())
879            .spawn()
880            .unwrap()
881            .stdout
882            .take()
883            .unwrap();
884
885        let transport1 = LspTransport::new(mock_stdin1, mock_stdout1);
886        let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
887
888        let server1 = LspServer {
889            client: client1,
890            capabilities: lsp_types::ServerCapabilities::default(),
891            position_encoding: PositionEncodingKind::UTF8,
892            _child: mock_child1,
893        };
894
895        result.add_server("rust".to_string(), server1);
896        assert_eq!(result.server_count(), 1);
897
898        let mock_child2 = tokio::process::Command::new("echo")
899            .stdin(Stdio::piped())
900            .stdout(Stdio::piped())
901            .kill_on_drop(true)
902            .spawn()
903            .unwrap();
904
905        let mock_stdin2 = tokio::process::Command::new("cat")
906            .stdin(Stdio::piped())
907            .spawn()
908            .unwrap()
909            .stdin
910            .take()
911            .unwrap();
912
913        let mock_stdout2 = tokio::process::Command::new("echo")
914            .stdout(Stdio::piped())
915            .spawn()
916            .unwrap()
917            .stdout
918            .take()
919            .unwrap();
920
921        let transport2 = LspTransport::new(mock_stdin2, mock_stdout2);
922        let client2 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport2);
923
924        let server2 = LspServer {
925            client: client2,
926            capabilities: lsp_types::ServerCapabilities::default(),
927            position_encoding: PositionEncodingKind::UTF16,
928            _child: mock_child2,
929        };
930
931        result.add_server("rust".to_string(), server2);
932        assert_eq!(result.server_count(), 1);
933    }
934
935    #[test]
936    fn test_server_init_result_debug() {
937        let mut result = ServerInitResult::new();
938
939        result.add_failure(ServerSpawnFailure {
940            language_id: "rust".to_string(),
941            command: "rust-analyzer".to_string(),
942            message: "not found".to_string(),
943        });
944
945        let debug_str = format!("{result:?}");
946        assert!(debug_str.contains("ServerInitResult"));
947    }
948
949    #[test]
950    fn test_server_init_result_multiple_failures() {
951        let mut result = ServerInitResult::new();
952
953        result.add_failure(ServerSpawnFailure {
954            language_id: "python".to_string(),
955            command: "pyright".to_string(),
956            message: "not found".to_string(),
957        });
958
959        result.add_failure(ServerSpawnFailure {
960            language_id: "typescript".to_string(),
961            command: "tsserver".to_string(),
962            message: "command not found".to_string(),
963        });
964
965        assert_eq!(result.failure_count(), 2);
966        assert_eq!(result.server_count(), 0);
967        assert!(result.all_failed());
968        assert!(!result.partial_success());
969    }
970
971    #[tokio::test]
972    async fn test_spawn_batch_empty_configs() {
973        let configs: &[ServerInitConfig] = &[];
974        let result = LspServer::spawn_batch(configs).await;
975
976        assert!(!result.has_servers());
977        assert!(!result.all_failed());
978        assert!(!result.partial_success());
979        assert_eq!(result.server_count(), 0);
980        assert_eq!(result.failure_count(), 0);
981    }
982
983    #[tokio::test]
984    async fn test_spawn_batch_single_invalid_config() {
985        let configs = vec![ServerInitConfig {
986            server_config: LspServerConfig {
987                language_id: "rust".to_string(),
988                command: "nonexistent-command-12345".to_string(),
989                args: vec![],
990                env: std::collections::HashMap::new(),
991                file_patterns: vec!["**/*.rs".to_string()],
992                initialization_options: None,
993                timeout_seconds: 10,
994                heuristics: None,
995            },
996            workspace_roots: vec![],
997            initialization_options: None,
998        }];
999
1000        let result = LspServer::spawn_batch(&configs).await;
1001
1002        assert!(!result.has_servers());
1003        assert!(result.all_failed());
1004        assert!(!result.partial_success());
1005        assert_eq!(result.server_count(), 0);
1006        assert_eq!(result.failure_count(), 1);
1007
1008        let failure = &result.failures[0];
1009        assert_eq!(failure.language_id, "rust");
1010        assert_eq!(failure.command, "nonexistent-command-12345");
1011        assert!(failure.message.contains("spawn"));
1012    }
1013
1014    #[tokio::test]
1015    async fn test_spawn_batch_all_invalid_configs() {
1016        let configs = vec![
1017            ServerInitConfig {
1018                server_config: LspServerConfig {
1019                    language_id: "rust".to_string(),
1020                    command: "nonexistent-rust-analyzer".to_string(),
1021                    args: vec![],
1022                    env: std::collections::HashMap::new(),
1023                    file_patterns: vec!["**/*.rs".to_string()],
1024                    initialization_options: None,
1025                    timeout_seconds: 10,
1026                    heuristics: None,
1027                },
1028                workspace_roots: vec![],
1029                initialization_options: None,
1030            },
1031            ServerInitConfig {
1032                server_config: LspServerConfig {
1033                    language_id: "python".to_string(),
1034                    command: "nonexistent-pyright".to_string(),
1035                    args: vec![],
1036                    env: std::collections::HashMap::new(),
1037                    file_patterns: vec!["**/*.py".to_string()],
1038                    initialization_options: None,
1039                    timeout_seconds: 10,
1040                    heuristics: None,
1041                },
1042                workspace_roots: vec![],
1043                initialization_options: None,
1044            },
1045            ServerInitConfig {
1046                server_config: LspServerConfig {
1047                    language_id: "typescript".to_string(),
1048                    command: "nonexistent-tsserver".to_string(),
1049                    args: vec![],
1050                    env: std::collections::HashMap::new(),
1051                    file_patterns: vec!["**/*.ts".to_string()],
1052                    initialization_options: None,
1053                    timeout_seconds: 10,
1054                    heuristics: None,
1055                },
1056                workspace_roots: vec![],
1057                initialization_options: None,
1058            },
1059        ];
1060
1061        let result = LspServer::spawn_batch(&configs).await;
1062
1063        assert!(!result.has_servers());
1064        assert!(result.all_failed());
1065        assert!(!result.partial_success());
1066        assert_eq!(result.server_count(), 0);
1067        assert_eq!(result.failure_count(), 3);
1068
1069        let failure_languages: Vec<_> = result
1070            .failures
1071            .iter()
1072            .map(|f| f.language_id.as_str())
1073            .collect();
1074        assert!(failure_languages.contains(&"rust"));
1075        assert!(failure_languages.contains(&"python"));
1076        assert!(failure_languages.contains(&"typescript"));
1077    }
1078
1079    #[tokio::test]
1080    async fn test_spawn_batch_multiple_invalid_configs_ordering() {
1081        let configs = vec![
1082            ServerInitConfig {
1083                server_config: LspServerConfig {
1084                    language_id: "lang1".to_string(),
1085                    command: "cmd1-nonexistent".to_string(),
1086                    args: vec![],
1087                    env: std::collections::HashMap::new(),
1088                    file_patterns: vec![],
1089                    initialization_options: None,
1090                    timeout_seconds: 10,
1091                    heuristics: None,
1092                },
1093                workspace_roots: vec![],
1094                initialization_options: None,
1095            },
1096            ServerInitConfig {
1097                server_config: LspServerConfig {
1098                    language_id: "lang2".to_string(),
1099                    command: "cmd2-nonexistent".to_string(),
1100                    args: vec![],
1101                    env: std::collections::HashMap::new(),
1102                    file_patterns: vec![],
1103                    initialization_options: None,
1104                    timeout_seconds: 10,
1105                    heuristics: None,
1106                },
1107                workspace_roots: vec![],
1108                initialization_options: None,
1109            },
1110        ];
1111
1112        let result = LspServer::spawn_batch(&configs).await;
1113
1114        assert_eq!(result.failure_count(), 2);
1115
1116        assert_eq!(result.failures[0].language_id, "lang1");
1117        assert_eq!(result.failures[0].command, "cmd1-nonexistent");
1118
1119        assert_eq!(result.failures[1].language_id, "lang2");
1120        assert_eq!(result.failures[1].command, "cmd2-nonexistent");
1121    }
1122
1123    #[tokio::test]
1124    async fn test_spawn_batch_logs_each_failure() {
1125        let configs = vec![
1126            ServerInitConfig {
1127                server_config: LspServerConfig {
1128                    language_id: "test1".to_string(),
1129                    command: "nonexistent-test1".to_string(),
1130                    args: vec![],
1131                    env: std::collections::HashMap::new(),
1132                    file_patterns: vec![],
1133                    initialization_options: None,
1134                    timeout_seconds: 10,
1135                    heuristics: None,
1136                },
1137                workspace_roots: vec![],
1138                initialization_options: None,
1139            },
1140            ServerInitConfig {
1141                server_config: LspServerConfig {
1142                    language_id: "test2".to_string(),
1143                    command: "nonexistent-test2".to_string(),
1144                    args: vec![],
1145                    env: std::collections::HashMap::new(),
1146                    file_patterns: vec![],
1147                    initialization_options: None,
1148                    timeout_seconds: 10,
1149                    heuristics: None,
1150                },
1151                workspace_roots: vec![],
1152                initialization_options: None,
1153            },
1154        ];
1155
1156        let result = LspServer::spawn_batch(&configs).await;
1157
1158        assert_eq!(result.failure_count(), 2);
1159        assert_eq!(result.failures[0].language_id, "test1");
1160        assert_eq!(result.failures[1].language_id, "test2");
1161    }
1162}