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