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 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 pub fn from_extension(ext: &str) -> Option<Self> {
68 LANGUAGES.iter().find(|language| language.extensions.contains(&ext)).map(|language| language.id)
69 }
70
71 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}