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(|| 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" => "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        "fsharp" | "f#" | "fs" | "fsi" | "fsx" => "fsharp",
90        "dart" => "dart",
91        "powershell" | "pwsh" | "ps1" | "psm1" => "powershell",
92        "fish" => "fish",
93        "make" | "makefile" => "make",
94        "cmake" => "cmake",
95        "ninja" => "ninja",
96        "meson" => "meson",
97        "just" | "justfile" => "just",
98        "hcl" | "terraform" | "tf" | "tfvars" => "hcl",
99        "graphql" | "gql" => "graphql",
100        "protobuf" | "proto" => "proto",
101        "xml" | "xhtml" | "svg" => "xml",
102        "vue" => "vue",
103        "svelte" => "svelte",
104        "scss" => "scss",
105        "asm" | "assembly" => "asm",
106        "x86asm" | "x86-asm" | "nasm" => "x86asm",
107        "objective-c" | "objectivec" | "objc" => "objc",
108        "perl" | "pl" | "pm" => "perl",
109        "r" => "r",
110        "solidity" | "sol" => "solidity",
111        "starlark" | "bzl" | "bazel" => "starlark",
112        "rego" => "rego",
113        "ini" | "cfg" => "ini",
114        "diff" | "patch" => "diff",
115        _ => return None,
116    })
117}
118
119fn extension_id(file: &str) -> Option<&'static str> {
120    let extension = file.rsplit_once('.')?.1;
121    canonical_id(extension).or(match extension {
122        // Meaningful as file extensions but too ambiguous to honor as bare
123        // language IDs or fence info strings.
124        "s" => Some("asm"),
125        "m" | "mm" => Some("objc"),
126        _ => None,
127    })
128}
129
130fn special_file(file: &str) -> Option<&'static str> {
131    match file {
132        "dockerfile" | "containerfile" => Some("dockerfile"),
133        "go.mod" | "go.sum" => Some("go"),
134        "makefile" | "gnumakefile" => Some("make"),
135        "cmakelists.txt" => Some("cmake"),
136        "build.ninja" => Some("ninja"),
137        "meson.build" | "meson_options.txt" => Some("meson"),
138        "justfile" => Some("just"),
139        "flake.nix" => Some("nix"),
140        ".terraformrc" => Some("hcl"),
141        "workspace" => Some("starlark"),
142        "build.sbt" => Some("scala"),
143        "deps.edn" => Some("clojure"),
144        ".zshrc" => Some("zsh"),
145        ".bashrc" => Some("bash"),
146        _ => None,
147    }
148}
149
150fn shebang_id(source: &str) -> Option<&'static str> {
151    let line = source.lines().next()?;
152    if !line.starts_with("#!") {
153        return None;
154    }
155    let lower = line.to_ascii_lowercase();
156    if lower.contains("python") {
157        Some("python")
158    } else if lower.contains("node") {
159        Some("javascript")
160    } else if lower.contains("zsh") {
161        Some("zsh")
162    } else if lower.contains("bash") || lower.contains("/sh") {
163        Some("bash")
164    } else if lower.contains("fish") {
165        Some("fish")
166    } else if lower.contains("pwsh") || lower.contains("powershell") {
167        Some("powershell")
168    } else {
169        None
170    }
171}