Skip to main content

rumdl_lib/code_block_tools/
linguist.rs

1//! Language alias resolution using GitHub Linguist data.
2//!
3//! This module provides mapping from language aliases (e.g., "py", "bash")
4//! to canonical language names (e.g., "python", "shell") for consistent
5//! tool configuration lookup.
6
7use std::collections::HashMap;
8use std::sync::LazyLock;
9
10/// Resolver for language aliases to canonical names.
11pub struct LinguistResolver {
12    /// Map from alias -> canonical name
13    alias_map: &'static HashMap<&'static str, &'static str>,
14}
15
16impl LinguistResolver {
17    /// Create a new resolver using embedded Linguist data.
18    pub fn new() -> Self {
19        Self {
20            alias_map: &LANGUAGE_ALIASES,
21        }
22    }
23
24    /// Resolve a language tag to its canonical name.
25    ///
26    /// Returns the canonical name if the input is a known alias,
27    /// otherwise returns the input lowercased.
28    pub fn resolve(&self, language: &str) -> String {
29        let lower = language.to_lowercase();
30        self.alias_map
31            .get(lower.as_str())
32            .map(|&s| s.to_string())
33            .unwrap_or(lower)
34    }
35
36    /// Check if a language (or alias) is known.
37    pub fn is_known(&self, language: &str) -> bool {
38        let lower = language.to_lowercase();
39        self.alias_map.contains_key(lower.as_str())
40    }
41}
42
43impl Default for LinguistResolver {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49/// Embedded language alias map.
50///
51/// Maps aliases and canonical names to canonical names.
52/// Curated subset inspired by GitHub Linguist languages.yml.
53///
54/// The map includes:
55/// - Canonical name -> canonical name (identity)
56/// - Alias -> canonical name
57/// - Extension (without dot) -> canonical name (for common extensions)
58static LANGUAGE_ALIASES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
59    let mut m = HashMap::new();
60
61    // Python
62    m.insert("python", "python");
63    m.insert("py", "python");
64    m.insert("python3", "python");
65    m.insert("py3", "python");
66    m.insert("pyw", "python");
67
68    // JavaScript
69    m.insert("javascript", "javascript");
70    m.insert("js", "javascript");
71    m.insert("node", "javascript");
72    m.insert("nodejs", "javascript");
73    m.insert("mjs", "javascript");
74    m.insert("cjs", "javascript");
75
76    // TypeScript
77    m.insert("typescript", "typescript");
78    m.insert("ts", "typescript");
79    m.insert("mts", "typescript");
80    m.insert("cts", "typescript");
81
82    // Shell/Bash
83    m.insert("shell", "shell");
84    m.insert("bash", "shell");
85    m.insert("sh", "shell");
86    m.insert("zsh", "shell");
87    m.insert("ksh", "shell");
88    m.insert("fish", "shell");
89    m.insert("shellscript", "shell");
90    m.insert("shell-script", "shell");
91
92    // Rust
93    m.insert("rust", "rust");
94    m.insert("rs", "rust");
95
96    // Go
97    m.insert("go", "go");
98    m.insert("golang", "go");
99
100    // Ruby
101    m.insert("ruby", "ruby");
102    m.insert("rb", "ruby");
103    m.insert("jruby", "ruby");
104
105    // Java
106    m.insert("java", "java");
107
108    // Kotlin
109    m.insert("kotlin", "kotlin");
110    m.insert("kt", "kotlin");
111    m.insert("kts", "kotlin");
112
113    // Scala
114    m.insert("scala", "scala");
115
116    // C
117    m.insert("c", "c");
118    m.insert("h", "c");
119
120    // C++
121    m.insert("c++", "cpp");
122    m.insert("cpp", "cpp");
123    m.insert("cxx", "cpp");
124    m.insert("cc", "cpp");
125    m.insert("hpp", "cpp");
126    m.insert("hxx", "cpp");
127
128    // C#
129    m.insert("c#", "csharp");
130    m.insert("csharp", "csharp");
131    m.insert("cs", "csharp");
132
133    // F#
134    m.insert("f#", "fsharp");
135    m.insert("fsharp", "fsharp");
136    m.insert("fs", "fsharp");
137
138    // Swift
139    m.insert("swift", "swift");
140
141    // Objective-C
142    m.insert("objective-c", "objective-c");
143    m.insert("objc", "objective-c");
144    m.insert("obj-c", "objective-c");
145
146    // PHP
147    m.insert("php", "php");
148
149    // Perl
150    m.insert("perl", "perl");
151    m.insert("pl", "perl");
152
153    // R
154    m.insert("r", "r");
155
156    // Lua
157    m.insert("lua", "lua");
158
159    // Haskell
160    m.insert("haskell", "haskell");
161    m.insert("hs", "haskell");
162
163    // Elixir
164    m.insert("elixir", "elixir");
165    m.insert("ex", "elixir");
166    m.insert("exs", "elixir");
167
168    // Erlang
169    m.insert("erlang", "erlang");
170    m.insert("erl", "erlang");
171
172    // Clojure
173    m.insert("clojure", "clojure");
174    m.insert("clj", "clojure");
175    m.insert("cljs", "clojure");
176    m.insert("cljc", "clojure");
177
178    // HTML
179    m.insert("html", "html");
180    m.insert("htm", "html");
181    m.insert("xhtml", "html");
182
183    // CSS
184    m.insert("css", "css");
185
186    // SCSS/Sass
187    m.insert("scss", "scss");
188    m.insert("sass", "sass");
189
190    // Less
191    m.insert("less", "less");
192
193    // JSON
194    m.insert("json", "json");
195    m.insert("jsonc", "json");
196    m.insert("json5", "json");
197
198    // YAML
199    m.insert("yaml", "yaml");
200    m.insert("yml", "yaml");
201
202    // TOML
203    m.insert("toml", "toml");
204
205    // XML
206    m.insert("xml", "xml");
207    m.insert("xsd", "xml");
208    m.insert("xsl", "xml");
209    m.insert("xslt", "xml");
210
211    // Markdown
212    m.insert("markdown", "markdown");
213    m.insert("md", "markdown");
214    m.insert("mkd", "markdown");
215    m.insert("mdx", "markdown");
216
217    // SQL
218    m.insert("sql", "sql");
219    m.insert("mysql", "sql");
220    m.insert("postgresql", "sql");
221    m.insert("postgres", "sql");
222    m.insert("sqlite", "sql");
223    m.insert("plsql", "sql");
224    m.insert("tsql", "sql");
225
226    // GraphQL
227    m.insert("graphql", "graphql");
228    m.insert("gql", "graphql");
229
230    // Protocol Buffers
231    m.insert("protobuf", "protobuf");
232    m.insert("proto", "protobuf");
233
234    // Terraform/HCL
235    m.insert("terraform", "terraform");
236    m.insert("tf", "terraform");
237    m.insert("hcl", "hcl");
238
239    // Dockerfile
240    m.insert("dockerfile", "dockerfile");
241    m.insert("docker", "dockerfile");
242
243    // Makefile
244    m.insert("makefile", "makefile");
245    m.insert("make", "makefile");
246
247    // Nix
248    m.insert("nix", "nix");
249
250    // Vim script
251    m.insert("vim", "vim");
252    m.insert("viml", "vim");
253    m.insert("vimscript", "vim");
254
255    // Zig
256    m.insert("zig", "zig");
257
258    // Nim
259    m.insert("nim", "nim");
260
261    // Julia
262    m.insert("julia", "julia");
263    m.insert("jl", "julia");
264
265    // OCaml
266    m.insert("ocaml", "ocaml");
267    m.insert("ml", "ocaml");
268
269    // ReasonML
270    m.insert("reason", "reason");
271    m.insert("re", "reason");
272
273    // Dart
274    m.insert("dart", "dart");
275
276    // V
277    m.insert("v", "v");
278    m.insert("vlang", "v");
279
280    // Awk
281    m.insert("awk", "awk");
282    m.insert("gawk", "awk");
283
284    // Sed
285    m.insert("sed", "sed");
286
287    // PowerShell
288    m.insert("powershell", "powershell");
289    m.insert("pwsh", "powershell");
290    m.insert("ps1", "powershell");
291
292    // Batch
293    m.insert("batch", "batch");
294    m.insert("bat", "batch");
295    m.insert("cmd", "batch");
296
297    // Diff
298    m.insert("diff", "diff");
299    m.insert("patch", "diff");
300
301    // INI
302    m.insert("ini", "ini");
303    m.insert("cfg", "ini");
304    m.insert("conf", "ini");
305
306    // AppleScript
307    m.insert("applescript", "applescript");
308
309    // Groovy
310    m.insert("groovy", "groovy");
311
312    // LaTeX
313    m.insert("latex", "latex");
314    m.insert("tex", "latex");
315
316    // Plain text
317    m.insert("text", "text");
318    m.insert("txt", "text");
319    m.insert("plaintext", "text");
320    m.insert("plain", "text");
321
322    m
323});
324
325#[cfg(test)]
326mod bridge_to_main_linguist_table {
327    use super::LANGUAGE_ALIASES;
328    use std::collections::BTreeMap;
329
330    /// Fence tags this table deliberately answers differently from rumdl's main Linguist
331    /// table (`crate::linguist_data`), as `alias -> (this table, the main table)`.
332    ///
333    /// Three reasons, and no fourth: a canonical name here is a TOML key under
334    /// `[code-block-tools.languages.*]`, so it avoids punctuation and spaces; and a
335    /// language whose blocks are handled by another language's tools is named after that
336    /// language, since the name exists to find a tool rather than to identify a grammar.
337    ///
338    /// Anything not listed here is drift between the two tables, which surfaces as a
339    /// language whose configured tools quietly stop being found for a fence tag.
340    const DELIBERATE_DIVERGENCES: &[(&str, &str, &str)] = &[
341        // Punctuation a bare TOML key cannot hold.
342        ("c#", "csharp", "c#"),
343        ("csharp", "csharp", "c#"),
344        ("c++", "cpp", "c++"),
345        ("cpp", "cpp", "c++"),
346        ("f#", "fsharp", "f#"),
347        ("fsharp", "fsharp", "f#"),
348        // Spaces a bare TOML key cannot hold.
349        ("bat", "batch", "batchfile"),
350        ("batch", "batch", "batchfile"),
351        ("proto", "protobuf", "protocol buffer"),
352        ("protobuf", "protobuf", "protocol buffer"),
353        ("vim", "vim", "vim script"),
354        ("viml", "vim", "vim script"),
355        ("vimscript", "vim", "vim script"),
356        // One language's tools serve another's blocks.
357        ("json5", "json", "json5"),
358        ("plsql", "sql", "plsql"),
359        ("tsql", "sql", "tsql"),
360        ("mdx", "markdown", "mdx"),
361        ("xsl", "xml", "xslt"),
362        ("xslt", "xml", "xslt"),
363        ("latex", "latex", "tex"),
364        ("tex", "latex", "tex"),
365        // `terraform fmt` is the tool users reach for, and it is what the built-in runs.
366        ("terraform", "terraform", "hcl"),
367    ];
368
369    fn deliberate() -> BTreeMap<&'static str, (&'static str, &'static str)> {
370        DELIBERATE_DIVERGENCES
371            .iter()
372            .map(|(alias, ours, theirs)| (*alias, (*ours, *theirs)))
373            .collect()
374    }
375
376    /// Every alias both tables know resolves to the same language, or is listed above.
377    #[test]
378    fn a_divergence_from_the_main_linguist_table_is_deliberate() {
379        let deliberate = deliberate();
380        let mut unlisted = Vec::new();
381
382        for (alias, ours) in LANGUAGE_ALIASES.iter() {
383            let Some(theirs) = crate::linguist_data::resolve_canonical(alias) else {
384                continue;
385            };
386            let theirs = theirs.to_lowercase();
387            if *ours == theirs {
388                continue;
389            }
390            match deliberate.get(alias) {
391                Some((listed_ours, listed_theirs)) if *listed_ours == *ours && *listed_theirs == theirs => {}
392                _ => unlisted.push(format!("(\"{alias}\", \"{ours}\", \"{theirs}\")")),
393            }
394        }
395
396        unlisted.sort();
397        assert!(
398            unlisted.is_empty(),
399            "these fence tags resolve to different languages in the two tables; add them to \
400             DELIBERATE_DIVERGENCES with a reason, or make the tables agree:\n{}",
401            unlisted.join("\n")
402        );
403    }
404
405    /// Every listed divergence is still one. A tie-break the main table has since adopted
406    /// is a line to delete, and an alias one of the tables has dropped is a real change
407    /// hiding behind an entry that reads as still current.
408    #[test]
409    fn a_listed_divergence_has_not_gone_stale() {
410        let mut stale = Vec::new();
411
412        for (alias, ours, theirs) in DELIBERATE_DIVERGENCES {
413            let Some(actual_ours) = LANGUAGE_ALIASES.get(alias) else {
414                stale.push(format!("{alias}: this table no longer knows it"));
415                continue;
416            };
417            let Some(actual_theirs) = crate::linguist_data::resolve_canonical(alias) else {
418                stale.push(format!("{alias}: the main table no longer knows it"));
419                continue;
420            };
421            let actual_theirs = actual_theirs.to_lowercase();
422            if *actual_ours != *ours || actual_theirs != *theirs {
423                stale.push(format!(
424                    "{alias}: listed as {ours}/{theirs}, actually {actual_ours}/{actual_theirs}"
425                ));
426            }
427        }
428
429        assert!(
430            stale.is_empty(),
431            "DELIBERATE_DIVERGENCES is out of date:\n{}",
432            stale.join("\n")
433        );
434    }
435
436    /// Every language this table resolves TO is one rumdl's main table knows.
437    ///
438    /// The key side is deliberately wider than the main table's alias list: it also holds
439    /// bare extensions (`kt`, `mjs`, `hpp`) and editor language ids (`shellscript`) that
440    /// people write in fences. The value side has no such licence - it is the name a user
441    /// puts in `[code-block-tools.languages.*]`, and inventing one there gives a language
442    /// rumdl recognizes nowhere else.
443    #[test]
444    fn every_language_this_table_resolves_to_is_one_rumdl_knows() {
445        let mut unknown: Vec<&str> = LANGUAGE_ALIASES
446            .values()
447            .filter(|canonical| crate::linguist_data::resolve_canonical(canonical).is_none())
448            .copied()
449            .collect();
450        unknown.sort_unstable();
451        unknown.dedup();
452        assert!(
453            unknown.is_empty(),
454            "languages known only to code-block-tools: {}",
455            unknown.join(", ")
456        );
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn test_resolve_known_alias() {
466        let resolver = LinguistResolver::new();
467
468        // Python aliases
469        assert_eq!(resolver.resolve("py"), "python");
470        assert_eq!(resolver.resolve("python3"), "python");
471        assert_eq!(resolver.resolve("Python"), "python");
472        assert_eq!(resolver.resolve("PY"), "python");
473
474        // Shell aliases
475        assert_eq!(resolver.resolve("bash"), "shell");
476        assert_eq!(resolver.resolve("sh"), "shell");
477        assert_eq!(resolver.resolve("zsh"), "shell");
478
479        // JavaScript aliases
480        assert_eq!(resolver.resolve("js"), "javascript");
481        assert_eq!(resolver.resolve("node"), "javascript");
482
483        // Rust
484        assert_eq!(resolver.resolve("rs"), "rust");
485        assert_eq!(resolver.resolve("Rust"), "rust");
486    }
487
488    #[test]
489    fn test_resolve_unknown_language() {
490        let resolver = LinguistResolver::new();
491
492        // Unknown languages are returned lowercased
493        assert_eq!(resolver.resolve("UnknownLang"), "unknownlang");
494        assert_eq!(resolver.resolve("CUSTOM"), "custom");
495    }
496
497    #[test]
498    fn test_resolve_canonical_name() {
499        let resolver = LinguistResolver::new();
500
501        // Canonical names resolve to themselves
502        assert_eq!(resolver.resolve("python"), "python");
503        assert_eq!(resolver.resolve("javascript"), "javascript");
504        assert_eq!(resolver.resolve("rust"), "rust");
505    }
506
507    #[test]
508    fn test_is_known() {
509        let resolver = LinguistResolver::new();
510
511        assert!(resolver.is_known("python"));
512        assert!(resolver.is_known("py"));
513        assert!(resolver.is_known("bash"));
514        assert!(resolver.is_known("JavaScript"));
515
516        assert!(!resolver.is_known("unknownlang"));
517        assert!(!resolver.is_known("customformat"));
518    }
519
520    #[test]
521    fn test_case_insensitivity() {
522        let resolver = LinguistResolver::new();
523
524        assert_eq!(resolver.resolve("PYTHON"), "python");
525        assert_eq!(resolver.resolve("Python"), "python");
526        assert_eq!(resolver.resolve("pYtHoN"), "python");
527        assert_eq!(resolver.resolve("JAVASCRIPT"), "javascript");
528        assert_eq!(resolver.resolve("JavaScript"), "javascript");
529    }
530}