pub mod bridge;
pub mod config;
pub mod error;
pub mod lsp;
pub mod mcp;
pub mod transport;
mod util;
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use bridge::resources::make_uri;
use bridge::{NotificationCache, ResourceSubscriptions, Translator};
pub use config::{ProjectConfigTrust, ServerConfig};
use config::{ServerId, ToolRouter};
pub use error::Error;
use lsp::{LspNotification, LspServer, ServerInitConfig};
use lsp_types::Uri;
use rmcp::model::ResourceUpdatedNotificationParam;
use tokio::sync::{Mutex, OnceCell};
use tokio::task::{JoinHandle, JoinSet};
use tracing::{debug, error, info, warn};
#[cfg(feature = "transport-http")]
pub use transport::HttpConfig;
pub use transport::Transport;
#[cfg(feature = "transport-http")]
use transport::run_http;
use transport::{ShutdownSignal, run_stdio};
fn diagnostic_path_in_workspace(uri: &Uri, workspace_roots: &[PathBuf]) -> bool {
if workspace_roots.is_empty() {
return true;
}
let Some(path) = bridge::uri_to_path(uri) else {
return false;
};
if path
.components()
.any(|c| matches!(c, Component::CurDir | Component::ParentDir))
{
return false;
}
workspace_roots.iter().any(|root| path.starts_with(root))
}
#[derive(Clone)]
pub(crate) struct PumpShared {
pub(crate) notification_cache: Arc<Mutex<NotificationCache>>,
pub(crate) subs: Arc<ResourceSubscriptions>,
pub(crate) peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
pub(crate) workspace_roots: Arc<[PathBuf]>,
}
pub(crate) async fn diagnostics_pump(
server_id: ServerId,
mut rx: tokio::sync::mpsc::Receiver<LspNotification>,
mut cancel_rx: tokio::sync::watch::Receiver<bool>,
caches_diagnostics: bool,
shared: PumpShared,
) {
let PumpShared {
notification_cache,
subs,
peer_cell,
workspace_roots,
} = shared;
loop {
tokio::select! {
result = cancel_rx.changed() => {
if result.is_err() || *cancel_rx.borrow() {
break;
}
}
msg = rx.recv() => {
let Some(notif) = msg else { break };
match notif {
LspNotification::PublishDiagnostics(p) => {
if !caches_diagnostics {
continue;
}
if !diagnostic_path_in_workspace(&p.uri, &workspace_roots) {
debug!(
"dropping diagnostics for out-of-workspace URI: {}",
p.uri.as_str()
);
continue;
}
{
let mut cache = notification_cache.lock().await;
cache.store_diagnostics(&server_id, &p.uri, p.version, p.diagnostics);
}
if subs.is_empty().await {
continue;
}
let Some(peer) = peer_cell.get() else { continue };
let Some(path) = bridge::uri_to_path(&p.uri) else { continue };
let Ok(mcp_uri) = make_uri(&path) else { continue };
if !subs.contains(&mcp_uri).await {
continue;
}
if peer
.notify_resource_updated(ResourceUpdatedNotificationParam::new(
mcp_uri,
))
.await
.is_err()
{
break;
}
}
LspNotification::LogMessage(m) => {
let mut cache = notification_cache.lock().await;
cache.store_log(m.typ.into(), m.message);
}
LspNotification::ShowMessage(m) => {
let mut cache = notification_cache.lock().await;
cache.store_message(m.typ.into(), m.message);
}
LspNotification::Progress { .. } | LspNotification::Other { .. } => {}
}
}
}
}
}
pub(crate) struct RegisteredServers {
pub(crate) receivers: HashMap<ServerId, tokio::sync::mpsc::Receiver<lsp::LspNotification>>,
pub(crate) diagnostics_flags: HashMap<ServerId, bool>,
}
pub(crate) fn register_servers(
mut result: lsp::ServerInitResult,
translator: &bridge::Translator,
configs: &HashMap<ServerId, ServerInitConfig>,
) -> RegisteredServers {
let mut receivers = HashMap::new();
for (id, server) in &mut result.servers {
receivers.insert(id.clone(), server.take_notification_rx());
}
let registered: HashSet<ServerId> = result.servers.keys().cloned().collect();
let mut language_by_id = HashMap::new();
for (id, server) in result.servers {
let client = server.client().clone();
language_by_id.insert(id.clone(), client.language_id().to_string());
translator.register_client(id.clone(), client);
if let Some(config) = configs.get(&id) {
translator.register_server_config(id.clone(), config.clone());
} else {
warn!(
"No respawn config registered for LSP server '{id}'; auto-respawn on crash will be unavailable for it"
);
}
translator.register_server(id, server);
}
translator.rebind_router(®istered);
let diagnostics_flags = language_by_id
.into_iter()
.map(|(id, language)| {
let is_diagnostics_server = translator.is_diagnostics_route(&language, &id);
(id, is_diagnostics_server)
})
.collect();
RegisteredServers {
receivers,
diagnostics_flags,
}
}
fn resolve_workspace_roots(
config_roots: &[PathBuf],
base_dir: &Path,
) -> Result<Vec<PathBuf>, Error> {
if !base_dir.is_absolute() {
return Err(Error::InvalidConfig(format!(
"workspace root base must be absolute: {}",
base_dir.display()
)));
}
if config_roots.is_empty() {
let root = match dunce::canonicalize(base_dir) {
Ok(canonical) => canonical,
Err(e) => {
warn!(
"Failed to canonicalize workspace base directory {}: {e}, using non-canonical absolute path",
base_dir.display()
);
base_dir.to_path_buf()
}
};
info!("Using workspace base directory as root: {}", root.display());
Ok(vec![root])
} else {
canonicalize_workspace_roots(config_roots, base_dir)
}
}
fn canonicalize_workspace_roots(roots: &[PathBuf], base_dir: &Path) -> Result<Vec<PathBuf>, Error> {
roots
.iter()
.map(|root| {
let is_relative = root.is_relative();
let resolved = if is_relative {
join_relative_root(base_dir, root)
} else {
root.clone()
};
match dunce::canonicalize(&resolved) {
Ok(canonical) => Ok(canonical),
Err(source) if is_relative => Err(Error::InvalidConfig(format!(
"workspace root '{}' resolved relative to '{}' as '{}' could not be canonicalized: {source}",
root.display(),
base_dir.display(),
resolved.display()
))),
Err(source) => {
warn!(
"Failed to canonicalize absolute workspace root {}: {source}, using non-canonical path",
resolved.display()
);
Ok(resolved)
}
}
})
.collect()
}
fn join_relative_root(base_dir: &Path, root: &Path) -> PathBuf {
let mut joined = base_dir.to_path_buf();
joined.extend(
root.components()
.skip_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir)),
);
joined
}
pub async fn serve(config: ServerConfig) -> Result<(), Error> {
serve_with(config, Transport::Stdio).await
}
pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<(), Error> {
info!("Starting MCPLS server...");
let shutdown_signal = ShutdownSignal::new();
config.validate()?;
let workspace_roots = if config.workspace.roots.is_empty()
|| config.workspace.roots.iter().any(|root| root.is_relative())
{
let workspace_base = std::env::current_dir().map_err(Error::Io)?;
resolve_workspace_roots(&config.workspace.roots, &workspace_base)?
} else {
canonicalize_workspace_roots(&config.workspace.roots, Path::new(""))?
};
let extension_map = config.build_effective_extension_map();
let max_depth = Some(config.workspace.heuristics_max_depth);
let applicable_configs: Vec<ServerInitConfig> = config
.lsp_servers
.iter()
.filter_map(|lsp_config| {
let should_spawn = workspace_roots
.iter()
.any(|root| lsp_config.should_spawn(root, max_depth));
if !should_spawn {
info!(
"Skipping LSP server '{}' ({}): no project markers found",
lsp_config.language_id, lsp_config.command
);
return None;
}
Some(ServerInitConfig {
server_config: lsp_config.clone(),
workspace_roots: workspace_roots.clone(),
initialization_options: lsp_config.initialization_options.clone(),
position_encodings: config.workspace.position_encodings.clone(),
notification_tx: None,
})
})
.collect();
info!(
"Attempting to spawn {} applicable LSP server(s)...",
applicable_configs.len()
);
let router = ToolRouter::from_configs(applicable_configs.iter().map(|c| &c.server_config))?;
let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
let mut translator = Translator::new()
.with_resource_limits(config.workspace.resource_limits())
.with_extensions(extension_map)
.with_router(router)
.with_notification_cache(Arc::clone(¬ification_cache));
let (project_config_ignored, mcp) = (config.project_config_ignored, config.mcp);
translator.set_workspace_roots(workspace_roots.clone());
let expected_servers: HashSet<ServerId> = applicable_configs
.iter()
.map(|c| c.server_config.id())
.collect();
translator.set_expected_servers(expected_servers);
let workspace_roots_snapshot: Arc<[PathBuf]> = Arc::from(workspace_roots.clone());
let translator = Arc::new(translator);
let subscriptions = Arc::new(ResourceSubscriptions::new());
let peer_cell = Arc::new(OnceCell::new());
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
let lsp_init_handle = if applicable_configs.is_empty() {
warn!("No applicable LSP servers configured — starting in protocol-only mode");
None
} else {
info!(
"Spawning {} LSP server(s) in the background...",
applicable_configs.len()
);
Some(spawn_lsp_servers_background(
applicable_configs,
Arc::clone(&translator),
Arc::clone(¬ification_cache),
Arc::clone(&subscriptions),
Arc::clone(&peer_cell),
cancel_rx.clone(),
Arc::clone(&workspace_roots_snapshot),
))
};
info!("Starting MCP server with rmcp...");
let mcp_server = mcp::McplsServer::new(
Arc::clone(&translator),
Arc::clone(¬ification_cache),
Arc::clone(&workspace_roots_snapshot),
Arc::clone(&subscriptions),
project_config_ignored,
mcp,
);
info!("MCPLS server initialized successfully");
let result = match transport {
Transport::Stdio => {
info!("Listening for MCP requests on stdio...");
run_stdio(mcp_server, &peer_cell, shutdown_signal).await
}
#[cfg(feature = "transport-http")]
Transport::Http(cfg) => run_http(mcp_server, cfg, shutdown_signal).await,
};
shutdown(&cancel_tx, &translator, lsp_init_handle).await;
info!("MCPLS server shutting down");
result
}
const LSP_INIT_TASK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
async fn await_lsp_init_handle(mut handle: JoinHandle<()>, timeout: Duration) {
match tokio::time::timeout(timeout, &mut handle).await {
Ok(Ok(())) => {}
Ok(Err(err)) => error!("Background LSP initialization task failed: {err}"),
Err(_) => {
warn!("Timed out waiting for background LSP initialization task to stop");
handle.abort();
let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
}
}
}
struct AbortOnDrop<'a, T>(&'a JoinHandle<T>);
impl<T> Drop for AbortOnDrop<'_, T> {
fn drop(&mut self) {
self.0.abort();
}
}
const fn should_escalate(repeat_signals: u32) -> bool {
repeat_signals >= 1
}
async fn shutdown(
cancel_tx: &tokio::sync::watch::Sender<bool>,
translator: &Translator,
lsp_init_handle: Option<JoinHandle<()>>,
) {
let _ = cancel_tx.send(true);
let mut cleanup_signal = ShutdownSignal::new();
let force_exit_on_signal = tokio::spawn(async move {
let mut repeat_signals = 0u32;
loop {
cleanup_signal.recv().await;
repeat_signals += 1;
if should_escalate(repeat_signals) {
error!("shutdown signal received during cleanup, forcing immediate exit");
std::process::exit(1);
}
}
});
let _abort_force_exit_on_signal = AbortOnDrop(&force_exit_on_signal);
info!("Shutting down LSP servers...");
translator.shutdown_servers().await;
if let Some(handle) = lsp_init_handle {
await_lsp_init_handle(handle, LSP_INIT_TASK_SHUTDOWN_TIMEOUT).await;
}
}
fn spawn_lsp_servers_background(
applicable_configs: Vec<ServerInitConfig>,
translator: Arc<Translator>,
notification_cache: Arc<Mutex<NotificationCache>>,
subscriptions: Arc<ResourceSubscriptions>,
peer_cell: Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>,
cancel_rx: tokio::sync::watch::Receiver<bool>,
workspace_roots: Arc<[PathBuf]>,
) -> JoinHandle<()> {
tokio::spawn(async move {
let configs_by_id: HashMap<ServerId, ServerInitConfig> = applicable_configs
.iter()
.map(|c| (c.server_config.id(), c.clone()))
.collect();
let result = LspServer::spawn_batch(&applicable_configs).await;
if result.all_failed() {
error!(
"All {} configured LSP server(s) failed to initialize",
result.failure_count()
);
for failure in &result.failures {
error!("Server initialization failed: {}", failure);
}
translator.rebind_router(&HashSet::new());
translator.clear_expected_servers();
return;
}
if result.partial_success() {
warn!(
"Partial server initialization: {} succeeded, {} failed",
result.server_count(),
result.failure_count()
);
for failure in &result.failures {
error!("Server initialization failed: {}", failure);
}
}
let server_count = result.server_count();
let registered = register_servers(result, &translator, &configs_by_id);
translator.clear_expected_servers();
info!("Proceeding with {} LSP server(s)", server_count);
let diagnostics_route_count = registered
.diagnostics_flags
.values()
.filter(|&&is_route| is_route)
.count();
notification_cache
.lock()
.await
.set_diagnostics_route_count(diagnostics_route_count);
let pump_shared = PumpShared {
notification_cache,
subs: subscriptions,
peer_cell,
workspace_roots,
};
let mut pumps: JoinSet<()> = JoinSet::new();
for (id, rx) in registered.receivers {
let caches_diagnostics = registered
.diagnostics_flags
.get(&id)
.copied()
.unwrap_or(false);
pumps.spawn(diagnostics_pump(
id,
rx,
cancel_rx.clone(),
caches_diagnostics,
pump_shared.clone(),
));
}
while pumps.join_next().await.is_some() {}
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test_support {
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, PoisonError};
static CWD_LOCK: Mutex<()> = Mutex::new(());
pub struct CwdGuard {
_lock: MutexGuard<'static, ()>,
original_dir: PathBuf,
}
impl CwdGuard {
pub fn enter(dir: &Path) -> Self {
let lock = CWD_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir).unwrap();
Self {
_lock: lock,
original_dir,
}
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
let restored = std::env::set_current_dir(&self.original_dir);
if !std::thread::panicking() {
#[allow(clippy::expect_used)]
restored.expect("CwdGuard failed to restore original working directory");
}
}
}
#[cfg(test)]
mod tests {
use super::CwdGuard;
#[test]
fn test_cwd_guard_restores_cwd_on_panic() {
let original_dir = std::env::current_dir().unwrap();
let tmp_dir = tempfile::TempDir::new().unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = CwdGuard::enter(tmp_dir.path());
panic!("boom");
}));
assert!(result.is_err());
assert_eq!(std::env::current_dir().unwrap(), original_dir);
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use bridge::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE};
use super::*;
#[test]
fn test_diagnostic_path_in_workspace_empty_roots_allows_any_uri() {
let uri: Uri = "file:///anywhere/at/all.rs".parse().unwrap();
assert!(diagnostic_path_in_workspace(&uri, &[]));
}
#[test]
fn test_diagnostic_path_in_workspace_accepts_uri_under_root() {
#[cfg(windows)]
let (root, uri_str) = (
PathBuf::from(r"C:\workspace\project"),
"file:///C:/workspace/project/src/main.rs",
);
#[cfg(not(windows))]
let (root, uri_str) = (
PathBuf::from("/workspace/project"),
"file:///workspace/project/src/main.rs",
);
let uri: Uri = uri_str.parse().unwrap();
assert!(diagnostic_path_in_workspace(&uri, &[root]));
}
#[test]
fn test_diagnostic_path_in_workspace_rejects_uri_outside_roots() {
#[cfg(windows)]
let (root, uri_str) = (
PathBuf::from(r"C:\workspace\project"),
"file:///C:/etc/passwd",
);
#[cfg(not(windows))]
let (root, uri_str) = (PathBuf::from("/workspace/project"), "file:///etc/passwd");
let uri: Uri = uri_str.parse().unwrap();
assert!(!diagnostic_path_in_workspace(&uri, &[root]));
}
#[test]
fn test_diagnostic_path_in_workspace_rejects_non_file_uri() {
let root = PathBuf::from("/workspace/project");
let uri: Uri = "untitled:Untitled-1".parse().unwrap();
assert!(!diagnostic_path_in_workspace(&uri, &[root]));
}
#[test]
fn test_diagnostic_path_in_workspace_rejects_parent_dir_traversal() {
#[cfg(windows)]
let (root, uri_str) = (
PathBuf::from(r"C:\workspace\project"),
"file:///C:/workspace/project/../../etc/passwd",
);
#[cfg(not(windows))]
let (root, uri_str) = (
PathBuf::from("/workspace/project"),
"file:///workspace/project/../../etc/passwd",
);
let uri: Uri = uri_str.parse().unwrap();
assert!(!diagnostic_path_in_workspace(&uri, &[root]));
}
#[test]
fn test_canonicalize_workspace_roots_falls_back_on_nonexistent_absolute_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let missing = base.join("missing");
let result = canonicalize_workspace_roots(std::slice::from_ref(&missing), &base).unwrap();
assert_eq!(result, vec![missing]);
}
#[test]
fn test_canonicalize_workspace_roots_rejects_nonexistent_relative_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let missing = PathBuf::from("missing");
let err = canonicalize_workspace_roots(std::slice::from_ref(&missing), &base).unwrap_err();
let Error::InvalidConfig(message) = err else {
panic!("expected InvalidConfig, got {err:?}");
};
assert!(message.contains("workspace root 'missing'"));
assert!(message.contains(&base.display().to_string()));
}
#[test]
#[cfg(unix)]
fn test_canonicalize_workspace_roots_resolves_symlink() {
use std::os::unix::fs::symlink;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let real_dir = base.join("real");
std::fs::create_dir(&real_dir).unwrap();
let link_dir = base.join("link");
symlink(&real_dir, &link_dir).unwrap();
let result = canonicalize_workspace_roots(&[link_dir], &base).unwrap();
assert_eq!(result, vec![real_dir]);
}
#[test]
fn test_join_relative_root_strips_leading_root_and_prefix_components() {
let base = Path::new("/base/dir");
assert_eq!(
join_relative_root(base, Path::new("/workspace")),
PathBuf::from("/base/dir/workspace")
);
assert_eq!(
join_relative_root(base, Path::new("/workspace/sub")),
PathBuf::from("/base/dir/workspace/sub")
);
assert_eq!(
join_relative_root(base, Path::new("workspace")),
PathBuf::from("/base/dir/workspace")
);
assert_eq!(
join_relative_root(base, Path::new("..")),
PathBuf::from("/base/dir/..")
);
}
#[test]
#[cfg(windows)]
fn test_canonicalize_workspace_roots_windows_root_without_prefix() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let nested = base.join("workspace");
std::fs::create_dir(&nested).unwrap();
let root = PathBuf::from(r"\workspace");
assert!(root.is_relative());
let result = canonicalize_workspace_roots(std::slice::from_ref(&root), &base).unwrap();
assert_eq!(result, vec![nested]);
}
#[test]
#[cfg(windows)]
fn test_canonicalize_workspace_roots_windows_drive_relative_root() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let nested = base.join("workspace");
std::fs::create_dir(&nested).unwrap();
let drive_prefix = base
.components()
.find_map(|c| match c {
Component::Prefix(p) => Some(p.as_os_str().to_owned()),
_ => None,
})
.expect("temp dir path should have a Windows drive prefix");
let mut root = drive_prefix;
root.push("workspace");
let root = PathBuf::from(root);
assert!(root.is_relative());
let result = canonicalize_workspace_roots(std::slice::from_ref(&root), &base).unwrap();
assert_eq!(result, vec![nested]);
}
#[test]
fn test_resolve_workspace_roots_empty_config() {
let cwd = std::env::current_dir().unwrap();
let roots = resolve_workspace_roots(&[], &cwd).unwrap();
assert_eq!(roots.len(), 1);
assert!(
roots[0].is_absolute(),
"Workspace root should be absolute path"
);
}
#[test]
fn test_resolve_workspace_roots_with_config() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let root = base.join("root");
std::fs::create_dir(&root).unwrap();
let roots = resolve_workspace_roots(std::slice::from_ref(&root), &base).unwrap();
assert_eq!(roots, vec![root]);
}
#[test]
fn test_resolve_workspace_roots_multiple_paths() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let config_roots = vec![base.join("root1"), base.join("root2")];
for root in &config_roots {
std::fs::create_dir(root).unwrap();
}
let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
assert_eq!(roots, config_roots);
assert_eq!(roots.len(), 2);
}
#[test]
fn test_resolve_workspace_roots_preserves_order() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let config_roots = vec![base.join("alpha"), base.join("beta"), base.join("gamma")];
for root in &config_roots {
std::fs::create_dir(root).unwrap();
}
let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
assert_eq!(roots, config_roots);
}
#[test]
fn test_resolve_workspace_roots_single_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let root = base.join("workspace");
std::fs::create_dir(&root).unwrap();
let roots = resolve_workspace_roots(std::slice::from_ref(&root), &base).unwrap();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0], root);
}
#[test]
fn test_resolve_workspace_roots_empty_returns_cwd() {
let cwd = std::env::current_dir().unwrap();
let roots = resolve_workspace_roots(&[], &cwd).unwrap();
assert_eq!(roots, vec![dunce::canonicalize(cwd).unwrap()]);
}
#[test]
fn test_resolve_workspace_roots_relative_paths() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let config_roots = vec![
PathBuf::from("relative/path1"),
PathBuf::from("relative/path2"),
];
for root in &config_roots {
std::fs::create_dir_all(base.join(root)).unwrap();
}
let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
assert_eq!(
roots,
vec![base.join("relative/path1"), base.join("relative/path2")]
);
assert!(roots.iter().all(|root| root.is_absolute()));
}
#[test]
fn test_resolve_workspace_roots_mixed_paths() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let absolute = base.join("absolute");
let relative = PathBuf::from("relative/path");
std::fs::create_dir(&absolute).unwrap();
std::fs::create_dir_all(base.join(&relative)).unwrap();
let config_roots = vec![absolute.clone(), relative];
let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
assert_eq!(roots.len(), 2);
assert_eq!(roots[0], absolute);
assert_eq!(roots[1], base.join("relative/path"));
assert!(roots.iter().all(|root| root.is_absolute()));
}
#[test]
fn test_canonicalize_workspace_roots_ignores_base_dir_when_all_absolute() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let root = base.join("root");
std::fs::create_dir(&root).unwrap();
let result =
canonicalize_workspace_roots(std::slice::from_ref(&root), Path::new("")).unwrap();
assert_eq!(result, vec![root]);
}
#[test]
fn test_resolve_workspace_roots_with_dot_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let config_roots = vec![PathBuf::from(".")];
let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
assert_eq!(roots, vec![base]);
assert!(roots[0].is_absolute());
}
#[test]
fn test_resolve_workspace_roots_with_parent_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let parent = dunce::canonicalize(temp_dir.path()).unwrap();
let base = parent.join("nested");
std::fs::create_dir(&base).unwrap();
let config_roots = vec![PathBuf::from("..")];
let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0], parent);
assert!(roots[0].is_absolute());
}
#[test]
fn test_resolve_workspace_roots_unicode_paths() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let config_roots = vec![
PathBuf::from("workspace/テスト"),
PathBuf::from("workspace/тест"),
];
for root in &config_roots {
std::fs::create_dir_all(base.join(root)).unwrap();
}
let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
assert_eq!(roots.len(), 2);
assert_eq!(roots[0], base.join("workspace/テスト"));
assert_eq!(roots[1], base.join("workspace/тест"));
}
#[test]
fn test_resolve_workspace_roots_spaces_in_paths() {
let temp_dir = tempfile::TempDir::new().unwrap();
let base = dunce::canonicalize(temp_dir.path()).unwrap();
let config_roots = vec![
PathBuf::from("workspace/path with spaces"),
PathBuf::from("another path/workspace"),
];
for root in &config_roots {
std::fs::create_dir_all(base.join(root)).unwrap();
}
let roots = resolve_workspace_roots(&config_roots, &base).unwrap();
assert_eq!(roots.len(), 2);
assert_eq!(roots[0], base.join("workspace/path with spaces"));
assert_eq!(roots[1], base.join("another path/workspace"));
}
mod graceful_degradation_tests {
use super::*;
use crate::error::ServerSpawnFailure;
use crate::lsp::ServerInitResult;
#[test]
fn test_all_servers_failed_error_handling() {
let mut result = ServerInitResult::new();
result.add_failure(ServerSpawnFailure {
server_id: ServerId::from("rust"),
language_id: "rust".to_string(),
command: "rust-analyzer".to_string(),
message: "not found".to_string(),
});
result.add_failure(ServerSpawnFailure {
server_id: ServerId::from("python"),
language_id: "python".to_string(),
command: "pyright".to_string(),
message: "not found".to_string(),
});
assert!(result.all_failed());
assert_eq!(result.failure_count(), 2);
assert_eq!(result.server_count(), 0);
}
#[test]
fn test_partial_success_detection() {
use std::collections::HashMap;
let mut result = ServerInitResult::new();
result.servers = HashMap::new(); result.add_failure(ServerSpawnFailure {
server_id: ServerId::from("python"),
language_id: "python".to_string(),
command: "pyright".to_string(),
message: "not found".to_string(),
});
assert_eq!(result.failure_count(), 1);
assert_eq!(result.server_count(), 0);
}
#[test]
fn test_all_servers_succeeded_detection() {
use std::collections::HashMap;
let mut result = ServerInitResult::new();
result.servers = HashMap::new();
assert_eq!(result.failure_count(), 0);
assert!(!result.all_failed());
assert!(!result.partial_success());
}
#[test]
fn test_all_servers_failed_to_init_error() {
let failures = vec![
ServerSpawnFailure {
server_id: ServerId::from("rust"),
language_id: "rust".to_string(),
command: "rust-analyzer".to_string(),
message: "command not found".to_string(),
},
ServerSpawnFailure {
server_id: ServerId::from("python"),
language_id: "python".to_string(),
command: "pyright".to_string(),
message: "permission denied".to_string(),
},
];
let err = Error::AllServersFailedToInit { count: 2, failures };
assert!(err.to_string().contains("all LSP servers failed"));
assert!(err.to_string().contains("2 configured"));
if let Error::AllServersFailedToInit { count, failures: f } = err {
assert_eq!(count, 2);
assert_eq!(f.len(), 2);
assert_eq!(f[0].language_id, "rust");
assert_eq!(f[1].language_id, "python");
} else {
panic!("Expected AllServersFailedToInit error");
}
}
#[test]
fn test_graceful_degradation_with_empty_config() {
let result = ServerInitResult::new();
assert!(!result.all_failed());
assert!(!result.partial_success());
assert!(!result.has_servers());
assert_eq!(result.server_count(), 0);
assert_eq!(result.failure_count(), 0);
}
#[test]
fn test_server_spawn_failure_display() {
let failure = ServerSpawnFailure {
server_id: ServerId::from("typescript"),
language_id: "typescript".to_string(),
command: "tsserver".to_string(),
message: "executable not found in PATH".to_string(),
};
let display = failure.to_string();
assert!(display.contains("typescript"));
assert!(display.contains("tsserver"));
assert!(display.contains("executable not found"));
}
#[test]
fn test_result_helpers_consistency() {
let mut result = ServerInitResult::new();
assert!(!result.has_servers());
assert!(!result.all_failed());
assert!(!result.partial_success());
result.add_failure(ServerSpawnFailure {
server_id: ServerId::from("go"),
language_id: "go".to_string(),
command: "gopls".to_string(),
message: "error".to_string(),
});
assert!(result.all_failed());
assert!(!result.has_servers());
assert!(!result.partial_success());
}
#[tokio::test]
async fn test_serve_degrades_when_all_servers_fail_to_spawn() {
use crate::config::{LspServerConfig, WorkspaceConfig};
let config = ServerConfig {
mcp: crate::config::McpConfig::default(),
workspace: WorkspaceConfig {
roots: vec![PathBuf::from("/tmp/test-workspace")],
position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
language_extensions: vec![],
heuristics_max_depth: 10,
max_documents: DEFAULT_MAX_DOCUMENTS,
max_file_size: DEFAULT_MAX_FILE_SIZE,
},
lsp_servers: vec![LspServerConfig {
language_id: "rust".to_string(),
command: "nonexistent-command-that-will-fail-12345".to_string(),
args: vec![],
env: std::collections::HashMap::new(),
file_patterns: vec!["**/*.rs".to_string()],
initialization_options: None,
timeout_seconds: 10,
request_timeout_seconds: 10,
heuristics: None,
name: None,
handles: None,
}],
project_config_ignored: false,
};
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
match outcome {
Err(_elapsed) => {}
Ok(Ok(())) => {}
Ok(Err(err)) => assert!(
!matches!(err, Error::NoServersAvailable(_))
&& !matches!(err, Error::AllServersFailedToInit { .. }),
"serve() must not fail fast now that LSP init is backgrounded; got: {err:?}"
),
}
}
#[tokio::test]
async fn test_serve_starts_with_empty_config() {
use crate::config::WorkspaceConfig;
let config = ServerConfig {
mcp: crate::config::McpConfig::default(),
workspace: WorkspaceConfig {
roots: vec![PathBuf::from("/tmp/test-workspace")],
position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
language_extensions: vec![],
heuristics_max_depth: 10,
max_documents: DEFAULT_MAX_DOCUMENTS,
max_file_size: DEFAULT_MAX_FILE_SIZE,
},
lsp_servers: vec![],
project_config_ignored: false,
};
let result = serve(config).await;
if let Err(ref err) = result {
assert!(
!matches!(err, Error::NoServersAvailable(_)),
"serve() must not return NoServersAvailable for empty lsp_servers config"
);
}
}
#[tokio::test]
#[cfg(unix)]
async fn test_serve_with_all_absolute_roots_skips_current_dir() {
use crate::config::WorkspaceConfig;
use crate::test_support::CwdGuard;
let workspace_root_dir = tempfile::TempDir::new().unwrap();
let workspace_root = dunce::canonicalize(workspace_root_dir.path()).unwrap();
let doomed_cwd = tempfile::TempDir::new().unwrap();
let _guard = CwdGuard::enter(doomed_cwd.path());
doomed_cwd.close().unwrap();
let config = ServerConfig {
mcp: crate::config::McpConfig::default(),
workspace: WorkspaceConfig {
roots: vec![workspace_root],
position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
language_extensions: vec![],
heuristics_max_depth: 10,
max_documents: DEFAULT_MAX_DOCUMENTS,
max_file_size: DEFAULT_MAX_FILE_SIZE,
},
lsp_servers: vec![],
project_config_ignored: false,
};
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
match outcome {
Err(_elapsed) => {}
Ok(Ok(())) => {}
Ok(Err(err)) => assert!(
!matches!(&err, Error::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
"serve() must not need a working process cwd for an all-absolute \
workspace.roots; got: {err:?}"
),
}
}
#[tokio::test]
async fn test_serve_rejects_invalid_caller_supplied_config() {
use crate::config::{LspServerConfig, WorkspaceConfig};
let config = ServerConfig {
mcp: crate::config::McpConfig::default(),
workspace: WorkspaceConfig {
roots: vec![PathBuf::from("/tmp/test-workspace")],
position_encodings: vec!["utf-8".to_string(), "utf-16".to_string()],
language_extensions: vec![],
heuristics_max_depth: 10,
max_documents: DEFAULT_MAX_DOCUMENTS,
max_file_size: DEFAULT_MAX_FILE_SIZE,
},
lsp_servers: vec![LspServerConfig {
language_id: "rust".to_string(),
command: String::new(),
args: vec![],
env: std::collections::HashMap::new(),
file_patterns: vec!["**/*.rs".to_string()],
initialization_options: None,
timeout_seconds: 10,
request_timeout_seconds: 10,
heuristics: None,
name: None,
handles: None,
}],
project_config_ignored: false,
};
let outcome =
tokio::time::timeout(std::time::Duration::from_secs(2), serve(config)).await;
match outcome {
Err(elapsed) => panic!(
"serve() must reject the invalid config immediately, not hang until \
timeout: {elapsed}"
),
Ok(result) => assert!(
matches!(result, Err(Error::InvalidConfig(_))),
"serve() must reject a caller-supplied config with an empty `command` via \
Error::InvalidConfig, matching the load_from path; got: {result:?}"
),
}
}
#[tokio::test]
async fn test_shutdown_drains_registered_lsp_server() {
let translator = Translator::new();
translator.register_server("fake-server", crate::lsp::fake_lsp_server());
assert_eq!(translator.registered_server_count(), 1);
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
let result = tokio::time::timeout(
std::time::Duration::from_secs(20),
super::super::shutdown(&cancel_tx, &translator, None),
)
.await;
assert!(
result.is_ok(),
"shutdown must not hang against a non-responsive mock LSP server"
);
assert_eq!(
translator.registered_server_count(),
0,
"shutdown must drain every registered LSP server"
);
assert!(
*cancel_rx.borrow(),
"shutdown must signal background pump tasks to exit"
);
}
#[tokio::test]
async fn test_shutdown_awaits_background_init_task() {
use std::sync::atomic::{AtomicBool, Ordering};
let translator = Translator::new();
let (cancel_tx, _cancel_rx) = tokio::sync::watch::channel(false);
let completed = Arc::new(AtomicBool::new(false));
let completed_clone = Arc::clone(&completed);
let handle = tokio::spawn(async move {
completed_clone.store(true, Ordering::SeqCst);
});
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
super::super::shutdown(&cancel_tx, &translator, Some(handle)),
)
.await;
assert!(result.is_ok(), "shutdown must not hang on a live handle");
assert!(
completed.load(Ordering::SeqCst),
"shutdown must await the background init task before returning"
);
}
#[tokio::test]
async fn test_await_lsp_init_handle_aborts_on_timeout() {
use std::sync::atomic::{AtomicBool, Ordering};
struct DropFlag(Arc<AtomicBool>);
impl Drop for DropFlag {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let future_dropped = Arc::new(AtomicBool::new(false));
let guard = DropFlag(Arc::clone(&future_dropped));
let handle = tokio::spawn(async move {
let _guard = guard;
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
});
super::super::await_lsp_init_handle(handle, std::time::Duration::from_millis(20)).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
future_dropped.load(Ordering::SeqCst),
"timed-out background init task's future must be dropped via abort(), \
not left running detached until its own sleep completes"
);
}
#[tokio::test]
async fn test_await_lsp_init_handle_logs_panic() {
use tracing_subscriber::layer::SubscriberExt as _;
let handle = tokio::spawn(async {
panic!("simulated background LSP init panic");
});
let captured = CapturedMessages::default();
let subscriber = tracing_subscriber::registry().with(captured.clone());
let guard = tracing::subscriber::set_default(subscriber);
super::super::await_lsp_init_handle(handle, std::time::Duration::from_secs(5)).await;
drop(guard);
let messages = captured.0.lock().unwrap().clone();
assert!(
messages
.iter()
.any(|m| m.contains("Background LSP initialization task failed")),
"expected an error! log for the panicking background init task, got: {messages:?}"
);
}
#[derive(Clone, Default)]
struct CapturedMessages(Arc<std::sync::Mutex<Vec<String>>>);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedMessages {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
struct MessageVisitor(String);
impl tracing::field::Visit for MessageVisitor {
fn record_debug(
&mut self,
field: &tracing::field::Field,
value: &dyn std::fmt::Debug,
) {
if field.name() == "message" {
self.0 = format!("{value:?}");
}
}
}
let mut visitor = MessageVisitor(String::new());
event.record(&mut visitor);
self.0.lock().unwrap().push(visitor.0);
}
}
}
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod pump_tests {
use lsp_types::{PublishDiagnosticsParams, Uri};
use tokio::sync::{mpsc, watch};
use super::*;
fn make_cache() -> Arc<Mutex<NotificationCache>> {
Arc::new(Mutex::new(NotificationCache::new()))
}
fn make_subs() -> Arc<ResourceSubscriptions> {
Arc::new(ResourceSubscriptions::new())
}
type PeerCell = Arc<OnceCell<rmcp::Peer<rmcp::RoleServer>>>;
fn make_peer_cell() -> PeerCell {
Arc::new(OnceCell::new())
}
fn no_workspace_roots() -> Arc<[PathBuf]> {
Arc::from([])
}
#[tokio::test]
async fn test_pump_caches_before_peer_set() {
let cache = make_cache();
let subs = make_subs();
let peer_cell = make_peer_cell();
let (tx, rx) = mpsc::channel(8);
let (_cancel_tx, cancel_rx) = watch::channel(false);
let c = Arc::clone(&cache);
tokio::spawn(diagnostics_pump(
ServerId::from("rust"),
rx,
cancel_rx,
true,
PumpShared {
notification_cache: c,
subs: Arc::clone(&subs),
peer_cell: Arc::clone(&peer_cell),
workspace_roots: no_workspace_roots(),
},
));
let uri: Uri = "file:///test/main.rs".parse().unwrap();
tx.send(LspNotification::PublishDiagnostics(
PublishDiagnosticsParams {
uri: uri.clone(),
diagnostics: vec![],
version: None,
},
))
.await
.unwrap();
drop(tx);
let cached = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
tokio::task::yield_now().await;
let found = {
let guard = cache.lock().await;
guard.get_diagnostics(uri.as_str()).is_some()
};
if found {
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
})
.await
.expect("pump did not cache diagnostics within 5 s");
assert!(cached, "diagnostics should be cached before peer is set");
}
#[tokio::test]
async fn test_pump_drops_diagnostics_outside_workspace_roots() {
let cache = make_cache();
let subs = make_subs();
let peer_cell = make_peer_cell();
let (tx, rx) = mpsc::channel(8);
let (_cancel_tx, cancel_rx) = watch::channel(false);
#[cfg(windows)]
let (workspace_root, outside_uri_str, inside_uri_str) = (
PathBuf::from(r"C:\workspace"),
"file:///C:/etc/passwd",
"file:///C:/workspace/src/main.rs",
);
#[cfg(not(windows))]
let (workspace_root, outside_uri_str, inside_uri_str) = (
PathBuf::from("/workspace"),
"file:///etc/passwd",
"file:///workspace/src/main.rs",
);
let workspace_roots: Arc<[PathBuf]> = Arc::from([workspace_root]);
tokio::spawn(diagnostics_pump(
ServerId::from("rust"),
rx,
cancel_rx,
true,
PumpShared {
notification_cache: Arc::clone(&cache),
subs: Arc::clone(&subs),
peer_cell: Arc::clone(&peer_cell),
workspace_roots,
},
));
let outside_uri: Uri = outside_uri_str.parse().unwrap();
let inside_uri: Uri = inside_uri_str.parse().unwrap();
tx.send(LspNotification::PublishDiagnostics(
PublishDiagnosticsParams {
uri: outside_uri.clone(),
diagnostics: vec![],
version: None,
},
))
.await
.unwrap();
tx.send(LspNotification::PublishDiagnostics(
PublishDiagnosticsParams {
uri: inside_uri.clone(),
diagnostics: vec![],
version: None,
},
))
.await
.unwrap();
drop(tx);
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
{
let guard = cache.lock().await;
if guard.get_diagnostics(inside_uri.as_str()).is_some() {
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
})
.await
.expect("pump did not cache in-workspace diagnostics within 5 s");
let found_outside = cache
.lock()
.await
.get_diagnostics(outside_uri.as_str())
.is_some();
assert!(
!found_outside,
"diagnostics for a URI outside workspace roots must not be cached"
);
}
#[tokio::test]
async fn test_pump_exits_on_cancel() {
let cache = make_cache();
let subs = make_subs();
let peer_cell = make_peer_cell();
let (_tx, rx) = mpsc::channel::<LspNotification>(8);
let (cancel_tx, cancel_rx) = watch::channel(false);
let handle = tokio::spawn(diagnostics_pump(
ServerId::from("rust"),
rx,
cancel_rx,
true,
PumpShared {
notification_cache: cache,
subs,
peer_cell,
workspace_roots: no_workspace_roots(),
},
));
cancel_tx.send(true).unwrap();
tokio::time::timeout(std::time::Duration::from_millis(200), handle)
.await
.expect("pump did not exit within timeout")
.unwrap();
}
#[tokio::test]
async fn test_pump_exits_when_cancel_sender_dropped() {
let cache = make_cache();
let subs = make_subs();
let peer_cell = make_peer_cell();
let (_tx, rx) = mpsc::channel::<LspNotification>(8);
let (cancel_tx, cancel_rx) = watch::channel(false);
let handle = tokio::spawn(diagnostics_pump(
ServerId::from("rust"),
rx,
cancel_rx,
true,
PumpShared {
notification_cache: cache,
subs,
peer_cell,
workspace_roots: no_workspace_roots(),
},
));
drop(cancel_tx); tokio::time::timeout(std::time::Duration::from_millis(200), handle)
.await
.expect("pump did not exit within timeout")
.unwrap();
}
#[tokio::test]
async fn test_pump_makes_progress_while_translator_lock_held() {
let translator = Arc::new(Mutex::new(Translator::new()));
let cache = make_cache();
let subs = make_subs();
let peer_cell = make_peer_cell();
let (tx, rx) = mpsc::channel(8);
let (_cancel_tx, cancel_rx) = watch::channel(false);
let lock_acquired = Arc::new(tokio::sync::Notify::new());
let notify = Arc::clone(&lock_acquired);
let holder = tokio::spawn(async move {
let _guard = translator.lock().await;
notify.notify_one();
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
});
lock_acquired.notified().await;
tokio::spawn(diagnostics_pump(
ServerId::from("rust"),
rx,
cancel_rx,
true,
PumpShared {
notification_cache: Arc::clone(&cache),
subs,
peer_cell,
workspace_roots: no_workspace_roots(),
},
));
let uri: Uri = "file:///test/locked.rs".parse().unwrap();
tx.send(LspNotification::PublishDiagnostics(
PublishDiagnosticsParams {
uri: uri.clone(),
diagnostics: vec![],
version: None,
},
))
.await
.unwrap();
drop(tx);
tokio::time::timeout(std::time::Duration::from_millis(500), async {
loop {
{
let guard = cache.lock().await;
if guard.get_diagnostics(uri.as_str()).is_some() {
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
})
.await
.expect("pump stalled behind translator lock");
holder.await.unwrap();
}
}
}