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, SymbolKind, 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
49const SUPPORTED_SYMBOL_KINDS: [SymbolKind; 26] = [
51 SymbolKind::FILE,
52 SymbolKind::MODULE,
53 SymbolKind::NAMESPACE,
54 SymbolKind::PACKAGE,
55 SymbolKind::CLASS,
56 SymbolKind::METHOD,
57 SymbolKind::PROPERTY,
58 SymbolKind::FIELD,
59 SymbolKind::CONSTRUCTOR,
60 SymbolKind::ENUM,
61 SymbolKind::INTERFACE,
62 SymbolKind::FUNCTION,
63 SymbolKind::VARIABLE,
64 SymbolKind::CONSTANT,
65 SymbolKind::STRING,
66 SymbolKind::NUMBER,
67 SymbolKind::BOOLEAN,
68 SymbolKind::ARRAY,
69 SymbolKind::OBJECT,
70 SymbolKind::KEY,
71 SymbolKind::NULL,
72 SymbolKind::ENUM_MEMBER,
73 SymbolKind::STRUCT,
74 SymbolKind::EVENT,
75 SymbolKind::OPERATOR,
76 SymbolKind::TYPE_PARAMETER,
77];
78
79#[cfg(windows)]
86const ENV_PASSTHROUGH_WINDOWS: &[&str] = &[
87 "SystemRoot",
88 "SystemDrive",
89 "windir",
90 "APPDATA",
91 "LOCALAPPDATA",
92 "ProgramData",
93 "ProgramFiles",
94 "COMSPEC",
95 "PATHEXT",
96 "NUMBER_OF_PROCESSORS",
97 "USERNAME",
98];
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum ServerState {
103 Uninitialized,
105 Initializing,
107 Ready,
109 ShuttingDown,
111 Shutdown,
113}
114
115impl ServerState {
116 #[must_use]
118 pub const fn is_ready(&self) -> bool {
119 matches!(self, Self::Ready)
120 }
121
122 #[must_use]
124 pub const fn can_accept_requests(&self) -> bool {
125 matches!(self, Self::Ready)
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct ServerInitConfig {
132 pub server_config: LspServerConfig,
134 pub workspace_roots: Vec<PathBuf>,
136 pub initialization_options: Option<serde_json::Value>,
138 pub position_encodings: Vec<String>,
152 pub notification_tx: Option<mpsc::Sender<LspNotification>>,
159}
160
161#[derive(Debug)]
185pub struct ServerInitResult {
186 pub servers: HashMap<ServerId, LspServer>,
188 pub failures: Vec<ServerSpawnFailure>,
190}
191
192impl ServerInitResult {
193 #[must_use]
195 pub fn new() -> Self {
196 Self {
197 servers: HashMap::new(),
198 failures: Vec::new(),
199 }
200 }
201
202 #[must_use]
206 pub fn has_servers(&self) -> bool {
207 !self.servers.is_empty()
208 }
209
210 #[must_use]
215 pub fn all_failed(&self) -> bool {
216 self.servers.is_empty() && !self.failures.is_empty()
217 }
218
219 #[must_use]
223 pub fn partial_success(&self) -> bool {
224 !self.servers.is_empty() && !self.failures.is_empty()
225 }
226
227 #[must_use]
229 pub fn server_count(&self) -> usize {
230 self.servers.len()
231 }
232
233 #[must_use]
235 pub const fn failure_count(&self) -> usize {
236 self.failures.len()
237 }
238
239 pub fn add_server(&mut self, id: impl Into<ServerId>, server: LspServer) {
243 self.servers.insert(id.into(), server);
244 }
245
246 pub fn add_failure(&mut self, failure: ServerSpawnFailure) {
248 self.failures.push(failure);
249 }
250}
251
252impl Default for ServerInitResult {
253 fn default() -> Self {
254 Self::new()
255 }
256}
257
258pub struct LspServer {
260 client: LspClient,
261 capabilities: ServerCapabilities,
262 position_encoding: PositionEncodingKind,
263 pub notification_rx: mpsc::Receiver<LspNotification>,
268 child: tokio::process::Child,
274}
275
276impl std::fmt::Debug for LspServer {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 f.debug_struct("LspServer")
279 .field("client", &self.client)
280 .field("capabilities", &self.capabilities)
281 .field("position_encoding", &self.position_encoding)
282 .field("notification_rx", &"<channel>")
283 .field("child", &"<process>")
284 .finish()
285 }
286}
287
288impl LspServer {
289 pub fn take_notification_rx(&mut self) -> tokio::sync::mpsc::Receiver<LspNotification> {
295 let (_, dummy) = tokio::sync::mpsc::channel(1);
296 std::mem::replace(&mut self.notification_rx, dummy)
297 }
298
299 pub async fn spawn(config: ServerInitConfig) -> Result<Self> {
314 info!(
315 "Spawning LSP server: {} {:?}",
316 config.server_config.command, config.server_config.args
317 );
318
319 let mut command = Self::build_command(&config.server_config, |key| std::env::var_os(key));
320
321 let passthrough_present = {
326 let base = ENV_PASSTHROUGH
327 .iter()
328 .filter(|key| std::env::var_os(key).is_some())
329 .count();
330 #[cfg(windows)]
331 let windows = ENV_PASSTHROUGH_WINDOWS
332 .iter()
333 .filter(|key| std::env::var_os(key).is_some())
334 .count();
335 #[cfg(not(windows))]
336 let windows = 0;
337 base + windows
338 };
339 debug!(
340 "Effective LSP server env: {passthrough_present} allowlisted key(s) present, \
341 {} configured override(s) applied",
342 config.server_config.env.len()
343 );
344
345 let mut child = command.spawn().map_err(|e| Error::ServerSpawnFailed {
346 command: config.server_config.command.clone(),
347 source: e,
348 })?;
349
350 let stdin = child
351 .stdin
352 .take()
353 .ok_or_else(|| Error::Transport("Failed to capture stdin".to_string()))?;
354 let stdout = child
355 .stdout
356 .take()
357 .ok_or_else(|| Error::Transport("Failed to capture stdout".to_string()))?;
358
359 let transport = LspTransport::new(stdin, stdout);
360 let (notification_tx, notification_rx) = mpsc::channel(64);
361 let client = LspClient::from_transport_with_notifications(
362 config.server_config.clone(),
363 transport,
364 notification_tx,
365 );
366
367 let (capabilities, position_encoding) = Self::initialize(&client, &config).await?;
368
369 info!("LSP server initialized successfully");
370
371 Ok(Self {
372 client,
373 capabilities,
374 position_encoding,
375 notification_rx,
376 child,
377 })
378 }
379
380 fn build_command(
390 config: &LspServerConfig,
391 parent_env: impl Fn(&str) -> Option<std::ffi::OsString>,
392 ) -> Command {
393 let mut command = Command::new(&config.command);
394 command.args(&config.args).env_clear();
395
396 for key in ENV_PASSTHROUGH {
397 if let Some(value) = parent_env(key) {
398 command.env(key, value);
399 }
400 }
401 #[cfg(windows)]
402 for key in ENV_PASSTHROUGH_WINDOWS {
403 if let Some(value) = parent_env(key) {
404 command.env(key, value);
405 }
406 }
407
408 command
409 .envs(&config.env)
410 .stdin(Stdio::piped())
411 .stdout(Stdio::piped())
412 .stderr(Stdio::null())
413 .kill_on_drop(true);
414
415 command
416 }
417
418 #[allow(clippy::too_many_lines)]
422 async fn initialize(
423 client: &LspClient,
424 config: &ServerInitConfig,
425 ) -> Result<(ServerCapabilities, PositionEncodingKind)> {
426 debug!("Sending initialize request");
427
428 let workspace_folders: Vec<WorkspaceFolder> = config
429 .workspace_roots
430 .iter()
431 .map(|root| workspace_folder(root))
432 .collect::<Result<Vec<_>>>()?;
433
434 let params = InitializeParams {
435 process_id: Some(std::process::id()),
436 #[allow(deprecated)]
437 root_uri: None,
438 initialization_options: config.initialization_options.clone(),
439 capabilities: ClientCapabilities {
440 general: Some(GeneralClientCapabilities {
441 position_encodings: Some(resolve_position_encodings(
442 &config.position_encodings,
443 )),
444 ..Default::default()
445 }),
446 text_document: Some(lsp_types::TextDocumentClientCapabilities {
447 document_symbol: Some(lsp_types::DocumentSymbolClientCapabilities {
448 dynamic_registration: Some(false),
449 symbol_kind: Some(lsp_types::SymbolKindCapability {
450 value_set: Some(SUPPORTED_SYMBOL_KINDS.to_vec()),
451 }),
452 hierarchical_document_symbol_support: Some(true),
453 ..Default::default()
454 }),
455 hover: Some(lsp_types::HoverClientCapabilities {
456 dynamic_registration: Some(false),
457 content_format: Some(vec![
458 lsp_types::MarkupKind::Markdown,
459 lsp_types::MarkupKind::PlainText,
460 ]),
461 }),
462 definition: Some(lsp_types::GotoCapability {
463 dynamic_registration: Some(false),
464 link_support: Some(true),
465 }),
466 references: Some(lsp_types::ReferenceClientCapabilities {
467 dynamic_registration: Some(false),
468 }),
469 code_action: Some(lsp_types::CodeActionClientCapabilities {
470 dynamic_registration: Some(false),
471 data_support: Some(true),
472 resolve_support: Some(lsp_types::CodeActionCapabilityResolveSupport {
473 properties: vec!["edit".to_string()],
474 }),
475 code_action_literal_support: Some(lsp_types::CodeActionLiteralSupport {
478 code_action_kind: lsp_types::CodeActionKindLiteralSupport {
479 value_set: [
480 lsp_types::CodeActionKind::EMPTY,
481 lsp_types::CodeActionKind::QUICKFIX,
482 lsp_types::CodeActionKind::REFACTOR,
483 lsp_types::CodeActionKind::REFACTOR_EXTRACT,
484 lsp_types::CodeActionKind::REFACTOR_INLINE,
485 lsp_types::CodeActionKind::REFACTOR_REWRITE,
486 lsp_types::CodeActionKind::SOURCE,
487 lsp_types::CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
488 ]
489 .iter()
490 .map(|k| k.as_str().to_string())
491 .collect(),
492 },
493 }),
494 ..Default::default()
495 }),
496 ..Default::default()
497 }),
498 workspace: Some(lsp_types::WorkspaceClientCapabilities {
499 workspace_folders: Some(true),
500 ..Default::default()
501 }),
502 ..Default::default()
503 },
504 client_info: Some(ClientInfo {
505 name: "mcpls".to_string(),
506 version: Some(env!("CARGO_PKG_VERSION").to_string()),
507 }),
508 workspace_folders: Some(workspace_folders),
509 ..Default::default()
510 };
511
512 let result: InitializeResult = client
516 .request(
517 "initialize",
518 params,
519 Duration::from_secs(
529 config
530 .server_config
531 .timeout_seconds
532 .clamp(1, crate::config::MAX_TIMEOUT_SECONDS),
533 ),
534 )
535 .await
536 .map_err(|e| Error::LspInitFailed {
537 message: format!("Initialize request failed: {e}"),
538 })?;
539
540 let position_encoding = result
541 .capabilities
542 .position_encoding
543 .clone()
544 .unwrap_or(PositionEncodingKind::UTF16);
545
546 debug!(
547 "Server capabilities received, encoding: {:?}",
548 position_encoding
549 );
550
551 client
552 .notify("initialized", InitializedParams {})
553 .await
554 .map_err(|e| Error::LspInitFailed {
555 message: format!("Initialized notification failed: {e}"),
556 })?;
557
558 Ok((result.capabilities, position_encoding))
559 }
560
561 #[must_use]
563 pub const fn capabilities(&self) -> &ServerCapabilities {
564 &self.capabilities
565 }
566
567 #[must_use]
569 pub fn position_encoding(&self) -> PositionEncodingKind {
570 self.position_encoding.clone()
571 }
572
573 #[must_use]
575 pub const fn client(&self) -> &LspClient {
576 &self.client
577 }
578
579 pub fn has_exited(&mut self) -> Result<bool> {
592 Ok(self.child.try_wait()?.is_some())
593 }
594
595 pub async fn shutdown(self) -> Result<()> {
610 debug!("Shutting down LSP server");
611
612 let handshake: Result<()> = async move {
613 let _: serde_json::Value = self
614 .client
615 .request("shutdown", serde_json::Value::Null, Duration::from_secs(5))
616 .await?;
617 self.client.notify("exit", serde_json::Value::Null).await?;
618 self.client.shutdown().await
619 }
620 .await;
621
622 let mut child = self.child;
623 match tokio::time::timeout(CHILD_EXIT_GRACE, child.wait()).await {
624 Ok(Ok(status)) => {
625 debug!(
626 ?status,
627 "LSP server process exited after `exit` notification"
628 );
629 }
630 Ok(Err(e)) => warn!(error = %e, "failed to wait for LSP server process exit"),
631 Err(_) => warn!(
632 timeout = ?CHILD_EXIT_GRACE,
633 "LSP server process did not exit within grace period after `exit` \
634 notification, killing it"
635 ),
636 }
637 handshake?;
641 info!("LSP server shut down successfully");
642 Ok(())
643 }
644
645 pub async fn spawn_batch(configs: &[ServerInitConfig]) -> ServerInitResult {
696 let mut result = ServerInitResult::new();
697
698 for config in configs {
699 let server_id = config.server_config.id();
700 let language_id = config.server_config.language_id.clone();
701 let command = config.server_config.command.clone();
702
703 match Self::spawn(config.clone()).await {
704 Ok(server) => {
705 info!(
706 "Successfully spawned LSP server: {} ({})",
707 server_id, command
708 );
709 result.add_server(server_id, server);
710 }
711 Err(e) => {
712 tracing::error!(
713 "Failed to spawn LSP server: {} ({}): {}",
714 server_id,
715 command,
716 e
717 );
718 result.add_failure(ServerSpawnFailure {
719 server_id,
720 language_id,
721 command,
722 message: e.to_string(),
723 });
724 }
725 }
726 }
727
728 result
729 }
730}
731
732fn resolve_position_encodings(configured: &[String]) -> Vec<PositionEncodingKind> {
741 let encodings: Vec<PositionEncodingKind> = configured
742 .iter()
743 .filter_map(|value| {
744 let kind = crate::config::parse_position_encoding(value);
745 if kind.is_none() {
746 warn!(value = %value, "ignoring invalid configured position encoding");
747 }
748 kind
749 })
750 .collect();
751
752 if encodings.is_empty() {
753 crate::config::default_position_encodings()
754 .iter()
755 .filter_map(|value| crate::config::parse_position_encoding(value))
756 .collect()
757 } else {
758 encodings
759 }
760}
761
762fn workspace_folder(root: &Path) -> Result<WorkspaceFolder> {
768 let uri = try_path_to_uri(root).ok_or_else(|| {
769 let root_display = root.display();
770 Error::InvalidUri(format!("Invalid workspace root: {root_display}"))
771 })?;
772 Ok(WorkspaceFolder {
773 uri,
774 name: root
775 .file_name()
776 .and_then(|n| n.to_str())
777 .unwrap_or("workspace")
778 .to_string(),
779 })
780}
781
782#[cfg(test)]
793#[allow(clippy::unwrap_used)]
794pub fn fake_lsp_server() -> LspServer {
795 let mock_child = tokio::process::Command::new("echo")
796 .stdin(Stdio::piped())
797 .stdout(Stdio::piped())
798 .kill_on_drop(true)
799 .spawn()
800 .unwrap();
801 let mock_stdin = tokio::process::Command::new("cat")
802 .stdin(Stdio::piped())
803 .spawn()
804 .unwrap()
805 .stdin
806 .take()
807 .unwrap();
808 let mock_stdout = tokio::process::Command::new("echo")
809 .stdout(Stdio::piped())
810 .spawn()
811 .unwrap()
812 .stdout
813 .take()
814 .unwrap();
815 let transport = LspTransport::new(mock_stdin, mock_stdout);
816 let client = LspClient::from_transport(LspServerConfig::pyright(), transport);
817 let (_, mock_notification_rx) = mpsc::channel(1);
818 LspServer {
819 client,
820 capabilities: lsp_types::ServerCapabilities::default(),
821 position_encoding: PositionEncodingKind::UTF8,
822 notification_rx: mock_notification_rx,
823 child: mock_child,
824 }
825}
826
827#[cfg(test)]
828impl LspServer {
829 #[allow(clippy::unwrap_used)]
841 pub(crate) fn new_for_test(capabilities: ServerCapabilities) -> Self {
842 Self::new_for_test_with_encoding(capabilities, PositionEncodingKind::UTF16)
843 }
844
845 #[allow(clippy::unwrap_used)]
850 pub(crate) fn new_for_test_with_encoding(
851 capabilities: ServerCapabilities,
852 position_encoding: PositionEncodingKind,
853 ) -> Self {
854 let child = Command::new("echo")
855 .stdin(Stdio::piped())
856 .stdout(Stdio::piped())
857 .kill_on_drop(true)
858 .spawn()
859 .unwrap();
860
861 let client = LspClient::new(LspServerConfig::rust_analyzer());
862 let (_, notification_rx) = mpsc::channel(1);
863
864 Self {
865 client,
866 capabilities,
867 position_encoding,
868 notification_rx,
869 child,
870 }
871 }
872}
873
874#[cfg(test)]
875#[allow(clippy::unwrap_used)]
876mod tests {
877 use super::*;
878
879 #[test]
880 fn test_resolve_position_encodings_preserves_configured_order() {
881 let result = resolve_position_encodings(&["utf-32".to_string(), "utf-8".to_string()]);
882 assert_eq!(
883 result,
884 vec![PositionEncodingKind::UTF32, PositionEncodingKind::UTF8]
885 );
886 }
887
888 #[test]
889 fn test_resolve_position_encodings_skips_invalid_and_keeps_valid() {
890 let result = resolve_position_encodings(&["utf-7".to_string(), "utf-16".to_string()]);
891 assert_eq!(result, vec![PositionEncodingKind::UTF16]);
892 }
893
894 #[test]
895 fn test_resolve_position_encodings_falls_back_when_all_invalid() {
896 let result = resolve_position_encodings(&["utf-7".to_string(), "bogus".to_string()]);
897 assert_eq!(
898 result,
899 vec![PositionEncodingKind::UTF8, PositionEncodingKind::UTF16]
900 );
901 }
902
903 #[test]
904 fn test_resolve_position_encodings_falls_back_when_empty() {
905 let result = resolve_position_encodings(&[]);
906 assert_eq!(
907 result,
908 vec![PositionEncodingKind::UTF8, PositionEncodingKind::UTF16]
909 );
910 }
911
912 #[test]
913 fn test_server_state_ready() {
914 assert!(ServerState::Ready.is_ready());
915 assert!(ServerState::Ready.can_accept_requests());
916 }
917
918 #[test]
919 fn test_server_state_uninitialized() {
920 assert!(!ServerState::Uninitialized.is_ready());
921 assert!(!ServerState::Uninitialized.can_accept_requests());
922 }
923
924 #[test]
925 fn test_server_state_initializing() {
926 assert!(!ServerState::Initializing.is_ready());
927 assert!(!ServerState::Initializing.can_accept_requests());
928 }
929
930 #[test]
931 fn test_workspace_folder_encodes_fragment_char() {
932 #[cfg(windows)]
935 let (root, expected) = (
936 Path::new(r"C:\home\me\dev\#work"),
937 "file:///C:/home/me/dev/%23work",
938 );
939 #[cfg(not(windows))]
940 let (root, expected) = (
941 Path::new("/home/me/dev/#work"),
942 "file:///home/me/dev/%23work",
943 );
944
945 let folder = workspace_folder(root).unwrap();
946
947 assert_eq!(folder.uri.as_str(), expected);
948 assert_eq!(folder.name, "#work");
949 }
950
951 #[test]
952 fn test_workspace_folder_encodes_bracket_chars() {
953 #[cfg(windows)]
954 let (root, expected) = (
955 Path::new(r"C:\home\me\dev\[env]"),
956 "file:///C:/home/me/dev/%5Benv%5D",
957 );
958 #[cfg(not(windows))]
959 let (root, expected) = (
960 Path::new("/home/me/dev/[env]"),
961 "file:///home/me/dev/%5Benv%5D",
962 );
963
964 let folder = workspace_folder(root).unwrap();
965
966 assert_eq!(folder.uri.as_str(), expected);
967 assert_eq!(folder.name, "[env]");
968 }
969
970 #[test]
971 fn test_workspace_folder_rejects_relative_root() {
972 let err = workspace_folder(Path::new("relative/root")).unwrap_err();
973 assert!(matches!(err, Error::InvalidUri(_)), "got {err:?}");
974 }
975
976 #[test]
977 fn test_server_state_shutting_down() {
978 assert!(!ServerState::ShuttingDown.is_ready());
979 assert!(!ServerState::ShuttingDown.can_accept_requests());
980 }
981
982 #[test]
983 fn test_server_state_shutdown() {
984 assert!(!ServerState::Shutdown.is_ready());
985 assert!(!ServerState::Shutdown.can_accept_requests());
986 }
987
988 #[test]
989 fn test_server_state_equality() {
990 assert_eq!(ServerState::Ready, ServerState::Ready);
991 assert_ne!(ServerState::Ready, ServerState::Uninitialized);
992 assert_eq!(ServerState::Shutdown, ServerState::Shutdown);
993 }
994
995 #[test]
996 fn test_server_state_clone() {
997 let state = ServerState::Ready;
998 let cloned = state;
999 assert_eq!(state, cloned);
1000 }
1001
1002 #[test]
1003 fn test_server_state_debug() {
1004 let state = ServerState::Ready;
1005 let debug_str = format!("{state:?}");
1006 assert!(debug_str.contains("Ready"));
1007 }
1008
1009 #[test]
1010 fn test_server_init_config_clone() {
1011 let config = ServerInitConfig {
1012 server_config: LspServerConfig::rust_analyzer(),
1013 workspace_roots: vec![PathBuf::from("/tmp/workspace")],
1014 initialization_options: Some(serde_json::json!({"key": "value"})),
1015 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1016 notification_tx: None,
1017 };
1018
1019 #[allow(clippy::redundant_clone)]
1020 let cloned = config.clone();
1021 assert_eq!(cloned.server_config.language_id, "rust");
1022 assert_eq!(cloned.workspace_roots.len(), 1);
1023 }
1024
1025 #[test]
1026 fn test_server_init_config_debug() {
1027 let config = ServerInitConfig {
1028 server_config: LspServerConfig::pyright(),
1029 workspace_roots: vec![],
1030 initialization_options: None,
1031 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1032 notification_tx: None,
1033 };
1034
1035 let debug_str = format!("{config:?}");
1036 assert!(debug_str.contains("python"));
1037 assert!(debug_str.contains("pyright"));
1038 }
1039
1040 #[test]
1041 fn test_server_init_config_with_options() {
1042 use std::collections::HashMap;
1043
1044 let init_opts = serde_json::json!({
1045 "settings": {
1046 "python": {
1047 "analysis": {
1048 "typeCheckingMode": "strict"
1049 }
1050 }
1051 }
1052 });
1053
1054 let mut env = HashMap::new();
1055 env.insert("PYTHONPATH".to_string(), "/usr/lib".to_string());
1056
1057 let config = ServerInitConfig {
1058 server_config: LspServerConfig {
1059 language_id: "python".to_string(),
1060 command: "pyright-langserver".to_string(),
1061 args: vec!["--stdio".to_string()],
1062 env,
1063 file_patterns: vec!["**/*.py".to_string()],
1064 initialization_options: Some(init_opts.clone()),
1065 timeout_seconds: 10,
1066 request_timeout_seconds: 10,
1067 heuristics: None,
1068 name: None,
1069 handles: None,
1070 },
1071 workspace_roots: vec![PathBuf::from("/workspace")],
1072 initialization_options: Some(init_opts),
1073 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1074 notification_tx: None,
1075 };
1076
1077 assert!(config.initialization_options.is_some());
1078 assert_eq!(config.workspace_roots.len(), 1);
1079 }
1080
1081 #[test]
1082 fn test_server_init_config_empty_workspace() {
1083 let config = ServerInitConfig {
1084 server_config: LspServerConfig::typescript(),
1085 workspace_roots: vec![],
1086 initialization_options: None,
1087 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1088 notification_tx: None,
1089 };
1090
1091 assert!(config.workspace_roots.is_empty());
1092 }
1093
1094 #[test]
1095 fn test_server_init_config_multiple_workspaces() {
1096 let config = ServerInitConfig {
1097 server_config: LspServerConfig::rust_analyzer(),
1098 workspace_roots: vec![
1099 PathBuf::from("/workspace1"),
1100 PathBuf::from("/workspace2"),
1101 PathBuf::from("/workspace3"),
1102 ],
1103 initialization_options: None,
1104 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1105 notification_tx: None,
1106 };
1107
1108 assert_eq!(config.workspace_roots.len(), 3);
1109 }
1110
1111 #[cfg(unix)]
1118 #[tokio::test]
1119 async fn test_has_exited_reflects_child_process_state() {
1120 use lsp_types::ServerCapabilities;
1121
1122 let mut mock_child = tokio::process::Command::new("sleep")
1123 .arg("2")
1124 .stdin(Stdio::piped())
1125 .stdout(Stdio::piped())
1126 .kill_on_drop(true)
1127 .spawn()
1128 .unwrap();
1129
1130 let mock_stdin = mock_child.stdin.take().unwrap();
1131 let mock_stdout = mock_child.stdout.take().unwrap();
1132
1133 let transport = LspTransport::new(mock_stdin, mock_stdout);
1134 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1135 let (_, mock_notification_rx) = mpsc::channel(1);
1136
1137 let mut server = LspServer {
1138 client,
1139 capabilities: ServerCapabilities::default(),
1140 position_encoding: PositionEncodingKind::UTF8,
1141 notification_rx: mock_notification_rx,
1142 child: mock_child,
1143 };
1144
1145 assert!(
1146 !server.has_exited().unwrap(),
1147 "freshly spawned `sleep 2` should still be running"
1148 );
1149
1150 server.child.kill().await.unwrap();
1151 assert!(
1154 server.has_exited().unwrap(),
1155 "killed child must report as exited"
1156 );
1157 }
1158
1159 #[tokio::test]
1160 async fn test_lsp_server_getters() {
1161 use lsp_types::ServerCapabilities;
1162
1163 let mock_child = tokio::process::Command::new("echo")
1164 .stdin(Stdio::piped())
1165 .stdout(Stdio::piped())
1166 .kill_on_drop(true)
1167 .spawn()
1168 .unwrap();
1169
1170 let mock_stdin = tokio::process::Command::new("cat")
1171 .stdin(Stdio::piped())
1172 .spawn()
1173 .unwrap()
1174 .stdin
1175 .take()
1176 .unwrap();
1177
1178 let mock_stdout = tokio::process::Command::new("echo")
1179 .stdout(Stdio::piped())
1180 .spawn()
1181 .unwrap()
1182 .stdout
1183 .take()
1184 .unwrap();
1185
1186 let transport = LspTransport::new(mock_stdin, mock_stdout);
1187 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1188 let (_, mock_notification_rx) = mpsc::channel(1);
1189
1190 let server = LspServer {
1191 client,
1192 capabilities: ServerCapabilities::default(),
1193 position_encoding: PositionEncodingKind::UTF8,
1194 notification_rx: mock_notification_rx,
1195 child: mock_child,
1196 };
1197
1198 assert_eq!(server.position_encoding(), PositionEncodingKind::UTF8);
1199 assert!(server.capabilities().text_document_sync.is_none());
1200
1201 let debug_str = format!("{server:?}");
1202 assert!(debug_str.contains("LspServer"));
1203 assert!(debug_str.contains("<process>"));
1204 }
1205
1206 #[test]
1207 fn test_server_init_result_new_empty() {
1208 let result = ServerInitResult::new();
1209 assert!(!result.has_servers());
1210 assert!(!result.all_failed());
1211 assert!(!result.partial_success());
1212 assert_eq!(result.server_count(), 0);
1213 assert_eq!(result.failure_count(), 0);
1214 }
1215
1216 #[test]
1217 fn test_server_init_result_default() {
1218 let result = ServerInitResult::default();
1219 assert!(!result.has_servers());
1220 assert_eq!(result.server_count(), 0);
1221 assert_eq!(result.failure_count(), 0);
1222 }
1223
1224 #[test]
1225 fn test_server_init_result_all_failures() {
1226 let mut result = ServerInitResult::new();
1227
1228 result.add_failure(ServerSpawnFailure {
1229 server_id: ServerId::from("rust"),
1230 language_id: "rust".to_string(),
1231 command: "rust-analyzer".to_string(),
1232 message: "not found".to_string(),
1233 });
1234
1235 result.add_failure(ServerSpawnFailure {
1236 server_id: ServerId::from("python"),
1237 language_id: "python".to_string(),
1238 command: "pyright".to_string(),
1239 message: "permission denied".to_string(),
1240 });
1241
1242 assert!(!result.has_servers());
1243 assert!(result.all_failed());
1244 assert!(!result.partial_success());
1245 assert_eq!(result.server_count(), 0);
1246 assert_eq!(result.failure_count(), 2);
1247 }
1248
1249 #[tokio::test]
1250 async fn test_server_init_result_all_success() {
1251 let mut result = ServerInitResult::new();
1252
1253 let mock_child1 = tokio::process::Command::new("echo")
1254 .stdin(Stdio::piped())
1255 .stdout(Stdio::piped())
1256 .kill_on_drop(true)
1257 .spawn()
1258 .unwrap();
1259
1260 let mock_stdin1 = tokio::process::Command::new("cat")
1261 .stdin(Stdio::piped())
1262 .spawn()
1263 .unwrap()
1264 .stdin
1265 .take()
1266 .unwrap();
1267
1268 let mock_stdout1 = tokio::process::Command::new("echo")
1269 .stdout(Stdio::piped())
1270 .spawn()
1271 .unwrap()
1272 .stdout
1273 .take()
1274 .unwrap();
1275
1276 let transport1 = LspTransport::new(mock_stdin1, mock_stdout1);
1277 let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
1278 let (_, mock_notification_rx1) = mpsc::channel(1);
1279
1280 let server1 = LspServer {
1281 client: client1,
1282 capabilities: lsp_types::ServerCapabilities::default(),
1283 position_encoding: PositionEncodingKind::UTF8,
1284 notification_rx: mock_notification_rx1,
1285 child: mock_child1,
1286 };
1287
1288 result.add_server("rust".to_string(), server1);
1289
1290 assert!(result.has_servers());
1291 assert!(!result.all_failed());
1292 assert!(!result.partial_success());
1293 assert_eq!(result.server_count(), 1);
1294 assert_eq!(result.failure_count(), 0);
1295 }
1296
1297 #[tokio::test]
1298 async fn test_server_init_result_partial_success() {
1299 let mut result = ServerInitResult::new();
1300
1301 let mock_child = tokio::process::Command::new("echo")
1302 .stdin(Stdio::piped())
1303 .stdout(Stdio::piped())
1304 .kill_on_drop(true)
1305 .spawn()
1306 .unwrap();
1307
1308 let mock_stdin = tokio::process::Command::new("cat")
1309 .stdin(Stdio::piped())
1310 .spawn()
1311 .unwrap()
1312 .stdin
1313 .take()
1314 .unwrap();
1315
1316 let mock_stdout = tokio::process::Command::new("echo")
1317 .stdout(Stdio::piped())
1318 .spawn()
1319 .unwrap()
1320 .stdout
1321 .take()
1322 .unwrap();
1323
1324 let transport = LspTransport::new(mock_stdin, mock_stdout);
1325 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1326 let (_, mock_notification_rx) = mpsc::channel(1);
1327
1328 let server = LspServer {
1329 client,
1330 capabilities: lsp_types::ServerCapabilities::default(),
1331 position_encoding: PositionEncodingKind::UTF8,
1332 notification_rx: mock_notification_rx,
1333 child: mock_child,
1334 };
1335
1336 result.add_server("rust".to_string(), server);
1337
1338 result.add_failure(ServerSpawnFailure {
1339 server_id: ServerId::from("python"),
1340 language_id: "python".to_string(),
1341 command: "pyright".to_string(),
1342 message: "not found".to_string(),
1343 });
1344
1345 assert!(result.has_servers());
1346 assert!(!result.all_failed());
1347 assert!(result.partial_success());
1348 assert_eq!(result.server_count(), 1);
1349 assert_eq!(result.failure_count(), 1);
1350 }
1351
1352 #[tokio::test]
1353 async fn test_server_init_result_multiple_servers() {
1354 let mut result = ServerInitResult::new();
1355
1356 for i in 0..3 {
1357 let mock_child = tokio::process::Command::new("echo")
1358 .stdin(Stdio::piped())
1359 .stdout(Stdio::piped())
1360 .kill_on_drop(true)
1361 .spawn()
1362 .unwrap();
1363
1364 let mock_stdin = tokio::process::Command::new("cat")
1365 .stdin(Stdio::piped())
1366 .spawn()
1367 .unwrap()
1368 .stdin
1369 .take()
1370 .unwrap();
1371
1372 let mock_stdout = tokio::process::Command::new("echo")
1373 .stdout(Stdio::piped())
1374 .spawn()
1375 .unwrap()
1376 .stdout
1377 .take()
1378 .unwrap();
1379
1380 let transport = LspTransport::new(mock_stdin, mock_stdout);
1381 let config = if i == 0 {
1382 LspServerConfig::rust_analyzer()
1383 } else if i == 1 {
1384 LspServerConfig::pyright()
1385 } else {
1386 LspServerConfig::typescript()
1387 };
1388 let client = LspClient::from_transport(config.clone(), transport);
1389 let (_, mock_notification_rx) = mpsc::channel(1);
1390
1391 let server = LspServer {
1392 client,
1393 capabilities: lsp_types::ServerCapabilities::default(),
1394 position_encoding: PositionEncodingKind::UTF8,
1395 notification_rx: mock_notification_rx,
1396 child: mock_child,
1397 };
1398
1399 result.add_server(config.language_id, server);
1400 }
1401
1402 assert!(result.has_servers());
1403 assert!(!result.all_failed());
1404 assert!(!result.partial_success());
1405 assert_eq!(result.server_count(), 3);
1406 assert_eq!(result.failure_count(), 0);
1407 }
1408
1409 #[tokio::test]
1410 async fn test_server_init_result_replace_server() {
1411 let mut result = ServerInitResult::new();
1412
1413 let mock_child1 = 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_stdin1 = tokio::process::Command::new("cat")
1421 .stdin(Stdio::piped())
1422 .spawn()
1423 .unwrap()
1424 .stdin
1425 .take()
1426 .unwrap();
1427
1428 let mock_stdout1 = tokio::process::Command::new("echo")
1429 .stdout(Stdio::piped())
1430 .spawn()
1431 .unwrap()
1432 .stdout
1433 .take()
1434 .unwrap();
1435
1436 let transport1 = LspTransport::new(mock_stdin1, mock_stdout1);
1437 let client1 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport1);
1438 let (_, mock_notification_rx1) = mpsc::channel(1);
1439
1440 let server1 = LspServer {
1441 client: client1,
1442 capabilities: lsp_types::ServerCapabilities::default(),
1443 position_encoding: PositionEncodingKind::UTF8,
1444 notification_rx: mock_notification_rx1,
1445 child: mock_child1,
1446 };
1447
1448 result.add_server("rust".to_string(), server1);
1449 assert_eq!(result.server_count(), 1);
1450
1451 let mock_child2 = tokio::process::Command::new("echo")
1452 .stdin(Stdio::piped())
1453 .stdout(Stdio::piped())
1454 .kill_on_drop(true)
1455 .spawn()
1456 .unwrap();
1457
1458 let mock_stdin2 = tokio::process::Command::new("cat")
1459 .stdin(Stdio::piped())
1460 .spawn()
1461 .unwrap()
1462 .stdin
1463 .take()
1464 .unwrap();
1465
1466 let mock_stdout2 = tokio::process::Command::new("echo")
1467 .stdout(Stdio::piped())
1468 .spawn()
1469 .unwrap()
1470 .stdout
1471 .take()
1472 .unwrap();
1473
1474 let transport2 = LspTransport::new(mock_stdin2, mock_stdout2);
1475 let client2 = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport2);
1476 let (_, mock_notification_rx2) = mpsc::channel(1);
1477
1478 let server2 = LspServer {
1479 client: client2,
1480 capabilities: lsp_types::ServerCapabilities::default(),
1481 position_encoding: PositionEncodingKind::UTF16,
1482 notification_rx: mock_notification_rx2,
1483 child: mock_child2,
1484 };
1485
1486 result.add_server("rust".to_string(), server2);
1487 assert_eq!(result.server_count(), 1);
1488 }
1489
1490 #[test]
1491 fn test_server_init_result_debug() {
1492 let mut result = ServerInitResult::new();
1493
1494 result.add_failure(ServerSpawnFailure {
1495 server_id: ServerId::from("rust"),
1496 language_id: "rust".to_string(),
1497 command: "rust-analyzer".to_string(),
1498 message: "not found".to_string(),
1499 });
1500
1501 let debug_str = format!("{result:?}");
1502 assert!(debug_str.contains("ServerInitResult"));
1503 }
1504
1505 #[test]
1506 fn test_server_init_result_multiple_failures() {
1507 let mut result = ServerInitResult::new();
1508
1509 result.add_failure(ServerSpawnFailure {
1510 server_id: ServerId::from("python"),
1511 language_id: "python".to_string(),
1512 command: "pyright".to_string(),
1513 message: "not found".to_string(),
1514 });
1515
1516 result.add_failure(ServerSpawnFailure {
1517 server_id: ServerId::from("typescript"),
1518 language_id: "typescript".to_string(),
1519 command: "tsserver".to_string(),
1520 message: "command not found".to_string(),
1521 });
1522
1523 assert_eq!(result.failure_count(), 2);
1524 assert_eq!(result.server_count(), 0);
1525 assert!(result.all_failed());
1526 assert!(!result.partial_success());
1527 }
1528
1529 #[tokio::test]
1530 async fn test_spawn_batch_empty_configs() {
1531 let configs: &[ServerInitConfig] = &[];
1532 let result = LspServer::spawn_batch(configs).await;
1533
1534 assert!(!result.has_servers());
1535 assert!(!result.all_failed());
1536 assert!(!result.partial_success());
1537 assert_eq!(result.server_count(), 0);
1538 assert_eq!(result.failure_count(), 0);
1539 }
1540
1541 #[tokio::test]
1542 async fn test_spawn_batch_single_invalid_config() {
1543 let configs = vec![ServerInitConfig {
1544 server_config: LspServerConfig {
1545 language_id: "rust".to_string(),
1546 command: "nonexistent-command-12345".to_string(),
1547 args: vec![],
1548 env: std::collections::HashMap::new(),
1549 file_patterns: vec!["**/*.rs".to_string()],
1550 initialization_options: None,
1551 timeout_seconds: 10,
1552 request_timeout_seconds: 10,
1553 heuristics: None,
1554 name: None,
1555 handles: None,
1556 },
1557 workspace_roots: vec![],
1558 initialization_options: None,
1559 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1560 notification_tx: None,
1561 }];
1562
1563 let result = LspServer::spawn_batch(&configs).await;
1564
1565 assert!(!result.has_servers());
1566 assert!(result.all_failed());
1567 assert!(!result.partial_success());
1568 assert_eq!(result.server_count(), 0);
1569 assert_eq!(result.failure_count(), 1);
1570
1571 let failure = &result.failures[0];
1572 assert_eq!(failure.language_id, "rust");
1573 assert_eq!(failure.command, "nonexistent-command-12345");
1574 assert!(failure.message.contains("spawn"));
1575 }
1576
1577 #[tokio::test]
1578 async fn test_spawn_batch_all_invalid_configs() {
1579 let configs = vec![
1580 ServerInitConfig {
1581 server_config: LspServerConfig {
1582 language_id: "rust".to_string(),
1583 command: "nonexistent-rust-analyzer".to_string(),
1584 args: vec![],
1585 env: std::collections::HashMap::new(),
1586 file_patterns: vec!["**/*.rs".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 ServerInitConfig {
1600 server_config: LspServerConfig {
1601 language_id: "python".to_string(),
1602 command: "nonexistent-pyright".to_string(),
1603 args: vec![],
1604 env: std::collections::HashMap::new(),
1605 file_patterns: vec!["**/*.py".to_string()],
1606 initialization_options: None,
1607 timeout_seconds: 10,
1608 request_timeout_seconds: 10,
1609 heuristics: None,
1610 name: None,
1611 handles: None,
1612 },
1613 workspace_roots: vec![],
1614 initialization_options: None,
1615 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1616 notification_tx: None,
1617 },
1618 ServerInitConfig {
1619 server_config: LspServerConfig {
1620 language_id: "typescript".to_string(),
1621 command: "nonexistent-tsserver".to_string(),
1622 args: vec![],
1623 env: std::collections::HashMap::new(),
1624 file_patterns: vec!["**/*.ts".to_string()],
1625 initialization_options: None,
1626 timeout_seconds: 10,
1627 request_timeout_seconds: 10,
1628 heuristics: None,
1629 name: None,
1630 handles: None,
1631 },
1632 workspace_roots: vec![],
1633 initialization_options: None,
1634 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1635 notification_tx: None,
1636 },
1637 ];
1638
1639 let result = LspServer::spawn_batch(&configs).await;
1640
1641 assert!(!result.has_servers());
1642 assert!(result.all_failed());
1643 assert!(!result.partial_success());
1644 assert_eq!(result.server_count(), 0);
1645 assert_eq!(result.failure_count(), 3);
1646
1647 let failure_languages: Vec<_> = result
1648 .failures
1649 .iter()
1650 .map(|f| f.language_id.as_str())
1651 .collect();
1652 assert!(failure_languages.contains(&"rust"));
1653 assert!(failure_languages.contains(&"python"));
1654 assert!(failure_languages.contains(&"typescript"));
1655 }
1656
1657 #[tokio::test]
1658 async fn test_spawn_batch_multiple_invalid_configs_ordering() {
1659 let configs = vec![
1660 ServerInitConfig {
1661 server_config: LspServerConfig {
1662 language_id: "lang1".to_string(),
1663 command: "cmd1-nonexistent".to_string(),
1664 args: vec![],
1665 env: std::collections::HashMap::new(),
1666 file_patterns: vec![],
1667 initialization_options: None,
1668 timeout_seconds: 10,
1669 request_timeout_seconds: 10,
1670 heuristics: None,
1671 name: None,
1672 handles: None,
1673 },
1674 workspace_roots: vec![],
1675 initialization_options: None,
1676 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1677 notification_tx: None,
1678 },
1679 ServerInitConfig {
1680 server_config: LspServerConfig {
1681 language_id: "lang2".to_string(),
1682 command: "cmd2-nonexistent".to_string(),
1683 args: vec![],
1684 env: std::collections::HashMap::new(),
1685 file_patterns: vec![],
1686 initialization_options: None,
1687 timeout_seconds: 10,
1688 request_timeout_seconds: 10,
1689 heuristics: None,
1690 name: None,
1691 handles: None,
1692 },
1693 workspace_roots: vec![],
1694 initialization_options: None,
1695 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1696 notification_tx: None,
1697 },
1698 ];
1699
1700 let result = LspServer::spawn_batch(&configs).await;
1701
1702 assert_eq!(result.failure_count(), 2);
1703
1704 assert_eq!(result.failures[0].language_id, "lang1");
1705 assert_eq!(result.failures[0].command, "cmd1-nonexistent");
1706
1707 assert_eq!(result.failures[1].language_id, "lang2");
1708 assert_eq!(result.failures[1].command, "cmd2-nonexistent");
1709 }
1710
1711 mod initialize_wire {
1717 use std::process::Stdio;
1718
1719 use serde_json::Value;
1720 use tempfile::TempDir;
1721 use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
1722 use tokio::process::{Child, ChildStdin, ChildStdout, Command};
1723
1724 use super::*;
1725 use crate::lsp::client::LspClient;
1726
1727 struct FakeServer {
1728 _write_half: Child,
1729 _read_half: Child,
1730 read_half_stdin: ChildStdin,
1731 write_stdout: ChildStdout,
1732 }
1733
1734 fn fake_lsp_client() -> (LspClient, FakeServer) {
1735 let mut write_half = Command::new("cat")
1736 .stdin(Stdio::piped())
1737 .stdout(Stdio::piped())
1738 .kill_on_drop(true)
1739 .spawn()
1740 .unwrap();
1741 let write_stdin = write_half.stdin.take().unwrap();
1742 let write_stdout = write_half.stdout.take().unwrap();
1743
1744 let mut read_half = Command::new("cat")
1745 .stdin(Stdio::piped())
1746 .stdout(Stdio::piped())
1747 .kill_on_drop(true)
1748 .spawn()
1749 .unwrap();
1750 let read_stdout = read_half.stdout.take().unwrap();
1751 let read_stdin = read_half.stdin.take().unwrap();
1752
1753 let transport = LspTransport::new(write_stdin, read_stdout);
1754 let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1755
1756 (
1757 client,
1758 FakeServer {
1759 _write_half: write_half,
1760 _read_half: read_half,
1761 read_half_stdin: read_stdin,
1762 write_stdout,
1763 },
1764 )
1765 }
1766
1767 async fn read_framed_message(reader: &mut BufReader<&mut ChildStdout>) -> Value {
1769 let mut content_length = None;
1770 let mut line = String::new();
1771 loop {
1772 line.clear();
1773 reader.read_line(&mut line).await.unwrap();
1774 if line == "\r\n" || line == "\n" {
1775 break;
1776 }
1777 if let Some((key, value)) = line.trim_end().split_once(':')
1778 && key.trim().eq_ignore_ascii_case("content-length")
1779 {
1780 content_length = Some(value.trim().parse::<usize>().unwrap());
1781 }
1782 }
1783 let mut buf = vec![0u8; content_length.unwrap()];
1784 reader.read_exact(&mut buf).await.unwrap();
1785 serde_json::from_slice(&buf).unwrap()
1786 }
1787
1788 async fn write_success_response(stdin: &mut ChildStdin, id: &Value, result: Value) {
1790 let response = serde_json::json!({
1791 "jsonrpc": "2.0",
1792 "id": id,
1793 "result": result,
1794 });
1795 let content = serde_json::to_string(&response).unwrap();
1796 let header = format!("Content-Length: {}\r\n\r\n", content.len());
1797 stdin.write_all(header.as_bytes()).await.unwrap();
1798 stdin.write_all(content.as_bytes()).await.unwrap();
1799 stdin.flush().await.unwrap();
1800 }
1801
1802 #[tokio::test]
1803 async fn test_initialize_sends_configured_position_encodings() {
1804 let (client, mut server) = fake_lsp_client();
1805
1806 let config = ServerInitConfig {
1807 server_config: LspServerConfig::rust_analyzer(),
1808 workspace_roots: vec![],
1809 initialization_options: None,
1810 position_encodings: vec!["utf-32".to_string(), "utf-8".to_string()],
1811 notification_tx: None,
1812 };
1813
1814 let init_task =
1815 tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1816
1817 let mut reader = BufReader::new(&mut server.write_stdout);
1818 let request = read_framed_message(&mut reader).await;
1819
1820 assert_eq!(request["method"], "initialize");
1821 assert_eq!(
1822 request["params"]["capabilities"]["general"]["positionEncodings"],
1823 serde_json::json!(["utf-32", "utf-8"]),
1824 "initialize request must carry the configured encoding order, not the \
1825 hardcoded [UTF8, UTF16] default"
1826 );
1827
1828 write_success_response(
1829 &mut server.read_half_stdin,
1830 &request["id"].clone(),
1831 serde_json::json!({ "capabilities": {} }),
1832 )
1833 .await;
1834
1835 init_task.await.unwrap().unwrap();
1837 }
1838
1839 #[tokio::test]
1840 async fn test_initialize_advertises_hierarchical_document_symbols() {
1841 let (client, mut server) = fake_lsp_client();
1842
1843 let config = ServerInitConfig {
1844 server_config: LspServerConfig::rust_analyzer(),
1845 workspace_roots: vec![],
1846 initialization_options: None,
1847 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1848 notification_tx: None,
1849 };
1850
1851 let init_task =
1852 tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1853
1854 let mut reader = BufReader::new(&mut server.write_stdout);
1855 let request = read_framed_message(&mut reader).await;
1856 let params: InitializeParams =
1857 serde_json::from_value(request["params"].clone()).unwrap();
1858 let document_symbol = params
1859 .capabilities
1860 .text_document
1861 .unwrap()
1862 .document_symbol
1863 .unwrap();
1864
1865 assert_eq!(request["method"], "initialize");
1866 assert_eq!(document_symbol.dynamic_registration, Some(false));
1867 assert_eq!(
1868 document_symbol.hierarchical_document_symbol_support,
1869 Some(true)
1870 );
1871 assert_eq!(
1872 document_symbol.symbol_kind.unwrap().value_set,
1873 Some(SUPPORTED_SYMBOL_KINDS.to_vec())
1874 );
1875
1876 write_success_response(
1877 &mut server.read_half_stdin,
1878 &request["id"].clone(),
1879 serde_json::json!({ "capabilities": {} }),
1880 )
1881 .await;
1882
1883 init_task.await.unwrap().unwrap();
1884 }
1885
1886 #[tokio::test]
1887 async fn test_initialize_accepts_resolved_dot_workspace_root() {
1888 let temp_dir = TempDir::new().unwrap();
1889 let base = dunce::canonicalize(temp_dir.path()).unwrap();
1890 let workspace_roots =
1891 crate::resolve_workspace_roots(&[PathBuf::from(".")], &base).unwrap();
1892 assert_eq!(workspace_roots, vec![base.clone()]);
1893
1894 let (client, mut server) = fake_lsp_client();
1895 let config = ServerInitConfig {
1896 server_config: LspServerConfig::rust_analyzer(),
1897 workspace_roots,
1898 initialization_options: None,
1899 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1900 notification_tx: None,
1901 };
1902
1903 let init_task =
1904 tokio::spawn(async move { LspServer::initialize(&client, &config).await });
1905
1906 let mut reader = BufReader::new(&mut server.write_stdout);
1907 let request = read_framed_message(&mut reader).await;
1908 let expected_uri = try_path_to_uri(&base).unwrap();
1909 assert_eq!(
1910 request["params"]["workspaceFolders"][0]["uri"],
1911 expected_uri.as_str()
1912 );
1913
1914 write_success_response(
1915 &mut server.read_half_stdin,
1916 &request["id"].clone(),
1917 serde_json::json!({ "capabilities": {} }),
1918 )
1919 .await;
1920
1921 init_task.await.unwrap().unwrap();
1922 }
1923 }
1924
1925 #[tokio::test]
1926 async fn test_spawn_batch_logs_each_failure() {
1927 let configs = vec![
1928 ServerInitConfig {
1929 server_config: LspServerConfig {
1930 language_id: "test1".to_string(),
1931 command: "nonexistent-test1".to_string(),
1932 args: vec![],
1933 env: std::collections::HashMap::new(),
1934 file_patterns: vec![],
1935 initialization_options: None,
1936 timeout_seconds: 10,
1937 request_timeout_seconds: 10,
1938 heuristics: None,
1939 name: None,
1940 handles: None,
1941 },
1942 workspace_roots: vec![],
1943 initialization_options: None,
1944 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1945 notification_tx: None,
1946 },
1947 ServerInitConfig {
1948 server_config: LspServerConfig {
1949 language_id: "test2".to_string(),
1950 command: "nonexistent-test2".to_string(),
1951 args: vec![],
1952 env: std::collections::HashMap::new(),
1953 file_patterns: vec![],
1954 initialization_options: None,
1955 timeout_seconds: 10,
1956 request_timeout_seconds: 10,
1957 heuristics: None,
1958 name: None,
1959 handles: None,
1960 },
1961 workspace_roots: vec![],
1962 initialization_options: None,
1963 position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
1964 notification_tx: None,
1965 },
1966 ];
1967
1968 let result = LspServer::spawn_batch(&configs).await;
1969
1970 assert_eq!(result.failure_count(), 2);
1971 assert_eq!(result.failures[0].language_id, "test1");
1972 assert_eq!(result.failures[1].language_id, "test2");
1973 }
1974
1975 fn bare_server_config(env: HashMap<String, String>) -> LspServerConfig {
1978 LspServerConfig {
1979 language_id: "test".to_string(),
1980 command: "irrelevant-for-build-command".to_string(),
1981 args: vec![],
1982 env,
1983 file_patterns: vec![],
1984 initialization_options: None,
1985 timeout_seconds: 5,
1986 request_timeout_seconds: 5,
1987 heuristics: None,
1988 name: None,
1989 handles: None,
1990 }
1991 }
1992
1993 fn effective_envs(command: &Command) -> HashMap<String, String> {
1997 command
1998 .as_std()
1999 .get_envs()
2000 .filter_map(|(k, v)| {
2001 v.map(|v| {
2002 (
2003 k.to_string_lossy().into_owned(),
2004 v.to_string_lossy().into_owned(),
2005 )
2006 })
2007 })
2008 .collect()
2009 }
2010
2011 #[test]
2015 fn test_build_command_excludes_non_allowlisted_parent_env_vars() {
2016 let config = bare_server_config(HashMap::new());
2017 let command = LspServer::build_command(&config, |key| match key {
2018 "PATH" => Some("/parent/bin".into()),
2019 "MCPLS_TEST_LEAK_CANARY" => Some("should-not-reach-child".into()),
2020 _ => None,
2021 });
2022
2023 let envs = effective_envs(&command);
2024
2025 assert!(
2026 !envs.contains_key("MCPLS_TEST_LEAK_CANARY"),
2027 "non-allowlisted parent env var leaked into child command: {envs:?}"
2028 );
2029
2030 #[cfg(unix)]
2041 assert!(
2042 format!("{:?}", command.as_std()).starts_with("env -i "),
2043 "build_command must call .env_clear() so the child doesn't inherit the full parent environment"
2044 );
2045 }
2046
2047 #[test]
2050 fn test_build_command_passes_through_allowlisted_env_vars() {
2051 let config = bare_server_config(HashMap::new());
2052 let command =
2053 LspServer::build_command(&config, |key| (key == "PATH").then(|| "/parent/bin".into()));
2054
2055 let envs = effective_envs(&command);
2056
2057 assert_eq!(envs.get("PATH"), Some(&"/parent/bin".to_string()));
2058 }
2059
2060 #[test]
2063 fn test_build_command_includes_configured_env_vars() {
2064 let mut env = HashMap::new();
2065 env.insert(
2066 "MCPLS_TEST_CONFIGURED".to_string(),
2067 "from-server-config".to_string(),
2068 );
2069 let config = bare_server_config(env);
2070 let command = LspServer::build_command(&config, |_| None);
2071
2072 let envs = effective_envs(&command);
2073
2074 assert_eq!(
2075 envs.get("MCPLS_TEST_CONFIGURED"),
2076 Some(&"from-server-config".to_string())
2077 );
2078 }
2079
2080 #[test]
2084 fn test_build_command_configured_env_overrides_allowlisted_var() {
2085 let mut env = HashMap::new();
2086 env.insert("PATH".to_string(), "/configured/override/path".to_string());
2087 let config = bare_server_config(env);
2088 let command =
2089 LspServer::build_command(&config, |key| (key == "PATH").then(|| "/parent/bin".into()));
2090
2091 let envs = effective_envs(&command);
2092
2093 assert_eq!(
2094 envs.get("PATH"),
2095 Some(&"/configured/override/path".to_string())
2096 );
2097 }
2098
2099 #[tokio::test]
2109 async fn test_register_servers_computes_diagnostics_flags_from_rebound_router() {
2110 use crate::bridge::Translator;
2111 use crate::config::{ServerId, ToolKind, ToolRouter};
2112
2113 let pylsp_id = ServerId::from("pylsp");
2114 let configs = vec![
2115 LspServerConfig {
2116 language_id: "python".to_string(),
2117 command: "pyright-langserver".to_string(),
2118 args: vec![],
2119 env: std::collections::HashMap::new(),
2120 file_patterns: vec![],
2121 initialization_options: None,
2122 timeout_seconds: 30,
2123 request_timeout_seconds: 30,
2124 heuristics: None,
2125 name: Some("pyright-diag".to_string()),
2126 handles: Some(vec![ToolKind::Diagnostics]),
2127 },
2128 LspServerConfig {
2129 language_id: "python".to_string(),
2130 command: "pylsp".to_string(),
2131 args: vec![],
2132 env: std::collections::HashMap::new(),
2133 file_patterns: vec![],
2134 initialization_options: None,
2135 timeout_seconds: 30,
2136 request_timeout_seconds: 30,
2137 heuristics: None,
2138 name: Some("pylsp".to_string()),
2139 handles: None,
2140 },
2141 ];
2142 let router = ToolRouter::from_configs(&configs).unwrap();
2143 let translator = Translator::new().with_router(router);
2144
2145 let mut result = ServerInitResult::new();
2147 result.add_server(pylsp_id.clone(), fake_lsp_server());
2148
2149 let registered = crate::register_servers(result, &translator, &HashMap::new());
2150
2151 assert_eq!(
2152 registered.diagnostics_flags.get(&pylsp_id),
2153 Some(&true),
2154 "pylsp must inherit the diagnostics route once pyright-diag is \
2155 known dead, and the flag must reflect that post-rebind state"
2156 );
2157 }
2158}