Skip to main content

aether_lspd/
language_catalog.rs

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    /// Get the LSP language ID string
39    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    /// Detect language from a file extension.
71    pub fn from_extension(ext: &str) -> Option<Self> {
72        from_extension(ext)
73    }
74
75    /// Detect language from file path.
76    ///
77    /// Returns `PlainText` for files with no extension or unknown extensions.
78    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}