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, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LayerDiagnostic {
#[serde(with = "strop_core::path_serde")]
pub path: PathBuf,
pub message: String,
#[serde(default)]
pub remote: Option<strop_workspace::RemoteEndpoint>,
}
impl LayerDiagnostic {
fn new(path: &Path, message: String) -> Self {
Self {
path: path.to_path_buf(),
message,
remote: None,
}
}
pub fn remote_layer(
endpoint: &strop_workspace::RemoteEndpoint,
path: &Path,
message: String,
) -> Self {
Self {
path: path.to_path_buf(),
message,
remote: Some(endpoint.clone()),
}
}
pub fn display(&self) -> String {
match &self.remote {
Some(endpoint) => format!("{endpoint}{}: {}", self.path.display(), self.message),
None => format!("{}: {}", self.path.display(), self.message),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct Languages {
pub servers: BTreeMap<String, ServerDef>,
pub languages: BTreeMap<String, LanguageDef>,
pub project_root: Option<PathBuf>,
pub(crate) project_commands: std::collections::BTreeSet<String>,
warnings: Vec<String>,
layer_diagnostics: Vec<LayerDiagnostic>,
}
#[derive(Debug, Clone, Copy)]
pub struct RemoteLayer<'a> {
pub endpoint: &'a strop_workspace::RemoteEndpoint,
pub path: &'a Path,
pub bytes: &'a [u8],
}
impl Languages {
pub fn warnings(&self) -> &[String] {
&self.warnings
}
pub fn layer_diagnostics(&self) -> &[LayerDiagnostic] {
&self.layer_diagnostics
}
pub fn load(xdg: Option<&Path>, project: Option<&Path>) -> Self {
let mut layers = Vec::new();
let xdg_file = xdg.and_then(|p| read_layer(p, &mut layers));
let project_file = project.and_then(|p| read_layer(p, &mut layers));
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);
merged.layer_diagnostics = layers;
merged
}
pub fn load_remote(xdg: Option<&Path>, project: Option<RemoteLayer<'_>>) -> Self {
let mut layers = Vec::new();
let xdg_file = xdg.and_then(|p| read_layer(p, &mut layers));
let project_file = project.and_then(|layer| match std::str::from_utf8(layer.bytes) {
Ok(text) => parse_layer(
layer.path,
text,
&mut layers,
|path: &Path, message: String| {
LayerDiagnostic::remote_layer(layer.endpoint, path, message)
},
),
Err(error) => {
layers.push(LayerDiagnostic::remote_layer(
layer.endpoint,
layer.path,
format!("invalid UTF-8 ({error}) — layer ignored"),
));
None
}
});
let mut merged = Self::merge(xdg_file, project_file);
merged.project_root = project
.and_then(|layer| layer.path.parent().and_then(Path::parent))
.map(Path::to_path_buf);
merged.layer_diagnostics = layers;
merged
}
pub(crate) fn merge(xdg: Option<LanguagesToml>, project: Option<LanguagesToml>) -> Self {
let project_commands = project
.as_ref()
.map(|layer| {
layer
.language_server
.iter()
.filter(|(_, definition)| {
definition.command.is_some() || definition.args.is_some()
})
.map(|(name, _)| name.clone())
.collect()
})
.unwrap_or_default();
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,
project_commands,
warnings,
layer_diagnostics: Vec::new(),
}
}
}
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, diagnostics: &mut Vec<LayerDiagnostic>) -> 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) => {
diagnostics.push(LayerDiagnostic::new(path, format!("{e} — layer ignored")));
return None;
}
};
parse_layer(path, &text, diagnostics, LayerDiagnostic::new)
}
fn parse_layer(
path: &Path,
text: &str,
diagnostics: &mut Vec<LayerDiagnostic>,
diagnostic: impl Fn(&Path, String) -> LayerDiagnostic,
) -> Option<LanguagesToml> {
match toml::from_str::<LanguagesToml>(text) {
Ok(f) => Some(f),
Err(e) => {
diagnostics.push(diagnostic(path, format!("{e} — layer ignored")));
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");
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");
}
#[test]
fn absolute_commands_skip_the_path_scan() {
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());
assert!(loaded.layer_diagnostics().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn broken_layer_diagnoses_with_its_exact_path_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);
let diagnostics = loaded.layer_diagnostics();
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].path, bad);
assert!(diagnostics[0]
.display()
.starts_with(bad.display().to_string().as_str()));
assert!(diagnostics[0].display().contains("layer ignored"));
assert!(registry::for_extension(".rs", &loaded).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn malformed_layer_keeps_valid_layering_and_config() {
let dir = std::env::temp_dir().join("strop-lsp-languages-mixed");
let _ = std::fs::remove_dir_all(&dir);
let xdg_dir = dir.join("xdg");
let project_dir = dir.join("proj/.strop");
std::fs::create_dir_all(&xdg_dir).unwrap();
std::fs::create_dir_all(&project_dir).unwrap();
let bad_xdg = xdg_dir.join("languages.toml");
std::fs::write(&bad_xdg, "[language-server.pyright\ncommand =").unwrap();
let project = project_dir.join("languages.toml");
std::fs::write(
&project,
r#"
[language-server.pyright.config.python.analysis]
extraPaths = ["proj"]
[language.python]
language-servers = ["pyright"]
"#,
)
.unwrap();
let loaded = Languages::load(Some(&bad_xdg), Some(&project));
let diagnostics = loaded.layer_diagnostics();
assert_eq!(diagnostics.len(), 1, "only the broken layer reports");
assert_eq!(diagnostics[0].path, bad_xdg);
assert_eq!(
loaded.project_root.as_deref(),
Some(dir.join("proj").as_path())
);
let spec = registry::for_extension(".py", &loaded).unwrap();
assert_eq!(spec.command, "pyright-langserver");
let extra = &spec.init_options.unwrap()["python"]["analysis"]["extraPaths"];
assert_eq!(extra, &serde_json::json!(["proj"]));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_broken_layer_reports_its_own_path() {
let dir = std::env::temp_dir().join("strop-lsp-languages-both-broken");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("xdg")).unwrap();
std::fs::create_dir_all(dir.join(".strop")).unwrap();
let bad_xdg = dir.join("xdg/languages.toml");
std::fs::write(&bad_xdg, "= no").unwrap();
let bad_project = dir.join(".strop/languages.toml");
std::fs::write(&bad_project, "language-server = 3").unwrap();
let loaded = Languages::load(Some(&bad_xdg), Some(&bad_project));
let paths: Vec<_> = loaded
.layer_diagnostics()
.iter()
.map(|d| d.path.clone())
.collect();
assert_eq!(paths, [bad_xdg, bad_project]);
assert!(registry::for_extension(".rs", &loaded).is_some());
assert!(registry::for_extension(".py", &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);
}
#[test]
fn remote_layers_merge_xdg_with_fetched_project_only() {
let xdg = tempfile::tempdir().unwrap();
let xdg_file = xdg.path().join("languages.toml");
std::fs::write(
&xdg_file,
r#"
[language-server.remote-helper]
command = "remote-helper"
"#,
)
.unwrap();
let remote_project = std::path::Path::new("/srv/proj/.strop/languages.toml");
let bytes = br#"
[language.rust]
language-servers = ["remote-helper"]
"#
.to_vec();
let merged = Languages::load_remote(
Some(&xdg_file),
Some(RemoteLayer {
endpoint: &strop_workspace::RemoteEndpoint::parse("ssh://builder.example").unwrap(),
path: remote_project,
bytes: &bytes,
}),
);
assert_eq!(
merged.project_root.as_deref(),
Some(std::path::Path::new("/srv/proj"))
);
let spec = registry::for_extension(".rs", &merged).unwrap();
assert_eq!(spec.command, "remote-helper");
assert!(!spec.project_executable);
assert!(merged.layer_diagnostics().is_empty());
}
#[test]
fn malformed_remote_layer_diagnoses_the_endpoint_not_a_local_path() {
let merged = Languages::load_remote(
None,
Some(RemoteLayer {
endpoint: &strop_workspace::RemoteEndpoint::parse("ssh://builder.example").unwrap(),
path: std::path::Path::new("/srv/proj/.strop/languages.toml"),
bytes: b"not toml at all [[".to_vec().as_slice(),
}),
);
let diagnostics = merged.layer_diagnostics();
assert_eq!(diagnostics.len(), 1);
let text = diagnostics[0].display();
assert!(
text.starts_with("ssh://builder.example/srv/proj/.strop/languages.toml"),
"{text}"
);
}
}