use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use lsp_types::{
ClientCapabilities, ClientInfo, DidChangeConfigurationNotification,
DidChangeConfigurationParams, ExitNotification, GeneralClientCapabilities, InitializeParams,
InitializeRequest, InitializeResult, InitializedNotification, InitializedParams,
PositionEncodingKind, Request, ServerCapabilities, ShutdownRequest, StaleRequestSupportOptions,
SymbolKind, WorkspaceFolder,
};
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::time::{Duration, Instant, timeout, timeout_at};
use tracing::{debug, info, warn};
use crate::bridge::{WorkspaceRoots, try_path_to_uri};
use crate::config::{LspServerConfig, LspSettings, PositionEncodings, ServerCommand};
use crate::error::{
BackgroundTask, Error, InitFailureHint, InitPhase, Result, ServerSpawnFailure, StartupFailure,
StdioStream, UntrustedRefusal,
};
use crate::lsp::client::{LspClient, SHUTDOWN_TIMEOUT};
#[cfg(unix)]
use crate::lsp::process::Binding;
use crate::lsp::process::{MarkOutcome, ServerProcess};
use crate::lsp::stderr::{EofWait, StderrCapture};
use crate::lsp::transport::LspTransport;
use crate::lsp::types::LspNotification;
use crate::lsp::vetting::UntrustedVetting;
use crate::lsp::{
CONTENT_MODIFIED_RETRY_METHODS, ManagedEnvVar, NotificationInbox, NotificationSink, ParentEnv,
process_env, tsserver_pin,
};
use crate::redaction::Redactions;
const ENV_PASSTHROUGH: &[&str] = &[
ManagedEnvVar::Path.name(),
ManagedEnvVar::Home.name(),
ManagedEnvVar::UserProfile.name(),
"TMPDIR",
"TEMP",
"TMP",
];
const CHILD_EXIT_GRACE: Duration = Duration::from_secs(3);
const MIN_SWEEP_BUDGET: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitGrace {
WithinBudget,
IfAnswered(Duration),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExitNotice {
Sent,
NotSent,
}
impl ExitNotice {
const fn of(handshake: &Result<MarkOutcome>) -> Self {
match handshake {
Ok(MarkOutcome::Confirmed) => Self::Sent,
_ => Self::NotSent,
}
}
}
fn child_exit_wait(
grace: ExitGrace,
mark: MarkOutcome,
answered: &Result<()>,
remaining: Duration,
) -> Duration {
match grace {
_ if mark != MarkOutcome::Confirmed => Duration::ZERO,
ExitGrace::WithinBudget => remaining.min(CHILD_EXIT_GRACE),
ExitGrace::IfAnswered(grace) if answered.is_ok() => grace,
ExitGrace::IfAnswered(_) => Duration::ZERO,
}
}
const EARLY_EXIT_PROBE: Duration = Duration::from_millis(500);
const NOTIFICATION_CHANNEL_CAPACITY: usize = 256;
const LIFECYCLE_CHANNEL_CAPACITY: usize = 128;
pub const SUPPORTED_SYMBOL_KINDS: [(SymbolKind, &str); 26] = [
(SymbolKind::File, "file"),
(SymbolKind::Module, "module"),
(SymbolKind::Namespace, "namespace"),
(SymbolKind::Package, "package"),
(SymbolKind::Class, "class"),
(SymbolKind::Method, "method"),
(SymbolKind::Property, "property"),
(SymbolKind::Field, "field"),
(SymbolKind::Constructor, "constructor"),
(SymbolKind::Enum, "enum"),
(SymbolKind::Interface, "interface"),
(SymbolKind::Function, "function"),
(SymbolKind::Variable, "variable"),
(SymbolKind::Constant, "constant"),
(SymbolKind::String, "string"),
(SymbolKind::Number, "number"),
(SymbolKind::Boolean, "boolean"),
(SymbolKind::Array, "array"),
(SymbolKind::Object, "object"),
(SymbolKind::Key, "key"),
(SymbolKind::Null, "null"),
(SymbolKind::EnumMember, "enummember"),
(SymbolKind::Struct, "struct"),
(SymbolKind::Event, "event"),
(SymbolKind::Operator, "operator"),
(SymbolKind::TypeParameter, "typeparameter"),
];
fn advertised_symbol_kinds() -> Vec<SymbolKind> {
SUPPORTED_SYMBOL_KINDS
.iter()
.map(|&(kind, _)| kind)
.collect()
}
#[cfg(windows)]
const ENV_PASSTHROUGH_WINDOWS: &[&str] = &[
ManagedEnvVar::NoDefaultCurrentDirectoryInExePath.name(),
"SystemRoot",
"SystemDrive",
"windir",
"APPDATA",
"LOCALAPPDATA",
"ProgramData",
"ProgramFiles",
"COMSPEC",
"PATHEXT",
"NUMBER_OF_PROCESSORS",
"USERNAME",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServerState {
Uninitialized,
Initializing,
Ready,
ShuttingDown,
Shutdown,
}
impl ServerState {
#[must_use]
pub const fn is_ready(&self) -> bool {
matches!(self, Self::Ready)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ChildWorkingDir {
#[default]
Inherit,
Fixed(PathBuf),
}
#[derive(Debug, Clone)]
pub enum SpawnTrust {
Trusted,
Untrusted(UntrustedVetting),
}
impl SpawnTrust {
pub const fn vetting(&self) -> Option<&UntrustedVetting> {
match self {
Self::Trusted => None,
Self::Untrusted(vetting) => Some(vetting),
}
}
}
#[derive(Debug, Clone)]
pub struct VettedInitConfig(ServerInitConfig);
impl VettedInitConfig {
pub(crate) fn into_inner(self) -> ServerInitConfig {
self.0
}
}
impl std::ops::Deref for VettedInitConfig {
type Target = ServerInitConfig;
fn deref(&self) -> &ServerInitConfig {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct ServerInitConfig {
server_config: LspServerConfig,
workspace_roots: WorkspaceRoots,
position_encodings: PositionEncodings,
redactions: Arc<Redactions>,
child_working_dir: ChildWorkingDir,
auto_pin: Option<PathBuf>,
trust: SpawnTrust,
}
impl ServerInitConfig {
#[must_use]
pub const fn new(
server_config: LspServerConfig,
workspace_roots: WorkspaceRoots,
position_encodings: PositionEncodings,
redactions: Arc<Redactions>,
) -> Self {
Self {
server_config,
workspace_roots,
position_encodings,
redactions,
child_working_dir: ChildWorkingDir::Inherit,
auto_pin: None,
trust: SpawnTrust::Trusted,
}
}
#[must_use]
pub(crate) fn with_trust(mut self, trust: SpawnTrust) -> Self {
self.trust = trust;
self
}
pub(crate) fn vet_arguments(&self) -> Result<()> {
let Some(vetting) = self.trust.vetting() else {
return Ok(());
};
vetting
.boundary()
.refuse_arguments(
&self.server_config.args,
&self.child_working_dir,
self.server_config.env.host(),
)
.map_err(|refusal| self.refusal(refusal))
}
pub(crate) async fn vet_arguments_off_runtime(&self) -> Result<()> {
if self.trust.vetting().is_none() {
return Ok(());
}
self.on_planning_pool(Self::vet_arguments).await
}
async fn on_planning_pool<T: Send + 'static>(
&self,
plan: impl FnOnce(&Self) -> Result<T> + Send + 'static,
) -> Result<T> {
let config = self.clone();
crate::on_blocking_pool(move || plan(&config))
.await
.map_err(|source| Error::TaskFailed {
task: crate::error::BackgroundTask::ServerPlanning,
source,
})?
}
#[must_use]
pub(crate) fn with_auto_pin(mut self, tsserver: PathBuf) -> Self {
self.auto_pin = Some(tsserver);
self
}
#[must_use]
pub fn with_child_working_dir(mut self, dir: ChildWorkingDir) -> Self {
self.child_working_dir = dir;
self
}
#[must_use]
pub const fn server_config(&self) -> &LspServerConfig {
&self.server_config
}
#[must_use]
pub const fn workspace_roots(&self) -> &WorkspaceRoots {
&self.workspace_roots
}
#[must_use]
pub const fn position_encodings(&self) -> &PositionEncodings {
&self.position_encodings
}
#[must_use]
pub const fn redactions(&self) -> &Arc<Redactions> {
&self.redactions
}
#[must_use]
pub const fn child_working_dir(&self) -> &ChildWorkingDir {
&self.child_working_dir
}
#[cfg(test)]
pub(crate) const fn server_config_mut(&mut self) -> &mut LspServerConfig {
&mut self.server_config
}
#[must_use]
pub fn pinned_tsserver(&self) -> Option<PathBuf> {
tsserver_pin::configured_tsserver_path(self.server_config.initialization_options.as_ref())
}
pub(crate) async fn for_respawn(&self) -> Result<VettedInitConfig> {
if self.auto_pin.is_none() && self.trust.vetting().is_none() {
return Ok(VettedInitConfig(self.clone()));
}
self.on_planning_pool(Self::plan_respawn).await
}
pub(crate) fn plan_respawn(&self) -> Result<VettedInitConfig> {
self.vet_arguments()?;
let pin_is_current = self
.auto_pin
.as_ref()
.is_none_or(|pin| dunce::canonicalize(pin).ok().as_ref() == Some(pin));
if pin_is_current {
return Ok(VettedInitConfig(self.clone()));
}
let mut unpinned = self.server_config.clone();
unpinned.initialization_options = None;
let plan = tsserver_pin::plan_typescript(unpinned, process_env);
if let Some(vetting) = self.trust.vetting() {
plan.vet_untrusted(vetting)
.map_err(|refusal| self.refusal(refusal))?;
}
let (server_config, auto_pin) = plan.apply(&self.workspace_roots);
Ok(VettedInitConfig(Self {
server_config,
auto_pin,
..self.clone()
}))
}
fn refusal(&self, refusal: UntrustedRefusal) -> Error {
Error::ServerFailedToStart(Box::new(ServerSpawnFailure {
server_id: self.server_config.id(),
language_id: self.server_config.language_id.clone(),
command: self.server_config.command.server_command().clone(),
reason: StartupFailure::RefusedUntrustedWorkspace(refusal),
}))
}
}
#[derive(Debug)]
pub enum ServerStartOutcome {
Started(Box<SpawnedServer>),
Failed(ServerSpawnFailure),
}
#[derive(Debug)]
pub struct ServerLanes {
pub notifications: NotificationInbox,
pub lifecycle: mpsc::Receiver<LspNotification>,
}
#[derive(Debug)]
pub struct SpawnedServer {
pub server: LspServer,
pub lanes: ServerLanes,
}
pub struct LspServer {
client: LspClient,
capabilities: ServerCapabilities,
position_encoding: PositionEncodingKind,
child: Option<ServerProcess>,
init_config: ServerInitConfig,
}
impl std::fmt::Debug for LspServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LspServer")
.field("client", &self.client)
.field("capabilities", &self.capabilities)
.field("position_encoding", &self.position_encoding)
.field("child", &"<process>")
.field("id", &self.init_config.server_config.id())
.finish()
}
}
pub fn child_env_var(
config: &LspServerConfig,
key: &str,
parent_env: impl ParentEnv,
) -> Option<std::ffi::OsString> {
config
.env
.get(key)
.map(std::ffi::OsString::from)
.or_else(|| parent_env(key))
}
impl LspServer {
pub(crate) const fn init_config(&self) -> &ServerInitConfig {
&self.init_config
}
#[cfg(test)]
pub(crate) fn set_init_config(&mut self, config: ServerInitConfig) {
self.init_config = config;
}
pub async fn spawn(config: ServerInitConfig) -> Result<SpawnedServer> {
config.vet_arguments_off_runtime().await?;
let own = Redactions::for_server(config.server_config(), current_environment());
let redactions = Arc::new(Redactions::union([config.redactions().as_ref(), &own]));
Self::log_spawn(config.server_config(), &redactions);
let command = Self::build_command(
config.server_config(),
config.child_working_dir(),
process_env,
);
let passthrough_present = {
let base = ENV_PASSTHROUGH
.iter()
.filter(|key| std::env::var_os(key).is_some())
.count();
#[cfg(windows)]
let windows = ENV_PASSTHROUGH_WINDOWS
.iter()
.filter(|key| std::env::var_os(key).is_some())
.count();
#[cfg(not(windows))]
let windows = 0;
base.saturating_add(windows)
};
debug!(
"Effective LSP server env: {passthrough_present} allowlisted key(s) present, \
{} configured override(s) applied",
config.server_config.env.len()
);
let mut child = ServerProcess::spawn(command)
.map_err(|e| spawn_error(config.server_config.command.server_command().clone(), e))?;
let stdin = child
.take_stdin()
.ok_or(Error::StdioCapture(StdioStream::Stdin))?;
let stdout = child
.take_stdout()
.ok_or(Error::StdioCapture(StdioStream::Stdout))?;
let stderr = child
.take_stderr()
.ok_or(Error::StdioCapture(StdioStream::Stderr))?;
let stderr_capture = StderrCapture::start(stderr);
let transport = LspTransport::with_redactions(stdin, stdout, Arc::clone(&redactions));
let (notification_tx, notification_rx) = mpsc::channel(NOTIFICATION_CHANNEL_CAPACITY);
let (notification_sink, publish_rx) = NotificationSink::new(notification_tx);
let (lifecycle_tx, lifecycle_rx) = mpsc::channel(LIFECYCLE_CHANNEL_CAPACITY);
let client = LspClient::from_transport_with_notifications(
config.server_config.clone(),
transport,
notification_sink,
lifecycle_tx,
Arc::clone(&redactions),
);
#[cfg(unix)]
let process_id = initialize_process_id(child.binding());
#[cfg(windows)]
let process_id = Some(mcpls_process_id());
let (capabilities, position_encoding) =
match Self::initialize_as(&client, &config, process_id).await {
Ok(negotiated) => negotiated,
Err(init_error) if is_connection_loss(&init_error) => {
return Err(Self::connection_loss_error(
&config,
&mut child,
stderr_capture,
&redactions,
init_error,
)
.await);
}
Err(Error::LspInitFailed { phase, cause, .. }) => {
let stderr = stderr_capture.finish(EofWait::Grace, &redactions).await;
let hint = Self::init_failure_hint(&config).await;
return Err(Error::LspInitFailed {
phase,
cause: Box::new(redactions.sanitize_error(*cause)),
hint,
stderr,
});
}
Err(init_error) => return Err(redactions.sanitize_error(init_error)),
};
info!("LSP server initialized successfully");
Ok(SpawnedServer {
server: Self {
client,
capabilities,
position_encoding,
child: Some(child),
init_config: config,
},
lanes: ServerLanes {
notifications: NotificationInbox::new(notification_rx, publish_rx),
lifecycle: lifecycle_rx,
},
})
}
async fn connection_loss_error(
config: &ServerInitConfig,
child: &mut ServerProcess,
stderr_capture: StderrCapture,
redactions: &Redactions,
init_error: Error,
) -> Error {
let exit_status = early_exit_status(child).await;
let eof_wait = if exit_status.is_some() {
EofWait::Grace
} else {
EofWait::Skip
};
let stderr = stderr_capture.finish(eof_wait, redactions).await;
let hint = Self::init_failure_hint(config).await;
match exit_status {
Some(status) => Error::ServerExitedDuringInit {
command: config.server_config.command.server_command().clone(),
exit_code: status.code(),
hint,
stderr,
},
None => Error::LspInitFailed {
phase: InitPhase::Initialize,
cause: Box::new(redactions.sanitize_error(init_error)),
hint,
stderr,
},
}
}
async fn init_failure_hint(config: &ServerInitConfig) -> Option<InitFailureHint> {
let config = config.clone();
let hint = crate::on_blocking_pool(move || {
tsserver_pin::init_failure_hint(
config.server_config(),
config.workspace_roots(),
process_env,
)
})
.await;
hint.unwrap_or_else(|error| {
debug!("Dropped the init failure hint: {error}");
None
})
}
fn log_spawn(server_config: &LspServerConfig, redactions: &Redactions) {
info!(
"Spawning LSP server: {} ({} arg(s))",
server_config.command,
server_config.args.len()
);
debug!(
"LSP server args: {:?}",
server_config
.args
.iter()
.map(|arg| redactions.apply(arg))
.collect::<Vec<_>>()
);
}
fn build_command(
config: &LspServerConfig,
working_dir: &ChildWorkingDir,
parent_env: impl ParentEnv,
) -> Command {
let mut command = Command::new(&config.command);
command.args(&config.args).env_clear();
if let ChildWorkingDir::Fixed(dir) = working_dir {
command.current_dir(dir);
}
for key in ENV_PASSTHROUGH {
if let Some(value) = child_env_var(config, key, &parent_env) {
command.env(key, value);
}
}
#[cfg(windows)]
for key in ENV_PASSTHROUGH_WINDOWS {
if let Some(value) = child_env_var(config, key, &parent_env) {
command.env(key, value);
}
}
command
.envs(config.env.iter())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command
}
fn folding_range_capabilities() -> lsp_types::FoldingRangeClientCapabilities {
lsp_types::FoldingRangeClientCapabilities {
dynamic_registration: Some(false),
line_folding_only: Some(false),
folding_range_kind: Some(lsp_types::ClientFoldingRangeKindOptions {
value_set: Some(vec![
lsp_types::FoldingRangeKind::Comment,
lsp_types::FoldingRangeKind::Imports,
lsp_types::FoldingRangeKind::Region,
]),
}),
folding_range: Some(lsp_types::ClientFoldingRangeOptions {
collapsed_text: Some(true),
}),
..Default::default()
}
}
fn client_capabilities(
position_encodings: &PositionEncodings,
settings: Option<&LspSettings>,
) -> ClientCapabilities {
ClientCapabilities {
general: Some(GeneralClientCapabilities {
position_encodings: Some(
position_encodings
.as_slice()
.iter()
.map(|encoding| encoding.to_kind())
.collect(),
),
stale_request_support: Some(StaleRequestSupportOptions {
cancel: false,
retry_on_content_modified: CONTENT_MODIFIED_RETRY_METHODS
.iter()
.map(ToString::to_string)
.collect(),
}),
..Default::default()
}),
text_document: Some(lsp_types::TextDocumentClientCapabilities {
synchronization: Some(lsp_types::TextDocumentSyncClientCapabilities {
dynamic_registration: Some(false),
did_save: Some(true),
..Default::default()
}),
document_symbol: Some(lsp_types::DocumentSymbolClientCapabilities {
dynamic_registration: Some(false),
symbol_kind: Some(lsp_types::ClientSymbolKindOptions {
value_set: Some(advertised_symbol_kinds()),
}),
hierarchical_document_symbol_support: Some(true),
..Default::default()
}),
hover: Some(lsp_types::HoverClientCapabilities {
dynamic_registration: Some(false),
content_format: Some(vec![
lsp_types::MarkupKind::Markdown,
lsp_types::MarkupKind::PlainText,
]),
}),
definition: Some(lsp_types::DefinitionClientCapabilities {
dynamic_registration: Some(false),
link_support: Some(true),
}),
declaration: Some(lsp_types::DeclarationClientCapabilities {
dynamic_registration: Some(false),
link_support: Some(true),
}),
references: Some(lsp_types::ReferenceClientCapabilities {
dynamic_registration: Some(false),
}),
rename: Some(lsp_types::RenameClientCapabilities {
dynamic_registration: Some(false),
prepare_support: Some(true),
prepare_support_default_behavior: Some(
lsp_types::PrepareSupportDefaultBehavior::Identifier,
),
..Default::default()
}),
folding_range: Some(Self::folding_range_capabilities()),
publish_diagnostics: Some(lsp_types::PublishDiagnosticsClientCapabilities {
version_support: Some(true),
..Default::default()
}),
selection_range: Some(lsp_types::SelectionRangeClientCapabilities {
dynamic_registration: Some(false),
}),
code_action: Some(lsp_types::CodeActionClientCapabilities {
dynamic_registration: Some(false),
data_support: Some(true),
resolve_support: Some(lsp_types::ClientCodeActionResolveOptions {
properties: vec!["edit".to_string()],
}),
code_action_literal_support: Some(lsp_types::ClientCodeActionLiteralOptions {
code_action_kind: lsp_types::ClientCodeActionKindOptions {
value_set: std::iter::once(lsp_types::CodeActionKind::Empty)
.chain(
crate::bridge::CodeActionKindFilter::ALL
.map(lsp_types::CodeActionKind::from),
)
.collect(),
},
}),
..Default::default()
}),
..Default::default()
}),
workspace: Some(lsp_types::WorkspaceClientCapabilities {
workspace_folders: Some(true),
configuration: settings.map(|_| true),
..Default::default()
}),
window: Some(lsp_types::WindowClientCapabilities {
work_done_progress: Some(true),
..Default::default()
}),
experimental: Some(serde_json::json!({ "serverStatusNotification": true })),
..Default::default()
}
}
#[cfg(test)]
async fn initialize(
client: &LspClient,
config: &ServerInitConfig,
) -> Result<(ServerCapabilities, PositionEncodingKind)> {
Self::initialize_as(client, config, Some(mcpls_process_id())).await
}
async fn initialize_as(
client: &LspClient,
config: &ServerInitConfig,
process_id: Option<i32>,
) -> Result<(ServerCapabilities, PositionEncodingKind)> {
debug!("Sending initialize request");
if config.server_config().initialization_options.is_some()
&& config.server_config().settings.is_some()
{
warn!(
"server `{}` has both initialization_options and settings: servers that pull \
workspace/configuration (rust-analyzer, jdtls) may replace the options with \
the settings",
config.server_config.command
);
}
let workspace_folders: Vec<WorkspaceFolder> = config
.workspace_roots()
.canonical()
.iter()
.map(|root| workspace_folder(root))
.collect::<Result<Vec<_>>>()?;
let params = InitializeParams {
process_id,
#[allow(
deprecated,
reason = "`root_uri` is deprecated but the struct literal must still set it"
)]
root_uri: None,
initialization_options: config.server_config().initialization_options.clone(),
capabilities: Self::client_capabilities(
&config.position_encodings,
config.server_config.settings.as_ref(),
),
client_info: Some(ClientInfo {
name: "mcpls".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}),
workspace_folders_initialize_params: lsp_types::WorkspaceFoldersInitializeParams {
workspace_folders: Some(lsp_types::WorkspaceFolders::WorkspaceFolderList(
workspace_folders,
)),
},
..Default::default()
};
let result: InitializeResult = client
.request_typed::<InitializeRequest>(
params,
config.server_config.timeout_seconds.as_duration(),
)
.await
.map_err(|e| {
if is_connection_loss(&e) {
e
} else {
Error::LspInitFailed {
phase: InitPhase::Initialize,
cause: Box::new(e),
hint: None,
stderr: None,
}
}
})?;
let position_encoding = result
.capabilities
.position_encoding
.clone()
.unwrap_or(PositionEncodingKind::UTF16);
debug!(
"Server capabilities received, encoding: {:?}",
position_encoding
);
notify_handshake::<InitializedNotification>(
client,
InitPhase::Initialized,
InitializedParams {},
)
.await?;
notify_handshake::<DidChangeConfigurationNotification>(
client,
InitPhase::DidChangeConfiguration,
DidChangeConfigurationParams {
settings: config
.server_config
.settings
.as_ref()
.map_or(serde_json::Value::Null, LspSettings::to_value),
},
)
.await?;
Ok((result.capabilities, position_encoding))
}
#[must_use]
pub const fn capabilities(&self) -> &ServerCapabilities {
&self.capabilities
}
#[must_use]
pub fn position_encoding(&self) -> PositionEncodingKind {
self.position_encoding.clone()
}
#[must_use]
pub const fn client(&self) -> &LspClient {
&self.client
}
pub fn has_exited(&mut self) -> Result<bool> {
match &mut self.child {
Some(child) => Ok(child.try_wait()?.is_some()),
None => Ok(false),
}
}
pub(crate) fn is_dead(&mut self) -> Result<bool> {
if self.has_exited()? {
return Ok(true);
}
Ok(self.child.is_some() && self.client.is_message_loop_finished())
}
pub async fn shutdown(mut self) -> Result<()> {
self.terminate(SHUTDOWN_TIMEOUT, ExitGrace::WithinBudget)
.await
}
#[allow(
clippy::arithmetic_side_effects,
reason = "SHUTDOWN_TIMEOUT and CHILD_EXIT_GRACE are small constants"
)]
pub(crate) async fn terminate(&mut self, budget: Duration, grace: ExitGrace) -> Result<()> {
debug!("Shutting down LSP server");
let deadline = Instant::now() + budget;
let client = &mut self.client;
let shutdown_request_timeout = Duration::from_secs(5).min(budget);
let handshake: Result<MarkOutcome> = timeout_at(deadline, async {
client.suppress_cancels();
let _: serde_json::Value = client
.request(
ShutdownRequest::METHOD.as_str(),
(),
shutdown_request_timeout,
)
.await?;
#[cfg(unix)]
let mark = match self.child.as_mut() {
Some(child) => child.mark_escapees().await,
None => MarkOutcome::Confirmed,
};
#[cfg(windows)]
let mark = MarkOutcome::Confirmed;
if mark == MarkOutcome::Confirmed {
client.notify_typed::<ExitNotification>(()).await?;
}
Ok(mark)
})
.await
.unwrap_or(Err(Error::ShutdownTimeout));
let mark = handshake
.as_ref()
.map_or(MarkOutcome::Confirmed, |mark| *mark);
let notice = ExitNotice::of(&handshake);
let handshake = handshake.map(drop);
let handshake = match client.shutdown_until(deadline).await {
Ok(()) => handshake,
Err(e) => handshake.and(Err(e)),
};
if let Some(child) = &mut self.child {
let wait = child_exit_wait(
grace,
mark,
&handshake,
deadline.saturating_duration_since(Instant::now()),
);
match timeout(wait, child.wait()).await {
Ok(Ok(status)) => match notice {
ExitNotice::Sent => {
debug!(
?status,
"LSP server process exited after `exit` notification"
);
}
ExitNotice::NotSent => {
debug!(
?status,
"LSP server process exited without an `exit` notification"
);
}
},
Ok(Err(e)) => warn!(error = %e, "failed to wait for LSP server process exit"),
Err(_) => match notice {
ExitNotice::Sent => warn!(
waited = ?wait,
exit = ?notice,
"LSP server process did not exit within the wait after `exit` \
notification, killing it"
),
ExitNotice::NotSent => warn!(
waited = ?wait,
exit = ?notice,
"LSP server process killed without an `exit` notification"
),
},
}
let sweep_budget = deadline
.saturating_duration_since(Instant::now())
.clamp(MIN_SWEEP_BUDGET, crate::lsp::LIFELINE_SWEEP_BUDGET);
child.terminate_tree(sweep_budget).await;
}
handshake?;
info!("LSP server shut down successfully");
Ok(())
}
pub(crate) async fn start_contained(config: &ServerInitConfig) -> ServerStartOutcome {
Box::pin(contain(config, Self::spawn(config.clone()))).await
}
}
async fn contain(
config: &ServerInitConfig,
start: impl std::future::Future<Output = Result<SpawnedServer>>,
) -> ServerStartOutcome {
let server_id = config.server_config.id();
let language_id = config.server_config.language_id.clone();
let command = config.server_config.command.server_command().clone();
let started = Instant::now();
let reason = match crate::util::catch_panic(start).await {
Ok(Ok(spawned)) => {
info!(
"Successfully spawned LSP server: {} ({}) in {:?}",
server_id,
command,
started.elapsed()
);
return ServerStartOutcome::Started(Box::new(spawned));
}
Ok(Err(e)) => {
tracing::error!(
"Failed to spawn LSP server: {} ({}) after {:?}: {}",
server_id,
command,
started.elapsed(),
e
);
StartupFailure::Spawn(Arc::new(e))
}
Err(panicked) => {
tracing::error!(
"Starting LSP server {} ({}) panicked after {:?}: {}",
server_id,
command,
started.elapsed(),
panicked.message()
);
StartupFailure::InitTaskPanicked
}
};
ServerStartOutcome::Failed(ServerSpawnFailure {
server_id,
language_id,
command,
reason,
})
}
fn mcpls_process_id() -> i32 {
i32::try_from(std::process::id()).unwrap_or(i32::MAX)
}
#[cfg(unix)]
fn initialize_process_id(binding: Binding) -> Option<i32> {
match binding {
Binding::Bound => None,
Binding::Unbound => Some(mcpls_process_id()),
}
}
async fn notify_handshake<N>(client: &LspClient, phase: InitPhase, params: N::Params) -> Result<()>
where
N: lsp_types::Notification,
{
client
.notify_typed::<N>(params)
.await
.map_err(|e| Error::LspInitFailed {
phase,
cause: Box::new(e),
hint: None,
stderr: None,
})
}
const fn is_connection_loss(error: &Error) -> bool {
matches!(
error,
Error::ServerTerminated
| Error::TaskFailed {
task: BackgroundTask::LspReceiver,
..
}
)
}
async fn early_exit_status(child: &mut ServerProcess) -> Option<std::process::ExitStatus> {
timeout(EARLY_EXIT_PROBE, child.wait()).await.ok()?.ok()
}
fn spawn_error(command: ServerCommand, source: std::io::Error) -> Error {
if source.kind() == std::io::ErrorKind::NotFound {
Error::ServerNotFound {
command,
io: source,
}
} else {
Error::ServerSpawnFailed { command, source }
}
}
pub fn current_environment() -> Vec<(String, String)> {
std::env::vars_os()
.filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?)))
.collect()
}
fn workspace_folder(root: &Path) -> Result<WorkspaceFolder> {
let uri = try_path_to_uri(root).ok_or_else(|| Error::PathToUri(root.to_path_buf()))?;
Ok(WorkspaceFolder {
uri,
name: root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace")
.to_string(),
})
}
#[cfg(test)]
pub fn fake_lsp_server() -> LspServer {
fake_lsp_server_with_config(LspServerConfig::pyright())
}
#[cfg(test)]
pub fn fake_lsp_server_with_config(server_config: LspServerConfig) -> LspServer {
let transport = crate::test_lsp::inert_transport();
let client = LspClient::from_transport(server_config.clone(), transport);
LspServer {
client,
capabilities: lsp_types::ServerCapabilities::default(),
position_encoding: PositionEncodingKind::UTF8,
child: None,
init_config: crate::test_lsp::init_config_for(server_config),
}
}
#[cfg(test)]
pub fn fake_spawned_server_with_config(server_config: LspServerConfig) -> SpawnedServer {
let (_, messages) = mpsc::channel(1);
let (_, lifecycle) = mpsc::channel(1);
SpawnedServer {
server: fake_lsp_server_with_config(server_config),
lanes: ServerLanes {
notifications: NotificationInbox::new(messages, crate::lsp::PublishReader::closed()),
lifecycle,
},
}
}
#[cfg(test)]
#[cfg(unix)]
pub fn fake_lsp_server_with_dead_loop_and_live_child() -> LspServer {
let child = tokio::process::Command::new("sleep")
.arg("30")
.kill_on_drop(true)
.spawn()
.unwrap_or_else(|e| panic!("failed to spawn sleep: {e}"));
let mut server = fake_lsp_server();
server.child = Some(ServerProcess::from_unbound(child));
server
}
#[cfg(test)]
impl LspServer {
pub(crate) fn new_for_test(capabilities: ServerCapabilities) -> Self {
Self::new_for_test_with_encoding(capabilities, PositionEncodingKind::UTF16)
}
pub(crate) fn new_for_test_with_encoding(
capabilities: ServerCapabilities,
position_encoding: PositionEncodingKind,
) -> Self {
Self::new_for_test_with_client(
capabilities,
position_encoding,
LspClient::new(LspServerConfig::rust_analyzer()),
)
}
pub(crate) fn new_for_test_with_client(
capabilities: ServerCapabilities,
position_encoding: PositionEncodingKind,
client: LspClient,
) -> Self {
Self {
client,
capabilities,
position_encoding,
child: None,
init_config: crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer()),
}
}
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use std::collections::HashMap;
use super::*;
use crate::bridge::PositionEncoding;
use crate::config::{FilePattern, HostOs, LanguageId, TimeoutSecs, ToolSet};
use crate::lsp::JsonRpcError;
#[test]
fn test_child_exit_wait_follows_the_grace_policy() {
let remaining = Duration::from_secs(10);
let grace = Duration::from_secs(7);
let answered = Ok(());
let unanswered = Err(Error::ShutdownTimeout);
let wait = |policy, mark, answered: &Result<()>| {
child_exit_wait(policy, mark, answered, remaining)
};
assert_eq!(
wait(ExitGrace::WithinBudget, MarkOutcome::Confirmed, &answered),
CHILD_EXIT_GRACE
);
assert_eq!(
child_exit_wait(
ExitGrace::WithinBudget,
MarkOutcome::Confirmed,
&answered,
Duration::from_secs(1)
),
Duration::from_secs(1)
);
assert_eq!(
wait(
ExitGrace::IfAnswered(grace),
MarkOutcome::Confirmed,
&answered
),
grace
);
assert_eq!(
wait(
ExitGrace::IfAnswered(grace),
MarkOutcome::Confirmed,
&unanswered
),
Duration::ZERO
);
}
#[test]
fn test_exit_notice_is_sent_only_after_a_confirmed_handshake() {
assert_eq!(
ExitNotice::of(&Ok(MarkOutcome::Confirmed)),
ExitNotice::Sent
);
assert_eq!(
ExitNotice::of(&Err(Error::ShutdownTimeout)),
ExitNotice::NotSent
);
#[cfg(unix)]
assert_eq!(
ExitNotice::of(&Ok(MarkOutcome::Failed(
crate::lsp::process::MarkFailure::NoConfirmation
))),
ExitNotice::NotSent,
"a withheld `exit` is not a sent one"
);
}
#[test]
fn test_client_capabilities_offer_configured_encodings_in_order() {
let encodings =
PositionEncodings::new(vec![PositionEncoding::Utf32, PositionEncoding::Utf8]).unwrap();
let general = LspServer::client_capabilities(&encodings, None)
.general
.unwrap();
assert_eq!(
general.position_encodings,
Some(vec![
PositionEncodingKind::UTF32,
PositionEncodingKind::UTF8
])
);
}
#[test]
fn test_client_capabilities_advertises_work_done_progress() {
let capabilities = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None);
assert_eq!(
capabilities.window.and_then(|w| w.work_done_progress),
Some(true)
);
}
#[test]
fn test_client_capabilities_advertises_prepare_rename_support() {
let rename = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
.text_document
.and_then(|t| t.rename)
.unwrap();
assert_eq!(rename.prepare_support, Some(true));
assert_eq!(
rename.prepare_support_default_behavior,
Some(lsp_types::PrepareSupportDefaultBehavior::Identifier)
);
}
#[test]
fn test_client_capabilities_advertises_folding_range_with_columns_and_collapsed_text() {
let folding = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
.text_document
.and_then(|t| t.folding_range)
.unwrap();
assert_eq!(folding.line_folding_only, Some(false));
assert_eq!(
folding
.folding_range
.and_then(|options| options.collapsed_text),
Some(true)
);
}
#[test]
fn test_client_capabilities_advertises_selection_range() {
let capabilities = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
.text_document
.and_then(|t| t.selection_range)
.unwrap();
assert_eq!(capabilities.dynamic_registration, Some(false));
}
#[test]
fn test_client_capabilities_advertise_did_save_without_dynamic_registration() {
let synchronization = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
.text_document
.and_then(|t| t.synchronization)
.unwrap();
assert_eq!(
synchronization,
lsp_types::TextDocumentSyncClientCapabilities {
dynamic_registration: Some(false),
did_save: Some(true),
..Default::default()
}
);
}
#[test]
fn test_client_capabilities_advertises_publish_diagnostics_with_version_support_only() {
let capabilities = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
.text_document
.and_then(|t| t.publish_diagnostics)
.unwrap();
assert_eq!(
capabilities,
lsp_types::PublishDiagnosticsClientCapabilities {
version_support: Some(true),
..Default::default()
}
);
}
#[test]
fn test_client_capabilities_do_not_advertise_pull_diagnostics() {
let text_document = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
.text_document
.unwrap();
assert!(text_document.diagnostic.is_none());
}
#[test]
fn test_server_state_ready() {
assert!(ServerState::Ready.is_ready());
}
#[test]
fn test_server_state_uninitialized() {
assert!(!ServerState::Uninitialized.is_ready());
}
#[test]
fn test_server_state_initializing() {
assert!(!ServerState::Initializing.is_ready());
}
#[test]
fn test_workspace_folder_encodes_fragment_char() {
#[cfg(windows)]
let (root, expected) = (
Path::new(r"C:\home\me\dev\#work"),
"file:///C:/home/me/dev/%23work",
);
#[cfg(not(windows))]
let (root, expected) = (
Path::new("/home/me/dev/#work"),
"file:///home/me/dev/%23work",
);
let folder = workspace_folder(root).unwrap();
assert_eq!(folder.uri.as_ref(), expected);
assert_eq!(folder.name, "#work");
}
#[test]
fn test_workspace_folder_encodes_bracket_chars() {
#[cfg(windows)]
let (root, expected) = (
Path::new(r"C:\home\me\dev\[env]"),
"file:///C:/home/me/dev/%5Benv%5D",
);
#[cfg(not(windows))]
let (root, expected) = (
Path::new("/home/me/dev/[env]"),
"file:///home/me/dev/%5Benv%5D",
);
let folder = workspace_folder(root).unwrap();
assert_eq!(folder.uri.as_ref(), expected);
assert_eq!(folder.name, "[env]");
}
#[test]
fn test_workspace_folder_rejects_relative_root() {
let err = workspace_folder(Path::new("relative/root")).unwrap_err();
assert_matches!(err, Error::PathToUri(_), "got {err:?}");
}
#[test]
fn test_server_state_shutting_down() {
assert!(!ServerState::ShuttingDown.is_ready());
}
#[test]
fn test_server_state_shutdown() {
assert!(!ServerState::Shutdown.is_ready());
}
#[test]
fn test_server_state_equality() {
assert_eq!(ServerState::Ready, ServerState::Ready);
assert_ne!(ServerState::Ready, ServerState::Uninitialized);
assert_eq!(ServerState::Shutdown, ServerState::Shutdown);
}
#[test]
fn test_server_state_clone() {
let state = ServerState::Ready;
let cloned = state;
assert_eq!(state, cloned);
}
#[test]
fn test_server_state_debug() {
let state = ServerState::Ready;
let debug_str = format!("{state:?}");
assert!(debug_str.contains("Ready"));
}
#[test]
fn test_server_init_config_clone() {
let config = ServerInitConfig::new(
{
let mut c = LspServerConfig::rust_analyzer();
c.initialization_options = Some(serde_json::json!({"key": "value"}));
c
},
WorkspaceRoots::for_test(vec![PathBuf::from("/tmp/workspace")], vec![]),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
#[allow(clippy::redundant_clone, reason = "the test exercises `Clone` itself")]
let cloned = config.clone();
assert_eq!(cloned.server_config().language_id, "rust");
assert_eq!(cloned.workspace_roots().canonical().len(), 1);
}
#[test]
fn test_server_init_config_debug() {
let config = ServerInitConfig::new(
LspServerConfig::pyright(),
WorkspaceRoots::default(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
let debug_str = format!("{config:?}");
assert!(debug_str.contains("python"));
assert!(debug_str.contains("pyright"));
}
#[test]
fn test_server_init_config_with_options() {
let init_opts = serde_json::json!({
"settings": {
"python": {
"analysis": {
"typeCheckingMode": "strict"
}
}
}
});
let mut env = crate::config::ServerEnv::default();
env.insert(
crate::config::EnvKey::from_static("PYTHONPATH"),
crate::config::EnvValue::from_static("/usr/lib"),
)
.unwrap();
let config = ServerInitConfig::new(
LspServerConfig {
language_id: LanguageId::from_static("python"),
command: ServerCommand::from_static("pyright-langserver").into(),
args: crate::config::ServerArgs::try_from(vec!["--stdio".to_string()]).unwrap(),
env,
file_patterns: vec![FilePattern::from_static("**/*.py")],
initialization_options: Some(init_opts),
settings: None,
timeout_seconds: TimeoutSecs::new(10).unwrap(),
request_timeout_seconds: TimeoutSecs::new(10).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
},
WorkspaceRoots::for_test(vec![PathBuf::from("/workspace")], vec![]),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
assert!(config.server_config().initialization_options.is_some());
assert_eq!(config.workspace_roots().canonical().len(), 1);
}
#[test]
fn test_server_init_config_empty_workspace() {
let config = ServerInitConfig::new(
LspServerConfig::typescript(),
WorkspaceRoots::default(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
assert_eq!(config.workspace_roots().canonical().len(), 0);
}
#[test]
fn test_server_init_config_multiple_workspaces() {
let config = ServerInitConfig::new(
LspServerConfig::rust_analyzer(),
WorkspaceRoots::for_test(
vec![
PathBuf::from("/workspace1"),
PathBuf::from("/workspace2"),
PathBuf::from("/workspace3"),
],
vec![],
),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
assert_eq!(config.workspace_roots().canonical().len(), 3);
}
#[cfg(unix)]
#[tokio::test]
async fn test_has_exited_reflects_child_process_state() {
use lsp_types::ServerCapabilities;
let mut mock_child = tokio::process::Command::new("sleep")
.arg("2")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let mock_stdin = mock_child.stdin.take().unwrap();
let mock_stdout = mock_child.stdout.take().unwrap();
let transport = LspTransport::new(mock_stdin, mock_stdout);
let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
let mut server = LspServer {
client,
capabilities: ServerCapabilities::default(),
position_encoding: PositionEncodingKind::UTF8,
child: Some(ServerProcess::from_unbound(mock_child)),
init_config: crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer()),
};
assert!(
!server.has_exited().unwrap(),
"freshly spawned `sleep 2` should still be running"
);
server.child.as_mut().unwrap().kill().await.unwrap();
assert!(
server.has_exited().unwrap(),
"killed child must report as exited"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_is_dead_when_message_loop_finished_but_child_alive() {
let mut server = fake_lsp_server_with_dead_loop_and_live_child();
tokio::time::timeout(Duration::from_secs(2), async {
while !server.client.is_message_loop_finished() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(!server.has_exited().unwrap());
assert!(server.is_dead().unwrap());
}
#[tokio::test(start_paused = true)]
async fn test_shutdown_reports_handshake_timeout_within_deadline() {
let (client, _fake_server) = crate::test_lsp::fake_lsp_client();
let probe = client.clone();
let server = LspServer {
client,
capabilities: lsp_types::ServerCapabilities::default(),
position_encoding: PositionEncodingKind::UTF8,
child: None,
init_config: crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer()),
};
let started = Instant::now();
let result = server.shutdown().await;
assert_matches!(result, Err(Error::Timeout(_)), "got {result:?}");
assert!(started.elapsed() <= SHUTDOWN_TIMEOUT);
assert_matches!(probe.state().await, ServerState::Shutdown);
}
#[tokio::test]
async fn test_is_dead_false_for_childless_fixture() {
let mut server = fake_lsp_server();
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!server.is_dead().unwrap());
}
#[tokio::test]
async fn test_lsp_server_getters() {
use lsp_types::ServerCapabilities;
let transport = crate::test_lsp::inert_transport();
let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
let server = LspServer {
client,
capabilities: ServerCapabilities::default(),
position_encoding: PositionEncodingKind::UTF8,
child: None,
init_config: crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer()),
};
assert_eq!(server.position_encoding(), PositionEncodingKind::UTF8);
assert!(server.capabilities().text_document_sync.is_none());
let debug_str = format!("{server:?}");
assert!(debug_str.contains("LspServer"));
assert!(debug_str.contains("<process>"));
}
#[test]
fn test_spawn_error_classifies_not_found() {
use std::io::{Error as IoError, ErrorKind};
let missing = spawn_error(
ServerCommand::from_static("x"),
IoError::from(ErrorKind::NotFound),
);
assert_matches!(missing, Error::ServerNotFound { .. });
let denied = spawn_error(
ServerCommand::from_static("x"),
IoError::from(ErrorKind::PermissionDenied),
);
assert_matches!(denied, Error::ServerSpawnFailed { .. });
}
#[tokio::test]
async fn test_spawn_nonexistent_command_is_server_not_found() {
let mut server_config = LspServerConfig::rust_analyzer();
server_config.command = ServerCommand::from_static("nonexistent-lsp-cmd-xyz").into();
let config = ServerInitConfig::new(
server_config,
WorkspaceRoots::default(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
let err = LspServer::spawn(config).await.unwrap_err();
assert_matches!(err, Error::ServerNotFound { .. }, "got {err:?}");
}
#[tokio::test]
async fn test_is_connection_loss_excludes_server_replies() {
let join_error = tokio::spawn(async { panic!("receiver died") })
.await
.unwrap_err();
assert!(is_connection_loss(&Error::ServerTerminated));
assert!(is_connection_loss(&Error::TaskFailed {
task: BackgroundTask::LspReceiver,
source: join_error,
}));
assert!(!is_connection_loss(&Error::StdioCapture(
StdioStream::Stdin
)));
assert!(!is_connection_loss(&Error::LspServerError(JsonRpcError {
code: -32603,
message: "bad".to_string(),
data: None,
})));
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_keeps_servers_own_initialize_error_when_it_exits_afterwards() {
let dir = tempfile::TempDir::new().unwrap();
let config = crate::test_lsp::sh_script_init_config(
dir.path(),
&crate::test_lsp::with_read_preamble(
r#"body='{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"rejected by server"}}'
printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
"#,
),
);
let err = LspServer::spawn(config).await.unwrap_err();
assert_matches!(
&err,
Error::LspInitFailed { phase: InitPhase::Initialize, cause, .. }
if matches!(**cause, Error::LspServerError(JsonRpcError { code: -32603, .. })),
"got {err:?}"
);
assert!(err.to_string().contains("rejected by server"), "{err}");
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_connection_loss_with_live_child_is_lsp_init_failed() {
let dir = tempfile::TempDir::new().unwrap();
let config = crate::test_lsp::sh_script_init_config(dir.path(), "exec 1>&-\nsleep 5\n");
let err = LspServer::spawn(config).await.unwrap_err();
assert_matches!(
&err,
Error::LspInitFailed {
phase: InitPhase::Initialize,
..
},
"got {err:?}"
);
assert!(
err.to_string().contains("Initialize request failed"),
"{err}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_exiting_before_initialize_reply_is_server_exited_during_init() {
let dir = tempfile::TempDir::new().unwrap();
let config = crate::test_lsp::sh_script_init_config(dir.path(), "exit 1\n");
let err = LspServer::spawn(config).await.unwrap_err();
assert_matches!(
err,
Error::ServerExitedDuringInit {
exit_code: Some(1),
..
},
"got {err:?}"
);
}
#[cfg(unix)]
#[test]
fn test_unbound_server_gets_the_real_process_id() {
assert_eq!(
initialize_process_id(Binding::Unbound),
Some(i32::try_from(std::process::id()).unwrap())
);
}
#[cfg(unix)]
#[test]
fn test_bound_server_gets_no_process_id() {
assert_eq!(initialize_process_id(Binding::Bound), None);
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_sends_null_process_id_while_a_lifeline_is_bound() {
let dir = tempfile::TempDir::new().unwrap();
let dump = dir.path().join("request");
let script = format!(
"read -r header\nread -r _\nhead -c \"$(printf %s \"$header\" | tr -dc 0-9)\" > '{}'\nexit 1\n",
dump.display()
);
let config = crate::test_lsp::sh_script_init_config(dir.path(), &script);
LspServer::spawn(config).await.unwrap_err();
let request = std::fs::read_to_string(&dump).unwrap();
assert!(request.contains(r#""processId":null"#), "got {request}");
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_exit_during_init_carries_stderr() {
let dir = tempfile::TempDir::new().unwrap();
let config = crate::test_lsp::sh_script_init_config(
dir.path(),
"echo 'fatal: bad toolchain' >&2\nexit 1\n",
);
let err = LspServer::spawn(config).await.unwrap_err();
let Error::ServerExitedDuringInit {
stderr: Some(stderr),
..
} = &err
else {
panic!("got {err:?}");
};
assert_eq!(stderr.head(), "fatal: bad toolchain");
assert!(err.to_string().contains("stderr: fatal: bad toolchain"));
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_rejection_keeps_stderr_written_just_before_exit() {
let dir = tempfile::TempDir::new().unwrap();
let config = crate::test_lsp::sh_script_init_config(
dir.path(),
&crate::test_lsp::with_read_preamble(
r#"body='{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"rejected by server"}}'
printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
# Shell-only pause: an external `sleep` can take longer than the 100 ms EOF grace just to start on a loaded macOS runner.
i=0; while [ $i -lt 300 ]; do i=$((i+1)); done
echo 'fatal: bad toolchain' >&2
"#,
),
);
let err = LspServer::spawn(config).await.unwrap_err();
let Error::LspInitFailed {
stderr: Some(stderr),
..
} = &err
else {
panic!("got {err:?}");
};
assert_eq!(stderr.head(), "fatal: bad toolchain");
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_initialize_timeout_carries_stderr() {
let dir = tempfile::TempDir::new().unwrap();
let mut config = crate::test_lsp::sh_script_init_config(
dir.path(),
"echo 'indexing forever' >&2\nsleep 5\n",
);
config.server_config.timeout_seconds = TimeoutSecs::new(1).unwrap();
let err = LspServer::spawn(config).await.unwrap_err();
let Error::LspInitFailed {
stderr: Some(stderr),
..
} = &err
else {
panic!("got {err:?}");
};
assert_eq!(stderr.head(), "indexing forever");
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_hides_another_servers_secret_from_stderr() {
let dir = tempfile::TempDir::new().unwrap();
let mut config = crate::test_lsp::sh_script_init_config(
dir.path(),
"echo \"seen=$OTHER_VALUE own=$API_TOKEN\" >&2\nexit 1\n",
);
config
.server_config
.env
.insert(
crate::config::EnvKey::from_static("OTHER_VALUE"),
crate::config::EnvValue::from_static("bravo-secret-222"),
)
.unwrap();
config
.server_config
.env
.insert(
crate::config::EnvKey::from_static("API_TOKEN"),
crate::config::EnvValue::from_static("s3cr3t-value"),
)
.unwrap();
config.redactions = std::sync::Arc::new(Redactions::new([(
"B_TOKEN".to_owned(),
"bravo-secret-222".to_owned(),
)]));
let err = LspServer::spawn(config).await.unwrap_err();
let text = err.to_string();
assert!(text.contains("seen=[redacted:B_TOKEN]"), "{text}");
assert!(text.contains("own=[redacted:API_TOKEN]"), "{text}");
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_stderr_redacts_configured_env_values() {
let dir = tempfile::TempDir::new().unwrap();
let mut config = crate::test_lsp::sh_script_init_config(
dir.path(),
"echo \"token=$API_TOKEN toolchain=$RUSTUP_TOOLCHAIN\" >&2\nexit 1\n",
);
config
.server_config
.env
.insert(
crate::config::EnvKey::from_static("RUSTUP_TOOLCHAIN"),
crate::config::EnvValue::from_static("nightly-2024-01-01"),
)
.unwrap();
config
.server_config
.env
.insert(
crate::config::EnvKey::from_static("API_TOKEN"),
crate::config::EnvValue::from_static("s3cr3t-value"),
)
.unwrap();
let err = LspServer::spawn(config).await.unwrap_err();
let text = err.to_string();
assert!(text.contains("token=[redacted:API_TOKEN]"), "{text}");
assert!(text.contains("toolchain=nightly-2024-01-01"), "{text}");
assert!(!text.contains("s3cr3t-value"), "{text}");
}
#[test]
fn test_log_spawn_hides_argument_values() {
use tracing_subscriber::prelude::*;
use crate::test_lsp::CapturedLogs;
let mut config = LspServerConfig::rust_analyzer();
config.args = crate::config::ServerArgs::new(["--api-key=SuperSecretArg456"]).unwrap();
let redactions = Redactions::for_server(&config, std::iter::empty());
let logs = CapturedLogs::default();
let info_only = tracing_subscriber::registry()
.with(tracing_subscriber::filter::LevelFilter::INFO)
.with(logs.clone());
tracing::subscriber::with_default(info_only, || {
LspServer::log_spawn(&config, &redactions);
});
let output = logs.messages().join("\n");
assert!(output.contains("(1 arg(s))"), "{output}");
assert!(!output.contains("SuperSecretArg456"), "{output}");
}
#[tokio::test]
async fn contain_attributes_a_panic_to_its_own_server() {
let config = crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer());
let outcome = contain(&config, async { panic!("start boom") }).await;
assert_matches!(
outcome,
ServerStartOutcome::Failed(failure)
if failure.server_id == config.server_config.id()
&& matches!(failure.reason, StartupFailure::InitTaskPanicked)
);
}
#[tokio::test]
async fn contain_reports_a_spawn_error_as_that_servers_failure() {
let config = crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer());
let outcome = contain(&config, async { Err(Error::ServerTerminated) }).await;
assert_matches!(
outcome,
ServerStartOutcome::Failed(failure) if matches!(failure.reason, StartupFailure::Spawn(_))
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_redacts_secrets_in_server_log_and_show_messages() {
let dir = tempfile::TempDir::new().unwrap();
let script = r#"body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}'
printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
body="{\"jsonrpc\":\"2.0\",\"method\":\"window/logMessage\",\"params\":{\"type\":3,\"message\":\"env=$API_TOKEN arg=$1\"}}"
printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
body="{\"jsonrpc\":\"2.0\",\"method\":\"window/showMessage\",\"params\":{\"type\":3,\"message\":\"env=$API_TOKEN\"}}"
printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
sleep 5
"#;
let mut config = crate::test_lsp::sh_script_init_config(
dir.path(),
&crate::test_lsp::with_read_preamble(script),
);
config.server_config.args = crate::config::ServerArgs::new(
config
.server_config
.args
.iter()
.cloned()
.chain(["--api-key=SuperSecretArg456".to_string()]),
)
.unwrap();
config
.server_config
.env
.insert(
crate::config::EnvKey::from_static("API_TOKEN"),
crate::config::EnvValue::from_static("SuperSecretValue123"),
)
.unwrap();
let SpawnedServer {
server: _server,
lanes,
} = LspServer::spawn(config).await.unwrap();
let mut rx = lanes.notifications.messages;
let mut texts = Vec::new();
for _ in 0..2 {
let notification = tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.unwrap()
.unwrap();
texts.push(format!("{notification:?}"));
}
let joined = texts.join("\n");
assert!(!joined.contains("SuperSecretValue123"), "{joined}");
assert!(!joined.contains("SuperSecretArg456"), "{joined}");
assert!(joined.contains("[redacted:API_TOKEN]"), "{joined}");
assert!(joined.contains("[redacted:api-key]"), "{joined}");
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_redacts_secrets_in_initialize_decode_error() {
let dir = tempfile::TempDir::new().unwrap();
let script = r#"body='{"jsonrpc":"2.0","id":1,"result":{"capabilities":"SuperSecretValue123"}}'
printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
sleep 5
"#;
let mut config = crate::test_lsp::sh_script_init_config(
dir.path(),
&crate::test_lsp::with_read_preamble(script),
);
config
.server_config
.env
.insert(
crate::config::EnvKey::from_static("API_TOKEN"),
crate::config::EnvValue::from_static("SuperSecretValue123"),
)
.unwrap();
let err = LspServer::spawn(config).await.unwrap_err();
let text = err.to_string();
assert!(!text.contains("SuperSecretValue123"), "{text}");
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_redacts_secrets_in_initialize_error_message() {
let dir = tempfile::TempDir::new().unwrap();
let script = r#"body="{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32603,\"message\":\"bad token $API_TOKEN\"}}"
printf 'Content-Length: %d\r\n\r\n%s' ${#body} "$body"
sleep 5
"#;
let mut config = crate::test_lsp::sh_script_init_config(
dir.path(),
&crate::test_lsp::with_read_preamble(script),
);
config
.server_config
.env
.insert(
crate::config::EnvKey::from_static("API_TOKEN"),
crate::config::EnvValue::from_static("SuperSecretValue123"),
)
.unwrap();
let err = LspServer::spawn(config).await.unwrap_err();
let text = err.to_string();
assert!(text.contains("[redacted:API_TOKEN]"), "{text}");
assert!(!text.contains("SuperSecretValue123"), "{text}");
}
#[cfg(unix)]
#[tokio::test]
async fn test_spawn_keeps_draining_stderr_after_initialize() {
let dir = tempfile::TempDir::new().unwrap();
let marker = dir.path().join("flooded");
let script = format!(
r#"head -c 200000 /dev/zero | tr '\0' 'e' >&2
body='{{"jsonrpc":"2.0","id":1,"result":{{"capabilities":{{}}}}}}'
printf 'Content-Length: %d\r\n\r\n%s' ${{#body}} "$body"
sleep 0.3
head -c 200000 /dev/zero | tr '\0' 'e' >&2 && echo done > '{}'
sleep 5
"#,
marker.display()
);
let config = crate::test_lsp::sh_script_init_config(
dir.path(),
&crate::test_lsp::with_read_preamble(&script),
);
let mut server = LspServer::spawn(config).await.unwrap().server;
let flooded = tokio::time::timeout(Duration::from_secs(5), async {
while !marker.exists() {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await;
assert!(
flooded.is_ok(),
"server's post-initialize stderr flood did not complete"
);
assert!(!server.has_exited().unwrap());
}
mod initialize_wire {
use tempfile::TempDir;
use tokio::io::BufReader;
use super::*;
use crate::test_lsp::{
fake_lsp_client, read_framed_message, write_response as write_success_response,
};
#[tokio::test]
async fn test_initialize_sends_configured_position_encodings() {
let (client, mut server) = fake_lsp_client();
let config = ServerInitConfig::new(
LspServerConfig::rust_analyzer(),
WorkspaceRoots::default(),
PositionEncodings::new(vec![PositionEncoding::Utf32, PositionEncoding::Utf8])
.unwrap(),
std::sync::Arc::default(),
);
let init_task =
tokio::spawn(async move { LspServer::initialize(&client, &config).await });
let mut reader = BufReader::new(&mut server.write_stdout);
let request = read_framed_message(&mut reader).await;
assert_eq!(request["method"], "initialize");
assert_eq!(
request["params"]["capabilities"]["general"]["positionEncodings"],
serde_json::json!(["utf-32", "utf-8"]),
"initialize request must carry the configured encoding order, not the \
hardcoded [UTF8, UTF16] default"
);
write_success_response(
&mut server.read_half_stdin,
&request["id"].clone(),
serde_json::json!({ "capabilities": {} }),
)
.await;
init_task.await.unwrap().unwrap();
}
struct Handshake {
initialize: serde_json::Value,
initialized: serde_json::Value,
configuration: serde_json::Value,
}
async fn handshake_with_settings(settings_json: Option<&str>) -> Handshake {
let (client, mut server) = fake_lsp_client();
let mut server_config = LspServerConfig::rust_analyzer();
server_config.settings = settings_json.map(|json| serde_json::from_str(json).unwrap());
let config = crate::test_lsp::init_config_for(server_config);
let init_task =
tokio::spawn(async move { LspServer::initialize(&client, &config).await });
let mut reader = BufReader::new(&mut server.write_stdout);
let initialize = read_framed_message(&mut reader).await;
assert_eq!(initialize["method"], "initialize");
write_success_response(
&mut server.read_half_stdin,
&initialize["id"].clone(),
serde_json::json!({ "capabilities": {} }),
)
.await;
let initialized = read_framed_message(&mut reader).await;
let configuration = read_framed_message(&mut reader).await;
init_task.await.unwrap().unwrap();
Handshake {
initialize,
initialized,
configuration,
}
}
#[tokio::test]
async fn test_initialize_pushes_null_settings_right_after_initialized() {
let wire = handshake_with_settings(None).await;
assert_eq!(wire.initialized["method"], "initialized");
assert_eq!(
wire.configuration["method"],
"workspace/didChangeConfiguration"
);
assert_eq!(
wire.configuration["params"],
serde_json::json!({ "settings": null })
);
}
#[tokio::test]
async fn test_initialize_pushes_configured_settings_and_advertises_configuration() {
let wire =
handshake_with_settings(Some(r#"{"python.analysis.typeCheckingMode": "strict"}"#))
.await;
assert_eq!(
wire.initialize["params"]["capabilities"]["workspace"]["configuration"],
serde_json::json!(true)
);
assert_eq!(
wire.configuration["params"],
serde_json::json!({
"settings": { "python": { "analysis": { "typeCheckingMode": "strict" } } }
})
);
}
#[tokio::test]
async fn test_initialize_pushes_gopls_flat_and_yaml_url_keys_on_the_wire() {
let wire = handshake_with_settings(Some(
r#"{"gopls":{"ui.semanticTokens":true},
"yaml.schemas":{"https://json.schemastore.org/x.json":"*.yml"}}"#,
))
.await;
let settings =
serde_json::to_string(&wire.configuration["params"]["settings"]).unwrap();
assert!(
settings.contains(r#""gopls":{"ui.semanticTokens":true}"#),
"{settings}"
);
assert!(
settings.contains(
r#""yaml":{"schemas":{"https://json.schemastore.org/x.json":"*.yml"}}"#
),
"{settings}"
);
}
#[test]
fn test_workspace_configuration_not_advertised_without_settings() {
let workspace = LspServer::client_capabilities(&PositionEncodings::DEFAULT, None)
.workspace
.unwrap();
assert_eq!(workspace.configuration, None);
}
#[tokio::test]
async fn test_initialize_advertises_stale_request_support() {
let (client, mut server) = fake_lsp_client();
let config = ServerInitConfig::new(
LspServerConfig::rust_analyzer(),
WorkspaceRoots::default(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
let init_task =
tokio::spawn(async move { LspServer::initialize(&client, &config).await });
let mut reader = BufReader::new(&mut server.write_stdout);
let request = read_framed_message(&mut reader).await;
let params: InitializeParams =
serde_json::from_value(request["params"].clone()).unwrap();
let stale_request_support = params
.capabilities
.general
.unwrap()
.stale_request_support
.unwrap();
assert_eq!(request["method"], "initialize");
assert!(
!stale_request_support.cancel,
"mcpls does not implement active in-flight request cancellation"
);
assert_eq!(
stale_request_support.retry_on_content_modified,
CONTENT_MODIFIED_RETRY_METHODS
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
"the wire-advertised capability must match the methods LspClient::request \
actually retries -32801 for, not drift from it"
);
write_success_response(
&mut server.read_half_stdin,
&request["id"].clone(),
serde_json::json!({ "capabilities": {} }),
)
.await;
init_task.await.unwrap().unwrap();
}
#[tokio::test]
async fn test_initialize_advertises_server_status_notification_support() {
let (client, mut server) = fake_lsp_client();
let config = ServerInitConfig::new(
LspServerConfig::rust_analyzer(),
WorkspaceRoots::default(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
let init_task =
tokio::spawn(async move { LspServer::initialize(&client, &config).await });
let mut reader = BufReader::new(&mut server.write_stdout);
let request = read_framed_message(&mut reader).await;
assert_eq!(request["method"], "initialize");
assert_eq!(
request["params"]["capabilities"]["experimental"]["serverStatusNotification"],
serde_json::json!(true),
"without this, rust-analyzer never emits experimental/serverStatus and the \
indexing-readiness gate in Translator::wait_for_indexing_ready is a \
permanent no-op"
);
write_success_response(
&mut server.read_half_stdin,
&request["id"].clone(),
serde_json::json!({ "capabilities": {} }),
)
.await;
init_task.await.unwrap().unwrap();
}
#[tokio::test]
async fn test_initialize_advertises_hierarchical_document_symbols() {
let (client, mut server) = fake_lsp_client();
let config = ServerInitConfig::new(
LspServerConfig::rust_analyzer(),
WorkspaceRoots::default(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
let init_task =
tokio::spawn(async move { LspServer::initialize(&client, &config).await });
let mut reader = BufReader::new(&mut server.write_stdout);
let request = read_framed_message(&mut reader).await;
let params: InitializeParams =
serde_json::from_value(request["params"].clone()).unwrap();
let document_symbol = params
.capabilities
.text_document
.unwrap()
.document_symbol
.unwrap();
assert_eq!(request["method"], "initialize");
assert_eq!(document_symbol.dynamic_registration, Some(false));
assert_eq!(
document_symbol.hierarchical_document_symbol_support,
Some(true)
);
assert_eq!(
document_symbol.symbol_kind.unwrap().value_set,
Some(advertised_symbol_kinds())
);
write_success_response(
&mut server.read_half_stdin,
&request["id"].clone(),
serde_json::json!({ "capabilities": {} }),
)
.await;
init_task.await.unwrap().unwrap();
}
#[tokio::test]
async fn test_initialize_accepts_resolved_dot_workspace_root() {
let temp_dir = TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let workspace_roots =
crate::bridge::WorkspaceRoots::from_paths_with(&[PathBuf::from(".")], || {
Ok(crate::bridge::ProcessCwd::new(base.clone(), None))
})
.unwrap()
.canonical()
.to_vec();
assert_eq!(workspace_roots, vec![base.clone()]);
let (client, mut server) = fake_lsp_client();
let config = ServerInitConfig::new(
LspServerConfig::rust_analyzer(),
WorkspaceRoots::for_test(workspace_roots, vec![]),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
let init_task =
tokio::spawn(async move { LspServer::initialize(&client, &config).await });
let mut reader = BufReader::new(&mut server.write_stdout);
let request = read_framed_message(&mut reader).await;
let expected_uri = try_path_to_uri(&base).unwrap();
assert_eq!(
request["params"]["workspaceFolders"][0]["uri"],
expected_uri.as_ref()
);
write_success_response(
&mut server.read_half_stdin,
&request["id"].clone(),
serde_json::json!({ "capabilities": {} }),
)
.await;
init_task.await.unwrap().unwrap();
}
}
#[test]
fn child_env_var_compares_names_as_the_host_does() {
let parent = |_: &str| Some(std::ffi::OsString::from("/parent"));
let on = |host| {
let mut config = bare_server_config(HashMap::new());
config.env = crate::config::ServerEnv::from_entries(
[(
crate::config::EnvKey::from_static("Path"),
crate::config::EnvValue::from_static("/over"),
)],
host,
)
.unwrap();
child_env_var(&config, "PATH", parent)
};
assert_eq!(on(HostOs::Windows), Some(std::ffi::OsString::from("/over")));
assert_eq!(on(HostOs::Other), Some(std::ffi::OsString::from("/parent")));
}
fn bare_server_config(
env: HashMap<crate::config::EnvKey, crate::config::EnvValue>,
) -> LspServerConfig {
LspServerConfig {
language_id: LanguageId::from_static("test"),
command: ServerCommand::from_static("irrelevant-for-build-command").into(),
args: crate::config::ServerArgs::default(),
env: crate::config::ServerEnv::from_entries(env, HostOs::CURRENT).unwrap(),
file_patterns: vec![],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(5).unwrap(),
request_timeout_seconds: TimeoutSecs::new(5).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
}
}
fn effective_envs(command: &Command) -> HashMap<String, String> {
command
.as_std()
.get_envs()
.filter_map(|(k, v)| {
v.map(|v| {
(
k.to_string_lossy().into_owned(),
v.to_string_lossy().into_owned(),
)
})
})
.collect()
}
#[test]
fn test_build_command_excludes_non_allowlisted_parent_env_vars() {
let config = bare_server_config(HashMap::new());
let command =
LspServer::build_command(&config, &ChildWorkingDir::Inherit, |key| match key {
"PATH" => Some("/parent/bin".into()),
"MCPLS_TEST_LEAK_CANARY" => Some("should-not-reach-child".into()),
_ => None,
});
let envs = effective_envs(&command);
assert!(
!envs.contains_key("MCPLS_TEST_LEAK_CANARY"),
"non-allowlisted parent env var leaked into child command: {envs:?}"
);
#[cfg(unix)]
assert!(
format!("{:?}", command.as_std()).starts_with("env -i "),
"build_command must call .env_clear() so the child doesn't inherit the full parent environment"
);
}
#[test]
fn test_build_command_passes_through_allowlisted_env_vars() {
let config = bare_server_config(HashMap::new());
let command = LspServer::build_command(&config, &ChildWorkingDir::Inherit, |key| {
(key == "PATH").then(|| "/parent/bin".into())
});
let envs = effective_envs(&command);
assert_eq!(envs.get("PATH"), Some(&"/parent/bin".to_string()));
}
#[test]
fn test_build_command_inherits_working_dir_unless_fixed() {
let config = bare_server_config(HashMap::new());
let inherited = LspServer::build_command(&config, &ChildWorkingDir::Inherit, |_| None);
assert_eq!(inherited.as_std().get_current_dir(), None);
let dir = PathBuf::from("/fixed/dir");
let fixed =
LspServer::build_command(&config, &ChildWorkingDir::Fixed(dir.clone()), |_| None);
assert_eq!(fixed.as_std().get_current_dir(), Some(dir.as_path()));
}
#[test]
fn test_server_init_config_defaults_to_inherited_working_dir() {
let config = ServerInitConfig::new(
LspServerConfig::rust_analyzer(),
WorkspaceRoots::default(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
assert_eq!(config.child_working_dir(), &ChildWorkingDir::Inherit);
let fixed = config.with_child_working_dir(ChildWorkingDir::Fixed(PathBuf::from("/x")));
assert_eq!(
fixed.child_working_dir(),
&ChildWorkingDir::Fixed(PathBuf::from("/x"))
);
}
#[tokio::test]
async fn test_for_respawn_keeps_the_config_and_pinned_tsserver() {
let mut server_config = LspServerConfig::typescript();
server_config.initialization_options =
Some(serde_json::json!({"tsserver": {"path": "/pin/tsserver.js"}}));
let config = ServerInitConfig::new(
server_config,
WorkspaceRoots::default(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
assert_eq!(
config.pinned_tsserver(),
Some(PathBuf::from("/pin/tsserver.js"))
);
let respawn = config.for_respawn().await.unwrap();
assert_eq!(
respawn.server_config().initialization_options,
config.server_config().initialization_options
);
assert_eq!(respawn.pinned_tsserver(), config.pinned_tsserver());
}
#[tokio::test]
async fn test_for_respawn_replans_an_auto_pinned_server_off_the_runtime() {
let mut server_config = LspServerConfig::typescript();
server_config.initialization_options =
Some(serde_json::json!({"tsserver": {"path": "/mcpls-test/stale/tsserver.js"}}));
let config = ServerInitConfig::new(
server_config,
WorkspaceRoots::default(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
)
.with_auto_pin(PathBuf::from("/mcpls-test/stale/tsserver.js"));
let respawn = config.for_respawn().await.unwrap();
assert_eq!(respawn.server_config().id(), config.server_config().id());
assert_ne!(respawn.pinned_tsserver(), config.pinned_tsserver());
}
fn untrusted_init_naming_a_tool_argument() -> (tempfile::TempDir, PathBuf, ServerInitConfig) {
let dir = tempfile::TempDir::new().unwrap();
let workspace = dunce::canonicalize(dir.path()).unwrap();
let tool = workspace.join("tool");
std::fs::write(&tool, "").unwrap();
let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&workspace)).unwrap();
let mut server_config = LspServerConfig::rust_analyzer();
server_config.command = ServerCommand::from_static("mcpls-test-no-such-server").into();
server_config.args =
crate::config::ServerArgs::new([tool.to_string_lossy().into_owned()]).unwrap();
let command = server_config.command.server_command().clone();
let init = ServerInitConfig::new(
server_config,
roots.clone(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
)
.with_trust(SpawnTrust::Untrusted(UntrustedVetting::new(roots, command)));
(dir, tool, init)
}
fn become_executable(tool: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(tool, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(windows)]
std::fs::write(tool.with_added_extension("exe"), "").unwrap();
}
fn refused_argument(error: &Error) -> bool {
matches!(
error,
Error::ServerFailedToStart(failure) if matches!(
failure.reason,
StartupFailure::RefusedUntrustedWorkspace(
UntrustedRefusal::WorkspaceExecutableArgument { index: 0, .. }
)
)
)
}
#[tokio::test]
async fn test_spawn_refuses_an_argument_that_became_executable_after_planning() {
let (_dir, tool, init) = untrusted_init_naming_a_tool_argument();
init.vet_arguments().unwrap();
become_executable(&tool);
let Err(error) = LspServer::spawn(init).await else {
panic!("the spawn must be refused");
};
assert!(refused_argument(&error), "{error:?}");
}
#[tokio::test]
async fn test_for_respawn_refuses_an_argument_that_became_executable_after_planning() {
let (_dir, tool, init) = untrusted_init_naming_a_tool_argument();
let unchanged = init.for_respawn().await.unwrap();
assert_eq!(unchanged.server_config().args, init.server_config().args);
become_executable(&tool);
let Err(error) = init.for_respawn().await else {
panic!("the respawn plan must be refused");
};
assert!(refused_argument(&error), "{error:?}");
}
#[tokio::test]
async fn test_a_trusted_config_is_never_vetted_at_spawn_time() {
let (_dir, tool, init) = untrusted_init_naming_a_tool_argument();
let trusted = ServerInitConfig::new(
init.server_config().clone(),
init.workspace_roots().clone(),
PositionEncodings::DEFAULT,
std::sync::Arc::default(),
);
become_executable(&tool);
trusted.vet_arguments().unwrap();
trusted.vet_arguments_off_runtime().await.unwrap();
trusted.for_respawn().await.unwrap();
}
#[tokio::test(start_paused = true)]
async fn test_terminate_sends_exit_after_a_shutdown_answer_that_is_not_null() {
use tokio::io::BufReader;
use crate::test_lsp::{fake_lsp_client, read_framed_message, write_response};
let (client, mut fake) = fake_lsp_client();
let mut server = LspServer {
client,
capabilities: lsp_types::ServerCapabilities::default(),
position_encoding: PositionEncodingKind::UTF8,
child: None,
init_config: crate::test_lsp::init_config_for(LspServerConfig::rust_analyzer()),
};
let terminating = tokio::spawn(async move {
server
.terminate(SHUTDOWN_TIMEOUT, ExitGrace::WithinBudget)
.await
});
let mut wire = BufReader::new(&mut fake.write_stdout);
let request = read_framed_message(&mut wire).await;
assert_eq!(request["method"], "shutdown");
write_response(
&mut fake.read_half_stdin,
&request["id"],
serde_json::json!({}),
)
.await;
let exit = read_framed_message(&mut wire).await;
assert_eq!(exit["method"], "exit");
assert_matches!(terminating.await.unwrap(), Ok(()));
}
#[test]
fn test_build_command_includes_configured_env_vars() {
let mut env = HashMap::new();
env.insert(
crate::config::EnvKey::from_static("MCPLS_TEST_CONFIGURED"),
crate::config::EnvValue::from_static("from-server-config"),
);
let config = bare_server_config(env);
let command = LspServer::build_command(&config, &ChildWorkingDir::Inherit, |_| None);
let envs = effective_envs(&command);
assert_eq!(
envs.get("MCPLS_TEST_CONFIGURED"),
Some(&"from-server-config".to_string())
);
}
#[test]
fn test_build_command_configured_env_overrides_allowlisted_var() {
let mut env = HashMap::new();
env.insert(
crate::config::EnvKey::from_static("PATH"),
crate::config::EnvValue::from_static("/configured/override/path"),
);
let config = bare_server_config(env);
let command = LspServer::build_command(&config, &ChildWorkingDir::Inherit, |key| {
(key == "PATH").then(|| "/parent/bin".into())
});
let envs = effective_envs(&command);
assert_eq!(
envs.get("PATH"),
Some(&"/configured/override/path".to_string())
);
}
#[tokio::test]
async fn test_settling_dead_diagnostics_server_hands_the_route_to_the_catch_all() {
use crate::bridge::Translator;
use crate::config::{ServerId, ToolKind, ToolRouter};
let pylsp_id = ServerId::from_static("pylsp");
let configs = vec![
LspServerConfig {
language_id: LanguageId::from_static("python"),
command: ServerCommand::from_static("pyright-langserver").into(),
args: crate::config::ServerArgs::default(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(30).unwrap(),
request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
heuristics: None,
name: Some(ServerId::from_static("pyright-diag")),
handles: Some(ToolSet::new(vec![ToolKind::Diagnostics]).unwrap()),
indexing: crate::bridge::IndexingPolicy::Auto,
},
LspServerConfig {
language_id: LanguageId::from_static("python"),
command: ServerCommand::from_static("pylsp").into(),
args: crate::config::ServerArgs::default(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(30).unwrap(),
request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
heuristics: None,
name: Some(ServerId::from_static("pylsp")),
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
},
];
let router = ToolRouter::from_configs(&configs).unwrap();
let translator = Translator::new().with_router(router);
translator.set_expected_servers(
[ServerId::from_static("pyright-diag"), pylsp_id.clone()]
.into_iter()
.collect(),
);
translator.settle_failed(&ServerSpawnFailure {
server_id: ServerId::from_static("pyright-diag"),
language_id: LanguageId::from_static("python"),
command: ServerCommand::from_static("pyright-langserver"),
reason: StartupFailure::InitTaskPanicked,
});
translator.settle_started(fake_lsp_server_with_config(configs[1].clone()));
assert!(
translator.is_diagnostics_route(&LanguageId::from_static("python"), &pylsp_id),
"pylsp must inherit the diagnostics route once pyright-diag is known dead"
);
}
}