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