Skip to main content

clankerdiff_syntax/
language.rs

1//! Deterministic resolution of language hints to Arborium grammar IDs.
2
3/// A structured language hint accepted by the highlighter.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5#[non_exhaustive]
6pub enum LanguageHint<'a> {
7    Id(&'a str),
8    InfoString(&'a str),
9    Path(&'a str),
10    Auto,
11}
12
13impl<'a> From<&'a str> for LanguageHint<'a> {
14    fn from(value: &'a str) -> Self {
15        Self::Id(value)
16    }
17}
18
19impl<'a> LanguageHint<'a> {
20    /// Flattens the hint to the token [`resolve_language`] matches against.
21    #[must_use]
22    pub fn as_str(&self) -> &'a str {
23        match self {
24            Self::Id(value) | Self::Path(value) => value,
25            Self::InfoString(value) => value.split_ascii_whitespace().next().unwrap_or_default(),
26            Self::Auto => "",
27        }
28    }
29}
30
31/// Resolves a language ID, alias, or repository path to a bundled Arborium grammar.
32pub fn resolve_language<'a>(
33    hint: impl Into<LanguageHint<'a>>,
34    source: &str,
35) -> Option<&'static str> {
36    let hint = hint.into().as_str();
37    let normalized = hint.trim().replace('\\', "/").to_ascii_lowercase();
38    if normalized.is_empty() {
39        return shebang_id(source);
40    }
41
42    let simple = normalized.rsplit('/').next().unwrap_or(&normalized);
43    canonical_id(&normalized)
44        .or_else(|| canonical_id(simple))
45        .or_else(|| extension_id(simple))
46        .or_else(|| special_file(simple))
47        .or_else(|| arborium::detect_language(&normalized).and_then(canonical_id))
48        .or_else(|| shebang_id(source))
49}
50
51fn canonical_id(hint: &str) -> Option<&'static str> {
52    Some(match hint {
53        "rust" | "rs" => "rust",
54        "javascript" | "js" | "mjs" | "cjs" | "node" | "jsx" => "javascript",
55        "typescript" | "ts" | "mts" | "cts" => "typescript",
56        "tsx" => "tsx",
57        "python" | "py" | "python3" => "python",
58        "bash" | "sh" | "shell" | "zsh" => "bash",
59        "c" | "h" => "c",
60        "csharp" | "c-sharp" | "c#" | "cs" => "c-sharp",
61        "cpp" | "c++" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => "cpp",
62        "go" | "golang" => "go",
63        "java" => "java",
64        "kotlin" | "kt" | "kts" => "kotlin",
65        "ruby" | "rb" => "ruby",
66        "swift" => "swift",
67        "php" => "php",
68        "sql" => "sql",
69        "lua" => "lua",
70        "dockerfile" => "dockerfile",
71        "json" | "jsonc" => "json",
72        "toml" => "toml",
73        "yaml" | "yml" => "yaml",
74        "html" | "htm" => "html",
75        "css" => "css",
76        "markdown" | "md" => "markdown",
77        "zig" => "zig",
78        "nix" => "nix",
79        "haskell" | "hs" => "haskell",
80        "elixir" | "ex" | "exs" => "elixir",
81        "erlang" | "erl" | "hrl" => "erlang",
82        "scala" | "sc" => "scala",
83        "clojure" | "clj" | "cljs" | "cljc" | "edn" => "clojure",
84        "commonlisp" | "common-lisp" | "lisp" | "cl" => "commonlisp",
85        "scheme" | "scm" | "ss" => "scheme",
86        "ocaml" | "ml" | "mli" => "ocaml",
87        "fsharp" | "f#" | "fs" | "fsi" | "fsx" => "fsharp",
88        "dart" => "dart",
89        "powershell" | "pwsh" | "ps1" | "psm1" => "powershell",
90        "fish" => "fish",
91        "make" | "makefile" => "make",
92        "cmake" => "cmake",
93        "ninja" => "ninja",
94        "meson" => "meson",
95        "just" | "justfile" => "just",
96        "hcl" | "terraform" | "tf" | "tfvars" => "hcl",
97        "graphql" | "gql" => "graphql",
98        "protobuf" | "proto" => "proto",
99        "xml" | "xhtml" | "svg" => "xml",
100        "vue" => "vue",
101        "svelte" => "svelte",
102        "scss" => "scss",
103        "asm" | "assembly" => "asm",
104        "x86asm" | "x86-asm" | "nasm" => "x86asm",
105        "objective-c" | "objectivec" | "objc" => "objc",
106        "perl" | "pl" | "pm" => "perl",
107        "r" => "r",
108        "solidity" | "sol" => "solidity",
109        "starlark" | "bzl" | "bazel" => "starlark",
110        "rego" => "rego",
111        "ini" | "cfg" => "ini",
112        "diff" | "patch" => "diff",
113        _ => return None,
114    })
115}
116
117fn extension_id(file: &str) -> Option<&'static str> {
118    let extension = file.rsplit_once('.')?.1;
119    canonical_id(extension).or(match extension {
120        // Meaningful as file extensions but too ambiguous to honor as bare
121        // language IDs or fence info strings.
122        "s" => Some("asm"),
123        "m" | "mm" => Some("objc"),
124        _ => None,
125    })
126}
127
128fn special_file(file: &str) -> Option<&'static str> {
129    match file {
130        "dockerfile" | "containerfile" => Some("dockerfile"),
131        "go.mod" | "go.sum" => Some("go"),
132        "makefile" | "gnumakefile" => Some("make"),
133        "cmakelists.txt" => Some("cmake"),
134        "build.ninja" => Some("ninja"),
135        "meson.build" | "meson_options.txt" => Some("meson"),
136        "justfile" => Some("just"),
137        "flake.nix" => Some("nix"),
138        ".terraformrc" => Some("hcl"),
139        "workspace" => Some("starlark"),
140        "build.sbt" => Some("scala"),
141        "deps.edn" => Some("clojure"),
142        ".zshrc" if cfg!(feature = "agent-languages") => Some("zsh"),
143        ".bashrc" | ".zshrc" => Some("bash"),
144        _ => None,
145    }
146}
147
148fn shebang_id(source: &str) -> Option<&'static str> {
149    let line = source.lines().next()?;
150    if !line.starts_with("#!") {
151        return None;
152    }
153    let lower = line.to_ascii_lowercase();
154    if lower.contains("python") {
155        Some("python")
156    } else if lower.contains("node") {
157        Some("javascript")
158    } else if lower.contains("bash") || lower.contains("/sh") || lower.contains("zsh") {
159        Some("bash")
160    } else if lower.contains("fish") {
161        Some("fish")
162    } else if lower.contains("pwsh") || lower.contains("powershell") {
163        Some("powershell")
164    } else {
165        None
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn resolves_aliases_paths_special_files_and_shebangs() {
175        for (hint, source, expected) in [
176            ("RUST", "", Some("rust")),
177            (".rs", "", Some("rust")),
178            ("src/lib.rs", "", Some("rust")),
179            ("src\\lib.rs", "", Some("rust")),
180            ("view.tsx", "", Some("tsx")),
181            ("x.d.ts", "", Some("typescript")),
182            ("x.d.mts", "", Some("typescript")),
183            ("x.d.cts", "", Some("typescript")),
184            ("foo.jsx", "", Some("javascript")),
185            ("foo.jsonc", "", Some("json")),
186            ("foo.yml", "", Some("yaml")),
187            ("Dockerfile", "", Some("dockerfile")),
188            ("Containerfile", "", Some("dockerfile")),
189            ("Program.cs", "", Some("c-sharp")),
190            ("Main.java", "", Some("java")),
191            ("Main.kt", "", Some("kotlin")),
192            ("script.rb", "", Some("ruby")),
193            ("query.sql", "", Some("sql")),
194            (".bashrc", "", Some("bash")),
195            (
196                ".zshrc",
197                "",
198                Some(if cfg!(feature = "agent-languages") {
199                    "zsh"
200                } else {
201                    "bash"
202                }),
203            ),
204            ("go.mod", "", Some("go")),
205            ("", "#!/usr/bin/env python3\n", Some("python")),
206            ("", "#!/usr/bin/env node\n", Some("javascript")),
207            ("unknown.bin", "bytes", None),
208            ("markdown-inline", "", None),
209        ] {
210            assert_eq!(resolve_language(hint, source), expected, "{hint}");
211        }
212    }
213}