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