1#[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 #[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
31pub 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 "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}