1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::process::Stdio;
13
14use lsp_types::{
15 ClientCapabilities, ClientInfo, GeneralClientCapabilities, InitializeParams, InitializeResult,
16 InitializedParams, PositionEncodingKind, ServerCapabilities, WorkspaceFolder,
17};
18use tokio::process::Command;
19use tokio::sync::mpsc;
20use tokio::time::Duration;
21use tracing::{debug, info, warn};
22
23use crate::bridge::try_path_to_uri;
24use crate::config::{LspServerConfig, ServerId};
25use crate::error::{Error, Result, ServerSpawnFailure};
26use crate::lsp::client::LspClient;
27use crate::lsp::transport::LspTransport;
28use crate::lsp::types::LspNotification;
29
30const ENV_PASSTHROUGH: &[&str] = &["PATH", "HOME", "USERPROFILE", "TMPDIR", "TEMP", "TMP"];
43
44const CHILD_EXIT_GRACE: Duration = Duration::from_secs(3);
48
49#[cfg(windows)]
56const ENV_PASSTHROUGH_WINDOWS: &[&str] = &[
57 "SystemRoot",
58 "SystemDrive",
59 "windir",
60 "APPDATA",
61 "LOCALAPPDATA",
62 "ProgramData",
63 "ProgramFiles",
64 "COMSPEC",
65 "PATHEXT",
66 "NUMBER_OF_PROCESSORS",
67 "USERNAME",
68];
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum ServerState {
73 Uninitialized,
75 Initializing,
77 Ready,
79 ShuttingDown,
81 Shutdown,
83}
84
85impl ServerState {
86 #[must_use]
88 pub const fn is_ready(&self) -> bool {
89 matches!(self, Self::Ready)
90 }
91
92 #[must_use]
94 pub const fn can_accept_requests(&self) -> bool {
95 matches!(self, Self::Ready)
96 }
97}
98
99#[derive(Debug, Clone)]
101pub struct ServerInitConfig {
102 pub server_config: LspServerConfig,
104 pub workspace_roots: Vec<PathBuf>,
106 pub initialization_options: Option<serde_json::Value>,
108 pub position_encodings: Vec<String>,
122 pub notification_tx: Option<mpsc::Sender<LspNotification>>,
129}
130
131#[derive(Debug)]
155pub struct ServerInitResult {
156 pub servers: HashMap<ServerId, LspServer>,
158 pub failures: Vec<ServerSpawnFailure>,
160}
161
162impl ServerInitResult {
163 #[must_use]
165 pub fn new() -> Self {
166 Self {
167 servers: HashMap::new(),
168 failures: Vec::new(),
169 }
170 }
171
172 #[must_use]
176 pub fn has_servers(&self) -> bool {
177 !self.servers.is_empty()
178 }
179
180 #[must_use]
185 pub fn all_failed(&self) -> bool {
186 self.servers.is_empty() && !self.failures.is_empty()
187 }
188
189 #[must_use]
193 pub fn partial_success(&self) -> bool {
194 !self.servers.is_empty() && !self.failures.is_empty()
195 }
196
197 #[must_use]
199 pub fn server_count(&self) -> usize {
200 self.servers.len()
201 }
202
203 #[must_use]
205 pub const fn failure_count(&self) -> usize {
206 self.failures.len()
207 }
208
209 pub fn add_server(&mut self, id: impl Into<ServerId>, server: LspServer) {
213 self.servers.insert(id.into(), server);
214 }
215
216 pub fn add_failure(&mut self, failure: ServerSpawnFailure) {
218 self.failures.push(failure);
219 }
220}
221
222impl Default for ServerInitResult {
223 fn default() -> Self {
224 Self::new()
225 }
226}
227
228pub struct LspServer {
230 client: LspClient,
231 capabilities: ServerCapabilities,
232 position_encoding: PositionEncodingKind,
233 pub notification_rx: mpsc::Receiver<LspNotification>,
238 child: tokio::process::Child,
244}
245
246impl std::fmt::Debug for LspServer {
247 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248 f.debug_struct("LspServer")
249 .field("client", &self.client)
250 .field("capabilities", &self.capabilities)
251 .field("position_encoding", &self.position_encoding)
252 .field("notification_rx", &"<channel>")
253 .field("child", &"<process>")
254 .finish()
255 }
256}
257
258impl LspServer {
259 pub fn take_notification_rx(&mut self) -> tokio::sync::mpsc::Receiver<LspNotification> {
265 let (_, dummy) = tokio::sync::mpsc::channel(1);
266 std::mem::replace(&mut self.notification_rx, dummy)
267 }
268
269 pub async fn spawn(config: ServerInitConfig) -> Result<Self> {
284 info!(
285 "Spawning LSP server: {} {:?}",
286 config.server_config.command, config.server_config.args
287 );
288
289 let mut command = Self::build_command(&config.server_config, |key| std::env::var_os(key));
290
291 let passthrough_present = {
296 let base = ENV_PASSTHROUGH
297 .iter()
298 .filter(|key| std::env::var_os(key).is_some())
299 .count();
300 #[cfg(windows)]
301 let windows = ENV_PASSTHROUGH_WINDOWS
302 .iter()
303 .filter(|key| std::env::var_os(key).is_some())
304 .count();
305 #[cfg(not(windows))]
306 let windows = 0;
307 base + windows
308 };
309 debug!(
310 "Effective LSP server env: {passthrough_present} allowlisted key(s) present, \
311 {} configured override(s) applied",
312 config.server_config.env.len()
313 );
314
315 let mut child = command.spawn().map_err(|e| Error::ServerSpawnFailed {
316 command: config.server_config.command.clone(),
317 source: e,
318 })?;
319
320 let stdin = child
321 .stdin
322 .take()
323 .ok_or_else(|| Error::Transport("Failed to capture stdin".to_string()))?;
324 let stdout = child
325 .stdout
326 .take()
327 .ok_or_else(|| Error::Transport("Failed to capture stdout".to_string()))?;
328
329 let transport = LspTransport::new(stdin, stdout);
330 let (notification_tx, notification_rx) = mpsc::channel(64);
331 let client = LspClient::from_transport_with_notifications(
332 config.server_config.clone(),
333 transport,
334 notification_tx,
335 );
336
337 let (capabilities, position_encoding) = Self::initialize(&client, &config).await?;
338
339 info!("LSP server initialized successfully");
340
341 Ok(Self {
342 client,
343 capabilities,
344 position_encoding,
345 notification_rx,
346 child,
347 })
348 }
349
350 fn build_command(
360 config: &LspServerConfig,
361 parent_env: impl Fn(&str) -> Option<std::ffi::OsString>,
362 ) -> Command {
363 let mut command = Command::new(&config.command);
364 command.args(&config.args).env_clear();
365
366 for key in ENV_PASSTHROUGH {
367 if let Some(value) = parent_env(key) {
368 command.env(key, value);
369 }
370 }
371 #[cfg(windows)]
372 for key in ENV_PASSTHROUGH_WINDOWS {
373 if let Some(value) = parent_env(key) {
374 command.env(key, value);
375 }
376 }
377
378 command
379 .envs(&config.env)
380 .stdin(Stdio::piped())
381 .stdout(Stdio::piped())
382 .stderr(Stdio::null())
383 .kill_on_drop(true);
384
385 command
386 }
387
388 #[allow(clippy::too_many_lines)]
392 async fn initialize(
393 client: &LspClient,
394 config: &ServerInitConfig,
395 ) -> Result<(ServerCapabilities, PositionEncodingKind)> {
396 debug!("Sending initialize request");
397
398 let workspace_folders: Vec<WorkspaceFolder> = config
399 .workspace_roots
400 .iter()
401 .map(|root| workspace_folder(root))
402 .collect::<Result<Vec<_>>>()?;
403
404 let params = InitializeParams {
405 process_id: Some(std::process::id()),
406 #[allow(deprecated)]
407 root_uri: None,
408 initialization_options: config.initialization_options.clone(),
409 capabilities: ClientCapabilities {
410 general: Some(GeneralClientCapabilities {
411 position_encodings: Some(resolve_position_encodings(
412 &config.position_encodings,
413 )),
414 ..Default::default()
415 }),
416 text_document: Some(lsp_types::TextDocumentClientCapabilities {
417 hover: Some(lsp_types::HoverClientCapabilities {
418 dynamic_registration: Some(false),
419 content_format: Some(vec![
420 lsp_types::MarkupKind::Markdown,
421 lsp_types::MarkupKind::PlainText,
422 ]),
423 }),
424 definition: Some(lsp_types::GotoCapability {
425 dynamic_registration: Some(false),
426 link_support: Some(true),
427 }),
428 references: Some(lsp_types::ReferenceClientCapabilities {
429 dynamic_registration: Some(false),
430 }),
431 code_action: Some(lsp_types::CodeActionClientCapabilities {
432 dynamic_registration: Some(false),
433 data_support: Some(true),
434 resolve_support: Some(lsp_types::CodeActionCapabilityResolveSupport {
435 properties: vec!["edit".to_string()],
436 }),
437 code_action_literal_support: Some(lsp_types::CodeActionLiteralSupport {
440 code_action_kind: lsp_types::CodeActionKindLiteralSupport {
441 value_set: [
442 lsp_types::CodeActionKind::EMPTY,
443 lsp_types::CodeActionKind::QUICKFIX,
444 lsp_types::CodeActionKind::REFACTOR,
445 lsp_types::CodeActionKind::REFACTOR_EXTRACT,
446 lsp_types::CodeActionKind::REFACTOR_INLINE,
447 lsp_types::CodeActionKind::REFACTOR_REWRITE,
448 lsp_types::CodeActionKind::SOURCE,
449 lsp_types::CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
450 ]
451 .iter()
452 .map(|k| k.as_str().to_string())
453 .collect(),
454 },
455 }),
456 ..Default::default()
457 }),
458 ..Default::default()
459 }),
460 workspace: Some(lsp_types::WorkspaceClientCapabilities {
461 workspace_folders: Some(true),
462 ..Default::default()
463 }),
464 ..Default::default()
465 },
466 client_info: Some(ClientInfo {
467 name: "mcpls".to_string(),
468 version: Some(env!("CARGO_PKG_VERSION").to_string()),
469 }),
470 workspace_folders: Some(workspace_folders),
471 ..Default::default()
472 };
473
474 let result: InitializeResult = client
478 .request(
479 "initialize",
480 params,
481 Duration::from_secs(
491 config
492 .server_config
493 .timeout_seconds
494 .clamp(1, crate::config::MAX_TIMEOUT_SECONDS),
495 ),
496 )
497 .await
498 .map_err(|e| Error::LspInitFailed {
499 message: format!("Initialize request failed: {e}"),
500 })?;
501
502 let position_encoding = result
503 .capabilities
504 .position_encoding
505 .clone()
506 .unwrap_or(PositionEncodingKind::UTF16);
507
508 debug!(
509 "Server capabilities received, encoding: {:?}",
510 position_encoding
511 );
512
513 client
514 .notify("initialized", InitializedParams {})
515 .await
516 .map_err(|e| Error::LspInitFailed {
517 message: format!("Initialized notification failed: {e}"),
518 })?;
519
520 Ok((result.capabilities, position_encoding))
521 }
522
523 #[must_use]
525 pub const fn capabilities(&self) -> &ServerCapabilities {
526 &self.capabilities
527 }
528
529 #[must_use]
531 pub fn position_encoding(&self) -> PositionEncodingKind {
532 self.position_encoding.clone()
533 }
534
535 #[must_use]
537 pub const fn client(&self) -> &LspClient {
538 &self.client
539 }
540
541 pub fn has_exited(&mut self) -> Result<bool> {
554 Ok(self.child.try_wait()?.is_some())
555 }
556
557 pub async fn shutdown(self) -> Result<()> {
572 debug!("Shutting down LSP server");
573
574 let handshake: Result<()> = async move {
575 let _: serde_json::Value = self
576 .client
577 .request("shutdown", serde_json::Value::Null, Duration::from_secs(5))
578 .await?;
579 self.client.notify("exit", serde_json::Value::Null).await?;
580 self.client.shutdown().await
581 }
582 .await;
583
584 let mut child = self.child;
585 match tokio::time::timeout(CHILD_EXIT_GRACE, child.wait()).await {
586 Ok(Ok(status)) => {
587 debug!(
588 ?status,
589 "LSP server process exited after `exit` notification"
590 );
591 }
592 Ok(Err(e)) => warn!(error = %e, "failed to wait for LSP server process exit"),
593 Err(_) => warn!(
594 timeout = ?CHILD_EXIT_GRACE,
595 "LSP server process did not exit within grace period after `exit` \
596 notification, killing it"
597 ),
598 }
599 handshake?;
603 info!("LSP server shut down successfully");
604 Ok(())
605 }
606
607 pub async fn spawn_batch(configs: &[ServerInitConfig]) -> ServerInitResult {
658 let mut result = ServerInitResult::new();
659
660 for config in configs {
661 let server_id = config.server_config.id();
662 let language_id = config.server_config.language_id.clone();
663 let command = config.server_config.command.clone();
664
665 match Self::spawn(config.clone()).await {
666 Ok(server) => {
667 info!(
668 "Successfully spawned LSP server: {} ({})",
669 server_id, command
670 );
671 result.add_server(server_id, server);
672 }
673 Err(e) => {
674 tracing::error!(
675 "Failed to spawn LSP server: {} ({}): {}",
676 server_id,
677 command,
678 e
679 );
680 result.add_failure(ServerSpawnFailure {
681 server_id,
682 language_id,
683 command,
684 message: e.to_string(),
685 });
686 }
687 }
688 }
689
690 result
691 }
692}
693
694fn resolve_position_encodings(configured: &[String]) -> Vec<PositionEncodingKind> {
703 let encodings: Vec<PositionEncodingKind> = configured
704 .iter()
705 .filter_map(|value| {
706 let kind = crate::config::parse_position_encoding(value);
707 if kind.is_none() {
708 warn!(value = %value, "ignoring invalid configured position encoding");
709 }
710 kind
711 })
712 .collect();
713
714 if encodings.is_empty() {
715 crate::config::default_position_encodings()
716 .iter()
717 .filter_map(|value| crate::config::parse_position_encoding(value))
718 .collect()
719 } else {
720 encodings
721 }
722}
723
724fn workspace_folder(root: &Path) -> Result<WorkspaceFolder> {
730 let uri = try_path_to_uri(root).ok_or_else(|| {
731 let root_display = root.display();
732 Error::InvalidUri(format!("Invalid workspace root: {root_display}"))
733 })?;
734 Ok(WorkspaceFolder {
735 uri,
736 name: root
737 .file_name()
738 .and_then(|n| n.to_str())
739 .unwrap_or("workspace")
740 .to_string(),
741 })
742}
743
744#[cfg(test)]
755#[allow(clippy::unwrap_used)]
756pub fn fake_lsp_server() -> LspServer {
757 let mock_child = tokio::process::Command::new("echo")
758 .stdin(Stdio::piped())
759 .stdout(Stdio::piped())
760 .kill_on_drop(true)
761 .spawn()
762 .unwrap();
763 let mock_stdin = tokio::process::Command::new("cat")
764 .stdin(Stdio::piped())
765 .spawn()
766 .unwrap()
767 .stdin
768 .take()
769 .unwrap();
770 let mock_stdout = tokio::process::Command::new("echo")
771 .stdout(Stdio::piped())
772 .spawn()
773 .unwrap()
774 .stdout
775 .take()
776 .unwrap();
777 let transport = LspTransport::new(mock_stdin, mock_stdout);
778 let client = LspClient::from_transport(LspServerConfig::pyright(), transport);
779 let (_, mock_notification_rx) = mpsc::channel(1);
780 LspServer {
781 client,
782 capabilities: lsp_types::ServerCapabilities::default(),
783 position_encoding: PositionEncodingKind::UTF8,
784 notification_rx: mock_notification_rx,
785 child: mock_child,
786 }
787}
788
789#[cfg(test)]
790impl LspServer {
791 #[allow(clippy::unwrap_used)]
803 pub(crate) fn new_for_test(capabilities: ServerCapabilities) -> Self {
804 Self::new_for_test_with_encoding(capabilities, PositionEncodingKind::UTF16)
805 }
806
807 #[allow(clippy::unwrap_used)]
812 pub(crate) fn new_for_test_with_encoding(
813 capabilities: ServerCapabilities,
814 position_encoding: PositionEncodingKind,
815 ) -> Self {
816 let child = Command::new("echo")
817 .stdin(Stdio::piped())
818 .stdout(Stdio::piped())
819 .kill_on_drop(true)
820 .spawn()
821 .unwrap();
822
823 let client = LspClient::new(LspServerConfig::rust_analyzer());
824 let (_, notification_rx) = mpsc::channel(1);
825
826 Self {
827 client,
828 capabilities,
829 position_encoding,
830 notification_rx,
831 child,
832 }
833 }
834}
835
836#[cfg(test)]
837#[allow(clippy::unwrap_used)]
838mod tests {
839 use super::*;
840
841 #[test]
842 fn test_resolve_position_encodings_preserves_configured_order() {
843 let result = resolve_position_encodings(&["utf-32".to_string(), "utf-8".to_string()]);
844 assert_eq!(
845 result,
846 vec![PositionEncodingKind::UTF32, PositionEncodingKind::UTF8]
847 );
848 }
849
850 #[test]
851 fn test_resolve_position_encodings_skips_invalid_and_keeps_valid() {
852 let result = resolve_position_encodings(&["utf-7".to_string(), "utf-16".to_string()]);
853 assert_eq!(result, vec![PositionEncodingKind::UTF16]);
854 }
855
856 #[test]
857 fn test_resolve_position_encodings_falls_back_when_all_invalid() {
858 let result = resolve_position_encodings(&["utf-7".to_string(), "bogus".to_string()]);
859 assert_eq!(
860 result,
861 vec![PositionEncodingKind::UTF8, PositionEncodingKind::UTF16]
862 );
863 }
864
865 #[test]
866 fn test_resolve_position_encodings_falls_back_when_empty() {
867 let result = resolve_position_encodings(&[]);
868 assert_eq!(
869 result,
870 vec![PositionEncodingKind::UTF8, PositionEncodingKind::UTF16]
871 );
872 }
873
874 #[test]
875 fn test_server_state_ready() {
876 assert!(ServerState::Ready.is_ready());
877 assert!(ServerState::Ready.can_accept_requests());
878 }
879
880 #[test]
881 fn test_server_state_uninitialized() {
882 assert!(!ServerState::Uninitialized.is_ready());
883 assert!(!ServerState::Uninitialized.can_accept_requests());
884 }
885
886 #[test]
887 fn test_server_state_initializing() {
888 assert!(!ServerState::Initializing.is_ready());
889 assert!(!ServerState::Initializing.can_accept_requests());
890 }
891
892 #[test]
893 fn test_workspace_folder_encodes_fragment_char() {
894 #[cfg(windows)]
897 let (root, expected) = (
898 Path::new(r"C:\home\me\dev\#work"),
899 "file:///C:/home/me/dev/%23work",
900 );
901 #[cfg(not(windows))]
902 let (root, expected) = (
903 Path::new("/home/me/dev/#work"),
904 "file:///home/me/dev/%23work",
905 );
906
907 let folder = workspace_folder(root).unwrap();
908
909 assert_eq!(folder.uri.as_str(), expected);
910 assert_eq!(folder.name, "#work");
911 }
912
913 #[test]
914 fn test_workspace_folder_encodes_bracket_chars() {
915 #[cfg(windows)]
916 let (root, expected) = (
917 Path::new(r"C:\home\me\dev\[env]"),
918 "file:///C:/home/me/dev/%5Benv%5D",
919 );
920 #[cfg(not(windows))]
921 let (root, expected) = (
922 Path::new("/home/me/dev/[env]"),
923 "file:///home/me/dev/%5Benv%5D",
924 );
925
926 let folder = workspace_folder(root).unwrap();
927
928 assert_eq!(folder.uri.as_str(), expected);
929 assert_eq!(folder.name, "[env]");
930 }
931
932 #[test]
933 fn test_workspace_folder_rejects_relative_root() {
934 let err = workspace_folder(Path::new("relative/root")).unwrap_err();
935 assert!(matches!(err, Error::InvalidUri(_)), "got {err:?}");
936 }
937
938 #[test]
939 fn test_server_state_shutting_down() {
940 assert!(!ServerState::ShuttingDown.is_ready());
941 assert!(!ServerState::ShuttingDown.can_accept_requests());
942 }
943
944 #[test]
945 fn test_server_state_shutdown() {
946 assert!(!ServerState::Shutdown.is_ready());
947 assert!(!ServerState::Shutdown.can_accept_requests());
948 }
949
950 #[test]
951 fn test_server_state_equality() {
952 assert_eq!(ServerState::Ready, ServerState::Ready);
953 assert_ne!(ServerState::Ready, ServerState::Uninitialized);
954 assert_eq!(ServerState::Shutdown, ServerState::Shutdown);
955 }
956
957 #[test]
958 fn test_server_state_clone() {
959 let state = ServerState::Ready;
960 let cloned = state;
961 assert_eq!(state, cloned);
962 }
963
964 #[test]
965 fn test_server_state_debug() {
966 let state = ServerState::Ready;
967 let debug_str = format!("{state:?}");
968 assert!(debug_str.contains("Ready"));
969 }
970
971 #[test]
972 fn test_server_init_config_clone() {
973 let config = ServerInitConfig {
974 server_config: LspServerConfig::rust_analyzer(),
975 workspace_roots: vec![PathBuf::from("/tmp/workspace")],
976 initialization_options: Some(serde_json::json!({"key": "value"})),
977 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
978 notification_tx: None,
979 };
980
981 #[allow(clippy::redundant_clone)]
982 let cloned = config.clone();
983 assert_eq!(cloned.server_config.language_id, "rust");
984 assert_eq!(cloned.workspace_roots.len(), 1);
985 }
986
987 #[test]
988 fn test_server_init_config_debug() {
989 let config = ServerInitConfig {
990 server_config: LspServerConfig::pyright(),
991 workspace_roots: vec![],
992 initialization_options: None,
993 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
994 notification_tx: None,
995 };
996
997 let debug_str = format!("{config:?}");
998 assert!(debug_str.contains("python"));
999 assert!(debug_str.contains("pyright"));
1000 }
1001
1002 #[test]
1003 fn test_server_init_config_with_options() {
1004 use std::collections::HashMap;
1005
1006 let init_opts = serde_json::json!({
1007 "settings": {
1008 "python": {
1009 "analysis": {
1010 "typeCheckingMode": "strict"
1011 }
1012 }
1013 }
1014 });
1015
1016 let mut env = HashMap::new();
1017 env.insert("PYTHONPATH".to_string(), "/usr/lib".to_string());
1018
1019 let config = ServerInitConfig {
1020 server_config: LspServerConfig {
1021 language_id: "python".to_string(),
1022 command: "pyright-langserver".to_string(),
1023 args: vec!["--stdio".to_string()],
1024 env,
1025 file_patterns: vec!["**/*.py".to_string()],
1026 initialization_options: Some(init_opts.clone()),
1027 timeout_seconds: 10,
1028 request_timeout_seconds: 10,
1029 heuristics: None,
1030 name: None,
1031 handles: None,
1032 },
1033 workspace_roots: vec![PathBuf::from("/workspace")],
1034 initialization_options: Some(init_opts),
1035 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1036 notification_tx: None,
1037 };
1038
1039 assert!(config.initialization_options.is_some());
1040 assert_eq!(config.workspace_roots.len(), 1);
1041 }
1042
1043 #[test]
1044 fn test_server_init_config_empty_workspace() {
1045 let config = ServerInitConfig {
1046 server_config: LspServerConfig::typescript(),
1047 workspace_roots: vec![],
1048 initialization_options: None,
1049 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1050 notification_tx: None,
1051 };
1052
1053 assert!(config.workspace_roots.is_empty());
1054 }
1055
1056 #[test]
1057 fn test_server_init_config_multiple_workspaces() {
1058 let config = ServerInitConfig {
1059 server_config: LspServerConfig::rust_analyzer(),
1060 workspace_roots: vec![
1061 PathBuf::from("/workspace1"),
1062 PathBuf::from("/workspace2"),
1063 PathBuf::from("/workspace3"),
1064 ],
1065 initialization_options: None,
1066 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1067 notification_tx: None,
1068 };
1069
1070 assert_eq!(config.workspace_roots.len(), 3);
1071 }
1072
1073 #[cfg(unix)]
1080 #[tokio::test]
1081 async fn test_has_exited_reflects_child_process_state() {
1082 use lsp_types::ServerCapabilities;
1083
1084 let mut mock_child = tokio::process::Command::new("sleep")
1085 .arg("2")
1086 .stdin(Stdio::piped())
1087 .stdout(Stdio::piped())
1088 .kill_on_drop(true)
1089 .spawn()
1090 .unwrap();
1091
1092 let mock_stdin = mock_child.stdin.take().unwrap();
1093 let mock_stdout = mock_child.stdout.take().unwrap();
1094
1095 let transport = LspTransport::new(mock_stdin, mock_stdout);
1096 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1097 let (_, mock_notification_rx) = mpsc::channel(1);
1098
1099 let mut server = LspServer {
1100 client,
1101 capabilities: ServerCapabilities::default(),
1102 position_encoding: PositionEncodingKind::UTF8,
1103 notification_rx: mock_notification_rx,
1104 child: mock_child,
1105 };
1106
1107 assert!(
1108 !server.has_exited().unwrap(),
1109 "freshly spawned `sleep 2` should still be running"
1110 );
1111
1112 server.child.kill().await.unwrap();
1113 assert!(
1116 server.has_exited().unwrap(),
1117 "killed child must report as exited"
1118 );
1119 }
1120
1121 #[tokio::test]
1122 async fn test_lsp_server_getters() {
1123 use lsp_types::ServerCapabilities;
1124
1125 let mock_child = tokio::process::Command::new("echo")
1126 .stdin(Stdio::piped())
1127 .stdout(Stdio::piped())
1128 .kill_on_drop(true)
1129 .spawn()
1130 .unwrap();
1131
1132 let mock_stdin = tokio::process::Command::new("cat")
1133 .stdin(Stdio::piped())
1134 .spawn()
1135 .unwrap()
1136 .stdin
1137 .take()
1138 .unwrap();
1139
1140 let mock_stdout = tokio::process::Command::new("echo")
1141 .stdout(Stdio::piped())
1142 .spawn()
1143 .unwrap()
1144 .stdout
1145 .take()
1146 .unwrap();
1147
1148 let transport = LspTransport::new(mock_stdin, mock_stdout);
1149 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1150 let (_, mock_notification_rx) = mpsc::channel(1);
1151
1152 let server = LspServer {
1153 client,
1154 capabilities: ServerCapabilities::default(),
1155 position_encoding: PositionEncodingKind::UTF8,
1156 notification_rx: mock_notification_rx,
1157 child: mock_child,
1158 };
1159
1160 assert_eq!(server.position_encoding(), PositionEncodingKind::UTF8);
1161 assert!(server.capabilities().text_document_sync.is_none());
1162
1163 let debug_str = format!("{server:?}");
1164 assert!(debug_str.contains("LspServer"));
1165 assert!(debug_str.contains("<process>"));
1166 }
1167
1168 #[test]
1169 fn test_server_init_result_new_empty() {
1170 let result = ServerInitResult::new();
1171 assert!(!result.has_servers());
1172 assert!(!result.all_failed());
1173 assert!(!result.partial_success());
1174 assert_eq!(result.server_count(), 0);
1175 assert_eq!(result.failure_count(), 0);
1176 }
1177
1178 #[test]
1179 fn test_server_init_result_default() {
1180 let result = ServerInitResult::default();
1181 assert!(!result.has_servers());
1182 assert_eq!(result.server_count(), 0);
1183 assert_eq!(result.failure_count(), 0);
1184 }
1185
1186 #[test]
1187 fn test_server_init_result_all_failures() {
1188 let mut result = ServerInitResult::new();
1189
1190 result.add_failure(ServerSpawnFailure {
1191 server_id: ServerId::from("rust"),
1192 language_id: "rust".to_string(),
1193 command: "rust-analyzer".to_string(),
1194 message: "not found".to_string(),
1195 });
1196
1197 result.add_failure(ServerSpawnFailure {
1198 server_id: ServerId::from("python"),
1199 language_id: "python".to_string(),
1200 command: "pyright".to_string(),
1201 message: "permission denied".to_string(),
1202 });
1203
1204 assert!(!result.has_servers());
1205 assert!(result.all_failed());
1206 assert!(!result.partial_success());
1207 assert_eq!(result.server_count(), 0);
1208 assert_eq!(result.failure_count(), 2);
1209 }
1210
1211 #[tokio::test]
1212 async fn test_server_init_result_all_success() {
1213 let mut result = ServerInitResult::new();
1214
1215 let mock_child1 = tokio::process::Command::new("echo")
1216 .stdin(Stdio::piped())
1217 .stdout(Stdio::piped())
1218 .kill_on_drop(true)
1219 .spawn()
1220 .unwrap();
1221
1222 let mock_stdin1 = tokio::process::Command::new("cat")
1223 .stdin(Stdio::piped())
1224 .spawn()
1225 .unwrap()
1226 .stdin
1227 .take()
1228 .unwrap();
1229
1230 let mock_stdout1 = tokio::process::Command::new("echo")
1231 .stdout(Stdio::piped())
1232 .spawn()
1233 .unwrap()
1234 .stdout
1235 .take()
1236 .unwrap();
1237
1238 let transport1 = LspTransport::new(mock_stdin1, mock_stdout1);
1239 let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
1240 let (_, mock_notification_rx1) = mpsc::channel(1);
1241
1242 let server1 = LspServer {
1243 client: client1,
1244 capabilities: lsp_types::ServerCapabilities::default(),
1245 position_encoding: PositionEncodingKind::UTF8,
1246 notification_rx: mock_notification_rx1,
1247 child: mock_child1,
1248 };
1249
1250 result.add_server("rust".to_string(), server1);
1251
1252 assert!(result.has_servers());
1253 assert!(!result.all_failed());
1254 assert!(!result.partial_success());
1255 assert_eq!(result.server_count(), 1);
1256 assert_eq!(result.failure_count(), 0);
1257 }
1258
1259 #[tokio::test]
1260 async fn test_server_init_result_partial_success() {
1261 let mut result = ServerInitResult::new();
1262
1263 let mock_child = tokio::process::Command::new("echo")
1264 .stdin(Stdio::piped())
1265 .stdout(Stdio::piped())
1266 .kill_on_drop(true)
1267 .spawn()
1268 .unwrap();
1269
1270 let mock_stdin = tokio::process::Command::new("cat")
1271 .stdin(Stdio::piped())
1272 .spawn()
1273 .unwrap()
1274 .stdin
1275 .take()
1276 .unwrap();
1277
1278 let mock_stdout = tokio::process::Command::new("echo")
1279 .stdout(Stdio::piped())
1280 .spawn()
1281 .unwrap()
1282 .stdout
1283 .take()
1284 .unwrap();
1285
1286 let transport = LspTransport::new(mock_stdin, mock_stdout);
1287 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1288 let (_, mock_notification_rx) = mpsc::channel(1);
1289
1290 let server = LspServer {
1291 client,
1292 capabilities: lsp_types::ServerCapabilities::default(),
1293 position_encoding: PositionEncodingKind::UTF8,
1294 notification_rx: mock_notification_rx,
1295 child: mock_child,
1296 };
1297
1298 result.add_server("rust".to_string(), server);
1299
1300 result.add_failure(ServerSpawnFailure {
1301 server_id: ServerId::from("python"),
1302 language_id: "python".to_string(),
1303 command: "pyright".to_string(),
1304 message: "not found".to_string(),
1305 });
1306
1307 assert!(result.has_servers());
1308 assert!(!result.all_failed());
1309 assert!(result.partial_success());
1310 assert_eq!(result.server_count(), 1);
1311 assert_eq!(result.failure_count(), 1);
1312 }
1313
1314 #[tokio::test]
1315 async fn test_server_init_result_multiple_servers() {
1316 let mut result = ServerInitResult::new();
1317
1318 for i in 0..3 {
1319 let mock_child = tokio::process::Command::new("echo")
1320 .stdin(Stdio::piped())
1321 .stdout(Stdio::piped())
1322 .kill_on_drop(true)
1323 .spawn()
1324 .unwrap();
1325
1326 let mock_stdin = tokio::process::Command::new("cat")
1327 .stdin(Stdio::piped())
1328 .spawn()
1329 .unwrap()
1330 .stdin
1331 .take()
1332 .unwrap();
1333
1334 let mock_stdout = tokio::process::Command::new("echo")
1335 .stdout(Stdio::piped())
1336 .spawn()
1337 .unwrap()
1338 .stdout
1339 .take()
1340 .unwrap();
1341
1342 let transport = LspTransport::new(mock_stdin, mock_stdout);
1343 let config = if i == 0 {
1344 LspServerConfig::rust_analyzer()
1345 } else if i == 1 {
1346 LspServerConfig::pyright()
1347 } else {
1348 LspServerConfig::typescript()
1349 };
1350 let client = LspClient::from_transport(config.clone(), transport);
1351 let (_, mock_notification_rx) = mpsc::channel(1);
1352
1353 let server = LspServer {
1354 client,
1355 capabilities: lsp_types::ServerCapabilities::default(),
1356 position_encoding: PositionEncodingKind::UTF8,
1357 notification_rx: mock_notification_rx,
1358 child: mock_child,
1359 };
1360
1361 result.add_server(config.language_id, server);
1362 }
1363
1364 assert!(result.has_servers());
1365 assert!(!result.all_failed());
1366 assert!(!result.partial_success());
1367 assert_eq!(result.server_count(), 3);
1368 assert_eq!(result.failure_count(), 0);
1369 }
1370
1371 #[tokio::test]
1372 async fn test_server_init_result_replace_server() {
1373 let mut result = ServerInitResult::new();
1374
1375 let mock_child1 = tokio::process::Command::new("echo")
1376 .stdin(Stdio::piped())
1377 .stdout(Stdio::piped())
1378 .kill_on_drop(true)
1379 .spawn()
1380 .unwrap();
1381
1382 let mock_stdin1 = tokio::process::Command::new("cat")
1383 .stdin(Stdio::piped())
1384 .spawn()
1385 .unwrap()
1386 .stdin
1387 .take()
1388 .unwrap();
1389
1390 let mock_stdout1 = tokio::process::Command::new("echo")
1391 .stdout(Stdio::piped())
1392 .spawn()
1393 .unwrap()
1394 .stdout
1395 .take()
1396 .unwrap();
1397
1398 let transport1 = LspTransport::new(mock_stdin1, mock_stdout1);
1399 let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
1400 let (_, mock_notification_rx1) = mpsc::channel(1);
1401
1402 let server1 = LspServer {
1403 client: client1,
1404 capabilities: lsp_types::ServerCapabilities::default(),
1405 position_encoding: PositionEncodingKind::UTF8,
1406 notification_rx: mock_notification_rx1,
1407 child: mock_child1,
1408 };
1409
1410 result.add_server("rust".to_string(), server1);
1411 assert_eq!(result.server_count(), 1);
1412
1413 let mock_child2 = tokio::process::Command::new("echo")
1414 .stdin(Stdio::piped())
1415 .stdout(Stdio::piped())
1416 .kill_on_drop(true)
1417 .spawn()
1418 .unwrap();
1419
1420 let mock_stdin2 = tokio::process::Command::new("cat")
1421 .stdin(Stdio::piped())
1422 .spawn()
1423 .unwrap()
1424 .stdin
1425 .take()
1426 .unwrap();
1427
1428 let mock_stdout2 = tokio::process::Command::new("echo")
1429 .stdout(Stdio::piped())
1430 .spawn()
1431 .unwrap()
1432 .stdout
1433 .take()
1434 .unwrap();
1435
1436 let transport2 = LspTransport::new(mock_stdin2, mock_stdout2);
1437 let client2 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport2);
1438 let (_, mock_notification_rx2) = mpsc::channel(1);
1439
1440 let server2 = LspServer {
1441 client: client2,
1442 capabilities: lsp_types::ServerCapabilities::default(),
1443 position_encoding: PositionEncodingKind::UTF16,
1444 notification_rx: mock_notification_rx2,
1445 child: mock_child2,
1446 };
1447
1448 result.add_server("rust".to_string(), server2);
1449 assert_eq!(result.server_count(), 1);
1450 }
1451
1452 #[test]
1453 fn test_server_init_result_debug() {
1454 let mut result = ServerInitResult::new();
1455
1456 result.add_failure(ServerSpawnFailure {
1457 server_id: ServerId::from("rust"),
1458 language_id: "rust".to_string(),
1459 command: "rust-analyzer".to_string(),
1460 message: "not found".to_string(),
1461 });
1462
1463 let debug_str = format!("{result:?}");
1464 assert!(debug_str.contains("ServerInitResult"));
1465 }
1466
1467 #[test]
1468 fn test_server_init_result_multiple_failures() {
1469 let mut result = ServerInitResult::new();
1470
1471 result.add_failure(ServerSpawnFailure {
1472 server_id: ServerId::from("python"),
1473 language_id: "python".to_string(),
1474 command: "pyright".to_string(),
1475 message: "not found".to_string(),
1476 });
1477
1478 result.add_failure(ServerSpawnFailure {
1479 server_id: ServerId::from("typescript"),
1480 language_id: "typescript".to_string(),
1481 command: "tsserver".to_string(),
1482 message: "command not found".to_string(),
1483 });
1484
1485 assert_eq!(result.failure_count(), 2);
1486 assert_eq!(result.server_count(), 0);
1487 assert!(result.all_failed());
1488 assert!(!result.partial_success());
1489 }
1490
1491 #[tokio::test]
1492 async fn test_spawn_batch_empty_configs() {
1493 let configs: &[ServerInitConfig] = &[];
1494 let result = LspServer::spawn_batch(configs).await;
1495
1496 assert!(!result.has_servers());
1497 assert!(!result.all_failed());
1498 assert!(!result.partial_success());
1499 assert_eq!(result.server_count(), 0);
1500 assert_eq!(result.failure_count(), 0);
1501 }
1502
1503 #[tokio::test]
1504 async fn test_spawn_batch_single_invalid_config() {
1505 let configs = vec![ServerInitConfig {
1506 server_config: LspServerConfig {
1507 language_id: "rust".to_string(),
1508 command: "nonexistent-command-12345".to_string(),
1509 args: vec![],
1510 env: std::collections::HashMap::new(),
1511 file_patterns: vec!["**/*.rs".to_string()],
1512 initialization_options: None,
1513 timeout_seconds: 10,
1514 request_timeout_seconds: 10,
1515 heuristics: None,
1516 name: None,
1517 handles: None,
1518 },
1519 workspace_roots: vec![],
1520 initialization_options: None,
1521 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1522 notification_tx: None,
1523 }];
1524
1525 let result = LspServer::spawn_batch(&configs).await;
1526
1527 assert!(!result.has_servers());
1528 assert!(result.all_failed());
1529 assert!(!result.partial_success());
1530 assert_eq!(result.server_count(), 0);
1531 assert_eq!(result.failure_count(), 1);
1532
1533 let failure = &result.failures[0];
1534 assert_eq!(failure.language_id, "rust");
1535 assert_eq!(failure.command, "nonexistent-command-12345");
1536 assert!(failure.message.contains("spawn"));
1537 }
1538
1539 #[tokio::test]
1540 async fn test_spawn_batch_all_invalid_configs() {
1541 let configs = vec![
1542 ServerInitConfig {
1543 server_config: LspServerConfig {
1544 language_id: "rust".to_string(),
1545 command: "nonexistent-rust-analyzer".to_string(),
1546 args: vec![],
1547 env: std::collections::HashMap::new(),
1548 file_patterns: vec!["**/*.rs".to_string()],
1549 initialization_options: None,
1550 timeout_seconds: 10,
1551 request_timeout_seconds: 10,
1552 heuristics: None,
1553 name: None,
1554 handles: None,
1555 },
1556 workspace_roots: vec![],
1557 initialization_options: None,
1558 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1559 notification_tx: None,
1560 },
1561 ServerInitConfig {
1562 server_config: LspServerConfig {
1563 language_id: "python".to_string(),
1564 command: "nonexistent-pyright".to_string(),
1565 args: vec![],
1566 env: std::collections::HashMap::new(),
1567 file_patterns: vec!["**/*.py".to_string()],
1568 initialization_options: None,
1569 timeout_seconds: 10,
1570 request_timeout_seconds: 10,
1571 heuristics: None,
1572 name: None,
1573 handles: None,
1574 },
1575 workspace_roots: vec![],
1576 initialization_options: None,
1577 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1578 notification_tx: None,
1579 },
1580 ServerInitConfig {
1581 server_config: LspServerConfig {
1582 language_id: "typescript".to_string(),
1583 command: "nonexistent-tsserver".to_string(),
1584 args: vec![],
1585 env: std::collections::HashMap::new(),
1586 file_patterns: vec!["**/*.ts".to_string()],
1587 initialization_options: None,
1588 timeout_seconds: 10,
1589 request_timeout_seconds: 10,
1590 heuristics: None,
1591 name: None,
1592 handles: None,
1593 },
1594 workspace_roots: vec![],
1595 initialization_options: None,
1596 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1597 notification_tx: None,
1598 },
1599 ];
1600
1601 let result = LspServer::spawn_batch(&configs).await;
1602
1603 assert!(!result.has_servers());
1604 assert!(result.all_failed());
1605 assert!(!result.partial_success());
1606 assert_eq!(result.server_count(), 0);
1607 assert_eq!(result.failure_count(), 3);
1608
1609 let failure_languages: Vec<_> = result
1610 .failures
1611 .iter()
1612 .map(|f| f.language_id.as_str())
1613 .collect();
1614 assert!(failure_languages.contains(&"rust"));
1615 assert!(failure_languages.contains(&"python"));
1616 assert!(failure_languages.contains(&"typescript"));
1617 }
1618
1619 #[tokio::test]
1620 async fn test_spawn_batch_multiple_invalid_configs_ordering() {
1621 let configs = vec![
1622 ServerInitConfig {
1623 server_config: LspServerConfig {
1624 language_id: "lang1".to_string(),
1625 command: "cmd1-nonexistent".to_string(),
1626 args: vec![],
1627 env: std::collections::HashMap::new(),
1628 file_patterns: vec![],
1629 initialization_options: None,
1630 timeout_seconds: 10,
1631 request_timeout_seconds: 10,
1632 heuristics: None,
1633 name: None,
1634 handles: None,
1635 },
1636 workspace_roots: vec![],
1637 initialization_options: None,
1638 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1639 notification_tx: None,
1640 },
1641 ServerInitConfig {
1642 server_config: LspServerConfig {
1643 language_id: "lang2".to_string(),
1644 command: "cmd2-nonexistent".to_string(),
1645 args: vec![],
1646 env: std::collections::HashMap::new(),
1647 file_patterns: vec![],
1648 initialization_options: None,
1649 timeout_seconds: 10,
1650 request_timeout_seconds: 10,
1651 heuristics: None,
1652 name: None,
1653 handles: None,
1654 },
1655 workspace_roots: vec![],
1656 initialization_options: None,
1657 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1658 notification_tx: None,
1659 },
1660 ];
1661
1662 let result = LspServer::spawn_batch(&configs).await;
1663
1664 assert_eq!(result.failure_count(), 2);
1665
1666 assert_eq!(result.failures[0].language_id, "lang1");
1667 assert_eq!(result.failures[0].command, "cmd1-nonexistent");
1668
1669 assert_eq!(result.failures[1].language_id, "lang2");
1670 assert_eq!(result.failures[1].command, "cmd2-nonexistent");
1671 }
1672
1673 mod initialize_wire {
1681 use std::process::Stdio;
1682
1683 use serde_json::Value;
1684 use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
1685 use tokio::process::{Child, ChildStdin, ChildStdout, Command};
1686
1687 use super::*;
1688 use crate::lsp::client::LspClient;
1689
1690 struct FakeServer {
1691 _write_half: Child,
1692 _read_half: Child,
1693 read_half_stdin: ChildStdin,
1694 write_stdout: ChildStdout,
1695 }
1696
1697 fn fake_lsp_client() -> (LspClient, FakeServer) {
1698 let mut write_half = Command::new("cat")
1699 .stdin(Stdio::piped())
1700 .stdout(Stdio::piped())
1701 .kill_on_drop(true)
1702 .spawn()
1703 .unwrap();
1704 let write_stdin = write_half.stdin.take().unwrap();
1705 let write_stdout = write_half.stdout.take().unwrap();
1706
1707 let mut read_half = Command::new("cat")
1708 .stdin(Stdio::piped())
1709 .stdout(Stdio::piped())
1710 .kill_on_drop(true)
1711 .spawn()
1712 .unwrap();
1713 let read_stdout = read_half.stdout.take().unwrap();
1714 let read_stdin = read_half.stdin.take().unwrap();
1715
1716 let transport = LspTransport::new(write_stdin, read_stdout);
1717 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1718
1719 (
1720 client,
1721 FakeServer {
1722 _write_half: write_half,
1723 _read_half: read_half,
1724 read_half_stdin: read_stdin,
1725 write_stdout,
1726 },
1727 )
1728 }
1729
1730 async fn read_framed_message(reader: &mut BufReader<&mut ChildStdout>) -> Value {
1732 let mut content_length = None;
1733 let mut line = String::new();
1734 loop {
1735 line.clear();
1736 reader.read_line(&mut line).await.unwrap();
1737 if line == "\r\n" || line == "\n" {
1738 break;
1739 }
1740 if let Some((key, value)) = line.trim_end().split_once(':')
1741 && key.trim().eq_ignore_ascii_case("content-length")
1742 {
1743 content_length = Some(value.trim().parse::<usize>().unwrap());
1744 }
1745 }
1746 let mut buf = vec![0u8; content_length.unwrap()];
1747 reader.read_exact(&mut buf).await.unwrap();
1748 serde_json::from_slice(&buf).unwrap()
1749 }
1750
1751 async fn write_success_response(stdin: &mut ChildStdin, id: &Value, result: Value) {
1753 let response = serde_json::json!({
1754 "jsonrpc": "2.0",
1755 "id": id,
1756 "result": result,
1757 });
1758 let content = serde_json::to_string(&response).unwrap();
1759 let header = format!("Content-Length: {}\r\n\r\n", content.len());
1760 stdin.write_all(header.as_bytes()).await.unwrap();
1761 stdin.write_all(content.as_bytes()).await.unwrap();
1762 stdin.flush().await.unwrap();
1763 }
1764
1765 #[tokio::test]
1766 async fn test_initialize_sends_configured_position_encodings() {
1767 let (client, mut server) = fake_lsp_client();
1768
1769 let config = ServerInitConfig {
1770 server_config: LspServerConfig::rust_analyzer(),
1771 workspace_roots: vec![],
1772 initialization_options: None,
1773 position_encodings: vec!["utf-32".to_string(), "utf-8".to_string()],
1774 notification_tx: None,
1775 };
1776
1777 let init_task =
1778 tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1779
1780 let mut reader = BufReader::new(&mut server.write_stdout);
1781 let request = read_framed_message(&mut reader).await;
1782
1783 assert_eq!(request["method"], "initialize");
1784 assert_eq!(
1785 request["params"]["capabilities"]["general"]["positionEncodings"],
1786 serde_json::json!(["utf-32", "utf-8"]),
1787 "initialize request must carry the configured encoding order, not the \
1788 hardcoded [UTF8, UTF16] default"
1789 );
1790
1791 write_success_response(
1792 &mut server.read_half_stdin,
1793 &request["id"].clone(),
1794 serde_json::json!({ "capabilities": {} }),
1795 )
1796 .await;
1797
1798 init_task.await.unwrap().unwrap();
1800 }
1801 }
1802
1803 #[tokio::test]
1804 async fn test_spawn_batch_logs_each_failure() {
1805 let configs = vec![
1806 ServerInitConfig {
1807 server_config: LspServerConfig {
1808 language_id: "test1".to_string(),
1809 command: "nonexistent-test1".to_string(),
1810 args: vec![],
1811 env: std::collections::HashMap::new(),
1812 file_patterns: vec![],
1813 initialization_options: None,
1814 timeout_seconds: 10,
1815 request_timeout_seconds: 10,
1816 heuristics: None,
1817 name: None,
1818 handles: None,
1819 },
1820 workspace_roots: vec![],
1821 initialization_options: None,
1822 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1823 notification_tx: None,
1824 },
1825 ServerInitConfig {
1826 server_config: LspServerConfig {
1827 language_id: "test2".to_string(),
1828 command: "nonexistent-test2".to_string(),
1829 args: vec![],
1830 env: std::collections::HashMap::new(),
1831 file_patterns: vec![],
1832 initialization_options: None,
1833 timeout_seconds: 10,
1834 request_timeout_seconds: 10,
1835 heuristics: None,
1836 name: None,
1837 handles: None,
1838 },
1839 workspace_roots: vec![],
1840 initialization_options: None,
1841 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1842 notification_tx: None,
1843 },
1844 ];
1845
1846 let result = LspServer::spawn_batch(&configs).await;
1847
1848 assert_eq!(result.failure_count(), 2);
1849 assert_eq!(result.failures[0].language_id, "test1");
1850 assert_eq!(result.failures[1].language_id, "test2");
1851 }
1852
1853 fn bare_server_config(env: HashMap<String, String>) -> LspServerConfig {
1856 LspServerConfig {
1857 language_id: "test".to_string(),
1858 command: "irrelevant-for-build-command".to_string(),
1859 args: vec![],
1860 env,
1861 file_patterns: vec![],
1862 initialization_options: None,
1863 timeout_seconds: 5,
1864 request_timeout_seconds: 5,
1865 heuristics: None,
1866 name: None,
1867 handles: None,
1868 }
1869 }
1870
1871 fn effective_envs(command: &Command) -> HashMap<String, String> {
1875 command
1876 .as_std()
1877 .get_envs()
1878 .filter_map(|(k, v)| {
1879 v.map(|v| {
1880 (
1881 k.to_string_lossy().into_owned(),
1882 v.to_string_lossy().into_owned(),
1883 )
1884 })
1885 })
1886 .collect()
1887 }
1888
1889 #[test]
1893 fn test_build_command_excludes_non_allowlisted_parent_env_vars() {
1894 let config = bare_server_config(HashMap::new());
1895 let command = LspServer::build_command(&config, |key| match key {
1896 "PATH" => Some("/parent/bin".into()),
1897 "MCPLS_TEST_LEAK_CANARY" => Some("should-not-reach-child".into()),
1898 _ => None,
1899 });
1900
1901 let envs = effective_envs(&command);
1902
1903 assert!(
1904 !envs.contains_key("MCPLS_TEST_LEAK_CANARY"),
1905 "non-allowlisted parent env var leaked into child command: {envs:?}"
1906 );
1907
1908 #[cfg(unix)]
1919 assert!(
1920 format!("{:?}", command.as_std()).starts_with("env -i "),
1921 "build_command must call .env_clear() so the child doesn't inherit the full parent environment"
1922 );
1923 }
1924
1925 #[test]
1928 fn test_build_command_passes_through_allowlisted_env_vars() {
1929 let config = bare_server_config(HashMap::new());
1930 let command =
1931 LspServer::build_command(&config, |key| (key == "PATH").then(|| "/parent/bin".into()));
1932
1933 let envs = effective_envs(&command);
1934
1935 assert_eq!(envs.get("PATH"), Some(&"/parent/bin".to_string()));
1936 }
1937
1938 #[test]
1941 fn test_build_command_includes_configured_env_vars() {
1942 let mut env = HashMap::new();
1943 env.insert(
1944 "MCPLS_TEST_CONFIGURED".to_string(),
1945 "from-server-config".to_string(),
1946 );
1947 let config = bare_server_config(env);
1948 let command = LspServer::build_command(&config, |_| None);
1949
1950 let envs = effective_envs(&command);
1951
1952 assert_eq!(
1953 envs.get("MCPLS_TEST_CONFIGURED"),
1954 Some(&"from-server-config".to_string())
1955 );
1956 }
1957
1958 #[test]
1962 fn test_build_command_configured_env_overrides_allowlisted_var() {
1963 let mut env = HashMap::new();
1964 env.insert("PATH".to_string(), "/configured/override/path".to_string());
1965 let config = bare_server_config(env);
1966 let command =
1967 LspServer::build_command(&config, |key| (key == "PATH").then(|| "/parent/bin".into()));
1968
1969 let envs = effective_envs(&command);
1970
1971 assert_eq!(
1972 envs.get("PATH"),
1973 Some(&"/configured/override/path".to_string())
1974 );
1975 }
1976
1977 #[tokio::test]
1987 async fn test_register_servers_computes_diagnostics_flags_from_rebound_router() {
1988 use crate::bridge::Translator;
1989 use crate::config::{ServerId, ToolKind, ToolRouter};
1990
1991 let pylsp_id = ServerId::from("pylsp");
1992 let configs = vec![
1993 LspServerConfig {
1994 language_id: "python".to_string(),
1995 command: "pyright-langserver".to_string(),
1996 args: vec![],
1997 env: std::collections::HashMap::new(),
1998 file_patterns: vec![],
1999 initialization_options: None,
2000 timeout_seconds: 30,
2001 request_timeout_seconds: 30,
2002 heuristics: None,
2003 name: Some("pyright-diag".to_string()),
2004 handles: Some(vec![ToolKind::Diagnostics]),
2005 },
2006 LspServerConfig {
2007 language_id: "python".to_string(),
2008 command: "pylsp".to_string(),
2009 args: vec![],
2010 env: std::collections::HashMap::new(),
2011 file_patterns: vec![],
2012 initialization_options: None,
2013 timeout_seconds: 30,
2014 request_timeout_seconds: 30,
2015 heuristics: None,
2016 name: Some("pylsp".to_string()),
2017 handles: None,
2018 },
2019 ];
2020 let router = ToolRouter::from_configs(&configs).unwrap();
2021 let translator = Translator::new().with_router(router);
2022
2023 let mut result = ServerInitResult::new();
2025 result.add_server(pylsp_id.clone(), fake_lsp_server());
2026
2027 let registered = crate::register_servers(result, &translator, &HashMap::new());
2028
2029 assert_eq!(
2030 registered.diagnostics_flags.get(&pylsp_id),
2031 Some(&true),
2032 "pylsp must inherit the diagnostics route once pyright-diag is \
2033 known dead, and the flag must reflect that post-rebind state"
2034 );
2035 }
2036}