use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Result, anyhow};
use serde::Deserialize;
use super::editor::EditorToml;
#[derive(Debug, Default, Deserialize, Clone)]
pub struct LspToml {
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub language_id: Option<String>,
pub root_markers: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct LspConfig {
pub name: String,
pub command: String,
pub args: Vec<String>,
pub language_id: Option<String>,
pub root_markers: Vec<String>,
}
impl LspConfig {
fn overlay(&mut self, user: LspToml) {
if let Some(c) = user.command {
self.command = c;
}
if let Some(a) = user.args {
self.args = a;
}
if user.language_id.is_some() {
self.language_id = user.language_id;
}
if let Some(r) = user.root_markers {
self.root_markers = r;
}
}
fn from_user(name: &str, user: LspToml) -> Result<Self> {
let command = user.command.ok_or_else(|| {
anyhow!(
"[lsp.{}] is a new server (no built-in to overlay onto) and \
must define `command`",
name
)
})?;
Ok(Self {
name: name.to_string(),
command,
args: user.args.unwrap_or_default(),
language_id: user.language_id,
root_markers: user.root_markers.unwrap_or_default(),
})
}
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct FormatterToml {
pub command: Option<String>,
pub args: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct FormatterConfig {
pub command: String,
pub args: Vec<String>,
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct LanguageConfig {
pub extensions: Option<Vec<String>>,
pub grammar: Option<String>,
pub grammar_dir: Option<PathBuf>,
pub query_dir: Option<PathBuf>,
pub comment_token: Option<String>,
#[serde(default, flatten)]
pub editor: EditorToml,
pub lsp: Option<Vec<String>>,
pub formatter: Option<FormatterToml>,
}
impl LanguageConfig {
pub fn overlay(&mut self, user: LanguageConfig) {
if user.extensions.is_some() {
self.extensions = user.extensions;
}
if user.grammar.is_some() {
self.grammar = user.grammar;
}
if user.grammar_dir.is_some() {
self.grammar_dir = user.grammar_dir;
}
if user.query_dir.is_some() {
self.query_dir = user.query_dir;
}
if user.comment_token.is_some() {
self.comment_token = user.comment_token;
}
if user.editor.indent_width.is_some() {
self.editor.indent_width = user.editor.indent_width;
}
if user.editor.tab_width.is_some() {
self.editor.tab_width = user.editor.tab_width;
}
if user.editor.use_tabs.is_some() {
self.editor.use_tabs = user.editor.use_tabs;
}
if user.editor.show_whitespace.is_some() {
self.editor.show_whitespace = user.editor.show_whitespace;
}
if user.lsp.is_some() {
self.lsp = user.lsp;
}
if user.formatter.is_some() {
self.formatter = user.formatter;
}
}
}
#[derive(Debug, Clone)]
pub struct Language {
pub name: String,
pub extensions: Vec<String>,
pub grammar: String,
pub grammar_dir: Option<PathBuf>,
pub query_dir: Option<PathBuf>,
pub comment_token: Option<String>,
pub editor: EditorToml,
pub lsp: Vec<LspConfig>,
pub formatter: Option<FormatterConfig>,
}
pub fn builtin_lsp() -> HashMap<String, LspConfig> {
let mut m = HashMap::new();
let add = |m: &mut HashMap<String, LspConfig>,
name: &str,
command: &str,
args: &[&str],
language_id: Option<&str>,
root_markers: &[&str]| {
m.insert(
name.to_string(),
LspConfig {
name: name.to_string(),
command: command.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
language_id: language_id.map(|s| s.to_string()),
root_markers: root_markers.iter().map(|s| s.to_string()).collect(),
},
);
};
add(&mut m, "rust-analyzer", "rust-analyzer", &[], None,
&["Cargo.toml", "rust-project.json"]);
add(&mut m, "pyright", "pyright-langserver", &["--stdio"], None,
&["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"]);
add(&mut m, "taplo", "taplo", &["lsp", "stdio"], None, &[]);
add(&mut m, "vtsls", "vtsls", &["--stdio"], None,
&["package.json", "tsconfig.json"]);
add(&mut m, "typescript-language-server", "typescript-language-server",
&["--stdio"], None, &["package.json", "tsconfig.json", "jsconfig.json"]);
add(&mut m, "gopls", "gopls", &[], None, &["go.mod", "go.work"]);
add(&mut m, "kotlin-language-server", "kotlin-language-server", &[], None,
&["settings.gradle.kts", "settings.gradle", "build.gradle.kts",
"build.gradle", "pom.xml"]);
add(&mut m, "clangd", "clangd", &[], None,
&["compile_commands.json", ".clangd", "Makefile", "CMakeLists.txt"]);
add(&mut m, "jdtls", "jdtls", &[], None,
&["pom.xml", "build.gradle", "build.gradle.kts", ".project"]);
add(&mut m, "bash-language-server", "bash-language-server", &["start"],
Some("shellscript"), &[]);
add(&mut m, "vscode-json-language-server", "vscode-json-language-server",
&["--stdio"], None, &[]);
add(&mut m, "yaml-language-server", "yaml-language-server", &["--stdio"],
None, &[]);
add(&mut m, "marksman", "marksman", &["server"], None,
&[".marksman.toml"]);
add(&mut m, "vscode-html-language-server", "vscode-html-language-server",
&["--stdio"], None, &[]);
add(&mut m, "vscode-css-language-server", "vscode-css-language-server",
&["--stdio"], None, &[]);
add(&mut m, "lua-language-server", "lua-language-server", &[], None,
&[".luarc.json", ".luarc.jsonc", "stylua.toml"]);
add(&mut m, "ruby-lsp", "ruby-lsp", &[], None,
&["Gemfile", ".rubocop.yml"]);
add(&mut m, "zls", "zls", &[], None, &["build.zig"]);
m
}
pub fn builtin_languages() -> HashMap<String, LanguageConfig> {
let mut m = HashMap::new();
let lsp = |names: &[&str]| Some(names.iter().map(|s| s.to_string()).collect());
m.insert(
"rust".into(),
LanguageConfig {
extensions: Some(vec!["rs".into()]),
comment_token: Some("//".into()),
lsp: lsp(&["rust-analyzer"]),
formatter: Some(FormatterToml {
command: Some("rustfmt".into()),
args: None,
}),
..Default::default()
},
);
m.insert(
"python".into(),
LanguageConfig {
extensions: Some(vec!["py".into()]),
comment_token: Some("#".into()),
lsp: lsp(&["pyright"]),
..Default::default()
},
);
m.insert(
"toml".into(),
LanguageConfig {
extensions: Some(vec!["toml".into()]),
comment_token: Some("#".into()),
lsp: lsp(&["taplo"]),
..Default::default()
},
);
m.insert(
"typescript".into(),
LanguageConfig {
extensions: Some(vec!["ts".into(), "tsx".into()]),
comment_token: Some("//".into()),
lsp: lsp(&["vtsls", "typescript-language-server"]),
..Default::default()
},
);
m.insert(
"javascript".into(),
LanguageConfig {
extensions: Some(vec!["js".into(), "jsx".into(), "mjs".into(), "cjs".into()]),
comment_token: Some("//".into()),
lsp: lsp(&["typescript-language-server"]),
..Default::default()
},
);
m.insert(
"go".into(),
LanguageConfig {
extensions: Some(vec!["go".into()]),
comment_token: Some("//".into()),
editor: EditorToml {
indent_width: Some(4),
tab_width: Some(4),
use_tabs: Some(true),
show_whitespace: None,
format_on_save: None,
},
lsp: lsp(&["gopls"]),
formatter: Some(FormatterToml {
command: Some("gofmt".into()),
args: None,
}),
..Default::default()
},
);
m.insert(
"kotlin".into(),
LanguageConfig {
extensions: Some(vec!["kt".into(), "kts".into()]),
comment_token: Some("//".into()),
lsp: lsp(&["kotlin-language-server"]),
..Default::default()
},
);
m.insert(
"c".into(),
LanguageConfig {
extensions: Some(vec!["c".into(), "h".into()]),
comment_token: Some("//".into()),
lsp: lsp(&["clangd"]),
..Default::default()
},
);
m.insert(
"cpp".into(),
LanguageConfig {
extensions: Some(vec![
"cpp".into(),
"cc".into(),
"cxx".into(),
"hpp".into(),
"hh".into(),
"hxx".into(),
]),
comment_token: Some("//".into()),
lsp: lsp(&["clangd"]),
..Default::default()
},
);
m.insert(
"java".into(),
LanguageConfig {
extensions: Some(vec!["java".into()]),
comment_token: Some("//".into()),
lsp: lsp(&["jdtls"]),
..Default::default()
},
);
m.insert(
"bash".into(),
LanguageConfig {
extensions: Some(vec!["sh".into(), "bash".into()]),
comment_token: Some("#".into()),
lsp: lsp(&["bash-language-server"]),
..Default::default()
},
);
m.insert(
"json".into(),
LanguageConfig {
extensions: Some(vec!["json".into()]),
comment_token: None,
lsp: lsp(&["vscode-json-language-server"]),
..Default::default()
},
);
m.insert(
"yaml".into(),
LanguageConfig {
extensions: Some(vec!["yaml".into(), "yml".into()]),
comment_token: Some("#".into()),
lsp: lsp(&["yaml-language-server"]),
..Default::default()
},
);
m.insert(
"markdown".into(),
LanguageConfig {
extensions: Some(vec!["md".into(), "markdown".into()]),
comment_token: None,
lsp: lsp(&["marksman"]),
..Default::default()
},
);
m.insert(
"html".into(),
LanguageConfig {
extensions: Some(vec!["html".into(), "htm".into()]),
comment_token: None,
lsp: lsp(&["vscode-html-language-server"]),
..Default::default()
},
);
m.insert(
"css".into(),
LanguageConfig {
extensions: Some(vec!["css".into()]),
comment_token: None,
lsp: lsp(&["vscode-css-language-server"]),
..Default::default()
},
);
m.insert(
"lua".into(),
LanguageConfig {
extensions: Some(vec!["lua".into()]),
comment_token: Some("--".into()),
lsp: lsp(&["lua-language-server"]),
..Default::default()
},
);
m.insert(
"ruby".into(),
LanguageConfig {
extensions: Some(vec!["rb".into()]),
comment_token: Some("#".into()),
lsp: lsp(&["ruby-lsp"]),
..Default::default()
},
);
m.insert(
"zig".into(),
LanguageConfig {
extensions: Some(vec!["zig".into(), "zon".into()]),
comment_token: Some("//".into()),
lsp: lsp(&["zls"]),
formatter: Some(FormatterToml {
command: Some("zig".into()),
args: Some(vec!["fmt".into(), "--stdin".into()]),
}),
..Default::default()
},
);
m
}
fn resolve_lsp_table(
user: HashMap<String, LspToml>,
) -> Result<HashMap<String, LspConfig>> {
let mut merged = builtin_lsp();
for (name, user_entry) in user {
if let Some(existing) = merged.get_mut(&name) {
existing.overlay(user_entry);
} else {
merged.insert(name.clone(), LspConfig::from_user(&name, user_entry)?);
}
}
Ok(merged)
}
pub fn resolve(
user_languages: HashMap<String, LanguageConfig>,
lsp_table: &HashMap<String, LspConfig>,
) -> Result<HashMap<String, Language>> {
let mut merged = builtin_languages();
for (name, user_lang) in user_languages {
merged
.entry(name)
.and_modify(|d| d.overlay(user_lang.clone()))
.or_insert(user_lang);
}
let mut out = HashMap::new();
for (name, cfg) in merged {
let lang = build_language(&name, cfg, lsp_table)?;
out.insert(name, lang);
}
Ok(out)
}
fn build_language(
name: &str,
c: LanguageConfig,
lsp_table: &HashMap<String, LspConfig>,
) -> Result<Language> {
let mut lsp = Vec::new();
if let Some(refs) = c.lsp {
for server_name in refs {
let entry = lsp_table.get(&server_name).ok_or_else(|| {
anyhow!(
"[languages.{}] references unknown server `{}` — add a \
`[lsp.{}]` table or use one of the built-in names",
name,
server_name,
server_name
)
})?;
lsp.push(entry.clone());
}
}
let formatter = match c.formatter {
Some(f) => Some(FormatterConfig {
command: f.command.ok_or_else(|| {
anyhow!(
"[languages.{}.formatter] requires a `command` field",
name
)
})?,
args: f.args.unwrap_or_default(),
}),
None => None,
};
Ok(Language {
name: name.to_string(),
extensions: c.extensions.unwrap_or_default(),
grammar: c.grammar.unwrap_or_else(|| name.to_string()),
grammar_dir: c.grammar_dir,
query_dir: c.query_dir,
comment_token: c.comment_token,
editor: c.editor,
lsp,
formatter,
})
}
fn build_extension_index(langs: &HashMap<String, Language>) -> HashMap<String, String> {
let mut idx = HashMap::new();
for (name, lang) in langs {
for ext in &lang.extensions {
idx.insert(ext.clone(), name.clone());
}
}
idx
}
#[derive(Debug, Clone, Default)]
pub struct LanguageRegistry {
by_name: HashMap<String, Language>,
extension_to_name: HashMap<String, String>,
}
impl LanguageRegistry {
pub fn build(
user_languages: HashMap<String, LanguageConfig>,
user_lsp: HashMap<String, LspToml>,
) -> Result<Self> {
let lsp_table = resolve_lsp_table(user_lsp)?;
let by_name = resolve(user_languages, &lsp_table)?;
let extension_to_name = build_extension_index(&by_name);
Ok(Self {
by_name,
extension_to_name,
})
}
pub fn by_extension(&self, ext: &str) -> Option<&Language> {
let name = self.extension_to_name.get(ext)?;
self.by_name.get(name)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_lsp() -> HashMap<String, LspConfig> {
resolve_lsp_table(HashMap::new()).unwrap()
}
#[test]
fn builtins_include_rust() {
let m = builtin_languages();
assert!(m.contains_key("rust"));
assert_eq!(
m["rust"].extensions.as_deref(),
Some(&["rs".to_string()][..])
);
}
#[test]
fn builtin_lsp_includes_vtsls_and_tsserver() {
let m = builtin_lsp();
assert!(m.contains_key("vtsls"));
assert!(m.contains_key("typescript-language-server"));
}
#[test]
fn overlay_replaces_only_provided_fields() {
let mut base = LanguageConfig {
extensions: Some(vec!["rs".into()]),
grammar: Some("rust".into()),
..Default::default()
};
let user = LanguageConfig {
grammar: Some("rust-tree-sitter".into()),
..Default::default()
};
base.overlay(user);
assert_eq!(base.grammar.as_deref(), Some("rust-tree-sitter"));
assert_eq!(base.extensions.as_deref(), Some(&["rs".to_string()][..]));
}
#[test]
fn resolve_adds_user_only_language() {
let mut user = HashMap::new();
user.insert(
"fish".into(),
LanguageConfig {
extensions: Some(vec!["fish".into()]),
..Default::default()
},
);
let langs = resolve(user, &empty_lsp()).unwrap();
assert!(langs.contains_key("fish"));
assert_eq!(langs["fish"].grammar, "fish");
assert!(langs["fish"].lsp.is_empty());
}
#[test]
fn resolve_falls_back_to_default_when_user_omits_field() {
let mut user = HashMap::new();
user.insert(
"rust".into(),
LanguageConfig {
grammar: Some("rust-custom".into()),
..Default::default()
},
);
let langs = resolve(user, &empty_lsp()).unwrap();
assert_eq!(langs["rust"].grammar, "rust-custom");
assert_eq!(langs["rust"].extensions, vec!["rs"]);
assert_eq!(langs["rust"].lsp.len(), 1);
assert_eq!(langs["rust"].lsp[0].name, "rust-analyzer");
}
#[test]
fn extension_index_routes_to_language_name() {
let langs = resolve(HashMap::new(), &empty_lsp()).unwrap();
let idx = build_extension_index(&langs);
assert_eq!(idx.get("rs"), Some(&"rust".to_string()));
assert_eq!(idx.get("py"), Some(&"python".to_string()));
}
#[test]
fn typescript_resolves_to_two_servers() {
let langs = resolve(HashMap::new(), &empty_lsp()).unwrap();
let ts = &langs["typescript"];
let names: Vec<&str> = ts.lsp.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["vtsls", "typescript-language-server"]);
}
#[test]
fn user_lsp_overlay_replaces_only_provided_fields() {
let mut user_lsp: HashMap<String, LspToml> = HashMap::new();
user_lsp.insert(
"vtsls".into(),
LspToml {
args: Some(vec!["--my-flag".into()]),
..Default::default()
},
);
let table = resolve_lsp_table(user_lsp).unwrap();
let entry = &table["vtsls"];
assert_eq!(entry.command, "vtsls"); assert_eq!(entry.args, vec!["--my-flag"]); }
#[test]
fn user_lsp_new_entry_requires_command() {
let mut user_lsp: HashMap<String, LspToml> = HashMap::new();
user_lsp.insert(
"my-server".into(),
LspToml {
args: Some(vec!["--stdio".into()]),
..Default::default()
},
);
assert!(resolve_lsp_table(user_lsp).is_err());
}
#[test]
fn user_lsp_new_entry_with_command_succeeds() {
let mut user_lsp: HashMap<String, LspToml> = HashMap::new();
user_lsp.insert(
"my-server".into(),
LspToml {
command: Some("my-bin".into()),
args: Some(vec!["--stdio".into()]),
..Default::default()
},
);
let table = resolve_lsp_table(user_lsp).unwrap();
assert_eq!(table["my-server"].command, "my-bin");
}
#[test]
fn language_ref_to_unknown_server_errors() {
let mut user_langs: HashMap<String, LanguageConfig> = HashMap::new();
user_langs.insert(
"rust".into(),
LanguageConfig {
lsp: Some(vec!["nonexistent".into()]),
..Default::default()
},
);
let err = resolve(user_langs, &empty_lsp()).unwrap_err();
assert!(err.to_string().contains("nonexistent"));
}
#[test]
fn user_can_pick_subset_of_servers() {
let mut user_langs: HashMap<String, LanguageConfig> = HashMap::new();
user_langs.insert(
"typescript".into(),
LanguageConfig {
lsp: Some(vec!["typescript-language-server".into()]),
..Default::default()
},
);
let langs = resolve(user_langs, &empty_lsp()).unwrap();
assert_eq!(langs["typescript"].lsp.len(), 1);
assert_eq!(langs["typescript"].lsp[0].name, "typescript-language-server");
}
}