use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct LanguagesToml {
#[serde(rename = "language-server")]
pub language_server: BTreeMap<String, ServerDef>,
pub language: BTreeMap<String, LanguageDef>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct ServerDef {
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub config: Option<serde_json::Value>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct LanguageDef {
#[serde(rename = "language-servers")]
pub language_servers: Vec<String>,
}
#[derive(Debug, Default, Clone)]
pub struct Languages {
pub servers: BTreeMap<String, ServerDef>,
pub languages: BTreeMap<String, LanguageDef>,
pub project_root: Option<PathBuf>,
warnings: Vec<String>,
}
impl Languages {
pub fn warnings(&self) -> &[String] {
&self.warnings
}
pub fn load(xdg: Option<&Path>, project: Option<&Path>) -> Self {
let mut warnings = Vec::new();
let xdg_file = xdg.and_then(|p| read_layer(p, &mut warnings));
let project_file = project.and_then(|p| read_layer(p, &mut warnings));
let mut merged = Self::merge(xdg_file, project_file);
merged.project_root = project
.and_then(|p| p.parent().and_then(Path::parent))
.map(Path::to_path_buf);
warnings.append(&mut merged.warnings);
merged.warnings = warnings;
merged
}
fn merge(xdg: Option<LanguagesToml>, project: Option<LanguagesToml>) -> Self {
let mut servers = BTreeMap::new();
let mut languages = BTreeMap::new();
for layer in [xdg, project].into_iter().flatten() {
servers.extend(layer.language_server);
languages.extend(layer.language);
}
let mut warnings = Vec::new();
let unspawnable: Vec<String> = servers
.iter()
.filter(|(name, def)| def.command.is_none() && !crate::registry::is_embedded(name))
.map(|(name, _)| name.clone())
.collect();
for name in &unspawnable {
servers.remove(name);
warnings.push(format!("language-server.{name}: no command — ignored"));
}
for (lang, def) in &languages {
for name in &def.language_servers {
if !servers.contains_key(name) && !crate::registry::is_embedded(name) {
warnings.push(format!("language.{lang}: unknown server {name}"));
}
}
}
Self {
servers,
languages,
project_root: None,
warnings,
}
}
}
pub fn xdg_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("strop").join("languages.toml"))
}
pub fn project_path(buffer: &Path) -> Option<PathBuf> {
let mut dir = buffer.parent()?.to_path_buf();
loop {
let f = dir.join(".strop").join("languages.toml");
if f.is_file() {
return Some(f);
}
if !dir.pop() {
return None;
}
}
}
fn read_layer(path: &Path, warnings: &mut Vec<String>) -> Option<LanguagesToml> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
warnings.push(format!("{}: {e} — layer ignored", path.display()));
return None;
}
};
match toml::from_str::<LanguagesToml>(&text) {
Ok(f) => Some(f),
Err(e) => {
warnings.push(format!("{}: {e} — layer ignored", path.display()));
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry;
const HELIX_STYLE: &str = r#"
[language-server.mypy-lsp]
command = "mypy-langserver"
args = ["--stdio"]
[language-server.pyright.config.python.analysis]
extraPaths = ["lib", "tools"]
[language.python]
language-servers = ["pyright", "mypy-lsp"]
"#;
fn file(text: &str) -> LanguagesToml {
toml::from_str(text).unwrap()
}
#[test]
fn parses_helix_style_fixture() {
let f = file(HELIX_STYLE);
let pyright = f.language_server.get("pyright").unwrap();
assert_eq!(pyright.command, None); let extra = &pyright.config.as_ref().unwrap()["python"]["analysis"]["extraPaths"];
assert_eq!(extra, &serde_json::json!(["lib", "tools"]));
let mypy = f.language_server.get("mypy-lsp").unwrap();
assert_eq!(mypy.command.as_deref(), Some("mypy-langserver"));
assert_eq!(
mypy.args.as_deref(),
Some(["--stdio".to_string()].as_slice())
);
assert_eq!(
f.language["python"].language_servers,
["pyright", "mypy-lsp"]
);
}
#[test]
fn resolution_project_over_xdg_over_embedded() {
let xdg = file(
r#"
[language-server.pyright.config.python.analysis]
extraPaths = ["xdg"]
"#,
);
let project = file(
r#"
[language-server.pyright.config.python.analysis]
extraPaths = ["project"]
"#,
);
let merged = Languages::merge(Some(xdg), Some(project));
let spec = registry::for_extension(".py", &merged).unwrap();
let extra = &spec.init_options.unwrap()["python"]["analysis"]["extraPaths"];
assert_eq!(extra, &serde_json::json!(["project"]));
assert_eq!(spec.command, "pyright-langserver");
assert_eq!(spec.args.first().map(String::as_str), Some("--stdio"));
let go = registry::for_extension(".go", &merged).unwrap();
assert_eq!(go.name, "gopls");
assert!(go.init_options.is_none());
let only_xdg = Languages::merge(
Some(file(
r#"
[language-server.pyright.config.python.analysis]
extraPaths = ["xdg"]
"#,
)),
None,
);
let spec = registry::for_extension(".py", &only_xdg).unwrap();
let extra = &spec.init_options.unwrap()["python"]["analysis"]["extraPaths"];
assert_eq!(extra, &serde_json::json!(["xdg"]));
}
#[test]
fn language_override_picks_the_config_defined_server() {
let merged = Languages::merge(None, Some(file(HELIX_STYLE)));
let spec = registry::for_extension(".py", &merged).unwrap();
assert_eq!(spec.name, "pyright");
assert_eq!(spec.install_hint, Some("npm i -g pyright"));
let swapped = Languages::merge(
None,
Some(file(
r#"
[language-server.mypy-lsp]
command = "mypy-langserver"
args = ["--stdio"]
[language.python]
language-servers = ["mypy-lsp"]
"#,
)),
);
let spec = registry::for_extension(".py", &swapped).unwrap();
assert_eq!(spec.command, "mypy-langserver");
assert_eq!(spec.install_hint, None); assert!(spec.init_options.is_none());
}
#[test]
fn absolute_commands_skip_the_path_probe() {
let merged = Languages::merge(
None,
Some(file(
r#"
[language-server.custom]
command = "/opt/lsp/custom-lsp"
[language.python]
language-servers = ["custom"]
"#,
)),
);
let spec = registry::for_extension(".py", &merged).unwrap();
assert!(spec.absolute_command());
assert!(!registry::for_extension(".rs", &merged)
.unwrap()
.absolute_command());
}
#[test]
fn per_language_granularity_of_the_merge() {
let merged = Languages::merge(
Some(file(
r#"
[language.rust]
language-servers = ["rust-analyzer"]
"#,
)),
Some(file(
r#"
[language.python]
language-servers = ["pyright"]
"#,
)),
);
assert_eq!(
registry::for_extension(".rs", &merged).unwrap().name,
"rust-analyzer"
);
assert_eq!(
registry::for_extension(".py", &merged).unwrap().name,
"pyright"
);
}
#[test]
fn project_root_is_the_project_layers_dir() {
let dir = std::env::temp_dir().join("strop-lsp-languages");
let _ = std::fs::remove_dir_all(&dir);
let layer = dir.join(".strop");
std::fs::create_dir_all(&layer).unwrap();
std::fs::write(layer.join("languages.toml"), HELIX_STYLE).unwrap();
let loaded = Languages::load(None, Some(&layer.join("languages.toml")));
assert_eq!(loaded.project_root.as_deref(), Some(dir.as_path()));
assert!(loaded.warnings().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn broken_layer_warns_and_never_bricks() {
let dir = std::env::temp_dir().join("strop-lsp-languages-broken");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let bad = dir.join("languages.toml");
std::fs::write(&bad, "language-server = 3").unwrap();
let loaded = Languages::load(Some(&bad), None);
assert!(!loaded.warnings().is_empty());
assert!(registry::for_extension(".rs", &loaded).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unspawnable_and_unknown_names_warn() {
let merged = Languages::merge(
None,
Some(file(
r#"
[language-server.ghost]
args = ["x"]
[language.python]
language-servers = ["nope"]
"#,
)),
);
assert!(merged.warnings().iter().any(|w| w.contains("ghost")));
assert!(merged.warnings().iter().any(|w| w.contains("nope")));
let spec = registry::for_extension(".py", &merged).unwrap();
assert_eq!(spec.name, "pyright");
}
#[test]
fn helix_extra_keys_are_ignored() {
let merged = Languages::merge(
None,
Some(file(
r#"
[language-server.pyright]
scope = "source.python"
[language.python]
file-types = ["py"]
roots = ["pyproject.toml"]
language-servers = ["pyright"]
"#,
)),
);
assert!(merged.warnings().is_empty());
assert_eq!(
registry::for_extension(".py", &merged).unwrap().name,
"pyright"
);
}
#[test]
fn project_layer_discovery_walks_up() {
let dir = std::env::temp_dir().join("strop-lsp-languages-walk");
let _ = std::fs::remove_dir_all(&dir);
let layer = dir.join(".strop");
std::fs::create_dir_all(dir.join("src/deep")).unwrap();
std::fs::create_dir_all(&layer).unwrap();
std::fs::write(layer.join("languages.toml"), "").unwrap();
let found = project_path(&dir.join("src/deep/main.py")).unwrap();
assert_eq!(found, layer.join("languages.toml"));
assert!(project_path(&std::env::temp_dir().join("nowhere.rs")).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}