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.
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(|| fallback_id(&normalized))
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" => "bash",
59        "zsh" => "zsh",
60        "batch" | "bat" | "cmd" => "batch",
61        "c" | "h" => "c",
62        "csharp" | "c-sharp" | "c#" | "cs" => "c-sharp",
63        "cpp" | "c++" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => "cpp",
64        "go" | "golang" => "go",
65        "java" => "java",
66        "kotlin" | "kt" | "kts" => "kotlin",
67        "ruby" | "rb" => "ruby",
68        "swift" => "swift",
69        "php" => "php",
70        "sql" => "sql",
71        "lua" => "lua",
72        "dockerfile" => "dockerfile",
73        "json" | "jsonc" => "json",
74        "toml" => "toml",
75        "yaml" | "yml" => "yaml",
76        "html" | "htm" => "html",
77        "css" => "css",
78        "markdown" | "md" => "markdown",
79        "zig" => "zig",
80        "nix" => "nix",
81        "haskell" | "hs" => "haskell",
82        "elixir" | "ex" | "exs" => "elixir",
83        "erlang" | "erl" | "hrl" => "erlang",
84        "scala" | "sc" => "scala",
85        "clojure" | "clj" | "cljs" | "cljc" | "edn" => "clojure",
86        "commonlisp" | "common-lisp" | "lisp" | "cl" => "commonlisp",
87        "scheme" | "scm" | "ss" => "scheme",
88        "ocaml" | "ml" | "mli" => "ocaml",
89        "dart" => "dart",
90        "powershell" | "pwsh" | "ps1" | "psm1" => "powershell",
91        "fish" => "fish",
92        "make" | "makefile" => "make",
93        "cmake" => "cmake",
94        "ninja" => "ninja",
95        "meson" => "meson",
96        "just" | "justfile" => "just",
97        "hcl" | "terraform" | "tf" | "tfvars" => "hcl",
98        "graphql" | "gql" => "graphql",
99        "protobuf" | "proto" => "proto",
100        "xml" | "xhtml" | "svg" => "xml",
101        "vue" => "vue",
102        "svelte" => "svelte",
103        "scss" => "scss",
104        "asm" | "assembly" => "asm",
105        "x86asm" | "x86-asm" | "nasm" => "x86asm",
106        "objective-c" | "objectivec" | "objc" => "objc",
107        "perl" | "pl" | "pm" => "perl",
108        "r" => "r",
109        "solidity" | "sol" => "solidity",
110        "starlark" | "bzl" | "bazel" => "starlark",
111        "rego" => "rego",
112        "ini" | "cfg" => "ini",
113        "diff" | "patch" => "diff",
114        _ => return None,
115    })
116}
117
118fn extension_id(file: &str) -> Option<&'static str> {
119    let extension = file.rsplit_once('.')?.1;
120    canonical_id(extension).or(match extension {
121        // Meaningful as file extensions but too ambiguous to honor as bare
122        // language IDs or fence info strings.
123        "s" => Some("asm"),
124        "m" | "mm" => Some("objc"),
125        _ => None,
126    })
127}
128
129fn special_file(file: &str) -> Option<&'static str> {
130    match file {
131        "dockerfile" | "containerfile" => Some("dockerfile"),
132        "go.mod" | "go.sum" => Some("go"),
133        "makefile" | "gnumakefile" => Some("make"),
134        "cmakelists.txt" => Some("cmake"),
135        "build.ninja" => Some("ninja"),
136        "meson.build" | "meson_options.txt" => Some("meson"),
137        "justfile" => Some("just"),
138        "flake.nix" => Some("nix"),
139        ".terraformrc" => Some("hcl"),
140        "workspace" => Some("starlark"),
141        "build.sbt" => Some("scala"),
142        "deps.edn" => Some("clojure"),
143        ".zshrc" => Some("zsh"),
144        ".bashrc" => Some("bash"),
145        _ => None,
146    }
147}
148
149fn fallback_id(path: &str) -> Option<&'static str> {
150    let extension = path.rsplit('.').next()?;
151    Some(match extension {
152        "conf" => "ini",
153        "docker" => "dockerfile",
154        "mdx" => "markdown",
155        "mm" => "objc",
156        "mysql" | "postgres" | "postgresql" | "sqlite" => "sql",
157        "opa" => "rego",
158        "py3" => "python",
159        "rkt" => "scheme",
160        "rlang" => "r",
161        "sass" => "scss",
162        "x86" => "x86asm",
163        "xsl" | "xslt" => "xml",
164        _ => return None,
165    })
166}
167
168fn shebang_id(source: &str) -> Option<&'static str> {
169    let line = source.lines().next()?;
170    if !line.starts_with("#!") {
171        return None;
172    }
173    let lower = line.to_ascii_lowercase();
174    if lower.contains("python") {
175        Some("python")
176    } else if lower.contains("node") {
177        Some("javascript")
178    } else if lower.contains("zsh") {
179        Some("zsh")
180    } else if lower.contains("bash") || lower.contains("/sh") {
181        Some("bash")
182    } else if lower.contains("fish") {
183        Some("fish")
184    } else if lower.contains("pwsh") || lower.contains("powershell") {
185        Some("powershell")
186    } else {
187        None
188    }
189}