1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::Path;
4use std::sync::LazyLock;
5
6#[doc = include_str!("docs/language_catalog.md")]
7#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
8pub enum LanguageId {
9 Rust,
10 Python,
11 JavaScript,
12 JavaScriptReact,
13 TypeScript,
14 TypeScriptReact,
15 Go,
16 Java,
17 C,
18 Cpp,
19 CSharp,
20 Ruby,
21 Php,
22 Swift,
23 Kotlin,
24 Scala,
25 Html,
26 Css,
27 Json,
28 Yaml,
29 Toml,
30 Markdown,
31 Xml,
32 Sql,
33 ShellScript,
34 PlainText,
35}
36
37impl LanguageId {
38 pub fn as_str(self) -> &'static str {
40 match self {
41 Self::Rust => "rust",
42 Self::Python => "python",
43 Self::JavaScript => "javascript",
44 Self::JavaScriptReact => "javascriptreact",
45 Self::TypeScript => "typescript",
46 Self::TypeScriptReact => "typescriptreact",
47 Self::Go => "go",
48 Self::Java => "java",
49 Self::C => "c",
50 Self::Cpp => "cpp",
51 Self::CSharp => "csharp",
52 Self::Ruby => "ruby",
53 Self::Php => "php",
54 Self::Swift => "swift",
55 Self::Kotlin => "kotlin",
56 Self::Scala => "scala",
57 Self::Html => "html",
58 Self::Css => "css",
59 Self::Json => "json",
60 Self::Yaml => "yaml",
61 Self::Toml => "toml",
62 Self::Markdown => "markdown",
63 Self::Xml => "xml",
64 Self::Sql => "sql",
65 Self::ShellScript => "shellscript",
66 Self::PlainText => "plaintext",
67 }
68 }
69
70 pub fn from_extension(ext: &str) -> Option<Self> {
72 from_extension(ext)
73 }
74
75 pub fn from_path(path: &Path) -> Self {
79 path.extension().and_then(|e| e.to_str()).and_then(Self::from_extension).unwrap_or(Self::PlainText)
80 }
81
82 pub fn primary_extension(self) -> Option<&'static str> {
83 metadata_for(self).and_then(|metadata| metadata.primary_extension)
84 }
85}
86
87#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
88pub(crate) enum ServerKind {
89 RustAnalyzer,
90 Tsc,
91 Pyright,
92 Gopls,
93 Clangd,
94}
95
96impl ServerKind {
97 pub(crate) fn as_str(self) -> &'static str {
98 match self {
99 Self::RustAnalyzer => "rust-analyzer",
100 Self::Tsc => "tsc",
101 Self::Pyright => "pyright-langserver",
102 Self::Gopls => "gopls",
103 Self::Clangd => "clangd",
104 }
105 }
106
107 pub(crate) fn env_key(self) -> &'static str {
108 match self {
109 Self::RustAnalyzer => "RUST_ANALYZER",
110 Self::Tsc => "TSC",
111 Self::Pyright => "PYRIGHT",
112 Self::Gopls => "GOPLS",
113 Self::Clangd => "CLANGD",
114 }
115 }
116}
117
118#[derive(Debug, Clone, Copy, Eq, PartialEq)]
119pub(crate) enum DiagnosticsMode {
120 Push,
121 Pull,
122}
123
124#[derive(Debug, Clone, Copy)]
125pub struct LanguageServerMetadata {
126 pub display_name: &'static str,
127 pub installation_instructions: Option<&'static str>,
128}
129
130#[derive(Debug, Clone, Copy)]
131pub struct LanguageMetadata {
132 pub id: LanguageId,
133 pub primary_extension: Option<&'static str>,
134 pub aliases: &'static [&'static str],
135 pub extensions: &'static [&'static str],
136}
137
138#[derive(Debug, Clone)]
139pub struct LspConfig {
140 pub command: String,
141 pub args: Vec<String>,
142 pub languages: Vec<LanguageId>,
143 pub(crate) diagnostics_mode: DiagnosticsMode,
144}
145
146#[derive(Clone, Copy)]
147struct ServerSpec {
148 kind: ServerKind,
149 command: &'static str,
150 display_name: &'static str,
151 args: &'static [&'static str],
152 diagnostics_mode: DiagnosticsMode,
153 installation_instructions: Option<&'static str>,
154}
155
156#[derive(Clone, Copy)]
157struct LanguageSpec {
158 metadata: LanguageMetadata,
159 server_kind: Option<ServerKind>,
160}
161
162const SERVER_SPECS: &[ServerSpec] = &[
163 ServerSpec {
164 kind: ServerKind::RustAnalyzer,
165 command: "rust-analyzer",
166 display_name: "rust-analyzer",
167 args: &[],
168 diagnostics_mode: DiagnosticsMode::Push,
169 installation_instructions: None,
170 },
171 ServerSpec {
172 kind: ServerKind::Tsc,
173 command: "tsc",
174 display_name: "TypeScript 7 language server",
175 args: &["--lsp", "--stdio"],
176 diagnostics_mode: DiagnosticsMode::Pull,
177 installation_instructions: Some(
178 "It requires TypeScript 7 or newer. Install it in this workspace with \
179 `npm install --save-dev typescript@^7`, or install it globally with \
180 `npm install --global typescript@^7`, then retry.",
181 ),
182 },
183 ServerSpec {
184 kind: ServerKind::Pyright,
185 command: "pyright-langserver",
186 display_name: "Pyright language server",
187 args: &["--stdio"],
188 diagnostics_mode: DiagnosticsMode::Push,
189 installation_instructions: None,
190 },
191 ServerSpec {
192 kind: ServerKind::Gopls,
193 command: "gopls",
194 display_name: "gopls language server",
195 args: &[],
196 diagnostics_mode: DiagnosticsMode::Push,
197 installation_instructions: None,
198 },
199 ServerSpec {
200 kind: ServerKind::Clangd,
201 command: "clangd",
202 display_name: "clangd language server",
203 args: &[],
204 diagnostics_mode: DiagnosticsMode::Push,
205 installation_instructions: None,
206 },
207];
208
209const PROJECT_LANGUAGE_SPECS: &[(LanguageId, &[&str])] = &[
210 (LanguageId::Rust, &["Cargo.toml"]),
211 (LanguageId::TypeScript, &["package.json"]),
212 (LanguageId::Python, &["pyproject.toml", "setup.py", "requirements.txt"]),
213 (LanguageId::Go, &["go.mod"]),
214 (LanguageId::Cpp, &["CMakeLists.txt"]),
215];
216
217const LANGUAGE_SPECS: &[LanguageSpec] = &[
218 LanguageSpec {
219 metadata: LanguageMetadata {
220 id: LanguageId::Rust,
221 primary_extension: Some("rs"),
222 aliases: &["rust", "rs"],
223 extensions: &["rs"],
224 },
225 server_kind: Some(ServerKind::RustAnalyzer),
226 },
227 LanguageSpec {
228 metadata: LanguageMetadata {
229 id: LanguageId::Python,
230 primary_extension: Some("py"),
231 aliases: &["python", "py"],
232 extensions: &["py", "pyi", "pyw"],
233 },
234 server_kind: Some(ServerKind::Pyright),
235 },
236 LanguageSpec {
237 metadata: LanguageMetadata {
238 id: LanguageId::JavaScript,
239 primary_extension: Some("js"),
240 aliases: &["javascript", "js"],
241 extensions: &["js", "mjs"],
242 },
243 server_kind: Some(ServerKind::Tsc),
244 },
245 LanguageSpec {
246 metadata: LanguageMetadata {
247 id: LanguageId::JavaScriptReact,
248 primary_extension: Some("jsx"),
249 aliases: &["javascript", "js", "javascriptreact", "jsx"],
250 extensions: &["jsx"],
251 },
252 server_kind: Some(ServerKind::Tsc),
253 },
254 LanguageSpec {
255 metadata: LanguageMetadata {
256 id: LanguageId::TypeScript,
257 primary_extension: Some("ts"),
258 aliases: &["typescript", "ts"],
259 extensions: &["ts"],
260 },
261 server_kind: Some(ServerKind::Tsc),
262 },
263 LanguageSpec {
264 metadata: LanguageMetadata {
265 id: LanguageId::TypeScriptReact,
266 primary_extension: Some("tsx"),
267 aliases: &["typescript", "ts", "typescriptreact", "tsx"],
268 extensions: &["tsx"],
269 },
270 server_kind: Some(ServerKind::Tsc),
271 },
272 LanguageSpec {
273 metadata: LanguageMetadata {
274 id: LanguageId::Go,
275 primary_extension: Some("go"),
276 aliases: &["go"],
277 extensions: &["go"],
278 },
279 server_kind: Some(ServerKind::Gopls),
280 },
281 LanguageSpec {
282 metadata: LanguageMetadata {
283 id: LanguageId::Java,
284 primary_extension: Some("java"),
285 aliases: &["java"],
286 extensions: &["java"],
287 },
288 server_kind: None,
289 },
290 LanguageSpec {
291 metadata: LanguageMetadata {
292 id: LanguageId::C,
293 primary_extension: Some("c"),
294 aliases: &["c"],
295 extensions: &["c", "h"],
296 },
297 server_kind: Some(ServerKind::Clangd),
298 },
299 LanguageSpec {
300 metadata: LanguageMetadata {
301 id: LanguageId::Cpp,
302 primary_extension: Some("cpp"),
303 aliases: &["cpp", "c++"],
304 extensions: &["cpp", "cxx", "cc", "hpp", "hxx", "hh"],
305 },
306 server_kind: Some(ServerKind::Clangd),
307 },
308 LanguageSpec {
309 metadata: LanguageMetadata {
310 id: LanguageId::CSharp,
311 primary_extension: Some("cs"),
312 aliases: &["csharp", "cs"],
313 extensions: &["cs"],
314 },
315 server_kind: None,
316 },
317 LanguageSpec {
318 metadata: LanguageMetadata {
319 id: LanguageId::Ruby,
320 primary_extension: Some("rb"),
321 aliases: &["ruby", "rb"],
322 extensions: &["rb"],
323 },
324 server_kind: None,
325 },
326 LanguageSpec {
327 metadata: LanguageMetadata {
328 id: LanguageId::Php,
329 primary_extension: Some("php"),
330 aliases: &["php"],
331 extensions: &["php"],
332 },
333 server_kind: None,
334 },
335 LanguageSpec {
336 metadata: LanguageMetadata {
337 id: LanguageId::Swift,
338 primary_extension: Some("swift"),
339 aliases: &["swift"],
340 extensions: &["swift"],
341 },
342 server_kind: None,
343 },
344 LanguageSpec {
345 metadata: LanguageMetadata {
346 id: LanguageId::Kotlin,
347 primary_extension: Some("kt"),
348 aliases: &["kotlin"],
349 extensions: &["kt", "kts"],
350 },
351 server_kind: None,
352 },
353 LanguageSpec {
354 metadata: LanguageMetadata {
355 id: LanguageId::Scala,
356 primary_extension: Some("scala"),
357 aliases: &["scala"],
358 extensions: &["scala"],
359 },
360 server_kind: None,
361 },
362 LanguageSpec {
363 metadata: LanguageMetadata {
364 id: LanguageId::Html,
365 primary_extension: Some("html"),
366 aliases: &["html"],
367 extensions: &["html", "htm"],
368 },
369 server_kind: None,
370 },
371 LanguageSpec {
372 metadata: LanguageMetadata {
373 id: LanguageId::Css,
374 primary_extension: Some("css"),
375 aliases: &["css"],
376 extensions: &["css"],
377 },
378 server_kind: None,
379 },
380 LanguageSpec {
381 metadata: LanguageMetadata {
382 id: LanguageId::Json,
383 primary_extension: Some("json"),
384 aliases: &["json"],
385 extensions: &["json"],
386 },
387 server_kind: None,
388 },
389 LanguageSpec {
390 metadata: LanguageMetadata {
391 id: LanguageId::Yaml,
392 primary_extension: Some("yaml"),
393 aliases: &["yaml", "yml"],
394 extensions: &["yaml", "yml"],
395 },
396 server_kind: None,
397 },
398 LanguageSpec {
399 metadata: LanguageMetadata {
400 id: LanguageId::Toml,
401 primary_extension: Some("toml"),
402 aliases: &["toml"],
403 extensions: &["toml"],
404 },
405 server_kind: None,
406 },
407 LanguageSpec {
408 metadata: LanguageMetadata {
409 id: LanguageId::Markdown,
410 primary_extension: Some("md"),
411 aliases: &["markdown", "md"],
412 extensions: &["md", "markdown"],
413 },
414 server_kind: None,
415 },
416 LanguageSpec {
417 metadata: LanguageMetadata {
418 id: LanguageId::Xml,
419 primary_extension: Some("xml"),
420 aliases: &["xml"],
421 extensions: &["xml"],
422 },
423 server_kind: None,
424 },
425 LanguageSpec {
426 metadata: LanguageMetadata {
427 id: LanguageId::Sql,
428 primary_extension: Some("sql"),
429 aliases: &["sql"],
430 extensions: &["sql"],
431 },
432 server_kind: None,
433 },
434 LanguageSpec {
435 metadata: LanguageMetadata {
436 id: LanguageId::ShellScript,
437 primary_extension: Some("sh"),
438 aliases: &["sh", "shell", "bash"],
439 extensions: &["sh", "bash", "zsh"],
440 },
441 server_kind: None,
442 },
443 LanguageSpec {
444 metadata: LanguageMetadata {
445 id: LanguageId::PlainText,
446 primary_extension: None,
447 aliases: &["plaintext", "text", "txt"],
448 extensions: &["txt"],
449 },
450 server_kind: None,
451 },
452];
453
454pub static LANGUAGE_METADATA: LazyLock<Vec<LanguageMetadata>> =
455 LazyLock::new(|| LANGUAGE_SPECS.iter().map(|spec| spec.metadata).collect());
456
457static CONFIG_MAP: LazyLock<HashMap<LanguageId, LspConfig>> = LazyLock::new(|| {
458 let languages_by_server: HashMap<ServerKind, Vec<LanguageId>> = LANGUAGE_SPECS
459 .iter()
460 .filter_map(|spec| spec.server_kind.map(|kind| (kind, spec.metadata.id)))
461 .fold(HashMap::new(), |mut acc, (kind, id)| {
462 acc.entry(kind).or_default().push(id);
463 acc
464 });
465
466 LANGUAGE_SPECS
467 .iter()
468 .filter_map(|spec| {
469 let server_kind = spec.server_kind?;
470 let server = SERVER_SPECS.iter().find(|server| server.kind == server_kind)?;
471 Some((
472 spec.metadata.id,
473 LspConfig {
474 command: server.command.to_string(),
475 args: server.args.iter().map(|arg| (*arg).to_string()).collect(),
476 languages: languages_by_server.get(&server_kind).cloned().unwrap_or_default(),
477 diagnostics_mode: server.diagnostics_mode,
478 },
479 ))
480 })
481 .collect()
482});
483
484pub(crate) fn server_kind_for_language(id: LanguageId) -> Option<ServerKind> {
485 LANGUAGE_SPECS.iter().find(|spec| spec.metadata.id == id).and_then(|spec| spec.server_kind)
486}
487
488pub(crate) fn socket_identity_for_language(id: LanguageId) -> &'static str {
489 server_kind_for_language(id).map_or_else(|| id.as_str(), ServerKind::as_str)
490}
491
492pub(crate) fn resolved_config_for_language(language: LanguageId) -> Option<LspConfig> {
493 let mut config = get_config_for_language(language)?.clone();
494 let server_kind = server_kind_for_language(language)?;
495
496 let command_key = format!("AETHER_LSPD_SERVER_COMMAND_{}", server_kind.env_key());
497 if let Some(command) = std::env::var_os(command_key) {
498 config.command = command.to_string_lossy().into_owned();
499 }
500
501 let args_key = format!("AETHER_LSPD_SERVER_ARGS_{}", server_kind.env_key());
502 if let Ok(args) = std::env::var(args_key)
503 && let Ok(parsed) = serde_json::from_str::<Vec<String>>(&args)
504 {
505 config.args = parsed;
506 }
507
508 Some(config)
509}
510
511pub(crate) fn from_extension(ext: &str) -> Option<LanguageId> {
512 LANGUAGE_SPECS.iter().find(|spec| spec.metadata.extensions.contains(&ext)).map(|spec| spec.metadata.id)
513}
514
515pub fn metadata_for(id: LanguageId) -> Option<&'static LanguageMetadata> {
516 LANGUAGE_METADATA.iter().find(|metadata| metadata.id == id)
517}
518
519pub fn from_lsp_id(lsp_id: &str) -> Option<LanguageId> {
520 LANGUAGE_SPECS.iter().find(|spec| spec.metadata.id.as_str() == lsp_id).map(|spec| spec.metadata.id)
521}
522
523pub fn extensions_for_alias(alias: &str) -> Vec<&'static str> {
524 let lower = alias.to_lowercase();
525 LANGUAGE_SPECS
526 .iter()
527 .filter(|spec| spec.metadata.aliases.iter().any(|candidate| *candidate == lower))
528 .flat_map(|spec| spec.metadata.extensions.iter().copied())
529 .collect()
530}
531
532pub fn detect_project_languages(root: &Path) -> Vec<LanguageId> {
533 PROJECT_LANGUAGE_SPECS
534 .iter()
535 .filter(|(_, manifests)| manifests.iter().any(|manifest| root.join(manifest).is_file()))
536 .map(|(language, _)| *language)
537 .collect()
538}
539
540pub fn server_metadata_for_language(language: LanguageId) -> Option<LanguageServerMetadata> {
541 let server_kind = server_kind_for_language(language)?;
542 let server = SERVER_SPECS.iter().find(|server| server.kind == server_kind)?;
543 Some(LanguageServerMetadata {
544 display_name: server.display_name,
545 installation_instructions: server.installation_instructions,
546 })
547}
548
549pub fn get_config_for_language(language: LanguageId) -> Option<&'static LspConfig> {
550 CONFIG_MAP.get(&language)
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn detects_project_languages_from_manifests() {
559 let root = tempfile::tempdir().unwrap();
560 std::fs::write(root.path().join("Cargo.toml"), "").unwrap();
561 std::fs::write(root.path().join("package.json"), "{}").unwrap();
562
563 assert_eq!(detect_project_languages(root.path()), vec![LanguageId::Rust, LanguageId::TypeScript]);
564 }
565
566 #[test]
567 fn typescript_server_metadata_has_installation_instructions() {
568 let metadata = server_metadata_for_language(LanguageId::TypeScript).unwrap();
569
570 assert_eq!(metadata.display_name, "TypeScript 7 language server");
571 assert!(metadata.installation_instructions.unwrap().contains("npm install --save-dev typescript@^7"));
572 }
573
574 #[test]
575 fn typescript_family_shares_server_kind() {
576 assert_eq!(
577 server_kind_for_language(LanguageId::TypeScript),
578 server_kind_for_language(LanguageId::TypeScriptReact)
579 );
580 assert_eq!(
581 socket_identity_for_language(LanguageId::TypeScript),
582 socket_identity_for_language(LanguageId::TypeScriptReact)
583 );
584 }
585
586 #[test]
587 fn c_family_shares_server_kind() {
588 assert_eq!(server_kind_for_language(LanguageId::C), server_kind_for_language(LanguageId::Cpp));
589 assert_eq!(socket_identity_for_language(LanguageId::C), socket_identity_for_language(LanguageId::Cpp));
590 }
591
592 #[test]
593 fn metadata_for_returns_correct_data() {
594 let meta = metadata_for(LanguageId::Rust).unwrap();
595 assert_eq!(meta.id.as_str(), "rust");
596 assert_eq!(meta.primary_extension, Some("rs"));
597 assert!(meta.aliases.contains(&"rust"));
598 assert!(meta.aliases.contains(&"rs"));
599 }
600
601 #[test]
602 fn from_lsp_id_resolves_known_languages() {
603 assert_eq!(from_lsp_id("rust"), Some(LanguageId::Rust));
604 assert_eq!(from_lsp_id("typescriptreact"), Some(LanguageId::TypeScriptReact));
605 assert_eq!(from_lsp_id("unknown"), None);
606 }
607
608 #[test]
609 fn primary_extension_delegates_to_catalog() {
610 assert_eq!(LanguageId::Rust.primary_extension(), Some("rs"));
611 assert_eq!(LanguageId::Python.primary_extension(), Some("py"));
612 assert_eq!(LanguageId::PlainText.primary_extension(), None);
613 }
614
615 #[test]
616 fn extensions_for_alias_includes_related_variants() {
617 let js_exts = extensions_for_alias("javascript");
618 assert!(js_exts.contains(&"js"));
619 assert!(js_exts.contains(&"mjs"));
620 assert!(js_exts.contains(&"jsx"));
621
622 let ts_exts = extensions_for_alias("typescript");
623 assert!(ts_exts.contains(&"ts"));
624 assert!(ts_exts.contains(&"tsx"));
625
626 let sh_exts = extensions_for_alias("bash");
627 assert!(sh_exts.contains(&"sh"));
628 assert!(sh_exts.contains(&"bash"));
629 assert!(sh_exts.contains(&"zsh"));
630 }
631
632 #[test]
633 fn all_languages_have_metadata() {
634 let variants = [
635 LanguageId::Rust,
636 LanguageId::Python,
637 LanguageId::JavaScript,
638 LanguageId::JavaScriptReact,
639 LanguageId::TypeScript,
640 LanguageId::TypeScriptReact,
641 LanguageId::Go,
642 LanguageId::Java,
643 LanguageId::C,
644 LanguageId::Cpp,
645 LanguageId::CSharp,
646 LanguageId::Ruby,
647 LanguageId::Php,
648 LanguageId::Swift,
649 LanguageId::Kotlin,
650 LanguageId::Scala,
651 LanguageId::Html,
652 LanguageId::Css,
653 LanguageId::Json,
654 LanguageId::Yaml,
655 LanguageId::Toml,
656 LanguageId::Markdown,
657 LanguageId::Xml,
658 LanguageId::Sql,
659 LanguageId::ShellScript,
660 LanguageId::PlainText,
661 ];
662
663 for variant in variants {
664 assert!(metadata_for(variant).is_some(), "Missing metadata for {variant:?}");
665 }
666 }
667
668 #[test]
669 fn language_id_as_str() {
670 assert_eq!(LanguageId::Rust.as_str(), "rust");
671 assert_eq!(LanguageId::TypeScriptReact.as_str(), "typescriptreact");
672 }
673
674 #[test]
675 fn language_id_from_extension() {
676 assert_eq!(LanguageId::from_extension("rs"), Some(LanguageId::Rust));
677 assert_eq!(LanguageId::from_extension("tsx"), Some(LanguageId::TypeScriptReact));
678 assert_eq!(LanguageId::from_extension("xyz"), None);
679 }
680
681 #[test]
682 fn language_id_from_path() {
683 assert_eq!(LanguageId::from_path(Path::new("foo.rs")), LanguageId::Rust);
684 assert_eq!(LanguageId::from_path(Path::new("bar.py")), LanguageId::Python);
685 assert_eq!(LanguageId::from_path(Path::new("baz.tsx")), LanguageId::TypeScriptReact);
686 assert_eq!(LanguageId::from_path(Path::new("unknown.xyz")), LanguageId::PlainText);
687 assert_eq!(LanguageId::from_path(Path::new("no_extension")), LanguageId::PlainText);
688 }
689
690 #[test]
691 fn get_config_for_known_languages() {
692 let rust_config = get_config_for_language(LanguageId::Rust);
693 assert!(rust_config.is_some());
694 assert_eq!(rust_config.unwrap().command, "rust-analyzer");
695
696 let ts_config = get_config_for_language(LanguageId::TypeScript).unwrap();
697 assert_eq!(ts_config.command, "tsc");
698 assert_eq!(ts_config.args, ["--lsp", "--stdio"]);
699 assert_eq!(ts_config.diagnostics_mode, DiagnosticsMode::Pull);
700
701 let plaintext_config = get_config_for_language(LanguageId::PlainText);
702 assert!(plaintext_config.is_none());
703 }
704}