Skip to main content

aether_lspd/
language_catalog.rs

1use serde::{Deserialize, Serialize};
2use std::path::Path;
3
4macro_rules! languages {
5    ($($variant:ident => $lsp_id:literal, [$($extension:literal),*], $server:expr;)*) => {
6        #[doc = include_str!("docs/language_catalog.md")]
7        #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
8        pub enum LanguageId {
9            $($variant,)*
10        }
11
12        pub const LANGUAGES: &[LanguageMetadata] = &[
13            $(LanguageMetadata {
14                id: LanguageId::$variant,
15                lsp_id: $lsp_id,
16                extensions: &[$($extension),*],
17                server: $server,
18            },)*
19        ];
20    };
21}
22
23languages! {
24    Rust => "rust", ["rs"], Some(&RUST_ANALYZER);
25    Python => "python", ["py", "pyi", "pyw"], Some(&PYRIGHT);
26    JavaScript => "javascript", ["js", "mjs"], Some(&TSC);
27    JavaScriptReact => "javascriptreact", ["jsx"], Some(&TSC);
28    TypeScript => "typescript", ["ts"], Some(&TSC);
29    TypeScriptReact => "typescriptreact", ["tsx"], Some(&TSC);
30    Go => "go", ["go"], Some(&GOPLS);
31    Java => "java", ["java"], None;
32    C => "c", ["c", "h"], Some(&CLANGD);
33    Cpp => "cpp", ["cpp", "cxx", "cc", "hpp", "hxx", "hh"], Some(&CLANGD);
34    CSharp => "csharp", ["cs"], None;
35    Ruby => "ruby", ["rb"], None;
36    Php => "php", ["php"], None;
37    Swift => "swift", ["swift"], None;
38    Kotlin => "kotlin", ["kt", "kts"], None;
39    Scala => "scala", ["scala"], None;
40    Html => "html", ["html", "htm"], None;
41    Css => "css", ["css"], None;
42    Json => "json", ["json"], None;
43    Yaml => "yaml", ["yaml", "yml"], None;
44    Toml => "toml", ["toml"], None;
45    Markdown => "markdown", ["md", "markdown"], None;
46    Xml => "xml", ["xml"], None;
47    Sql => "sql", ["sql"], None;
48    ShellScript => "shellscript", ["sh", "bash", "zsh"], None;
49    PlainText => "plaintext", ["txt"], None;
50}
51
52impl LanguageId {
53    /// Get the LSP language ID string
54    pub fn as_str(self) -> &'static str {
55        self.metadata().lsp_id
56    }
57
58    pub fn metadata(self) -> &'static LanguageMetadata {
59        &LANGUAGES[self as usize]
60    }
61
62    pub fn server(self) -> Option<&'static ServerSpec> {
63        self.metadata().server
64    }
65
66    /// Detect language from a file extension.
67    pub fn from_extension(ext: &str) -> Option<Self> {
68        LANGUAGES.iter().find(|language| language.extensions.contains(&ext)).map(|language| language.id)
69    }
70
71    /// Detect language from file path.
72    ///
73    /// Returns `PlainText` for files with no extension or unknown extensions.
74    pub fn from_path(path: &Path) -> Self {
75        path.extension().and_then(|e| e.to_str()).and_then(Self::from_extension).unwrap_or(Self::PlainText)
76    }
77}
78
79#[derive(Debug, Clone, Copy)]
80pub struct LanguageMetadata {
81    pub id: LanguageId,
82    pub lsp_id: &'static str,
83    pub extensions: &'static [&'static str],
84    pub server: Option<&'static ServerSpec>,
85}
86
87#[derive(Debug)]
88pub struct ServerSpec {
89    pub name: &'static str,
90    pub display_name: &'static str,
91    pub installation_instructions: Option<&'static str>,
92    pub(crate) env_key: &'static str,
93    args: &'static [&'static str],
94    diagnostics_mode: DiagnosticsMode,
95    reports_server_status: bool,
96}
97
98pub fn detect_project_languages(root: &Path) -> Vec<LanguageId> {
99    PROJECT_LANGUAGE_SPECS
100        .iter()
101        .filter(|(_, manifests)| manifests.iter().any(|manifest| root.join(manifest).is_file()))
102        .map(|(language, _)| *language)
103        .collect()
104}
105
106#[derive(Debug, Clone, Copy, Eq, PartialEq)]
107pub(crate) enum DiagnosticsMode {
108    Push,
109    Pull,
110}
111
112#[derive(Debug, Clone)]
113pub(crate) struct LspConfig {
114    pub command: String,
115    pub args: Vec<String>,
116    pub languages: Vec<LanguageId>,
117    pub diagnostics_mode: DiagnosticsMode,
118    pub reports_server_status: bool,
119}
120
121pub(crate) fn socket_identity_for_language(id: LanguageId) -> &'static str {
122    id.server().map_or_else(|| id.as_str(), |server| server.name)
123}
124
125pub(crate) fn resolved_config_for_language(language: LanguageId) -> Option<LspConfig> {
126    let server = language.server()?;
127    let command = std::env::var_os(format!("AETHER_LSPD_SERVER_COMMAND_{}", server.env_key))
128        .map_or_else(|| server.name.to_string(), |command| command.to_string_lossy().into_owned());
129    let args = std::env::var(format!("AETHER_LSPD_SERVER_ARGS_{}", server.env_key))
130        .ok()
131        .and_then(|args| serde_json::from_str(&args).ok())
132        .unwrap_or_else(|| server.args.iter().map(ToString::to_string).collect());
133    let languages = LANGUAGES
134        .iter()
135        .filter(|candidate| candidate.server.is_some_and(|candidate| candidate.name == server.name))
136        .map(|candidate| candidate.id)
137        .collect();
138
139    Some(LspConfig {
140        command,
141        args,
142        languages,
143        diagnostics_mode: server.diagnostics_mode,
144        reports_server_status: server.reports_server_status,
145    })
146}
147
148const RUST_ANALYZER: ServerSpec = ServerSpec {
149    name: "rust-analyzer",
150    display_name: "rust-analyzer",
151    installation_instructions: None,
152    env_key: "RUST_ANALYZER",
153    args: &[],
154    diagnostics_mode: DiagnosticsMode::Push,
155    reports_server_status: true,
156};
157
158const TSC: ServerSpec = ServerSpec {
159    name: "tsc",
160    display_name: "TypeScript 7 language server",
161    installation_instructions: Some(
162        "It requires TypeScript 7 or newer. Install it in this workspace with \
163         `npm install --save-dev typescript@^7`, or install it globally with \
164         `npm install --global typescript@^7`, then retry.",
165    ),
166    env_key: "TSC",
167    args: &["--lsp", "--stdio"],
168    diagnostics_mode: DiagnosticsMode::Pull,
169    reports_server_status: false,
170};
171
172const PYRIGHT: ServerSpec = ServerSpec {
173    name: "pyright-langserver",
174    display_name: "Pyright language server",
175    installation_instructions: None,
176    env_key: "PYRIGHT",
177    args: &["--stdio"],
178    diagnostics_mode: DiagnosticsMode::Push,
179    reports_server_status: false,
180};
181
182const GOPLS: ServerSpec = ServerSpec {
183    name: "gopls",
184    display_name: "gopls language server",
185    installation_instructions: None,
186    env_key: "GOPLS",
187    args: &[],
188    diagnostics_mode: DiagnosticsMode::Push,
189    reports_server_status: false,
190};
191
192const CLANGD: ServerSpec = ServerSpec {
193    name: "clangd",
194    display_name: "clangd language server",
195    installation_instructions: None,
196    env_key: "CLANGD",
197    args: &[],
198    diagnostics_mode: DiagnosticsMode::Push,
199    reports_server_status: false,
200};
201
202const PROJECT_LANGUAGE_SPECS: &[(LanguageId, &[&str])] = &[
203    (LanguageId::Rust, &["Cargo.toml"]),
204    (LanguageId::TypeScript, &["package.json"]),
205    (LanguageId::Python, &["pyproject.toml", "setup.py", "requirements.txt"]),
206    (LanguageId::Go, &["go.mod"]),
207    (LanguageId::Cpp, &["CMakeLists.txt"]),
208];
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn detects_project_languages_from_manifests() {
216        let root = tempfile::tempdir().unwrap();
217        std::fs::write(root.path().join("Cargo.toml"), "").unwrap();
218        std::fs::write(root.path().join("package.json"), "{}").unwrap();
219
220        assert_eq!(detect_project_languages(root.path()), vec![LanguageId::Rust, LanguageId::TypeScript]);
221    }
222
223    #[test]
224    fn typescript_server_has_installation_instructions() {
225        let server = LanguageId::TypeScript.server().unwrap();
226
227        assert_eq!(server.display_name, "TypeScript 7 language server");
228        assert!(server.installation_instructions.unwrap().contains("npm install --save-dev typescript@^7"));
229    }
230
231    #[test]
232    fn languages_sharing_a_server_share_a_socket_identity() {
233        assert_eq!(socket_identity_for_language(LanguageId::TypeScript), "tsc");
234        assert_eq!(socket_identity_for_language(LanguageId::TypeScriptReact), "tsc");
235        assert_eq!(socket_identity_for_language(LanguageId::C), "clangd");
236        assert_eq!(socket_identity_for_language(LanguageId::Cpp), "clangd");
237        assert_eq!(socket_identity_for_language(LanguageId::Java), "java");
238    }
239
240    #[test]
241    fn metadata_returns_the_language_row() {
242        for language in LANGUAGES {
243            assert_eq!(language.id.metadata().id, language.id);
244        }
245        assert_eq!(LanguageId::Rust.metadata().extensions, &["rs"]);
246    }
247
248    #[test]
249    fn language_id_as_str() {
250        assert_eq!(LanguageId::Rust.as_str(), "rust");
251        assert_eq!(LanguageId::TypeScriptReact.as_str(), "typescriptreact");
252    }
253
254    #[test]
255    fn language_id_from_extension() {
256        assert_eq!(LanguageId::from_extension("rs"), Some(LanguageId::Rust));
257        assert_eq!(LanguageId::from_extension("tsx"), Some(LanguageId::TypeScriptReact));
258        assert_eq!(LanguageId::from_extension("xyz"), None);
259    }
260
261    #[test]
262    fn language_id_from_path() {
263        assert_eq!(LanguageId::from_path(Path::new("foo.rs")), LanguageId::Rust);
264        assert_eq!(LanguageId::from_path(Path::new("bar.py")), LanguageId::Python);
265        assert_eq!(LanguageId::from_path(Path::new("baz.tsx")), LanguageId::TypeScriptReact);
266        assert_eq!(LanguageId::from_path(Path::new("unknown.xyz")), LanguageId::PlainText);
267        assert_eq!(LanguageId::from_path(Path::new("no_extension")), LanguageId::PlainText);
268    }
269
270    #[test]
271    fn resolved_config_groups_languages_by_server() {
272        assert_eq!(resolved_config_for_language(LanguageId::Rust).unwrap().command, "rust-analyzer");
273
274        let ts_config = resolved_config_for_language(LanguageId::TypeScript).unwrap();
275        assert_eq!(ts_config.command, "tsc");
276        assert_eq!(ts_config.args, ["--lsp", "--stdio"]);
277        assert_eq!(ts_config.diagnostics_mode, DiagnosticsMode::Pull);
278        assert_eq!(
279            ts_config.languages,
280            [LanguageId::JavaScript, LanguageId::JavaScriptReact, LanguageId::TypeScript, LanguageId::TypeScriptReact]
281        );
282
283        assert!(resolved_config_for_language(LanguageId::PlainText).is_none());
284    }
285}