use std::path::Path;
use std::sync::LazyLock;
use crate::languages::Languages;
#[derive(Debug, Clone)]
pub struct ServerSpec<'a> {
pub name: &'a str,
pub command: &'a str,
pub args: &'a [String],
pub install_hint: Option<&'a str>,
pub init_options: Option<&'a serde_json::Value>,
pub project_executable: bool,
}
impl ServerSpec<'_> {
pub fn absolute_command(&self) -> bool {
Path::new(self.command).is_absolute()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandStatus {
Executable,
Unrunnable(&'static str),
}
pub fn command_status(
spec: &ServerSpec<'_>,
root: &Path,
path: Option<&std::ffi::OsStr>,
) -> CommandStatus {
let command = spec.command;
if spec.absolute_command() || command.contains(std::path::is_separator) {
return file_status(&root.join(command));
}
let Some(path) = path else {
return CommandStatus::Unrunnable("not found on PATH");
};
for dir in std::env::split_paths(path) {
let candidate = if dir.as_os_str().is_empty() {
root.join(command)
} else {
dir.join(command)
};
if let CommandStatus::Executable = file_status(&candidate) {
return CommandStatus::Executable;
}
}
CommandStatus::Unrunnable("not found on PATH")
}
fn file_status(path: &Path) -> CommandStatus {
match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() && executable(&metadata) => CommandStatus::Executable,
Ok(_) => CommandStatus::Unrunnable("not executable"),
Err(_) => CommandStatus::Unrunnable("no such file"),
}
}
#[cfg(unix)]
fn executable(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn executable(_metadata: &std::fs::Metadata) -> bool {
true
}
struct EmbeddedServer {
name: &'static str,
command: &'static str,
args: Vec<String>,
hint: &'static str,
}
fn embedded_spec(e: &'static EmbeddedServer) -> ServerSpec<'static> {
ServerSpec {
name: e.name,
command: e.command,
args: &e.args,
install_hint: Some(e.hint),
init_options: None,
project_executable: false,
}
}
static EMBEDDED: LazyLock<Vec<EmbeddedServer>> = LazyLock::new(|| {
vec![
EmbeddedServer {
name: "rust-analyzer",
command: "rust-analyzer",
args: vec![],
hint: "rustup component add rust-analyzer",
},
EmbeddedServer {
name: "clangd",
command: "clangd",
args: vec![],
hint: "install clangd (brew install llvm / apt install clangd)",
},
EmbeddedServer {
name: "pyright",
command: "pyright-langserver",
args: vec!["--stdio".into()],
hint: "npm i -g pyright",
},
EmbeddedServer {
name: "gopls",
command: "gopls",
args: vec![],
hint: "go install golang.org/x/tools/gopls@latest",
},
EmbeddedServer {
name: "typescript-language-server",
command: "typescript-language-server",
args: vec!["--stdio".into()],
hint: "npm i -g typescript-language-server typescript",
},
EmbeddedServer {
name: "vscode-json-language-server",
command: "vscode-json-language-server",
args: vec!["--stdio".into()],
hint: "npm i -g vscode-langservers-extracted",
},
EmbeddedServer {
name: "bash-language-server",
command: "bash-language-server",
args: vec!["start".into()],
hint: "npm i -g bash-language-server",
},
]
});
fn embedded_by_name(name: &str) -> Option<&'static EmbeddedServer> {
EMBEDDED.iter().find(|e| e.name == name)
}
pub(crate) fn is_embedded(name: &str) -> bool {
embedded_by_name(name).is_some()
}
pub fn language_for_extension(ext_with_dot: &str) -> Option<&'static str> {
strop_core::languages::language_for_extension(ext_with_dot)
}
pub fn language_for_extension_name(ext: &str) -> Option<&'static str> {
strop_core::languages::language_for_extension_name(ext)
}
fn embedded_name_for_language(lang: &str) -> Option<&'static str> {
Some(match lang {
"rust" => "rust-analyzer",
"c" | "cpp" => "clangd",
"python" => "pyright",
"go" => "gopls",
"javascript" | "typescript" => "typescript-language-server",
"json" => "vscode-json-language-server",
"shellscript" => "bash-language-server",
_ => return None,
})
}
pub fn for_extension<'a>(ext: &str, cfg: &'a Languages) -> Option<ServerSpec<'a>> {
let lang = language_for_extension(ext)?;
if let Some(names) = cfg
.languages
.get(lang)
.map(|l| l.language_servers.as_slice())
{
for name in names {
if let Some(spec) = server_by_name(cfg, name) {
return Some(spec);
}
}
}
server_by_name(cfg, embedded_name_for_language(lang)?)
}
fn server_by_name<'a>(cfg: &'a Languages, name: &str) -> Option<ServerSpec<'a>> {
let emb = embedded_by_name(name);
let Some((key, def)) = cfg.servers.get_key_value(name) else {
return emb.map(embedded_spec);
};
let command = def.command.as_deref().or(emb.map(|e| e.command))?;
let project_executable = cfg.project_root.is_some() && cfg.project_commands.contains(name);
Some(ServerSpec {
name: key.as_str(),
command,
args: def
.args
.as_deref()
.or(emb.map(|e| e.args.as_slice()))
.unwrap_or(&[]),
install_hint: if def.command.is_none() {
emb.map(|e| e.hint)
} else {
None
},
init_options: def.config.as_ref(),
project_executable,
})
}
pub fn workspace_root(path: &Path, cwd: &Path) -> std::path::PathBuf {
let mut dir = if path.is_absolute() {
path.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| cwd.to_path_buf())
} else {
cwd.to_path_buf()
};
loop {
if dir.join(".git").exists() {
return dir;
}
if !dir.pop() {
return cwd.to_path_buf();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn curated_languages_have_servers() {
let cfg = Languages::default();
for ext in [".rs", ".cpp", ".py", ".go", ".ts", ".json", ".sh"] {
assert!(for_extension(ext, &cfg).is_some(), "missing {ext}");
}
assert!(for_extension(".xyz", &cfg).is_none());
}
#[test]
fn command_status_is_a_pure_metadata_check() {
let fixture = tempfile::tempdir().unwrap();
let dir = fixture.path();
let root = dir.join("root");
let bin = dir.join("bin");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&bin).unwrap();
fn status_for(command: &str, root: &Path, path: Option<&std::ffi::OsStr>) -> CommandStatus {
let merged = Languages::merge(
None,
Some(
toml::from_str(&format!(
"[language-server.custom]\ncommand = \"{command}\"\n\
\n[language.python]\nlanguage-servers = [\"custom\"]\n"
))
.unwrap(),
),
);
let spec = for_extension(".py", &merged).unwrap();
command_status(&spec, root, path)
}
assert_eq!(
status_for("/nonexistent/custom-lsp", &root, None),
CommandStatus::Unrunnable("no such file")
);
let tool = dir.join("tool");
std::fs::write(&tool, b"#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
status_for(tool.to_str().unwrap(), &root, None),
CommandStatus::Unrunnable("not executable")
);
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
}
assert_eq!(
status_for(tool.to_str().unwrap(), &root, None),
CommandStatus::Executable
);
std::fs::create_dir_all(root.join("sub")).unwrap();
let rel = root.join("sub/rel-lsp");
std::fs::write(&rel, b"#!/bin/sh\n").unwrap();
make_executable(&rel);
assert_eq!(
status_for("sub/rel-lsp", &root, None),
CommandStatus::Executable
);
let onpath = bin.join("onpath-lsp");
std::fs::write(&onpath, b"#!/bin/sh\n").unwrap();
make_executable(&onpath);
assert_eq!(
status_for("onpath-lsp", &root, Some(bin.as_os_str())),
CommandStatus::Executable
);
assert_eq!(
status_for("nowhere-lsp", &root, Some(bin.as_os_str())),
CommandStatus::Unrunnable("not found on PATH")
);
assert_eq!(
status_for("onpath-lsp", &root, None),
CommandStatus::Unrunnable("not found on PATH")
);
let incwd = root.join("incwd-lsp");
std::fs::write(&incwd, b"#!/bin/sh\n").unwrap();
make_executable(&incwd);
assert_eq!(
status_for("incwd-lsp", &root, Some(std::ffi::OsStr::new(""))),
CommandStatus::Executable
);
}
#[cfg(unix)]
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).unwrap();
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) {}
#[test]
fn root_walks_to_git() {
let dir = std::env::temp_dir().join("strop-lsp-root");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src/deep")).unwrap();
std::fs::create_dir_all(dir.join(".git")).unwrap();
let root = workspace_root(&dir.join("src/deep/f.rs"), &dir);
assert_eq!(root, dir);
let _ = std::fs::remove_dir_all(&dir);
}
}