use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde::Deserialize;
use tokio::sync::Mutex as AsyncMutex;
use tokio::time::timeout;
pub mod client;
pub mod diagnostics;
pub mod registry;
pub use client::{LspTransport, StdioLspTransport};
pub use diagnostics::{Diagnostic, DiagnosticBlock, Severity, render_blocks};
pub use registry::Language;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CustomLspDef {
pub language_id: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct LspConfig {
pub enabled: bool,
pub poll_after_edit_ms: u64,
pub max_diagnostics_per_file: usize,
pub include_warnings: bool,
pub servers: HashMap<String, Vec<String>>,
#[serde(default)]
pub custom: HashMap<String, CustomLspDef>,
}
impl Default for LspConfig {
fn default() -> Self {
Self {
enabled: true,
poll_after_edit_ms: 5_000,
max_diagnostics_per_file: 20,
include_warnings: false,
servers: HashMap::new(),
custom: HashMap::new(),
}
}
}
impl LspConfig {
fn resolve_command(&self, lang: Language) -> Option<(String, Vec<String>)> {
if let Some(parts) = self.servers.get(lang.as_key())
&& let Some((first, rest)) = parts.split_first()
{
return Some((first.clone(), rest.to_vec()));
}
let (cmd, args) = registry::server_for(lang)?;
Some((
cmd.to_string(),
args.iter().map(|a| (*a).to_string()).collect(),
))
}
}
pub struct LspManager {
config: LspConfig,
workspace: PathBuf,
transports: AsyncMutex<HashMap<Language, Arc<dyn LspTransport>>>,
missing_warned: AsyncMutex<HashSet<Language>>,
test_transports: AsyncMutex<HashMap<Language, Arc<dyn LspTransport>>>,
custom_transports: AsyncMutex<HashMap<String, Arc<dyn LspTransport>>>,
custom_missing_warned: AsyncMutex<HashSet<String>>,
}
impl LspManager {
#[must_use]
pub fn new(config: LspConfig, workspace: PathBuf) -> Self {
Self {
config,
workspace,
transports: AsyncMutex::new(HashMap::new()),
missing_warned: AsyncMutex::new(HashSet::new()),
test_transports: AsyncMutex::new(HashMap::new()),
custom_transports: AsyncMutex::new(HashMap::new()),
custom_missing_warned: AsyncMutex::new(HashSet::new()),
}
}
#[must_use]
pub fn config(&self) -> &LspConfig {
&self.config
}
#[cfg(test)]
pub async fn install_test_transport(&self, lang: Language, transport: Arc<dyn LspTransport>) {
self.test_transports.lock().await.insert(lang, transport);
}
pub async fn diagnostics_for(&self, file: &Path, _edit_seq: u64) -> Option<DiagnosticBlock> {
if !self.config.enabled {
return None;
}
let lang = registry::detect_language(file);
if lang == Language::Other {
if let Some(custom) = self.config.custom_for_extension(file) {
return self.diagnostics_for_custom(file, custom).await;
}
return None;
}
let text = match tokio::fs::read_to_string(file).await {
Ok(text) => text,
Err(err) => {
tracing::debug!(?err, file = %file.display(), "lsp: read file failed");
return None;
}
};
let transport = match self.transport_for(lang).await {
Some(t) => t,
None => return None,
};
self.poll_diagnostics(file, &text, transport).await
}
async fn poll_diagnostics(
&self,
file: &Path,
text: &str,
transport: Arc<dyn LspTransport>,
) -> Option<DiagnosticBlock> {
let wait = Duration::from_millis(self.config.poll_after_edit_ms);
let inner_wait = wait;
let raw = match timeout(wait, transport.diagnostics_for(file, text, inner_wait)).await {
Ok(Ok(items)) => items,
Ok(Err(err)) => {
tracing::debug!(?err, file = %file.display(), "lsp: diagnostics call failed");
return None;
}
Err(_) => {
tracing::debug!(file = %file.display(), "lsp: diagnostics timed out");
return None;
}
};
let include_warnings = self.config.include_warnings;
let mut items: Vec<Diagnostic> = raw
.into_iter()
.filter(|d| match d.severity {
Severity::Error => true,
Severity::Warning => include_warnings,
_ => false,
})
.collect();
items.sort_by_key(|d| match d.severity {
Severity::Error => 0u8,
Severity::Warning => 1u8,
Severity::Information => 2u8,
Severity::Hint => 3u8,
});
let mut block = DiagnosticBlock {
file: relative_to_workspace(&self.workspace, file),
items,
};
block.truncate(self.config.max_diagnostics_per_file);
if block.items.is_empty() {
None
} else {
Some(block)
}
}
async fn diagnostics_for_custom(
&self,
file: &Path,
custom: &CustomLspDef,
) -> Option<DiagnosticBlock> {
let ext = file.extension()?.to_str()?.to_ascii_lowercase();
let text = match tokio::fs::read_to_string(file).await {
Ok(t) => t,
Err(err) => {
tracing::debug!(?err, file = %file.display(), "lsp: read file failed");
return None;
}
};
let transport = match self.transport_for_custom(&ext, custom).await {
Some(t) => t,
None => return None,
};
self.poll_diagnostics(file, &text, transport).await
}
async fn transport_for_custom(
&self,
ext: &str,
def: &CustomLspDef,
) -> Option<Arc<dyn LspTransport>> {
if let Some(t) = self.custom_transports.lock().await.get(ext) {
return Some(t.clone());
}
match StdioLspTransport::spawn(
&def.command,
&def.args,
&def.language_id,
self.workspace.clone(),
)
.await
{
Ok(t) => {
let arc: Arc<dyn LspTransport> = Arc::new(t);
self.custom_transports
.lock()
.await
.insert(ext.to_string(), arc.clone());
Some(arc)
}
Err(err) => {
let key = ext.to_string();
let mut warned = self.custom_missing_warned.lock().await;
if warned.insert(key) {
tracing::warn!(
extension = %ext,
command = %def.command,
error = %err,
"lsp: custom server unavailable; diagnostics disabled for this extension"
);
}
None
}
}
}
async fn transport_for(&self, lang: Language) -> Option<Arc<dyn LspTransport>> {
if let Some(t) = self.test_transports.lock().await.get(&lang) {
return Some(t.clone());
}
if let Some(t) = self.transports.lock().await.get(&lang) {
return Some(t.clone());
}
let (cmd, args) = self.config.resolve_command(lang)?;
match StdioLspTransport::spawn(&cmd, &args, lang.language_id(), self.workspace.clone())
.await
{
Ok(transport) => {
let arc: Arc<dyn LspTransport> = Arc::new(transport);
self.transports.lock().await.insert(lang, arc.clone());
Some(arc)
}
Err(err) => {
self.warn_missing_once(lang, &cmd, &err).await;
None
}
}
}
async fn warn_missing_once(&self, lang: Language, cmd: &str, err: &anyhow::Error) {
let mut warned = self.missing_warned.lock().await;
if warned.insert(lang) {
tracing::warn!(
language = %lang.as_key(),
command = %cmd,
error = %err,
"lsp: server unavailable; diagnostics disabled for this language"
);
}
}
async fn transport_for_path(&self, file: &Path) -> Option<Arc<dyn LspTransport>> {
if !self.config.enabled {
return None;
}
let lang = registry::detect_language(file);
if lang != Language::Other {
return self.transport_for(lang).await;
}
if let Some(custom) = self.config.custom_for_extension(file) {
let ext = file.extension()?.to_str()?.to_ascii_lowercase();
return self.transport_for_custom(&ext, custom).await;
}
None
}
pub async fn intelligence(
&self,
operation: &str,
file: &Path,
line: Option<u32>,
character: Option<u32>,
query: Option<&str>,
) -> Result<serde_json::Value, String> {
if !self.config.enabled {
return Err("LSP is disabled ([lsp] enabled = false)".to_string());
}
let wait = Duration::from_millis(self.config.poll_after_edit_ms);
match operation {
"diagnostics" => {
let block = self
.diagnostics_for(file, 0)
.await
.map(|b| {
serde_json::json!({
"file": b.file.display().to_string(),
"items": b.items.iter().map(|d| serde_json::json!({
"line": d.line,
"column": d.column,
"severity": format!("{:?}", d.severity).to_ascii_lowercase(),
"message": d.message,
})).collect::<Vec<_>>(),
})
})
.unwrap_or_else(|| {
serde_json::json!({
"file": relative_to_workspace(&self.workspace, file).display().to_string(),
"items": [],
})
});
Ok(block)
}
"symbols" | "definition" | "references" => {
let transport = self
.transport_for_path(file)
.await
.ok_or_else(|| format!("no LSP server for {}", file.display()))?;
let text = tokio::fs::read_to_string(file)
.await
.map_err(|err| format!("read {}: {err}", file.display()))?;
transport
.ensure_open(file, &text)
.await
.map_err(|err| err.to_string())?;
let uri = client::uri_from_path(file);
let result = match operation {
"symbols" => {
if let Some(q) = query.filter(|s| !s.trim().is_empty()) {
transport
.request(
"workspace/symbol",
serde_json::json!({ "query": q }),
wait,
)
.await
} else {
transport
.request(
"textDocument/documentSymbol",
serde_json::json!({
"textDocument": { "uri": uri }
}),
wait,
)
.await
}
}
"definition" => {
let line = line.ok_or("definition requires line (1-based)")?;
let character = character.unwrap_or(1);
transport
.request(
"textDocument/definition",
serde_json::json!({
"textDocument": { "uri": uri },
"position": {
"line": line.saturating_sub(1),
"character": character.saturating_sub(1),
}
}),
wait,
)
.await
}
"references" => {
let line = line.ok_or("references requires line (1-based)")?;
let character = character.unwrap_or(1);
transport
.request(
"textDocument/references",
serde_json::json!({
"textDocument": { "uri": uri },
"position": {
"line": line.saturating_sub(1),
"character": character.saturating_sub(1),
},
"context": { "includeDeclaration": true }
}),
wait,
)
.await
}
_ => unreachable!(),
}
.map_err(|err| err.to_string())?;
Ok(serde_json::json!({
"operation": operation,
"file": relative_to_workspace(&self.workspace, file).display().to_string(),
"result": truncate_intelligence_result(result),
}))
}
other => Err(format!(
"unknown LSP operation '{other}'; use diagnostics, symbols, definition, or references"
)),
}
}
#[allow(dead_code)]
pub async fn shutdown_all(&self) {
let transports: Vec<Arc<dyn LspTransport>> =
self.transports.lock().await.values().cloned().collect();
let custom: Vec<Arc<dyn LspTransport>> = self
.custom_transports
.lock()
.await
.values()
.cloned()
.collect();
for transport in transports {
transport.shutdown().await;
}
for transport in custom {
transport.shutdown().await;
}
}
}
impl LspConfig {
fn custom_for_extension(&self, file: &Path) -> Option<&CustomLspDef> {
let ext = file.extension()?.to_str()?;
self.custom.get(&ext.to_ascii_lowercase())
}
}
fn truncate_intelligence_result(value: serde_json::Value) -> serde_json::Value {
const MAX_ITEMS: usize = 40;
const MAX_CHARS: usize = 12_000;
match value {
serde_json::Value::Array(mut items) => {
let total = items.len();
if total > MAX_ITEMS {
items.truncate(MAX_ITEMS);
serde_json::json!({
"items": items,
"truncated": true,
"total": total,
})
} else {
serde_json::Value::Array(items)
}
}
other => {
let rendered = other.to_string();
if rendered.len() > MAX_CHARS {
serde_json::json!({
"truncated": true,
"preview": &rendered[..MAX_CHARS],
"total_chars": rendered.len(),
})
} else {
other
}
}
}
}
fn relative_to_workspace(workspace: &Path, path: &Path) -> PathBuf {
if let Ok(rel) = path.strip_prefix(workspace) {
return rel.to_path_buf();
}
PathBuf::from(
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| String::from("unknown")),
)
}
impl LspManager {
#[must_use]
pub fn disabled() -> Self {
Self::new(
LspConfig {
enabled: false,
..LspConfig::default()
},
PathBuf::new(),
)
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
pub(crate) struct FakeTransport {
items: Vec<Diagnostic>,
calls: AtomicUsize,
}
impl FakeTransport {
pub(crate) fn new(items: Vec<Diagnostic>) -> Self {
Self {
items,
calls: AtomicUsize::new(0),
}
}
pub(crate) fn call_count(&self) -> usize {
self.calls.load(Ordering::Relaxed)
}
}
#[async_trait]
impl LspTransport for FakeTransport {
async fn diagnostics_for(
&self,
_path: &Path,
_text: &str,
_wait: Duration,
) -> anyhow::Result<Vec<Diagnostic>> {
self.calls.fetch_add(1, Ordering::Relaxed);
Ok(self.items.clone())
}
async fn shutdown(&self) {}
}
#[tokio::test]
async fn returns_none_when_disabled() {
let mgr = LspManager::new(
LspConfig {
enabled: false,
..LspConfig::default()
},
PathBuf::from("/tmp"),
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("foo.rs");
tokio::fs::write(&path, b"fn main() {}").await.unwrap();
assert!(mgr.diagnostics_for(&path, 1).await.is_none());
}
#[tokio::test]
async fn returns_none_for_unknown_language() {
let dir = tempfile::tempdir().unwrap();
let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
let path = dir.path().join("notes.txt");
tokio::fs::write(&path, b"hi").await.unwrap();
assert!(mgr.diagnostics_for(&path, 1).await.is_none());
}
#[tokio::test]
async fn forwards_errors_through_fake_transport() {
let dir = tempfile::tempdir().unwrap();
let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
let path = dir.path().join("foo.rs");
tokio::fs::write(&path, b"let x: i32 = \"oops\";")
.await
.unwrap();
let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
line: 1,
column: 14,
severity: Severity::Error,
message: "expected i32, found &str".to_string(),
}]));
mgr.install_test_transport(Language::Rust, fake.clone())
.await;
let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
let rendered = block.render();
assert!(rendered.contains("ERROR [1:14] expected i32, found &str"));
assert!(rendered.contains("foo.rs"));
assert_eq!(fake.call_count(), 1);
}
#[tokio::test]
async fn drops_warnings_by_default() {
let dir = tempfile::tempdir().unwrap();
let mgr = LspManager::new(LspConfig::default(), dir.path().to_path_buf());
let path = dir.path().join("foo.rs");
tokio::fs::write(&path, b"fn main() {}").await.unwrap();
let fake = Arc::new(FakeTransport::new(vec![
Diagnostic {
line: 1,
column: 1,
severity: Severity::Warning,
message: "unused import".to_string(),
},
Diagnostic {
line: 2,
column: 1,
severity: Severity::Error,
message: "type error".to_string(),
},
]));
mgr.install_test_transport(Language::Rust, fake).await;
let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
assert_eq!(block.items.len(), 1);
assert_eq!(block.items[0].severity, Severity::Error);
}
#[tokio::test]
async fn keeps_warnings_when_opted_in() {
let dir = tempfile::tempdir().unwrap();
let mgr = LspManager::new(
LspConfig {
include_warnings: true,
..LspConfig::default()
},
dir.path().to_path_buf(),
);
let path = dir.path().join("foo.rs");
tokio::fs::write(&path, b"fn main() {}").await.unwrap();
let fake = Arc::new(FakeTransport::new(vec![
Diagnostic {
line: 1,
column: 1,
severity: Severity::Warning,
message: "unused".to_string(),
},
Diagnostic {
line: 2,
column: 1,
severity: Severity::Error,
message: "broken".to_string(),
},
]));
mgr.install_test_transport(Language::Rust, fake).await;
let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
assert_eq!(block.items.len(), 2);
assert_eq!(block.items[0].severity, Severity::Error);
assert_eq!(block.items[1].severity, Severity::Warning);
}
#[tokio::test]
async fn truncates_to_max_per_file() {
let dir = tempfile::tempdir().unwrap();
let mgr = LspManager::new(
LspConfig {
max_diagnostics_per_file: 3,
..LspConfig::default()
},
dir.path().to_path_buf(),
);
let path = dir.path().join("foo.rs");
tokio::fs::write(&path, b"fn main() {}").await.unwrap();
let fake = Arc::new(FakeTransport::new(
(0..10)
.map(|i| Diagnostic {
line: i + 1,
column: 1,
severity: Severity::Error,
message: format!("err {i}"),
})
.collect(),
));
mgr.install_test_transport(Language::Rust, fake).await;
let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
assert_eq!(block.items.len(), 3);
}
#[tokio::test]
async fn render_blocks_concatenates() {
let blocks = vec![
DiagnosticBlock {
file: PathBuf::from("a.rs"),
items: vec![Diagnostic {
line: 1,
column: 1,
severity: Severity::Error,
message: "err in a".to_string(),
}],
},
DiagnosticBlock {
file: PathBuf::from("b.rs"),
items: vec![Diagnostic {
line: 2,
column: 2,
severity: Severity::Error,
message: "err in b".to_string(),
}],
},
];
let rendered = render_blocks(&blocks);
assert!(rendered.contains("file=\"a.rs\""));
assert!(rendered.contains("file=\"b.rs\""));
}
#[test]
fn relative_path_falls_back_to_filename_when_outside_workspace() {
let workspace = PathBuf::from("/foo/bar");
let path = PathBuf::from("/baz/qux.rs");
assert_eq!(
relative_to_workspace(&workspace, &path),
PathBuf::from("qux.rs")
);
}
#[test]
fn config_resolve_uses_overrides() {
let mut cfg = LspConfig::default();
cfg.servers.insert(
"rust".to_string(),
vec!["custom-rls".to_string(), "--lsp".to_string()],
);
let (cmd, args) = cfg.resolve_command(Language::Rust).unwrap();
assert_eq!(cmd, "custom-rls");
assert_eq!(args, vec!["--lsp".to_string()]);
}
#[test]
fn config_resolve_falls_back_to_registry() {
let cfg = LspConfig::default();
let (cmd, _) = cfg.resolve_command(Language::Rust).unwrap();
assert_eq!(cmd, "rust-analyzer");
}
#[test]
fn custom_for_extension_none_for_empty_config() {
let cfg = LspConfig::default();
assert!(cfg.custom_for_extension(&PathBuf::from("foo.rb")).is_none());
}
#[test]
fn custom_for_extension_finds_registered_extension() {
let mut cfg = LspConfig::default();
cfg.custom.insert(
"rb".to_string(),
CustomLspDef {
language_id: "ruby".to_string(),
command: "ruby-lsp".to_string(),
args: vec!["--stdio".to_string()],
},
);
let def = cfg
.custom_for_extension(&PathBuf::from("lib/hello.rb"))
.expect("should find rb");
assert_eq!(def.language_id, "ruby");
assert_eq!(def.command, "ruby-lsp");
}
#[test]
fn custom_for_extension_case_insensitive() {
let mut cfg = LspConfig::default();
cfg.custom.insert(
"cs".to_string(),
CustomLspDef {
language_id: "csharp".to_string(),
command: "csharp-ls".to_string(),
args: vec![],
},
);
assert!(cfg.custom_for_extension(&PathBuf::from("App.CS")).is_some());
assert!(cfg.custom_for_extension(&PathBuf::from("App.Cs")).is_some());
}
#[tokio::test]
async fn custom_fallback_only_for_other_language() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = LspConfig::default();
cfg.custom.insert(
"go".to_string(),
CustomLspDef {
language_id: "go".to_string(),
command: "custom-gopls".to_string(),
args: vec![],
},
);
let mgr = LspManager::new(cfg, dir.path().to_path_buf());
let path = dir.path().join("main.go");
tokio::fs::write(&path, b"package main\n").await.unwrap();
let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
line: 1,
column: 1,
severity: Severity::Error,
message: "builtin-go-diag".to_string(),
}]));
mgr.install_test_transport(Language::Go, fake).await;
let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
let rendered = block.render();
assert!(
rendered.contains("builtin-go-diag"),
"should use built-in Go transport, not custom override: {rendered}"
);
}
#[tokio::test]
async fn diagnostics_for_custom_returns_diagnostics() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = LspConfig::default();
cfg.custom.insert(
"rb".to_string(),
CustomLspDef {
language_id: "ruby".to_string(),
command: "ruby-lsp".to_string(),
args: vec![],
},
);
let mgr = LspManager::new(cfg, dir.path().to_path_buf());
let path = dir.path().join("app.rb");
tokio::fs::write(&path, b"def foo; end\n").await.unwrap();
let fake = Arc::new(FakeTransport::new(vec![Diagnostic {
line: 1,
column: 5,
severity: Severity::Error,
message: "ruby type error".to_string(),
}]));
mgr.custom_transports
.lock()
.await
.insert("rb".to_string(), fake.clone());
let block = mgr.diagnostics_for(&path, 1).await.expect("has block");
let rendered = block.render();
assert!(rendered.contains("ruby type error"));
assert_eq!(fake.call_count(), 1);
}
#[tokio::test]
async fn custom_unregistered_extension_returns_none() {
let dir = tempfile::tempdir().unwrap();
let cfg = LspConfig::default();
let mgr = LspManager::new(cfg, dir.path().to_path_buf());
let path = dir.path().join("script.lua");
tokio::fs::write(&path, b"print('hi')\n").await.unwrap();
assert!(mgr.diagnostics_for(&path, 1).await.is_none());
}
}