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: &str, first_line: Option<&str>) -> Option<LanguageSpec> {
176 let p = Path::new(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(&format!("/home/tarek/{name}"), None)
208 .unwrap_or_else(|| panic!("{name} unresolved"));
209 assert_eq!(spec.name, "bash", "{name}");
210 }
211 let spec = detect("/home/tarek/.bashrc", Some("#!/usr/bin/env fish\n")).unwrap();
213 assert_eq!(spec.name, "bash");
214 assert_eq!(detect("PKGBUILD.fish", None).unwrap().name, "fish");
216 }
217
218 #[test]
219 fn shebang_resolves_when_extension_unknown_or_absent() {
220 for (line, lang) in [
221 ("#!/bin/bash\n", "bash"),
222 ("#!/bin/bash -euo pipefail\n", "bash"),
223 ("#!/usr/bin/env bash\n", "bash"),
224 ("#!/usr/bin/env -S bash --norc\n", "bash"),
225 ("#!/bin/sh\n", "bash"),
226 ("#!/usr/bin/env zsh\n", "bash"),
227 ("#!/usr/bin/fish\n", "fish"),
228 ("#!/usr/bin/env fish\n", "fish"),
229 ] {
230 let spec = detect("some-script", Some(line))
231 .unwrap_or_else(|| panic!("unresolved shebang {line:?}"));
232 assert_eq!(spec.name, lang, "{line:?}");
233 }
234 assert_eq!(
236 detect("weird.tool", Some("#!/bin/bash\n")).unwrap().name,
237 "bash"
238 );
239 assert!(detect("README", Some("# comment\n")).is_none());
241 assert!(detect("run.pl", Some("#!/usr/bin/perl\n")).is_none());
242 assert!(detect("empty", Some("")).is_none());
243 }
244
245 #[test]
246 fn known_extension_beats_shebang() {
247 let spec = detect("x.fish", Some("#!/bin/bash\n")).unwrap();
248 assert_eq!(spec.name, "fish");
249 }
250}