use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use tracing::{debug, info, warn};
pub type RootDetector = Arc<dyn Fn(&Path) -> Option<PathBuf> + Send + Sync>;
pub type SpawnFn = Arc<dyn Fn(&Path) -> Result<LspServerHandle> + Send + Sync>;
pub struct LspServerHandle {
pub process: Child,
pub initialization: Option<serde_json::Value>,
}
pub struct LspServerInfo {
pub id: String,
pub extensions: Vec<String>,
pub global: bool,
pub root_detector: RootDetector,
pub spawn_fn: SpawnFn,
}
impl LspServerInfo {
pub fn new(
id: impl Into<String>,
extensions: Vec<&str>,
root_detector: RootDetector,
spawn_fn: SpawnFn,
) -> Self {
Self {
id: id.into(),
extensions: extensions.into_iter().map(String::from).collect(),
global: false,
root_detector,
spawn_fn,
}
}
pub fn handles_extension(&self, ext: &str) -> bool {
self.extensions.is_empty() || self.extensions.iter().any(|e| e == ext)
}
pub fn detect_root(&self, file: &Path) -> Option<PathBuf> {
(self.root_detector)(file)
}
pub fn spawn(&self, root: &Path) -> Result<LspServerHandle> {
(self.spawn_fn)(root)
}
}
pub struct LspRegistry {
servers: HashMap<String, LspServerInfo>,
}
impl Default for LspRegistry {
fn default() -> Self {
Self::new()
}
}
impl LspRegistry {
pub fn new() -> Self {
let mut registry = Self {
servers: HashMap::new(),
};
registry.register(Self::rust_analyzer());
registry.register(Self::typescript());
registry.register(Self::pyright());
registry.register(Self::gopls());
registry.register(Self::deno());
registry.register(Self::clangd());
registry.register(Self::lua_ls());
registry.register(Self::css());
registry.register(Self::html());
registry.register(Self::json());
registry.register(Self::yaml());
registry.register(Self::vue());
registry.register(Self::svelte());
registry.register(Self::tailwindcss());
registry.register(Self::eslint());
registry.register(Self::ruby());
registry.register(Self::php());
registry.register(Self::zig());
registry
}
pub fn register(&mut self, server: LspServerInfo) {
info!(server_id = %server.id, "Registering LSP server");
self.servers.insert(server.id.clone(), server);
}
pub fn get(&self, id: &str) -> Option<&LspServerInfo> {
self.servers.get(id)
}
pub fn servers_for_extension(&self, ext: &str) -> Vec<&LspServerInfo> {
self.servers
.values()
.filter(|s| s.handles_extension(ext))
.collect()
}
pub fn all_servers(&self) -> impl Iterator<Item = &LspServerInfo> {
self.servers.values()
}
pub fn server_ids(&self) -> Vec<&str> {
self.servers.keys().map(|s| s.as_str()).collect()
}
fn rust_analyzer() -> LspServerInfo {
LspServerInfo::new(
"rust-analyzer",
vec![".rs"],
Arc::new(|file| find_project_root(file, &["Cargo.toml"])),
Arc::new(|root| {
let binary = which::which("rust-analyzer")
.context("rust-analyzer not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning rust-analyzer");
let process = Command::new(binary)
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn rust-analyzer")?;
Ok(LspServerHandle {
process,
initialization: Some(serde_json::json!({
"checkOnSave": {
"command": "clippy"
}
})),
})
}),
)
}
fn typescript() -> LspServerInfo {
LspServerInfo::new(
"typescript",
vec![".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
Arc::new(|file| {
if find_project_root(file, &["deno.json", "deno.jsonc"]).is_some() {
return None;
}
find_project_root(
file,
&[
"package-lock.json",
"bun.lockb",
"bun.lock",
"pnpm-lock.yaml",
"yarn.lock",
"package.json",
],
)
}),
Arc::new(|root| {
let binary = which::which("typescript-language-server")
.context("typescript-language-server not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning typescript-language-server");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn typescript-language-server")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn pyright() -> LspServerInfo {
LspServerInfo::new(
"pyright",
vec![".py", ".pyi"],
Arc::new(|file| {
find_project_root(
file,
&[
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"pyrightconfig.json",
],
)
}),
Arc::new(|root| {
let binary = which::which("pyright-langserver")
.or_else(|_| which::which("pyright"))
.context("pyright not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning pyright");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn pyright")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn gopls() -> LspServerInfo {
LspServerInfo::new(
"gopls",
vec![".go"],
Arc::new(|file| find_project_root(file, &["go.mod", "go.work"])),
Arc::new(|root| {
let binary =
which::which("gopls").context("gopls not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning gopls");
let process = Command::new(binary)
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn gopls")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn deno() -> LspServerInfo {
LspServerInfo::new(
"deno",
vec![".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"],
Arc::new(|file| {
find_project_root(file, &["deno.json", "deno.jsonc"])
}),
Arc::new(|root| {
let binary = which::which("deno").context("deno not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning deno lsp");
let process = Command::new(binary)
.arg("lsp")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn deno lsp")?;
Ok(LspServerHandle {
process,
initialization: Some(serde_json::json!({
"enable": true,
"lint": true,
"unstable": false
})),
})
}),
)
}
fn clangd() -> LspServerInfo {
LspServerInfo::new(
"clangd",
vec![".c", ".h", ".cpp", ".hpp", ".cc", ".hh", ".cxx", ".hxx", ".C", ".H"],
Arc::new(|file| {
find_project_root(
file,
&["compile_commands.json", "CMakeLists.txt", "Makefile", ".clangd"],
)
}),
Arc::new(|root| {
let binary = which::which("clangd").context("clangd not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning clangd");
let process = Command::new(binary)
.arg("--background-index")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn clangd")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn lua_ls() -> LspServerInfo {
LspServerInfo::new(
"lua-language-server",
vec![".lua"],
Arc::new(|file| {
find_project_root(file, &[".luarc.json", ".luarc.jsonc", ".luacheckrc"])
.or_else(|| file.parent().map(|p| p.to_path_buf()))
}),
Arc::new(|root| {
let binary = which::which("lua-language-server")
.context("lua-language-server not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning lua-language-server");
let process = Command::new(binary)
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn lua-language-server")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn css() -> LspServerInfo {
LspServerInfo::new(
"css",
vec![".css", ".scss", ".sass", ".less"],
Arc::new(|file| {
find_project_root(file, &["package.json"]).or_else(|| file.parent().map(|p| p.to_path_buf()))
}),
Arc::new(|root| {
let binary = which::which("vscode-css-language-server")
.or_else(|_| which::which("css-languageserver"))
.context("css language server not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning CSS language server");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn CSS language server")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn html() -> LspServerInfo {
LspServerInfo::new(
"html",
vec![".html", ".htm"],
Arc::new(|file| {
find_project_root(file, &["package.json"]).or_else(|| file.parent().map(|p| p.to_path_buf()))
}),
Arc::new(|root| {
let binary = which::which("vscode-html-language-server")
.or_else(|_| which::which("html-languageserver"))
.context("HTML language server not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning HTML language server");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn HTML language server")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn json() -> LspServerInfo {
LspServerInfo::new(
"json",
vec![".json", ".jsonc"],
Arc::new(|file| file.parent().map(|p| p.to_path_buf())),
Arc::new(|root| {
let binary = which::which("vscode-json-language-server")
.or_else(|_| which::which("json-languageserver"))
.context("JSON language server not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning JSON language server");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn JSON language server")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn yaml() -> LspServerInfo {
LspServerInfo::new(
"yaml",
vec![".yaml", ".yml"],
Arc::new(|file| file.parent().map(|p| p.to_path_buf())),
Arc::new(|root| {
let binary =
which::which("yaml-language-server").context("yaml-language-server not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning YAML language server");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn yaml-language-server")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn vue() -> LspServerInfo {
LspServerInfo::new(
"vue",
vec![".vue"],
Arc::new(|file| find_project_root(file, &["package.json", "vue.config.js", "vite.config.js"])),
Arc::new(|root| {
let binary = which::which("vue-language-server")
.or_else(|_| which::which("volar-server"))
.context("Vue language server not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning Vue language server");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn Vue language server")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn svelte() -> LspServerInfo {
LspServerInfo::new(
"svelte",
vec![".svelte"],
Arc::new(|file| find_project_root(file, &["package.json", "svelte.config.js"])),
Arc::new(|root| {
let binary = which::which("svelteserver")
.or_else(|_| which::which("svelte-language-server"))
.context("Svelte language server not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning Svelte language server");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn Svelte language server")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn tailwindcss() -> LspServerInfo {
LspServerInfo::new(
"tailwindcss",
vec![".html", ".jsx", ".tsx", ".vue", ".svelte"],
Arc::new(|file| {
find_project_root(file, &["tailwind.config.js", "tailwind.config.ts", "tailwind.config.cjs"])
}),
Arc::new(|root| {
let binary = which::which("tailwindcss-language-server")
.context("tailwindcss-language-server not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning Tailwind CSS language server");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn tailwindcss-language-server")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn eslint() -> LspServerInfo {
LspServerInfo::new(
"eslint",
vec![".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"],
Arc::new(|file| {
find_project_root(
file,
&[
".eslintrc",
".eslintrc.js",
".eslintrc.json",
".eslintrc.yaml",
".eslintrc.yml",
"eslint.config.js",
"eslint.config.mjs",
],
)
}),
Arc::new(|root| {
let binary = which::which("vscode-eslint-language-server")
.or_else(|_| which::which("eslint-language-server"))
.context("ESLint language server not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning ESLint language server");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn ESLint language server")?;
Ok(LspServerHandle {
process,
initialization: Some(serde_json::json!({
"validate": "on",
"packageManager": "npm",
"codeActionOnSave": {
"enable": false
}
})),
})
}),
)
}
fn ruby() -> LspServerInfo {
LspServerInfo::new(
"solargraph",
vec![".rb", ".rake", ".gemspec"],
Arc::new(|file| {
find_project_root(file, &["Gemfile", ".solargraph.yml", "Rakefile"])
}),
Arc::new(|root| {
let binary =
which::which("solargraph").context("solargraph not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning Solargraph");
let process = Command::new(binary)
.arg("stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn solargraph")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn php() -> LspServerInfo {
LspServerInfo::new(
"intelephense",
vec![".php", ".phtml"],
Arc::new(|file| {
find_project_root(file, &["composer.json", "index.php"])
}),
Arc::new(|root| {
let binary =
which::which("intelephense").context("intelephense not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning Intelephense");
let process = Command::new(binary)
.arg("--stdio")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn intelephense")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
fn zig() -> LspServerInfo {
LspServerInfo::new(
"zls",
vec![".zig"],
Arc::new(|file| {
find_project_root(file, &["build.zig", "zls.json"])
}),
Arc::new(|root| {
let binary = which::which("zls").context("zls not found in PATH")?;
debug!(binary = ?binary, root = ?root, "Spawning ZLS");
let process = Command::new(binary)
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("Failed to spawn zls")?;
Ok(LspServerHandle {
process,
initialization: None,
})
}),
)
}
}
pub fn find_project_root(file: &Path, markers: &[&str]) -> Option<PathBuf> {
let start_dir = if file.is_file() {
file.parent()?
} else {
file
};
let mut current = start_dir;
loop {
for marker in markers {
if current.join(marker).exists() {
return Some(current.to_path_buf());
}
}
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
None
}
pub fn find_project_root_with_stop(
file: &Path,
markers: &[&str],
stop_at: &Path,
) -> Option<PathBuf> {
let start_dir = if file.is_file() {
file.parent()?
} else {
file
};
let mut current = start_dir;
loop {
if current == stop_at {
break;
}
for marker in markers {
if current.join(marker).exists() {
return Some(current.to_path_buf());
}
}
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_find_project_root() {
let temp = tempdir().unwrap();
let project_dir = temp.path().join("my-project");
let src_dir = project_dir.join("src");
let nested_dir = src_dir.join("nested");
fs::create_dir_all(&nested_dir).unwrap();
fs::write(project_dir.join("Cargo.toml"), "").unwrap();
let file = nested_dir.join("main.rs");
fs::write(&file, "").unwrap();
let root = find_project_root(&file, &["Cargo.toml"]);
assert_eq!(root, Some(project_dir));
}
#[test]
fn test_find_project_root_not_found() {
let temp = tempdir().unwrap();
let file = temp.path().join("orphan.rs");
fs::write(&file, "").unwrap();
let root = find_project_root(&file, &["Cargo.toml"]);
assert_eq!(root, None);
}
#[test]
fn test_registry_default_servers() {
let registry = LspRegistry::new();
assert!(registry.get("rust-analyzer").is_some());
assert!(registry.get("typescript").is_some());
assert!(registry.get("pyright").is_some());
assert!(registry.get("gopls").is_some());
}
#[test]
fn test_servers_for_extension() {
let registry = LspRegistry::new();
let rust_servers = registry.servers_for_extension(".rs");
assert_eq!(rust_servers.len(), 1);
assert_eq!(rust_servers[0].id, "rust-analyzer");
let ts_servers = registry.servers_for_extension(".ts");
assert_eq!(ts_servers.len(), 1);
assert_eq!(ts_servers[0].id, "typescript");
}
}