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, 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]) -> Vec<PathBuf> {
if config_roots.is_empty() {
match std::env::current_dir() {
Ok(cwd) => {
match cwd.canonicalize() {
Ok(canonical) => {
info!(
"Using current directory as workspace root: {}",
canonical.display()
);
vec![canonical]
}
Err(e) => {
warn!(
"Failed to canonicalize current directory: {e}, using non-canonical path"
);
vec![cwd]
}
}
}
Err(e) => {
warn!("Failed to get current directory: {e}, using fallback");
vec![PathBuf::from(".")]
}
}
} else {
config_roots.to_vec()
}
}
fn canonicalize_workspace_roots(roots: &[PathBuf]) -> Vec<PathBuf> {
roots
.iter()
.map(|root| dunce::canonicalize(root).unwrap_or_else(|_| root.clone()))
.collect()
}
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 project_config_ignored = config.project_config_ignored;
let workspace_roots = resolve_workspace_roots(&config.workspace.roots);
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));
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(canonicalize_workspace_roots(&workspace_roots));
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,
);
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;
}
}
}
async fn shutdown(
cancel_tx: &tokio::sync::watch::Sender<bool>,
translator: &Translator,
lsp_init_handle: Option<JoinHandle<()>>,
) {
let _ = cancel_tx.send(true);
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 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_path() {
let missing = PathBuf::from("/definitely/does/not/exist/anywhere");
let result = canonicalize_workspace_roots(std::slice::from_ref(&missing));
assert_eq!(result, vec![missing]);
}
#[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 = temp_dir.path().canonicalize().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]);
assert_eq!(result, vec![real_dir]);
}
#[test]
fn test_resolve_workspace_roots_empty_config() {
let roots = resolve_workspace_roots(&[]);
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 config_roots = vec![PathBuf::from("/test/root")];
let roots = resolve_workspace_roots(&config_roots);
assert_eq!(roots, config_roots);
}
#[test]
fn test_resolve_workspace_roots_multiple_paths() {
let config_roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
let roots = resolve_workspace_roots(&config_roots);
assert_eq!(roots, config_roots);
assert_eq!(roots.len(), 2);
}
#[test]
fn test_resolve_workspace_roots_preserves_order() {
let config_roots = vec![
PathBuf::from("/workspace/alpha"),
PathBuf::from("/workspace/beta"),
PathBuf::from("/workspace/gamma"),
];
let roots = resolve_workspace_roots(&config_roots);
assert_eq!(roots[0], PathBuf::from("/workspace/alpha"));
assert_eq!(roots[1], PathBuf::from("/workspace/beta"));
assert_eq!(roots[2], PathBuf::from("/workspace/gamma"));
}
#[test]
fn test_resolve_workspace_roots_single_path() {
let config_roots = vec![PathBuf::from("/single/workspace")];
let roots = resolve_workspace_roots(&config_roots);
assert_eq!(roots.len(), 1);
assert_eq!(roots[0], PathBuf::from("/single/workspace"));
}
#[test]
fn test_resolve_workspace_roots_empty_returns_cwd() {
let roots = resolve_workspace_roots(&[]);
assert!(
!roots.is_empty(),
"Should return at least one workspace root"
);
}
#[test]
fn test_resolve_workspace_roots_relative_paths() {
let config_roots = vec![
PathBuf::from("relative/path1"),
PathBuf::from("relative/path2"),
];
let roots = resolve_workspace_roots(&config_roots);
assert_eq!(roots.len(), 2);
assert_eq!(roots[0], PathBuf::from("relative/path1"));
assert_eq!(roots[1], PathBuf::from("relative/path2"));
}
#[test]
fn test_resolve_workspace_roots_mixed_paths() {
let config_roots = vec![
PathBuf::from("/absolute/path"),
PathBuf::from("relative/path"),
];
let roots = resolve_workspace_roots(&config_roots);
assert_eq!(roots.len(), 2);
assert_eq!(roots[0], PathBuf::from("/absolute/path"));
assert_eq!(roots[1], PathBuf::from("relative/path"));
}
#[test]
fn test_resolve_workspace_roots_with_dot_path() {
let config_roots = vec![PathBuf::from(".")];
let roots = resolve_workspace_roots(&config_roots);
assert_eq!(roots, config_roots);
}
#[test]
fn test_resolve_workspace_roots_with_parent_path() {
let config_roots = vec![PathBuf::from("..")];
let roots = resolve_workspace_roots(&config_roots);
assert_eq!(roots.len(), 1);
assert_eq!(roots[0], PathBuf::from(".."));
}
#[test]
fn test_resolve_workspace_roots_unicode_paths() {
let config_roots = vec![
PathBuf::from("/workspace/テスト"),
PathBuf::from("/workspace/тест"),
];
let roots = resolve_workspace_roots(&config_roots);
assert_eq!(roots.len(), 2);
assert_eq!(roots[0], PathBuf::from("/workspace/テスト"));
assert_eq!(roots[1], PathBuf::from("/workspace/тест"));
}
#[test]
fn test_resolve_workspace_roots_spaces_in_paths() {
let config_roots = vec![
PathBuf::from("/workspace/path with spaces"),
PathBuf::from("/another path/workspace"),
];
let roots = resolve_workspace_roots(&config_roots);
assert_eq!(roots.len(), 2);
assert_eq!(roots[0], PathBuf::from("/workspace/path with spaces"));
}
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 {
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 {
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]
async fn test_serve_rejects_invalid_caller_supplied_config() {
use crate::config::{LspServerConfig, WorkspaceConfig};
let config = ServerConfig {
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();
}
}
}