1use std::path::Path;
14
15use tree_sitter::Language;
16
17pub struct LanguageSpec {
18 pub name: &'static str,
19 pub language: Language,
20 pub highlights: &'static str,
21}
22
23macro_rules! lang_fn {
26 ($name:literal, $f:expr, $q:expr) => {
27 LanguageSpec {
28 name: $name,
29 language: $f.into(),
30 highlights: $q,
31 }
32 };
33}
34
35pub fn for_extension(ext: &str) -> Option<LanguageSpec> {
37 Some(match ext {
38 ".rs" => lang_fn!(
39 "rust",
40 tree_sitter_rust::LANGUAGE,
41 include_str!("../queries/rust/highlights.scm")
42 ),
43 ".py" | ".pyi" => {
44 lang_fn!(
45 "python",
46 tree_sitter_python::LANGUAGE,
47 include_str!("../queries/python/highlights.scm")
48 )
49 }
50 ".js" | ".jsx" | ".mjs" | ".cjs" => {
51 lang_fn!(
52 "javascript",
53 tree_sitter_javascript::LANGUAGE,
54 include_str!("../queries/javascript/highlights.scm")
55 )
56 }
57 ".ts" => lang_fn!(
58 "typescript",
59 tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
60 include_str!("../queries/typescript/highlights.scm")
61 ),
62 ".tsx" => lang_fn!(
63 "tsx",
64 tree_sitter_typescript::LANGUAGE_TSX,
65 include_str!("../queries/tsx/highlights.scm")
66 ),
67 ".go" => lang_fn!(
68 "go",
69 tree_sitter_go::LANGUAGE,
70 include_str!("../queries/go/highlights.scm")
71 ),
72 ".c" | ".h" => lang_fn!(
73 "c",
74 tree_sitter_c::LANGUAGE,
75 include_str!("../queries/c/highlights.scm")
76 ),
77 ".cpp" | ".cc" | ".cxx" | ".hpp" | ".hh" => {
78 lang_fn!(
79 "cpp",
80 tree_sitter_cpp::LANGUAGE,
81 include_str!("../queries/cpp/highlights.scm")
82 )
83 }
84 ".json" => lang_fn!(
85 "json",
86 tree_sitter_json::LANGUAGE,
87 include_str!("../queries/json/highlights.scm")
88 ),
89 ".sh" | ".bash" => lang_fn!(
90 "bash",
91 tree_sitter_bash::LANGUAGE,
92 include_str!("../queries/bash/highlights.scm")
93 ),
94 ".fish" => lang_fn!(
95 "fish",
96 tree_sitter_fish::language(),
97 include_str!("../queries/fish/highlights.scm")
98 ),
99 ".lua" => lang_fn!(
100 "lua",
101 tree_sitter_lua::LANGUAGE,
102 include_str!("../queries/lua/highlights.scm")
103 ),
104 ".sql" => lang_fn!(
105 "sql",
106 tree_sitter_sequel::LANGUAGE,
107 include_str!("../queries/sql/highlights.scm")
108 ),
109 _ => return None,
110 })
111}
112
113fn for_basename(name: &str) -> Option<LanguageSpec> {
117 (matches!(name, ".bashrc" | ".bash_profile" | ".profile" | "PKGBUILD")).then(|| {
118 lang_fn!(
119 "bash",
120 tree_sitter_bash::LANGUAGE,
121 include_str!("../queries/bash/highlights.scm")
122 )
123 })
124}
125
126fn for_interpreter(interp: &str) -> Option<LanguageSpec> {
129 Some(match interp {
130 "bash" | "sh" | "dash" | "zsh" => lang_fn!(
131 "bash",
132 tree_sitter_bash::LANGUAGE,
133 include_str!("../queries/bash/highlights.scm")
134 ),
135 "fish" => lang_fn!(
136 "fish",
137 tree_sitter_fish::language(),
138 include_str!("../queries/fish/highlights.scm")
139 ),
140 _ => return None,
141 })
142}
143
144pub fn interpreter_of(first_line: &str) -> Option<&str> {
148 let mut tokens = first_line.strip_prefix("#!")?.split_whitespace();
149 let program = tokens.next()?;
150 let program = if basename(program) == Some("env") {
153 tokens.find(|t| !t.starts_with('-'))?
154 } else {
155 program
156 };
157 basename(program)
158}
159
160fn basename(program: &str) -> Option<&str> {
161 Path::new(program)
162 .file_name()
163 .and_then(|n| n.to_str())
164 .filter(|n| !n.is_empty())
165}
166
167pub fn for_shebang(first_line: &str) -> Option<LanguageSpec> {
169 for_interpreter(interpreter_of(first_line)?)
170}
171
172pub fn detect(path: &Path, first_line: Option<&str>) -> Option<LanguageSpec> {
176 let p = path;
177 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
178 if let Some(spec) = for_basename(name) {
179 return Some(spec);
180 }
181 }
182 let ext = p.extension().map(|e| format!(".{}", e.to_string_lossy()));
183 if let Some(spec) = ext.as_deref().and_then(for_extension) {
184 return Some(spec);
185 }
186 first_line.and_then(for_shebang)
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn covers_the_curated_set() {
195 for ext in [
196 ".rs", ".py", ".js", ".ts", ".tsx", ".go", ".c", ".cpp", ".json", ".sh", ".fish",
197 ".lua", ".sql",
198 ] {
199 assert!(for_extension(ext).is_some(), "missing {ext}");
200 }
201 assert!(for_extension(".xyz").is_none());
202 }
203
204 #[test]
205 fn exact_filenames_beat_extension_and_shebang() {
206 for name in [".bashrc", ".bash_profile", ".profile", "PKGBUILD"] {
207 let spec = detect(std::path::Path::new(&format!("/home/tarek/{name}")), None)
208 .unwrap_or_else(|| panic!("{name} unresolved"));
209 assert_eq!(spec.name, "bash", "{name}");
210 }
211 let spec = detect(
213 std::path::Path::new("/home/tarek/.bashrc"),
214 Some("#!/usr/bin/env fish\n"),
215 )
216 .unwrap();
217 assert_eq!(spec.name, "bash");
218 assert_eq!(
220 detect(std::path::Path::new("PKGBUILD.fish"), None)
221 .unwrap()
222 .name,
223 "fish"
224 );
225 }
226
227 #[test]
228 fn shebang_resolves_when_extension_unknown_or_absent() {
229 for (line, lang) in [
230 ("#!/bin/bash\n", "bash"),
231 ("#!/bin/bash -euo pipefail\n", "bash"),
232 ("#!/usr/bin/env bash\n", "bash"),
233 ("#!/usr/bin/env -S bash --norc\n", "bash"),
234 ("#!/bin/sh\n", "bash"),
235 ("#!/usr/bin/env zsh\n", "bash"),
236 ("#!/usr/bin/fish\n", "fish"),
237 ("#!/usr/bin/env fish\n", "fish"),
238 ] {
239 let spec = detect(std::path::Path::new("some-script"), Some(line))
240 .unwrap_or_else(|| panic!("unresolved shebang {line:?}"));
241 assert_eq!(spec.name, lang, "{line:?}");
242 }
243 assert_eq!(
245 detect(std::path::Path::new("weird.tool"), Some("#!/bin/bash\n"))
246 .unwrap()
247 .name,
248 "bash"
249 );
250 assert!(detect(std::path::Path::new("README"), Some("# comment\n")).is_none());
252 assert!(detect(std::path::Path::new("run.pl"), Some("#!/usr/bin/perl\n")).is_none());
253 assert!(detect(std::path::Path::new("empty"), Some("")).is_none());
254 }
255
256 #[test]
257 fn known_extension_beats_shebang() {
258 let spec = detect(std::path::Path::new("x.fish"), Some("#!/bin/bash\n")).unwrap();
259 assert_eq!(spec.name, "fish");
260 }
261}