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, StaleRequestSupportOptions,
17 SymbolKind, WorkspaceFolder,
18};
19use tokio::process::Command;
20use tokio::sync::mpsc;
21use tokio::time::Duration;
22use tracing::{debug, info, warn};
23
24use crate::bridge::try_path_to_uri;
25use crate::config::{LspServerConfig, ServerId};
26use crate::error::{Error, Result, ServerSpawnFailure};
27use crate::lsp::CONTENT_MODIFIED_RETRY_METHODS;
28use crate::lsp::client::LspClient;
29use crate::lsp::transport::LspTransport;
30use crate::lsp::types::LspNotification;
31
32const ENV_PASSTHROUGH: &[&str] = &["PATH", "HOME", "USERPROFILE", "TMPDIR", "TEMP", "TMP"];
45
46const CHILD_EXIT_GRACE: Duration = Duration::from_secs(3);
50
51pub const SUPPORTED_SYMBOL_KINDS: [SymbolKind; 26] = [
58 SymbolKind::File,
59 SymbolKind::Module,
60 SymbolKind::Namespace,
61 SymbolKind::Package,
62 SymbolKind::Class,
63 SymbolKind::Method,
64 SymbolKind::Property,
65 SymbolKind::Field,
66 SymbolKind::Constructor,
67 SymbolKind::Enum,
68 SymbolKind::Interface,
69 SymbolKind::Function,
70 SymbolKind::Variable,
71 SymbolKind::Constant,
72 SymbolKind::String,
73 SymbolKind::Number,
74 SymbolKind::Boolean,
75 SymbolKind::Array,
76 SymbolKind::Object,
77 SymbolKind::Key,
78 SymbolKind::Null,
79 SymbolKind::EnumMember,
80 SymbolKind::Struct,
81 SymbolKind::Event,
82 SymbolKind::Operator,
83 SymbolKind::TypeParameter,
84];
85
86#[cfg(windows)]
93const ENV_PASSTHROUGH_WINDOWS: &[&str] = &[
94 "SystemRoot",
95 "SystemDrive",
96 "windir",
97 "APPDATA",
98 "LOCALAPPDATA",
99 "ProgramData",
100 "ProgramFiles",
101 "COMSPEC",
102 "PATHEXT",
103 "NUMBER_OF_PROCESSORS",
104 "USERNAME",
105];
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum ServerState {
110 Uninitialized,
112 Initializing,
114 Ready,
116 ShuttingDown,
118 Shutdown,
120}
121
122impl ServerState {
123 #[must_use]
125 pub const fn is_ready(&self) -> bool {
126 matches!(self, Self::Ready)
127 }
128
129 #[must_use]
131 pub const fn can_accept_requests(&self) -> bool {
132 matches!(self, Self::Ready)
133 }
134}
135
136#[derive(Debug, Clone)]
138pub struct ServerInitConfig {
139 pub server_config: LspServerConfig,
141 pub workspace_roots: Vec<PathBuf>,
143 pub initialization_options: Option<serde_json::Value>,
145 pub position_encodings: Vec<String>,
159 pub notification_tx: Option<mpsc::Sender<LspNotification>>,
166}
167
168#[derive(Debug)]
192pub struct ServerInitResult {
193 pub servers: HashMap<ServerId, LspServer>,
195 pub failures: Vec<ServerSpawnFailure>,
197}
198
199impl ServerInitResult {
200 #[must_use]
202 pub fn new() -> Self {
203 Self {
204 servers: HashMap::new(),
205 failures: Vec::new(),
206 }
207 }
208
209 #[must_use]
213 pub fn has_servers(&self) -> bool {
214 !self.servers.is_empty()
215 }
216
217 #[must_use]
222 pub fn all_failed(&self) -> bool {
223 self.servers.is_empty() && !self.failures.is_empty()
224 }
225
226 #[must_use]
230 pub fn partial_success(&self) -> bool {
231 !self.servers.is_empty() && !self.failures.is_empty()
232 }
233
234 #[must_use]
236 pub fn server_count(&self) -> usize {
237 self.servers.len()
238 }
239
240 #[must_use]
242 pub const fn failure_count(&self) -> usize {
243 self.failures.len()
244 }
245
246 pub fn add_server(&mut self, id: impl Into<ServerId>, server: LspServer) {
250 self.servers.insert(id.into(), server);
251 }
252
253 pub fn add_failure(&mut self, failure: ServerSpawnFailure) {
255 self.failures.push(failure);
256 }
257}
258
259impl Default for ServerInitResult {
260 fn default() -> Self {
261 Self::new()
262 }
263}
264
265pub struct LspServer {
267 client: LspClient,
268 capabilities: ServerCapabilities,
269 position_encoding: PositionEncodingKind,
270 pub notification_rx: mpsc::Receiver<LspNotification>,
275 child: Option<tokio::process::Child>,
285}
286
287impl std::fmt::Debug for LspServer {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 f.debug_struct("LspServer")
290 .field("client", &self.client)
291 .field("capabilities", &self.capabilities)
292 .field("position_encoding", &self.position_encoding)
293 .field("notification_rx", &"<channel>")
294 .field("child", &"<process>")
295 .finish()
296 }
297}
298
299impl LspServer {
300 pub fn take_notification_rx(&mut self) -> tokio::sync::mpsc::Receiver<LspNotification> {
306 let (_, dummy) = tokio::sync::mpsc::channel(1);
307 std::mem::replace(&mut self.notification_rx, dummy)
308 }
309
310 pub async fn spawn(config: ServerInitConfig) -> Result<Self> {
325 info!(
326 "Spawning LSP server: {} {:?}",
327 config.server_config.command, config.server_config.args
328 );
329
330 let mut command = Self::build_command(&config.server_config, |key| std::env::var_os(key));
331
332 let passthrough_present = {
337 let base = ENV_PASSTHROUGH
338 .iter()
339 .filter(|key| std::env::var_os(key).is_some())
340 .count();
341 #[cfg(windows)]
342 let windows = ENV_PASSTHROUGH_WINDOWS
343 .iter()
344 .filter(|key| std::env::var_os(key).is_some())
345 .count();
346 #[cfg(not(windows))]
347 let windows = 0;
348 base + windows
349 };
350 debug!(
351 "Effective LSP server env: {passthrough_present} allowlisted key(s) present, \
352 {} configured override(s) applied",
353 config.server_config.env.len()
354 );
355
356 let mut child = command.spawn().map_err(|e| Error::ServerSpawnFailed {
357 command: config.server_config.command.clone(),
358 source: e,
359 })?;
360
361 let stdin = child
362 .stdin
363 .take()
364 .ok_or_else(|| Error::Transport("Failed to capture stdin".to_string()))?;
365 let stdout = child
366 .stdout
367 .take()
368 .ok_or_else(|| Error::Transport("Failed to capture stdout".to_string()))?;
369
370 let transport = LspTransport::new(stdin, stdout);
371 let (notification_tx, notification_rx) = mpsc::channel(64);
372 let client = LspClient::from_transport_with_notifications(
373 config.server_config.clone(),
374 transport,
375 notification_tx,
376 );
377
378 let (capabilities, position_encoding) = Self::initialize(&client, &config).await?;
379
380 info!("LSP server initialized successfully");
381
382 Ok(Self {
383 client,
384 capabilities,
385 position_encoding,
386 notification_rx,
387 child: Some(child),
388 })
389 }
390
391 fn build_command(
401 config: &LspServerConfig,
402 parent_env: impl Fn(&str) -> Option<std::ffi::OsString>,
403 ) -> Command {
404 let mut command = Command::new(&config.command);
405 command.args(&config.args).env_clear();
406
407 for key in ENV_PASSTHROUGH {
408 if let Some(value) = parent_env(key) {
409 command.env(key, value);
410 }
411 }
412 #[cfg(windows)]
413 for key in ENV_PASSTHROUGH_WINDOWS {
414 if let Some(value) = parent_env(key) {
415 command.env(key, value);
416 }
417 }
418
419 command
420 .envs(&config.env)
421 .stdin(Stdio::piped())
422 .stdout(Stdio::piped())
423 .stderr(Stdio::null())
424 .kill_on_drop(true);
425
426 command
427 }
428
429 #[allow(clippy::too_many_lines)]
433 async fn initialize(
434 client: &LspClient,
435 config: &ServerInitConfig,
436 ) -> Result<(ServerCapabilities, PositionEncodingKind)> {
437 debug!("Sending initialize request");
438
439 let workspace_folders: Vec<WorkspaceFolder> = config
440 .workspace_roots
441 .iter()
442 .map(|root| workspace_folder(root))
443 .collect::<Result<Vec<_>>>()?;
444
445 let params = InitializeParams {
446 process_id: Some(i32::try_from(std::process::id()).unwrap_or(i32::MAX)),
447 #[allow(deprecated)]
448 root_uri: None,
449 initialization_options: config.initialization_options.clone(),
450 capabilities: ClientCapabilities {
451 general: Some(GeneralClientCapabilities {
452 position_encodings: Some(resolve_position_encodings(
453 &config.position_encodings,
454 )),
455 stale_request_support: Some(StaleRequestSupportOptions {
456 cancel: false,
459 retry_on_content_modified: CONTENT_MODIFIED_RETRY_METHODS
460 .iter()
461 .map(ToString::to_string)
462 .collect(),
463 }),
464 ..Default::default()
465 }),
466 text_document: Some(lsp_types::TextDocumentClientCapabilities {
467 document_symbol: Some(lsp_types::DocumentSymbolClientCapabilities {
468 dynamic_registration: Some(false),
469 symbol_kind: Some(lsp_types::ClientSymbolKindOptions {
470 value_set: Some(SUPPORTED_SYMBOL_KINDS.to_vec()),
471 }),
472 hierarchical_document_symbol_support: Some(true),
473 ..Default::default()
474 }),
475 hover: Some(lsp_types::HoverClientCapabilities {
476 dynamic_registration: Some(false),
477 content_format: Some(vec![
478 lsp_types::MarkupKind::Markdown,
479 lsp_types::MarkupKind::PlainText,
480 ]),
481 }),
482 definition: Some(lsp_types::DefinitionClientCapabilities {
483 dynamic_registration: Some(false),
484 link_support: Some(true),
485 }),
486 references: Some(lsp_types::ReferenceClientCapabilities {
487 dynamic_registration: Some(false),
488 }),
489 code_action: Some(lsp_types::CodeActionClientCapabilities {
490 dynamic_registration: Some(false),
491 data_support: Some(true),
492 resolve_support: Some(lsp_types::ClientCodeActionResolveOptions {
493 properties: vec!["edit".to_string()],
494 }),
495 code_action_literal_support: Some(
498 lsp_types::ClientCodeActionLiteralOptions {
499 code_action_kind: lsp_types::ClientCodeActionKindOptions {
500 value_set: vec![
501 lsp_types::CodeActionKind::Empty,
502 lsp_types::CodeActionKind::QuickFix,
503 lsp_types::CodeActionKind::Refactor,
504 lsp_types::CodeActionKind::RefactorExtract,
505 lsp_types::CodeActionKind::RefactorInline,
506 lsp_types::CodeActionKind::RefactorRewrite,
507 lsp_types::CodeActionKind::Source,
508 lsp_types::CodeActionKind::SourceOrganizeImports,
509 ],
510 },
511 },
512 ),
513 ..Default::default()
514 }),
515 ..Default::default()
516 }),
517 workspace: Some(lsp_types::WorkspaceClientCapabilities {
518 workspace_folders: Some(true),
519 ..Default::default()
520 }),
521 ..Default::default()
522 },
523 client_info: Some(ClientInfo {
524 name: "mcpls".to_string(),
525 version: Some(env!("CARGO_PKG_VERSION").to_string()),
526 }),
527 workspace_folders_initialize_params: lsp_types::WorkspaceFoldersInitializeParams {
528 workspace_folders: Some(lsp_types::WorkspaceFolders::WorkspaceFolderList(
529 workspace_folders,
530 )),
531 },
532 ..Default::default()
533 };
534
535 let result: InitializeResult = client
539 .request(
540 "initialize",
541 params,
542 Duration::from_secs(
552 config
553 .server_config
554 .timeout_seconds
555 .clamp(1, crate::config::MAX_TIMEOUT_SECONDS),
556 ),
557 )
558 .await
559 .map_err(|e| Error::LspInitFailed {
560 message: format!("Initialize request failed: {e}"),
561 })?;
562
563 let position_encoding = result
564 .capabilities
565 .position_encoding
566 .clone()
567 .unwrap_or(PositionEncodingKind::UTF16);
568
569 debug!(
570 "Server capabilities received, encoding: {:?}",
571 position_encoding
572 );
573
574 client
575 .notify("initialized", InitializedParams {})
576 .await
577 .map_err(|e| Error::LspInitFailed {
578 message: format!("Initialized notification failed: {e}"),
579 })?;
580
581 Ok((result.capabilities, position_encoding))
582 }
583
584 #[must_use]
586 pub const fn capabilities(&self) -> &ServerCapabilities {
587 &self.capabilities
588 }
589
590 #[must_use]
592 pub fn position_encoding(&self) -> PositionEncodingKind {
593 self.position_encoding.clone()
594 }
595
596 #[must_use]
598 pub const fn client(&self) -> &LspClient {
599 &self.client
600 }
601
602 pub fn has_exited(&mut self) -> Result<bool> {
620 match &mut self.child {
621 Some(child) => Ok(child.try_wait()?.is_some()),
622 None => Ok(false),
623 }
624 }
625
626 pub async fn shutdown(self) -> Result<()> {
643 debug!("Shutting down LSP server");
644
645 let handshake: Result<()> = async move {
646 let _: serde_json::Value = self
647 .client
648 .request("shutdown", serde_json::Value::Null, Duration::from_secs(5))
649 .await?;
650 self.client.notify("exit", serde_json::Value::Null).await?;
651 self.client.shutdown().await
652 }
653 .await;
654
655 if let Some(mut child) = self.child {
656 match tokio::time::timeout(CHILD_EXIT_GRACE, child.wait()).await {
657 Ok(Ok(status)) => {
658 debug!(
659 ?status,
660 "LSP server process exited after `exit` notification"
661 );
662 }
663 Ok(Err(e)) => warn!(error = %e, "failed to wait for LSP server process exit"),
664 Err(_) => warn!(
665 timeout = ?CHILD_EXIT_GRACE,
666 "LSP server process did not exit within grace period after `exit` \
667 notification, killing it"
668 ),
669 }
670 }
673
674 handshake?;
675 info!("LSP server shut down successfully");
676 Ok(())
677 }
678
679 pub async fn spawn_batch(configs: &[ServerInitConfig]) -> ServerInitResult {
730 let mut result = ServerInitResult::new();
731
732 for config in configs {
733 let server_id = config.server_config.id();
734 let language_id = config.server_config.language_id.clone();
735 let command = config.server_config.command.clone();
736
737 match Self::spawn(config.clone()).await {
738 Ok(server) => {
739 info!(
740 "Successfully spawned LSP server: {} ({})",
741 server_id, command
742 );
743 result.add_server(server_id, server);
744 }
745 Err(e) => {
746 tracing::error!(
747 "Failed to spawn LSP server: {} ({}): {}",
748 server_id,
749 command,
750 e
751 );
752 result.add_failure(ServerSpawnFailure {
753 server_id,
754 language_id,
755 command,
756 message: e.to_string(),
757 });
758 }
759 }
760 }
761
762 result
763 }
764}
765
766fn resolve_position_encodings(configured: &[String]) -> Vec<PositionEncodingKind> {
775 let encodings: Vec<PositionEncodingKind> = configured
776 .iter()
777 .filter_map(|value| {
778 let kind = crate::config::parse_position_encoding(value);
779 if kind.is_none() {
780 warn!(value = %value, "ignoring invalid configured position encoding");
781 }
782 kind
783 })
784 .collect();
785
786 if encodings.is_empty() {
787 crate::config::default_position_encodings()
788 .iter()
789 .filter_map(|value| crate::config::parse_position_encoding(value))
790 .collect()
791 } else {
792 encodings
793 }
794}
795
796fn workspace_folder(root: &Path) -> Result<WorkspaceFolder> {
802 let uri = try_path_to_uri(root).ok_or_else(|| {
803 let root_display = root.display();
804 Error::InvalidUri(format!("Invalid workspace root: {root_display}"))
805 })?;
806 Ok(WorkspaceFolder {
807 uri,
808 name: root
809 .file_name()
810 .and_then(|n| n.to_str())
811 .unwrap_or("workspace")
812 .to_string(),
813 })
814}
815
816#[cfg(test)]
829pub fn fake_lsp_server() -> LspServer {
830 let transport = crate::test_lsp::inert_transport();
831 let client = LspClient::from_transport(LspServerConfig::pyright(), transport);
832 let (_, mock_notification_rx) = mpsc::channel(1);
833 LspServer {
834 client,
835 capabilities: lsp_types::ServerCapabilities::default(),
836 position_encoding: PositionEncodingKind::UTF8,
837 notification_rx: mock_notification_rx,
838 child: None,
839 }
840}
841
842#[cfg(test)]
843impl LspServer {
844 pub(crate) fn new_for_test(capabilities: ServerCapabilities) -> Self {
856 Self::new_for_test_with_encoding(capabilities, PositionEncodingKind::UTF16)
857 }
858
859 pub(crate) fn new_for_test_with_encoding(
864 capabilities: ServerCapabilities,
865 position_encoding: PositionEncodingKind,
866 ) -> Self {
867 let client = LspClient::new(LspServerConfig::rust_analyzer());
868 let (_, notification_rx) = mpsc::channel(1);
869
870 Self {
871 client,
872 capabilities,
873 position_encoding,
874 notification_rx,
875 child: None,
876 }
877 }
878}
879
880#[cfg(test)]
881#[allow(clippy::unwrap_used)]
882mod tests {
883 use super::*;
884
885 #[test]
886 fn test_resolve_position_encodings_preserves_configured_order() {
887 let result = resolve_position_encodings(&["utf-32".to_string(), "utf-8".to_string()]);
888 assert_eq!(
889 result,
890 vec![PositionEncodingKind::UTF32, PositionEncodingKind::UTF8]
891 );
892 }
893
894 #[test]
895 fn test_resolve_position_encodings_skips_invalid_and_keeps_valid() {
896 let result = resolve_position_encodings(&["utf-7".to_string(), "utf-16".to_string()]);
897 assert_eq!(result, vec![PositionEncodingKind::UTF16]);
898 }
899
900 #[test]
901 fn test_resolve_position_encodings_falls_back_when_all_invalid() {
902 let result = resolve_position_encodings(&["utf-7".to_string(), "bogus".to_string()]);
903 assert_eq!(
904 result,
905 vec![PositionEncodingKind::UTF8, PositionEncodingKind::UTF16]
906 );
907 }
908
909 #[test]
910 fn test_resolve_position_encodings_falls_back_when_empty() {
911 let result = resolve_position_encodings(&[]);
912 assert_eq!(
913 result,
914 vec![PositionEncodingKind::UTF8, PositionEncodingKind::UTF16]
915 );
916 }
917
918 #[test]
919 fn test_server_state_ready() {
920 assert!(ServerState::Ready.is_ready());
921 assert!(ServerState::Ready.can_accept_requests());
922 }
923
924 #[test]
925 fn test_server_state_uninitialized() {
926 assert!(!ServerState::Uninitialized.is_ready());
927 assert!(!ServerState::Uninitialized.can_accept_requests());
928 }
929
930 #[test]
931 fn test_server_state_initializing() {
932 assert!(!ServerState::Initializing.is_ready());
933 assert!(!ServerState::Initializing.can_accept_requests());
934 }
935
936 #[test]
937 fn test_workspace_folder_encodes_fragment_char() {
938 #[cfg(windows)]
941 let (root, expected) = (
942 Path::new(r"C:\home\me\dev\#work"),
943 "file:///C:/home/me/dev/%23work",
944 );
945 #[cfg(not(windows))]
946 let (root, expected) = (
947 Path::new("/home/me/dev/#work"),
948 "file:///home/me/dev/%23work",
949 );
950
951 let folder = workspace_folder(root).unwrap();
952
953 assert_eq!(folder.uri.as_ref(), expected);
954 assert_eq!(folder.name, "#work");
955 }
956
957 #[test]
958 fn test_workspace_folder_encodes_bracket_chars() {
959 #[cfg(windows)]
960 let (root, expected) = (
961 Path::new(r"C:\home\me\dev\[env]"),
962 "file:///C:/home/me/dev/%5Benv%5D",
963 );
964 #[cfg(not(windows))]
965 let (root, expected) = (
966 Path::new("/home/me/dev/[env]"),
967 "file:///home/me/dev/%5Benv%5D",
968 );
969
970 let folder = workspace_folder(root).unwrap();
971
972 assert_eq!(folder.uri.as_ref(), expected);
973 assert_eq!(folder.name, "[env]");
974 }
975
976 #[test]
977 fn test_workspace_folder_rejects_relative_root() {
978 let err = workspace_folder(Path::new("relative/root")).unwrap_err();
979 assert!(matches!(err, Error::InvalidUri(_)), "got {err:?}");
980 }
981
982 #[test]
983 fn test_server_state_shutting_down() {
984 assert!(!ServerState::ShuttingDown.is_ready());
985 assert!(!ServerState::ShuttingDown.can_accept_requests());
986 }
987
988 #[test]
989 fn test_server_state_shutdown() {
990 assert!(!ServerState::Shutdown.is_ready());
991 assert!(!ServerState::Shutdown.can_accept_requests());
992 }
993
994 #[test]
995 fn test_server_state_equality() {
996 assert_eq!(ServerState::Ready, ServerState::Ready);
997 assert_ne!(ServerState::Ready, ServerState::Uninitialized);
998 assert_eq!(ServerState::Shutdown, ServerState::Shutdown);
999 }
1000
1001 #[test]
1002 fn test_server_state_clone() {
1003 let state = ServerState::Ready;
1004 let cloned = state;
1005 assert_eq!(state, cloned);
1006 }
1007
1008 #[test]
1009 fn test_server_state_debug() {
1010 let state = ServerState::Ready;
1011 let debug_str = format!("{state:?}");
1012 assert!(debug_str.contains("Ready"));
1013 }
1014
1015 #[test]
1016 fn test_server_init_config_clone() {
1017 let config = ServerInitConfig {
1018 server_config: LspServerConfig::rust_analyzer(),
1019 workspace_roots: vec![PathBuf::from("/tmp/workspace")],
1020 initialization_options: Some(serde_json::json!({"key": "value"})),
1021 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1022 notification_tx: None,
1023 };
1024
1025 #[allow(clippy::redundant_clone)]
1026 let cloned = config.clone();
1027 assert_eq!(cloned.server_config.language_id, "rust");
1028 assert_eq!(cloned.workspace_roots.len(), 1);
1029 }
1030
1031 #[test]
1032 fn test_server_init_config_debug() {
1033 let config = ServerInitConfig {
1034 server_config: LspServerConfig::pyright(),
1035 workspace_roots: vec![],
1036 initialization_options: None,
1037 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1038 notification_tx: None,
1039 };
1040
1041 let debug_str = format!("{config:?}");
1042 assert!(debug_str.contains("python"));
1043 assert!(debug_str.contains("pyright"));
1044 }
1045
1046 #[test]
1047 fn test_server_init_config_with_options() {
1048 use std::collections::HashMap;
1049
1050 let init_opts = serde_json::json!({
1051 "settings": {
1052 "python": {
1053 "analysis": {
1054 "typeCheckingMode": "strict"
1055 }
1056 }
1057 }
1058 });
1059
1060 let mut env = HashMap::new();
1061 env.insert("PYTHONPATH".to_string(), "/usr/lib".to_string());
1062
1063 let config = ServerInitConfig {
1064 server_config: LspServerConfig {
1065 language_id: "python".to_string(),
1066 command: "pyright-langserver".to_string(),
1067 args: vec!["--stdio".to_string()],
1068 env,
1069 file_patterns: vec!["**/*.py".to_string()],
1070 initialization_options: Some(init_opts.clone()),
1071 timeout_seconds: 10,
1072 request_timeout_seconds: 10,
1073 heuristics: None,
1074 name: None,
1075 handles: None,
1076 },
1077 workspace_roots: vec![PathBuf::from("/workspace")],
1078 initialization_options: Some(init_opts),
1079 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1080 notification_tx: None,
1081 };
1082
1083 assert!(config.initialization_options.is_some());
1084 assert_eq!(config.workspace_roots.len(), 1);
1085 }
1086
1087 #[test]
1088 fn test_server_init_config_empty_workspace() {
1089 let config = ServerInitConfig {
1090 server_config: LspServerConfig::typescript(),
1091 workspace_roots: vec![],
1092 initialization_options: None,
1093 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1094 notification_tx: None,
1095 };
1096
1097 assert!(config.workspace_roots.is_empty());
1098 }
1099
1100 #[test]
1101 fn test_server_init_config_multiple_workspaces() {
1102 let config = ServerInitConfig {
1103 server_config: LspServerConfig::rust_analyzer(),
1104 workspace_roots: vec![
1105 PathBuf::from("/workspace1"),
1106 PathBuf::from("/workspace2"),
1107 PathBuf::from("/workspace3"),
1108 ],
1109 initialization_options: None,
1110 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1111 notification_tx: None,
1112 };
1113
1114 assert_eq!(config.workspace_roots.len(), 3);
1115 }
1116
1117 #[cfg(unix)]
1124 #[tokio::test]
1125 async fn test_has_exited_reflects_child_process_state() {
1126 use lsp_types::ServerCapabilities;
1127
1128 let mut mock_child = tokio::process::Command::new("sleep")
1129 .arg("2")
1130 .stdin(Stdio::piped())
1131 .stdout(Stdio::piped())
1132 .kill_on_drop(true)
1133 .spawn()
1134 .unwrap();
1135
1136 let mock_stdin = mock_child.stdin.take().unwrap();
1137 let mock_stdout = mock_child.stdout.take().unwrap();
1138
1139 let transport = LspTransport::new(mock_stdin, mock_stdout);
1140 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1141 let (_, mock_notification_rx) = mpsc::channel(1);
1142
1143 let mut server = LspServer {
1144 client,
1145 capabilities: ServerCapabilities::default(),
1146 position_encoding: PositionEncodingKind::UTF8,
1147 notification_rx: mock_notification_rx,
1148 child: Some(mock_child),
1149 };
1150
1151 assert!(
1152 !server.has_exited().unwrap(),
1153 "freshly spawned `sleep 2` should still be running"
1154 );
1155
1156 server.child.as_mut().unwrap().kill().await.unwrap();
1157 assert!(
1160 server.has_exited().unwrap(),
1161 "killed child must report as exited"
1162 );
1163 }
1164
1165 #[tokio::test]
1166 async fn test_lsp_server_getters() {
1167 use lsp_types::ServerCapabilities;
1168
1169 let transport = crate::test_lsp::inert_transport();
1170 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1171 let (_, mock_notification_rx) = mpsc::channel(1);
1172
1173 let server = LspServer {
1174 client,
1175 capabilities: ServerCapabilities::default(),
1176 position_encoding: PositionEncodingKind::UTF8,
1177 notification_rx: mock_notification_rx,
1178 child: None,
1179 };
1180
1181 assert_eq!(server.position_encoding(), PositionEncodingKind::UTF8);
1182 assert!(server.capabilities().text_document_sync.is_none());
1183
1184 let debug_str = format!("{server:?}");
1185 assert!(debug_str.contains("LspServer"));
1186 assert!(debug_str.contains("<process>"));
1187 }
1188
1189 #[test]
1190 fn test_server_init_result_new_empty() {
1191 let result = ServerInitResult::new();
1192 assert!(!result.has_servers());
1193 assert!(!result.all_failed());
1194 assert!(!result.partial_success());
1195 assert_eq!(result.server_count(), 0);
1196 assert_eq!(result.failure_count(), 0);
1197 }
1198
1199 #[test]
1200 fn test_server_init_result_default() {
1201 let result = ServerInitResult::default();
1202 assert!(!result.has_servers());
1203 assert_eq!(result.server_count(), 0);
1204 assert_eq!(result.failure_count(), 0);
1205 }
1206
1207 #[test]
1208 fn test_server_init_result_all_failures() {
1209 let mut result = ServerInitResult::new();
1210
1211 result.add_failure(ServerSpawnFailure {
1212 server_id: ServerId::from("rust"),
1213 language_id: "rust".to_string(),
1214 command: "rust-analyzer".to_string(),
1215 message: "not found".to_string(),
1216 });
1217
1218 result.add_failure(ServerSpawnFailure {
1219 server_id: ServerId::from("python"),
1220 language_id: "python".to_string(),
1221 command: "pyright".to_string(),
1222 message: "permission denied".to_string(),
1223 });
1224
1225 assert!(!result.has_servers());
1226 assert!(result.all_failed());
1227 assert!(!result.partial_success());
1228 assert_eq!(result.server_count(), 0);
1229 assert_eq!(result.failure_count(), 2);
1230 }
1231
1232 #[tokio::test]
1233 async fn test_server_init_result_all_success() {
1234 let mut result = ServerInitResult::new();
1235
1236 let transport1 = crate::test_lsp::inert_transport();
1237 let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
1238 let (_, mock_notification_rx1) = mpsc::channel(1);
1239
1240 let server1 = LspServer {
1241 client: client1,
1242 capabilities: lsp_types::ServerCapabilities::default(),
1243 position_encoding: PositionEncodingKind::UTF8,
1244 notification_rx: mock_notification_rx1,
1245 child: None,
1246 };
1247
1248 result.add_server("rust".to_string(), server1);
1249
1250 assert!(result.has_servers());
1251 assert!(!result.all_failed());
1252 assert!(!result.partial_success());
1253 assert_eq!(result.server_count(), 1);
1254 assert_eq!(result.failure_count(), 0);
1255 }
1256
1257 #[tokio::test]
1258 async fn test_server_init_result_partial_success() {
1259 let mut result = ServerInitResult::new();
1260
1261 let transport = crate::test_lsp::inert_transport();
1262 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1263 let (_, mock_notification_rx) = mpsc::channel(1);
1264
1265 let server = LspServer {
1266 client,
1267 capabilities: lsp_types::ServerCapabilities::default(),
1268 position_encoding: PositionEncodingKind::UTF8,
1269 notification_rx: mock_notification_rx,
1270 child: None,
1271 };
1272
1273 result.add_server("rust".to_string(), server);
1274
1275 result.add_failure(ServerSpawnFailure {
1276 server_id: ServerId::from("python"),
1277 language_id: "python".to_string(),
1278 command: "pyright".to_string(),
1279 message: "not found".to_string(),
1280 });
1281
1282 assert!(result.has_servers());
1283 assert!(!result.all_failed());
1284 assert!(result.partial_success());
1285 assert_eq!(result.server_count(), 1);
1286 assert_eq!(result.failure_count(), 1);
1287 }
1288
1289 #[tokio::test]
1290 async fn test_server_init_result_multiple_servers() {
1291 let mut result = ServerInitResult::new();
1292
1293 for i in 0..3 {
1294 let transport = crate::test_lsp::inert_transport();
1295 let config = if i == 0 {
1296 LspServerConfig::rust_analyzer()
1297 } else if i == 1 {
1298 LspServerConfig::pyright()
1299 } else {
1300 LspServerConfig::typescript()
1301 };
1302 let client = LspClient::from_transport(config.clone(), transport);
1303 let (_, mock_notification_rx) = mpsc::channel(1);
1304
1305 let server = LspServer {
1306 client,
1307 capabilities: lsp_types::ServerCapabilities::default(),
1308 position_encoding: PositionEncodingKind::UTF8,
1309 notification_rx: mock_notification_rx,
1310 child: None,
1311 };
1312
1313 result.add_server(config.language_id, server);
1314 }
1315
1316 assert!(result.has_servers());
1317 assert!(!result.all_failed());
1318 assert!(!result.partial_success());
1319 assert_eq!(result.server_count(), 3);
1320 assert_eq!(result.failure_count(), 0);
1321 }
1322
1323 #[tokio::test]
1324 async fn test_server_init_result_replace_server() {
1325 let mut result = ServerInitResult::new();
1326
1327 let transport1 = crate::test_lsp::inert_transport();
1328 let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
1329 let (_, mock_notification_rx1) = mpsc::channel(1);
1330
1331 let server1 = LspServer {
1332 client: client1,
1333 capabilities: lsp_types::ServerCapabilities::default(),
1334 position_encoding: PositionEncodingKind::UTF8,
1335 notification_rx: mock_notification_rx1,
1336 child: None,
1337 };
1338
1339 result.add_server("rust".to_string(), server1);
1340 assert_eq!(result.server_count(), 1);
1341
1342 let transport2 = crate::test_lsp::inert_transport();
1343 let client2 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport2);
1344 let (_, mock_notification_rx2) = mpsc::channel(1);
1345
1346 let server2 = LspServer {
1347 client: client2,
1348 capabilities: lsp_types::ServerCapabilities::default(),
1349 position_encoding: PositionEncodingKind::UTF16,
1350 notification_rx: mock_notification_rx2,
1351 child: None,
1352 };
1353
1354 result.add_server("rust".to_string(), server2);
1355 assert_eq!(result.server_count(), 1);
1356 }
1357
1358 #[test]
1359 fn test_server_init_result_debug() {
1360 let mut result = ServerInitResult::new();
1361
1362 result.add_failure(ServerSpawnFailure {
1363 server_id: ServerId::from("rust"),
1364 language_id: "rust".to_string(),
1365 command: "rust-analyzer".to_string(),
1366 message: "not found".to_string(),
1367 });
1368
1369 let debug_str = format!("{result:?}");
1370 assert!(debug_str.contains("ServerInitResult"));
1371 }
1372
1373 #[test]
1374 fn test_server_init_result_multiple_failures() {
1375 let mut result = ServerInitResult::new();
1376
1377 result.add_failure(ServerSpawnFailure {
1378 server_id: ServerId::from("python"),
1379 language_id: "python".to_string(),
1380 command: "pyright".to_string(),
1381 message: "not found".to_string(),
1382 });
1383
1384 result.add_failure(ServerSpawnFailure {
1385 server_id: ServerId::from("typescript"),
1386 language_id: "typescript".to_string(),
1387 command: "tsserver".to_string(),
1388 message: "command not found".to_string(),
1389 });
1390
1391 assert_eq!(result.failure_count(), 2);
1392 assert_eq!(result.server_count(), 0);
1393 assert!(result.all_failed());
1394 assert!(!result.partial_success());
1395 }
1396
1397 #[tokio::test]
1398 async fn test_spawn_batch_empty_configs() {
1399 let configs: &[ServerInitConfig] = &[];
1400 let result = LspServer::spawn_batch(configs).await;
1401
1402 assert!(!result.has_servers());
1403 assert!(!result.all_failed());
1404 assert!(!result.partial_success());
1405 assert_eq!(result.server_count(), 0);
1406 assert_eq!(result.failure_count(), 0);
1407 }
1408
1409 #[tokio::test]
1410 async fn test_spawn_batch_single_invalid_config() {
1411 let configs = vec![ServerInitConfig {
1412 server_config: LspServerConfig {
1413 language_id: "rust".to_string(),
1414 command: "nonexistent-command-12345".to_string(),
1415 args: vec![],
1416 env: std::collections::HashMap::new(),
1417 file_patterns: vec!["**/*.rs".to_string()],
1418 initialization_options: None,
1419 timeout_seconds: 10,
1420 request_timeout_seconds: 10,
1421 heuristics: None,
1422 name: None,
1423 handles: None,
1424 },
1425 workspace_roots: vec![],
1426 initialization_options: None,
1427 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1428 notification_tx: None,
1429 }];
1430
1431 let result = LspServer::spawn_batch(&configs).await;
1432
1433 assert!(!result.has_servers());
1434 assert!(result.all_failed());
1435 assert!(!result.partial_success());
1436 assert_eq!(result.server_count(), 0);
1437 assert_eq!(result.failure_count(), 1);
1438
1439 let failure = &result.failures[0];
1440 assert_eq!(failure.language_id, "rust");
1441 assert_eq!(failure.command, "nonexistent-command-12345");
1442 assert!(failure.message.contains("spawn"));
1443 }
1444
1445 #[tokio::test]
1446 async fn test_spawn_batch_all_invalid_configs() {
1447 let configs = vec![
1448 ServerInitConfig {
1449 server_config: LspServerConfig {
1450 language_id: "rust".to_string(),
1451 command: "nonexistent-rust-analyzer".to_string(),
1452 args: vec![],
1453 env: std::collections::HashMap::new(),
1454 file_patterns: vec!["**/*.rs".to_string()],
1455 initialization_options: None,
1456 timeout_seconds: 10,
1457 request_timeout_seconds: 10,
1458 heuristics: None,
1459 name: None,
1460 handles: None,
1461 },
1462 workspace_roots: vec![],
1463 initialization_options: None,
1464 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1465 notification_tx: None,
1466 },
1467 ServerInitConfig {
1468 server_config: LspServerConfig {
1469 language_id: "python".to_string(),
1470 command: "nonexistent-pyright".to_string(),
1471 args: vec![],
1472 env: std::collections::HashMap::new(),
1473 file_patterns: vec!["**/*.py".to_string()],
1474 initialization_options: None,
1475 timeout_seconds: 10,
1476 request_timeout_seconds: 10,
1477 heuristics: None,
1478 name: None,
1479 handles: None,
1480 },
1481 workspace_roots: vec![],
1482 initialization_options: None,
1483 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1484 notification_tx: None,
1485 },
1486 ServerInitConfig {
1487 server_config: LspServerConfig {
1488 language_id: "typescript".to_string(),
1489 command: "nonexistent-tsserver".to_string(),
1490 args: vec![],
1491 env: std::collections::HashMap::new(),
1492 file_patterns: vec!["**/*.ts".to_string()],
1493 initialization_options: None,
1494 timeout_seconds: 10,
1495 request_timeout_seconds: 10,
1496 heuristics: None,
1497 name: None,
1498 handles: None,
1499 },
1500 workspace_roots: vec![],
1501 initialization_options: None,
1502 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1503 notification_tx: None,
1504 },
1505 ];
1506
1507 let result = LspServer::spawn_batch(&configs).await;
1508
1509 assert!(!result.has_servers());
1510 assert!(result.all_failed());
1511 assert!(!result.partial_success());
1512 assert_eq!(result.server_count(), 0);
1513 assert_eq!(result.failure_count(), 3);
1514
1515 let failure_languages: Vec<_> = result
1516 .failures
1517 .iter()
1518 .map(|f| f.language_id.as_str())
1519 .collect();
1520 assert!(failure_languages.contains(&"rust"));
1521 assert!(failure_languages.contains(&"python"));
1522 assert!(failure_languages.contains(&"typescript"));
1523 }
1524
1525 #[tokio::test]
1526 async fn test_spawn_batch_multiple_invalid_configs_ordering() {
1527 let configs = vec![
1528 ServerInitConfig {
1529 server_config: LspServerConfig {
1530 language_id: "lang1".to_string(),
1531 command: "cmd1-nonexistent".to_string(),
1532 args: vec![],
1533 env: std::collections::HashMap::new(),
1534 file_patterns: vec![],
1535 initialization_options: None,
1536 timeout_seconds: 10,
1537 request_timeout_seconds: 10,
1538 heuristics: None,
1539 name: None,
1540 handles: None,
1541 },
1542 workspace_roots: vec![],
1543 initialization_options: None,
1544 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1545 notification_tx: None,
1546 },
1547 ServerInitConfig {
1548 server_config: LspServerConfig {
1549 language_id: "lang2".to_string(),
1550 command: "cmd2-nonexistent".to_string(),
1551 args: vec![],
1552 env: std::collections::HashMap::new(),
1553 file_patterns: vec![],
1554 initialization_options: None,
1555 timeout_seconds: 10,
1556 request_timeout_seconds: 10,
1557 heuristics: None,
1558 name: None,
1559 handles: None,
1560 },
1561 workspace_roots: vec![],
1562 initialization_options: None,
1563 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1564 notification_tx: None,
1565 },
1566 ];
1567
1568 let result = LspServer::spawn_batch(&configs).await;
1569
1570 assert_eq!(result.failure_count(), 2);
1571
1572 assert_eq!(result.failures[0].language_id, "lang1");
1573 assert_eq!(result.failures[0].command, "cmd1-nonexistent");
1574
1575 assert_eq!(result.failures[1].language_id, "lang2");
1576 assert_eq!(result.failures[1].command, "cmd2-nonexistent");
1577 }
1578
1579 mod initialize_wire {
1585 use tempfile::TempDir;
1586 use tokio::io::BufReader;
1587
1588 use super::*;
1589 use crate::test_lsp::{
1590 fake_lsp_client, read_framed_message, write_response as write_success_response,
1591 };
1592
1593 #[tokio::test]
1594 async fn test_initialize_sends_configured_position_encodings() {
1595 let (client, mut server) = fake_lsp_client();
1596
1597 let config = ServerInitConfig {
1598 server_config: LspServerConfig::rust_analyzer(),
1599 workspace_roots: vec![],
1600 initialization_options: None,
1601 position_encodings: vec!["utf-32".to_string(), "utf-8".to_string()],
1602 notification_tx: None,
1603 };
1604
1605 let init_task =
1606 tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1607
1608 let mut reader = BufReader::new(&mut server.write_stdout);
1609 let request = read_framed_message(&mut reader).await;
1610
1611 assert_eq!(request["method"], "initialize");
1612 assert_eq!(
1613 request["params"]["capabilities"]["general"]["positionEncodings"],
1614 serde_json::json!(["utf-32", "utf-8"]),
1615 "initialize request must carry the configured encoding order, not the \
1616 hardcoded [UTF8, UTF16] default"
1617 );
1618
1619 write_success_response(
1620 &mut server.read_half_stdin,
1621 &request["id"].clone(),
1622 serde_json::json!({ "capabilities": {} }),
1623 )
1624 .await;
1625
1626 init_task.await.unwrap().unwrap();
1628 }
1629
1630 #[tokio::test]
1631 async fn test_initialize_advertises_stale_request_support() {
1632 let (client, mut server) = fake_lsp_client();
1633
1634 let config = ServerInitConfig {
1635 server_config: LspServerConfig::rust_analyzer(),
1636 workspace_roots: vec![],
1637 initialization_options: None,
1638 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1639 notification_tx: None,
1640 };
1641
1642 let init_task =
1643 tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1644
1645 let mut reader = BufReader::new(&mut server.write_stdout);
1646 let request = read_framed_message(&mut reader).await;
1647 let params: InitializeParams =
1648 serde_json::from_value(request["params"].clone()).unwrap();
1649 let stale_request_support = params
1650 .capabilities
1651 .general
1652 .unwrap()
1653 .stale_request_support
1654 .unwrap();
1655
1656 assert_eq!(request["method"], "initialize");
1657 assert!(
1658 !stale_request_support.cancel,
1659 "mcpls does not implement active in-flight request cancellation"
1660 );
1661 assert_eq!(
1662 stale_request_support.retry_on_content_modified,
1663 CONTENT_MODIFIED_RETRY_METHODS
1664 .iter()
1665 .map(ToString::to_string)
1666 .collect::<Vec<_>>(),
1667 "the wire-advertised capability must match the methods LspClient::request \
1668 actually retries -32801 for, not drift from it"
1669 );
1670
1671 write_success_response(
1672 &mut server.read_half_stdin,
1673 &request["id"].clone(),
1674 serde_json::json!({ "capabilities": {} }),
1675 )
1676 .await;
1677
1678 init_task.await.unwrap().unwrap();
1679 }
1680
1681 #[tokio::test]
1682 async fn test_initialize_advertises_hierarchical_document_symbols() {
1683 let (client, mut server) = fake_lsp_client();
1684
1685 let config = ServerInitConfig {
1686 server_config: LspServerConfig::rust_analyzer(),
1687 workspace_roots: vec![],
1688 initialization_options: None,
1689 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1690 notification_tx: None,
1691 };
1692
1693 let init_task =
1694 tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1695
1696 let mut reader = BufReader::new(&mut server.write_stdout);
1697 let request = read_framed_message(&mut reader).await;
1698 let params: InitializeParams =
1699 serde_json::from_value(request["params"].clone()).unwrap();
1700 let document_symbol = params
1701 .capabilities
1702 .text_document
1703 .unwrap()
1704 .document_symbol
1705 .unwrap();
1706
1707 assert_eq!(request["method"], "initialize");
1708 assert_eq!(document_symbol.dynamic_registration, Some(false));
1709 assert_eq!(
1710 document_symbol.hierarchical_document_symbol_support,
1711 Some(true)
1712 );
1713 assert_eq!(
1714 document_symbol.symbol_kind.unwrap().value_set,
1715 Some(SUPPORTED_SYMBOL_KINDS.to_vec())
1716 );
1717
1718 write_success_response(
1719 &mut server.read_half_stdin,
1720 &request["id"].clone(),
1721 serde_json::json!({ "capabilities": {} }),
1722 )
1723 .await;
1724
1725 init_task.await.unwrap().unwrap();
1726 }
1727
1728 #[tokio::test]
1729 async fn test_initialize_accepts_resolved_dot_workspace_root() {
1730 let temp_dir = TempDir::new().unwrap();
1731 let base = dunce::canonicalize(temp_dir.path()).unwrap();
1732 let workspace_roots =
1733 crate::resolve_workspace_roots(&[PathBuf::from(".")], &base).unwrap();
1734 assert_eq!(workspace_roots, vec![base.clone()]);
1735
1736 let (client, mut server) = fake_lsp_client();
1737 let config = ServerInitConfig {
1738 server_config: LspServerConfig::rust_analyzer(),
1739 workspace_roots,
1740 initialization_options: None,
1741 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1742 notification_tx: None,
1743 };
1744
1745 let init_task =
1746 tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1747
1748 let mut reader = BufReader::new(&mut server.write_stdout);
1749 let request = read_framed_message(&mut reader).await;
1750 let expected_uri = try_path_to_uri(&base).unwrap();
1751 assert_eq!(
1752 request["params"]["workspaceFolders"][0]["uri"],
1753 expected_uri.as_ref()
1754 );
1755
1756 write_success_response(
1757 &mut server.read_half_stdin,
1758 &request["id"].clone(),
1759 serde_json::json!({ "capabilities": {} }),
1760 )
1761 .await;
1762
1763 init_task.await.unwrap().unwrap();
1764 }
1765 }
1766
1767 #[tokio::test]
1768 async fn test_spawn_batch_logs_each_failure() {
1769 let configs = vec![
1770 ServerInitConfig {
1771 server_config: LspServerConfig {
1772 language_id: "test1".to_string(),
1773 command: "nonexistent-test1".to_string(),
1774 args: vec![],
1775 env: std::collections::HashMap::new(),
1776 file_patterns: vec![],
1777 initialization_options: None,
1778 timeout_seconds: 10,
1779 request_timeout_seconds: 10,
1780 heuristics: None,
1781 name: None,
1782 handles: None,
1783 },
1784 workspace_roots: vec![],
1785 initialization_options: None,
1786 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1787 notification_tx: None,
1788 },
1789 ServerInitConfig {
1790 server_config: LspServerConfig {
1791 language_id: "test2".to_string(),
1792 command: "nonexistent-test2".to_string(),
1793 args: vec![],
1794 env: std::collections::HashMap::new(),
1795 file_patterns: vec![],
1796 initialization_options: None,
1797 timeout_seconds: 10,
1798 request_timeout_seconds: 10,
1799 heuristics: None,
1800 name: None,
1801 handles: None,
1802 },
1803 workspace_roots: vec![],
1804 initialization_options: None,
1805 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1806 notification_tx: None,
1807 },
1808 ];
1809
1810 let result = LspServer::spawn_batch(&configs).await;
1811
1812 assert_eq!(result.failure_count(), 2);
1813 assert_eq!(result.failures[0].language_id, "test1");
1814 assert_eq!(result.failures[1].language_id, "test2");
1815 }
1816
1817 fn bare_server_config(env: HashMap<String, String>) -> LspServerConfig {
1820 LspServerConfig {
1821 language_id: "test".to_string(),
1822 command: "irrelevant-for-build-command".to_string(),
1823 args: vec![],
1824 env,
1825 file_patterns: vec![],
1826 initialization_options: None,
1827 timeout_seconds: 5,
1828 request_timeout_seconds: 5,
1829 heuristics: None,
1830 name: None,
1831 handles: None,
1832 }
1833 }
1834
1835 fn effective_envs(command: &Command) -> HashMap<String, String> {
1839 command
1840 .as_std()
1841 .get_envs()
1842 .filter_map(|(k, v)| {
1843 v.map(|v| {
1844 (
1845 k.to_string_lossy().into_owned(),
1846 v.to_string_lossy().into_owned(),
1847 )
1848 })
1849 })
1850 .collect()
1851 }
1852
1853 #[test]
1857 fn test_build_command_excludes_non_allowlisted_parent_env_vars() {
1858 let config = bare_server_config(HashMap::new());
1859 let command = LspServer::build_command(&config, |key| match key {
1860 "PATH" => Some("/parent/bin".into()),
1861 "MCPLS_TEST_LEAK_CANARY" => Some("should-not-reach-child".into()),
1862 _ => None,
1863 });
1864
1865 let envs = effective_envs(&command);
1866
1867 assert!(
1868 !envs.contains_key("MCPLS_TEST_LEAK_CANARY"),
1869 "non-allowlisted parent env var leaked into child command: {envs:?}"
1870 );
1871
1872 #[cfg(unix)]
1883 assert!(
1884 format!("{:?}", command.as_std()).starts_with("env -i "),
1885 "build_command must call .env_clear() so the child doesn't inherit the full parent environment"
1886 );
1887 }
1888
1889 #[test]
1892 fn test_build_command_passes_through_allowlisted_env_vars() {
1893 let config = bare_server_config(HashMap::new());
1894 let command =
1895 LspServer::build_command(&config, |key| (key == "PATH").then(|| "/parent/bin".into()));
1896
1897 let envs = effective_envs(&command);
1898
1899 assert_eq!(envs.get("PATH"), Some(&"/parent/bin".to_string()));
1900 }
1901
1902 #[test]
1905 fn test_build_command_includes_configured_env_vars() {
1906 let mut env = HashMap::new();
1907 env.insert(
1908 "MCPLS_TEST_CONFIGURED".to_string(),
1909 "from-server-config".to_string(),
1910 );
1911 let config = bare_server_config(env);
1912 let command = LspServer::build_command(&config, |_| None);
1913
1914 let envs = effective_envs(&command);
1915
1916 assert_eq!(
1917 envs.get("MCPLS_TEST_CONFIGURED"),
1918 Some(&"from-server-config".to_string())
1919 );
1920 }
1921
1922 #[test]
1926 fn test_build_command_configured_env_overrides_allowlisted_var() {
1927 let mut env = HashMap::new();
1928 env.insert("PATH".to_string(), "/configured/override/path".to_string());
1929 let config = bare_server_config(env);
1930 let command =
1931 LspServer::build_command(&config, |key| (key == "PATH").then(|| "/parent/bin".into()));
1932
1933 let envs = effective_envs(&command);
1934
1935 assert_eq!(
1936 envs.get("PATH"),
1937 Some(&"/configured/override/path".to_string())
1938 );
1939 }
1940
1941 #[tokio::test]
1951 async fn test_register_servers_computes_diagnostics_flags_from_rebound_router() {
1952 use crate::bridge::Translator;
1953 use crate::config::{ServerId, ToolKind, ToolRouter};
1954
1955 let pylsp_id = ServerId::from("pylsp");
1956 let configs = vec![
1957 LspServerConfig {
1958 language_id: "python".to_string(),
1959 command: "pyright-langserver".to_string(),
1960 args: vec![],
1961 env: std::collections::HashMap::new(),
1962 file_patterns: vec![],
1963 initialization_options: None,
1964 timeout_seconds: 30,
1965 request_timeout_seconds: 30,
1966 heuristics: None,
1967 name: Some("pyright-diag".to_string()),
1968 handles: Some(vec![ToolKind::Diagnostics]),
1969 },
1970 LspServerConfig {
1971 language_id: "python".to_string(),
1972 command: "pylsp".to_string(),
1973 args: vec![],
1974 env: std::collections::HashMap::new(),
1975 file_patterns: vec![],
1976 initialization_options: None,
1977 timeout_seconds: 30,
1978 request_timeout_seconds: 30,
1979 heuristics: None,
1980 name: Some("pylsp".to_string()),
1981 handles: None,
1982 },
1983 ];
1984 let router = ToolRouter::from_configs(&configs).unwrap();
1985 let translator = Translator::new().with_router(router);
1986
1987 let mut result = ServerInitResult::new();
1989 result.add_server(pylsp_id.clone(), fake_lsp_server());
1990
1991 let registered = crate::register_servers(result, &translator, &HashMap::new());
1992
1993 assert_eq!(
1994 registered.diagnostics_flags.get(&pylsp_id),
1995 Some(&true),
1996 "pylsp must inherit the diagnostics route once pyright-diag is \
1997 known dead, and the flag must reflect that post-rebind state"
1998 );
1999 }
2000}