1use crate::{Dialect, Language};
2use std::path::Path;
3
4#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct Detection {
7 pub language: Language,
9 pub dialect: Dialect,
12 pub reason: &'static str,
15}
16
17impl Detection {
18 const fn new(language: Language, dialect: Dialect, reason: &'static str) -> Self {
19 Self {
20 language,
21 dialect,
22 reason,
23 }
24 }
25}
26
27#[derive(Clone, Copy, Eq, PartialEq)]
29enum Spelling {
30 Exact,
32 NumericVersion,
35}
36
37const SHEBANGS: [(&str, Language, Dialect, Spelling); 20] = [
45 (
46 "python",
47 Language::Python,
48 Dialect::Standard,
49 Spelling::NumericVersion,
50 ),
51 ("bash", Language::Shell, Dialect::Bash53, Spelling::Exact),
52 ("zsh", Language::Shell, Dialect::Zsh, Spelling::Exact),
53 ("luajit", Language::Lua, Dialect::Standard, Spelling::Exact),
54 (
55 "lua",
56 Language::Lua,
57 Dialect::Standard,
58 Spelling::NumericVersion,
59 ),
60 ("php", Language::Php, Dialect::Standard, Spelling::Exact),
61 (
62 "truffleruby",
63 Language::Ruby,
64 Dialect::Standard,
65 Spelling::Exact,
66 ),
67 ("jruby", Language::Ruby, Dialect::Standard, Spelling::Exact),
68 ("ruby", Language::Ruby, Dialect::Standard, Spelling::Exact),
69 ("rscript", Language::R, Dialect::Standard, Spelling::Exact),
70 ("dart", Language::Dart, Dialect::Standard, Spelling::Exact),
71 ("swift", Language::Swift, Dialect::Standard, Spelling::Exact),
72 (
73 "dotnet-script",
74 Language::CSharp,
75 Dialect::Standard,
76 Spelling::Exact,
77 ),
78 ("perl", Language::Perl, Dialect::Standard, Spelling::Exact),
79 (
80 "scala-cli",
81 Language::Scala,
82 Dialect::Standard,
83 Spelling::Exact,
84 ),
85 ("scala", Language::Scala, Dialect::Standard, Spelling::Exact),
86 ("sh", Language::Shell, Dialect::PosixSh, Spelling::Exact),
87 (
88 "node",
89 Language::JavaScript,
90 Dialect::Standard,
91 Spelling::Exact,
92 ),
93 (
94 "deno",
95 Language::JavaScript,
96 Dialect::Standard,
97 Spelling::Exact,
98 ),
99 ("r", Language::R, Dialect::Standard, Spelling::Exact),
100];
101
102fn shebang_executable(line: &[u8]) -> Option<String> {
109 let text = std::str::from_utf8(line.strip_prefix(b"#!")?).ok()?;
110 let mut words: Vec<String> = text.split_ascii_whitespace().map(str::to_owned).collect();
111 let direct = words.first()?;
112 if executable_basename(direct) != Some("env") {
113 return Some(direct.clone());
114 }
115 words.remove(0);
116 env_executable(words)
117}
118
119fn env_executable(mut words: Vec<String>) -> Option<String> {
120 let mut index = 0usize;
121 while index < words.len() {
122 let word = &words[index];
123 if word == "--" {
124 return words.get(index + 1).cloned();
125 }
126 if word == "-S" || word == "--split-string" {
127 let split = words.get(index + 1..)?.join(" ");
128 words = split_env_string(&split)?;
129 index = 0;
130 continue;
131 }
132 if let Some(value) = word
133 .strip_prefix("--split-string=")
134 .or_else(|| word.strip_prefix("-S").filter(|value| !value.is_empty()))
135 {
136 let mut split = value.to_owned();
137 if let Some(rest) = words.get(index + 1..)
138 && !rest.is_empty()
139 {
140 split.push(' ');
141 split.push_str(&rest.join(" "));
142 }
143 words = split_env_string(&split)?;
144 index = 0;
145 continue;
146 }
147 if matches!(
148 word.as_str(),
149 "-u" | "--unset" | "-C" | "--chdir" | "-a" | "--argv0"
150 ) {
151 index = index.checked_add(2)?;
152 continue;
153 }
154 if ["-u", "-C", "-a"]
155 .iter()
156 .any(|option| word.starts_with(option) && word.len() > option.len())
157 || ["--unset=", "--chdir=", "--argv0="]
158 .iter()
159 .any(|option| word.starts_with(option))
160 {
161 index += 1;
162 continue;
163 }
164 if matches!(
165 word.as_str(),
166 "-i" | "--ignore-environment"
167 | "-0"
168 | "--null"
169 | "-v"
170 | "--debug"
171 | "--block-signal"
172 | "--default-signal"
173 | "--ignore-signal"
174 | "--list-signal-handling"
175 ) || ["--block-signal=", "--default-signal=", "--ignore-signal="]
176 .iter()
177 .any(|option| word.starts_with(option))
178 {
179 index += 1;
180 continue;
181 }
182 if word.starts_with('-') {
183 return None;
187 }
188 if is_env_assignment(word) {
189 index += 1;
190 continue;
191 }
192 return Some(word.clone());
193 }
194 None
195}
196
197fn split_env_string(text: &str) -> Option<Vec<String>> {
202 let mut words = Vec::new();
203 let mut word = String::new();
204 let mut started = false;
205 let mut quote = None;
206 let mut escaped = false;
207 for character in text.chars() {
208 if escaped {
209 word.push(character);
210 started = true;
211 escaped = false;
212 continue;
213 }
214 match quote {
215 Some(mark) if character == mark => quote = None,
216 Some('\'') => {
217 word.push(character);
218 started = true;
219 }
220 Some('"') if character == '\\' => escaped = true,
221 Some('"') => {
222 word.push(character);
223 started = true;
224 }
225 Some(_) => unreachable!("only quote characters are stored"),
226 None if character == '\\' => escaped = true,
227 None if matches!(character, '\'' | '"') => {
228 quote = Some(character);
229 started = true;
230 }
231 None if character.is_ascii_whitespace() => {
232 if started {
233 words.push(std::mem::take(&mut word));
234 started = false;
235 }
236 }
237 None => {
238 word.push(character);
239 started = true;
240 }
241 }
242 }
243 if quote.is_some() || escaped {
244 return None;
245 }
246 if started {
247 words.push(word);
248 }
249 Some(words)
250}
251
252fn is_env_assignment(word: &str) -> bool {
253 let Some((name, _)) = word.split_once('=') else {
254 return false;
255 };
256 let mut characters = name.chars();
257 characters
258 .next()
259 .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
260 && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
261}
262
263fn executable_basename(executable: &str) -> Option<&str> {
264 executable
265 .rsplit('/')
266 .next()
267 .filter(|name| !name.is_empty())
268}
269
270fn interpreter_matches(basename: &str, name: &str, spelling: Spelling) -> bool {
271 let basename = basename.to_ascii_lowercase();
272 match spelling {
273 Spelling::Exact => basename == name,
274 Spelling::NumericVersion => basename.strip_prefix(name).is_some_and(|suffix| {
275 let mut characters = suffix.chars();
276 let first = characters.next();
277 let last = suffix.chars().next_back();
278 suffix.is_empty()
279 || (first.is_some_and(|character| character.is_ascii_digit())
280 && last.is_some_and(|character| character.is_ascii_digit())
281 && suffix
282 .chars()
283 .all(|character| character.is_ascii_digit() || character == '.'))
284 }),
285 }
286}
287
288pub fn shebang_interpreters() -> impl Iterator<Item = &'static str> {
316 SHEBANGS.iter().map(|(name, _, _, _)| *name)
317}
318
319pub fn detect_language(path: Option<&Path>, source: &[u8]) -> Option<Detection> {
346 if let Some(path) = path {
347 let name = path
348 .file_name()
349 .and_then(|value| value.to_str())
350 .unwrap_or("");
351 let lower = name.to_ascii_lowercase();
352 let extension = path
353 .extension()
354 .and_then(|value| value.to_str())
355 .unwrap_or("")
356 .to_ascii_lowercase();
357 let by_extension = match extension.as_str() {
358 "rs" => Some((Language::Rust, Dialect::Standard)),
359 "ml" | "mli" | "mlt" => Some((Language::Ocaml, Dialect::Standard)),
360 "c" | "h" => Some((Language::C, Dialect::Standard)),
361 "m" => Some((Language::C, Dialect::ObjectiveC)),
362 "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => Some((Language::Cpp, Dialect::Standard)),
363 "mm" => Some((Language::Cpp, Dialect::ObjectiveCpp)),
364 "cu" | "cuh" => Some((Language::Cpp, Dialect::Cuda)),
365 "go" => Some((Language::Go, Dialect::Standard)),
366 "java" => Some((Language::Java, Dialect::Standard)),
367 "js" | "mjs" | "cjs" => Some((Language::JavaScript, Dialect::Standard)),
368 "jsx" => Some((Language::JavaScript, Dialect::Jsx)),
369 "ts" | "mts" | "cts" => Some((Language::TypeScript, Dialect::Standard)),
370 "tsx" => Some((Language::TypeScript, Dialect::Tsx)),
371 "py" | "pyw" | "pyi" => Some((Language::Python, Dialect::Standard)),
372 "sh" => Some((Language::Shell, Dialect::PosixSh)),
373 "bash" => Some((Language::Shell, Dialect::Bash53)),
374 "zsh" => Some((Language::Shell, Dialect::Zsh)),
375 "html" | "htm" | "xhtml" | "shtml" => Some((Language::Html, Dialect::Standard)),
376 "css" => Some((Language::Css, Dialect::Standard)),
377 "jsonc" | "json5" => Some((Language::Jsonc, Dialect::Standard)),
378 "sql" => Some((Language::Sql, Dialect::Standard)),
379 "kt" | "kts" => Some((Language::Kotlin, Dialect::Standard)),
380 "toml" => Some((Language::Toml, Dialect::Standard)),
381 "lua" | "rockspec" => Some((Language::Lua, Dialect::Standard)),
382 "yml" | "yaml" => Some((Language::Yaml, Dialect::Standard)),
383 "php" | "phtml" | "phpt" => Some((Language::Php, Dialect::Standard)),
388 "rb" | "rbw" | "rake" | "gemspec" | "ru" | "podspec" | "jbuilder" | "thor" | "rbi" => {
397 Some((Language::Ruby, Dialect::Standard))
398 }
399 "zig" | "zon" => Some((Language::Zig, Dialect::Standard)),
405 "r" => Some((Language::R, Dialect::Standard)),
410 "dart" => Some((Language::Dart, Dialect::Standard)),
414 "swift" => Some((Language::Swift, Dialect::Standard)),
421 "cs" | "csx" => Some((Language::CSharp, Dialect::Standard)),
428 "scala" | "sc" => Some((Language::Scala, Dialect::Standard)),
435 "md" | "markdown" | "rmd" => Some((Language::Markdown, Dialect::Standard)),
446 "pl" | "pm" | "t" => Some((Language::Perl, Dialect::Standard)),
451 "vue" => Some((Language::Vue, Dialect::Standard)),
452 "svelte" => Some((Language::Svelte, Dialect::Standard)),
453 "scss" => Some((Language::Css, Dialect::Scss)),
454 "sass" => Some((Language::Css, Dialect::Sass)),
455 _ => None,
456 };
457 if let Some((language, dialect)) = by_extension {
458 return Some(Detection::new(language, dialect, "extension"));
459 }
460 let reserved = match lower.as_str() {
461 "dockerfile" | "containerfile" | ".profile" | ".bashrc" | ".zshrc" => {
462 Some((Language::Shell, Dialect::PosixSh))
463 }
464 "makefile" | "gnumakefile" => Some((Language::Shell, Dialect::PosixSh)),
465 "tsconfig.json" | "jsconfig.json" => Some((Language::Jsonc, Dialect::Standard)),
466 "cargo.lock" | "pipfile" | "poetry.lock" | "uv.lock" | "pdm.lock" => {
471 Some((Language::Toml, Dialect::Standard))
472 }
473 ".clang-format" | ".clang-tidy" | ".yamllint" => {
479 Some((Language::Yaml, Dialect::Standard))
480 }
481 "gemfile" | "rakefile" | "guardfile" | "capfile" | "vagrantfile" | "brewfile"
489 | "podfile" | "fastfile" | "appfile" | "berksfile" | "thorfile" | "dangerfile"
490 | ".irbrc" | ".pryrc" => Some((Language::Ruby, Dialect::Standard)),
491 ".rprofile" => Some((Language::R, Dialect::Standard)),
499 _ => None,
500 };
501 if let Some((language, dialect)) = reserved {
502 return Some(Detection::new(language, dialect, "reserved-filename"));
503 }
504 }
505
506 let first_line = source.split(|byte| *byte == b'\n').next().unwrap_or(source);
507 if let Some(executable) = shebang_executable(first_line)
508 && let Some(basename) = executable_basename(&executable)
509 && let Some((_, language, dialect, _)) = SHEBANGS
510 .iter()
511 .find(|(name, _, _, spelling)| interpreter_matches(basename, name, *spelling))
512 {
513 return Some(Detection::new(*language, *dialect, "shebang"));
514 }
515
516 let prefix = &source[..source.len().min(4096)];
517 let text = String::from_utf8_lossy(prefix).to_ascii_lowercase();
518 if text.contains("<!doctype html") || text.contains("<html") {
519 return Some(Detection::new(Language::Html, Dialect::Standard, "content"));
520 }
521 if text.trim_start().starts_with("<?xml") && text.contains("<html") {
522 return Some(Detection::new(Language::Html, Dialect::Standard, "content"));
523 }
524 None
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 type ExpectedDetection = Option<(Language, Dialect)>;
532
533 #[test]
534 fn extensions_and_shebangs() {
535 assert_eq!(
536 detect_language(Some(Path::new("x.tsx")), b"")
537 .unwrap()
538 .dialect,
539 Dialect::Tsx
540 );
541 assert_eq!(
542 detect_language(None, b"#!/usr/bin/env python3\n")
543 .unwrap()
544 .language,
545 Language::Python
546 );
547 }
548
549 #[test]
550 fn shebang_uses_only_the_executable_basename() {
551 let cases: &[(&[u8], ExpectedDetection)] = &[
552 (
553 b"#!/opt/python/bin/ruby -w\n",
554 Some((Language::Ruby, Dialect::Standard)),
555 ),
556 (
557 b"#!/usr/share/swift/usr/bin/swift\n",
558 Some((Language::Swift, Dialect::Standard)),
559 ),
560 (
561 b"#!/usr/bin/python3.12 -I\n",
562 Some((Language::Python, Dialect::Standard)),
563 ),
564 (b"#!/opt/python/bin/custom\n", None),
565 (b"#!/usr/bin/custom ruby python node\n", None),
566 (b"#!/usr/bin/myenv python3\n", None),
567 (b"#!/usr/bin/python-wrapper\n", None),
568 (b"#!/usr/bin/python3.\n", None),
569 (b"#!/usr/bin/bashful\n", None),
570 ];
571 for (line, expected) in cases {
572 let actual = detect_language(None, line)
573 .map(|detection| (detection.language, detection.dialect));
574 assert_eq!(
575 actual,
576 *expected,
577 "shebang: {}",
578 String::from_utf8_lossy(line)
579 );
580 }
581 }
582
583 #[test]
584 fn env_options_assignments_and_separator_reach_only_the_command() {
585 let cases: &[(&[u8], Language)] = &[
586 (
587 b"#!/usr/bin/env -i LANG=C -- python3 -I\n",
588 Language::Python,
589 ),
590 (b"#!/usr/bin/env -u python -- ruby -w\n", Language::Ruby),
591 (
592 b"#!/usr/bin/env --unset=python LUA=perl lua\n",
593 Language::Lua,
594 ),
595 (b"#!/usr/bin/env -C /python -- node\n", Language::JavaScript),
596 (b"#!/usr/bin/env PYTHON=python perl -w\n", Language::Perl),
597 (
598 b"#!/usr/bin/env --argv0=python dotnet-script\n",
599 Language::CSharp,
600 ),
601 ];
602 for (line, expected) in cases {
603 let detection = detect_language(None, line)
604 .unwrap_or_else(|| panic!("did not detect {}", String::from_utf8_lossy(line)));
605 assert_eq!(
606 detection.language,
607 *expected,
608 "shebang: {}",
609 String::from_utf8_lossy(line)
610 );
611 }
612
613 for line in [
614 b"#!/usr/bin/env PYTHON=python custom ruby\n".as_slice(),
615 b"#!/usr/bin/env -u python custom node\n",
616 b"#!/usr/bin/env --unknown python\n",
617 ] {
618 assert!(
619 detect_language(None, line).is_none(),
620 "an env value or argument was mistaken for a command: {}",
621 String::from_utf8_lossy(line)
622 );
623 }
624 }
625
626 #[test]
627 fn env_split_string_finds_its_first_command_word() {
628 let cases: &[(&[u8], Language)] = &[
629 (b"#!/usr/bin/env -S python3 -I\n", Language::Python),
630 (b"#!/usr/bin/env --split-string=ruby -w\n", Language::Ruby),
631 (
632 b"#!/usr/bin/env --split-string=node --no-warnings\n",
633 Language::JavaScript,
634 ),
635 (
636 b"#!/usr/bin/env -S LANG=C -- scala-cli shebang\n",
637 Language::Scala,
638 ),
639 ];
640 for (line, expected) in cases {
641 let detection = detect_language(None, line)
642 .unwrap_or_else(|| panic!("did not detect {}", String::from_utf8_lossy(line)));
643 assert_eq!(
644 detection.language,
645 *expected,
646 "shebang: {}",
647 String::from_utf8_lossy(line)
648 );
649 }
650 }
651}