use crate::{Dialect, Language};
use std::path::Path;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Detection {
pub language: Language,
pub dialect: Dialect,
pub reason: &'static str,
}
impl Detection {
const fn new(language: Language, dialect: Dialect, reason: &'static str) -> Self {
Self {
language,
dialect,
reason,
}
}
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum Spelling {
Exact,
NumericVersion,
}
const SHEBANGS: [(&str, Language, Dialect, Spelling); 20] = [
(
"python",
Language::Python,
Dialect::Standard,
Spelling::NumericVersion,
),
("bash", Language::Shell, Dialect::Bash53, Spelling::Exact),
("zsh", Language::Shell, Dialect::Zsh, Spelling::Exact),
("luajit", Language::Lua, Dialect::Standard, Spelling::Exact),
(
"lua",
Language::Lua,
Dialect::Standard,
Spelling::NumericVersion,
),
("php", Language::Php, Dialect::Standard, Spelling::Exact),
(
"truffleruby",
Language::Ruby,
Dialect::Standard,
Spelling::Exact,
),
("jruby", Language::Ruby, Dialect::Standard, Spelling::Exact),
("ruby", Language::Ruby, Dialect::Standard, Spelling::Exact),
("rscript", Language::R, Dialect::Standard, Spelling::Exact),
("dart", Language::Dart, Dialect::Standard, Spelling::Exact),
("swift", Language::Swift, Dialect::Standard, Spelling::Exact),
(
"dotnet-script",
Language::CSharp,
Dialect::Standard,
Spelling::Exact,
),
("perl", Language::Perl, Dialect::Standard, Spelling::Exact),
(
"scala-cli",
Language::Scala,
Dialect::Standard,
Spelling::Exact,
),
("scala", Language::Scala, Dialect::Standard, Spelling::Exact),
("sh", Language::Shell, Dialect::PosixSh, Spelling::Exact),
(
"node",
Language::JavaScript,
Dialect::Standard,
Spelling::Exact,
),
(
"deno",
Language::JavaScript,
Dialect::Standard,
Spelling::Exact,
),
("r", Language::R, Dialect::Standard, Spelling::Exact),
];
fn shebang_executable(line: &[u8]) -> Option<String> {
let text = std::str::from_utf8(line.strip_prefix(b"#!")?).ok()?;
let mut words: Vec<String> = text.split_ascii_whitespace().map(str::to_owned).collect();
let direct = words.first()?;
if executable_basename(direct) != Some("env") {
return Some(direct.clone());
}
words.remove(0);
env_executable(words)
}
fn env_executable(mut words: Vec<String>) -> Option<String> {
let mut index = 0usize;
while index < words.len() {
let word = &words[index];
if word == "--" {
return words.get(index + 1).cloned();
}
if word == "-S" || word == "--split-string" {
let split = words.get(index + 1..)?.join(" ");
words = split_env_string(&split)?;
index = 0;
continue;
}
if let Some(value) = word
.strip_prefix("--split-string=")
.or_else(|| word.strip_prefix("-S").filter(|value| !value.is_empty()))
{
let mut split = value.to_owned();
if let Some(rest) = words.get(index + 1..)
&& !rest.is_empty()
{
split.push(' ');
split.push_str(&rest.join(" "));
}
words = split_env_string(&split)?;
index = 0;
continue;
}
if matches!(
word.as_str(),
"-u" | "--unset" | "-C" | "--chdir" | "-a" | "--argv0"
) {
index = index.checked_add(2)?;
continue;
}
if ["-u", "-C", "-a"]
.iter()
.any(|option| word.starts_with(option) && word.len() > option.len())
|| ["--unset=", "--chdir=", "--argv0="]
.iter()
.any(|option| word.starts_with(option))
{
index += 1;
continue;
}
if matches!(
word.as_str(),
"-i" | "--ignore-environment"
| "-0"
| "--null"
| "-v"
| "--debug"
| "--block-signal"
| "--default-signal"
| "--ignore-signal"
| "--list-signal-handling"
) || ["--block-signal=", "--default-signal=", "--ignore-signal="]
.iter()
.any(|option| word.starts_with(option))
{
index += 1;
continue;
}
if word.starts_with('-') {
return None;
}
if is_env_assignment(word) {
index += 1;
continue;
}
return Some(word.clone());
}
None
}
fn split_env_string(text: &str) -> Option<Vec<String>> {
let mut words = Vec::new();
let mut word = String::new();
let mut started = false;
let mut quote = None;
let mut escaped = false;
for character in text.chars() {
if escaped {
word.push(character);
started = true;
escaped = false;
continue;
}
match quote {
Some(mark) if character == mark => quote = None,
Some('\'') => {
word.push(character);
started = true;
}
Some('"') if character == '\\' => escaped = true,
Some('"') => {
word.push(character);
started = true;
}
Some(_) => unreachable!("only quote characters are stored"),
None if character == '\\' => escaped = true,
None if matches!(character, '\'' | '"') => {
quote = Some(character);
started = true;
}
None if character.is_ascii_whitespace() => {
if started {
words.push(std::mem::take(&mut word));
started = false;
}
}
None => {
word.push(character);
started = true;
}
}
}
if quote.is_some() || escaped {
return None;
}
if started {
words.push(word);
}
Some(words)
}
fn is_env_assignment(word: &str) -> bool {
let Some((name, _)) = word.split_once('=') else {
return false;
};
let mut characters = name.chars();
characters
.next()
.is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
&& characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
}
fn executable_basename(executable: &str) -> Option<&str> {
executable
.rsplit('/')
.next()
.filter(|name| !name.is_empty())
}
fn interpreter_matches(basename: &str, name: &str, spelling: Spelling) -> bool {
let basename = basename.to_ascii_lowercase();
match spelling {
Spelling::Exact => basename == name,
Spelling::NumericVersion => basename.strip_prefix(name).is_some_and(|suffix| {
let mut characters = suffix.chars();
let first = characters.next();
let last = suffix.chars().next_back();
suffix.is_empty()
|| (first.is_some_and(|character| character.is_ascii_digit())
&& last.is_some_and(|character| character.is_ascii_digit())
&& suffix
.chars()
.all(|character| character.is_ascii_digit() || character == '.'))
}),
}
}
pub fn shebang_interpreters() -> impl Iterator<Item = &'static str> {
SHEBANGS.iter().map(|(name, _, _, _)| *name)
}
pub fn detect_language(path: Option<&Path>, source: &[u8]) -> Option<Detection> {
if let Some(path) = path {
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("");
let lower = name.to_ascii_lowercase();
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let by_extension = match extension.as_str() {
"rs" => Some((Language::Rust, Dialect::Standard)),
"ml" | "mli" | "mlt" => Some((Language::Ocaml, Dialect::Standard)),
"c" | "h" => Some((Language::C, Dialect::Standard)),
"m" => Some((Language::C, Dialect::ObjectiveC)),
"cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => Some((Language::Cpp, Dialect::Standard)),
"mm" => Some((Language::Cpp, Dialect::ObjectiveCpp)),
"cu" | "cuh" => Some((Language::Cpp, Dialect::Cuda)),
"go" => Some((Language::Go, Dialect::Standard)),
"java" => Some((Language::Java, Dialect::Standard)),
"js" | "mjs" | "cjs" => Some((Language::JavaScript, Dialect::Standard)),
"jsx" => Some((Language::JavaScript, Dialect::Jsx)),
"ts" | "mts" | "cts" => Some((Language::TypeScript, Dialect::Standard)),
"tsx" => Some((Language::TypeScript, Dialect::Tsx)),
"py" | "pyw" | "pyi" => Some((Language::Python, Dialect::Standard)),
"sh" => Some((Language::Shell, Dialect::PosixSh)),
"bash" => Some((Language::Shell, Dialect::Bash53)),
"zsh" => Some((Language::Shell, Dialect::Zsh)),
"html" | "htm" | "xhtml" | "shtml" => Some((Language::Html, Dialect::Standard)),
"css" => Some((Language::Css, Dialect::Standard)),
"jsonc" | "json5" => Some((Language::Jsonc, Dialect::Standard)),
"sql" => Some((Language::Sql, Dialect::Standard)),
"kt" | "kts" => Some((Language::Kotlin, Dialect::Standard)),
"toml" => Some((Language::Toml, Dialect::Standard)),
"lua" | "rockspec" => Some((Language::Lua, Dialect::Standard)),
"yml" | "yaml" => Some((Language::Yaml, Dialect::Standard)),
"php" | "phtml" | "phpt" => Some((Language::Php, Dialect::Standard)),
"rb" | "rbw" | "rake" | "gemspec" | "ru" | "podspec" | "jbuilder" | "thor" | "rbi" => {
Some((Language::Ruby, Dialect::Standard))
}
"zig" | "zon" => Some((Language::Zig, Dialect::Standard)),
"r" => Some((Language::R, Dialect::Standard)),
"dart" => Some((Language::Dart, Dialect::Standard)),
"swift" => Some((Language::Swift, Dialect::Standard)),
"cs" | "csx" => Some((Language::CSharp, Dialect::Standard)),
"scala" | "sc" => Some((Language::Scala, Dialect::Standard)),
"md" | "markdown" | "rmd" => Some((Language::Markdown, Dialect::Standard)),
"pl" | "pm" | "t" => Some((Language::Perl, Dialect::Standard)),
"vue" => Some((Language::Vue, Dialect::Standard)),
"svelte" => Some((Language::Svelte, Dialect::Standard)),
"scss" => Some((Language::Css, Dialect::Scss)),
"sass" => Some((Language::Css, Dialect::Sass)),
_ => None,
};
if let Some((language, dialect)) = by_extension {
return Some(Detection::new(language, dialect, "extension"));
}
let reserved = match lower.as_str() {
"dockerfile" | "containerfile" | ".profile" | ".bashrc" | ".zshrc" => {
Some((Language::Shell, Dialect::PosixSh))
}
"makefile" | "gnumakefile" => Some((Language::Shell, Dialect::PosixSh)),
"tsconfig.json" | "jsconfig.json" => Some((Language::Jsonc, Dialect::Standard)),
"cargo.lock" | "pipfile" | "poetry.lock" | "uv.lock" | "pdm.lock" => {
Some((Language::Toml, Dialect::Standard))
}
".clang-format" | ".clang-tidy" | ".yamllint" => {
Some((Language::Yaml, Dialect::Standard))
}
"gemfile" | "rakefile" | "guardfile" | "capfile" | "vagrantfile" | "brewfile"
| "podfile" | "fastfile" | "appfile" | "berksfile" | "thorfile" | "dangerfile"
| ".irbrc" | ".pryrc" => Some((Language::Ruby, Dialect::Standard)),
".rprofile" => Some((Language::R, Dialect::Standard)),
_ => None,
};
if let Some((language, dialect)) = reserved {
return Some(Detection::new(language, dialect, "reserved-filename"));
}
}
let first_line = source.split(|byte| *byte == b'\n').next().unwrap_or(source);
if let Some(executable) = shebang_executable(first_line)
&& let Some(basename) = executable_basename(&executable)
&& let Some((_, language, dialect, _)) = SHEBANGS
.iter()
.find(|(name, _, _, spelling)| interpreter_matches(basename, name, *spelling))
{
return Some(Detection::new(*language, *dialect, "shebang"));
}
let prefix = &source[..source.len().min(4096)];
let text = String::from_utf8_lossy(prefix).to_ascii_lowercase();
if text.contains("<!doctype html") || text.contains("<html") {
return Some(Detection::new(Language::Html, Dialect::Standard, "content"));
}
if text.trim_start().starts_with("<?xml") && text.contains("<html") {
return Some(Detection::new(Language::Html, Dialect::Standard, "content"));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
type ExpectedDetection = Option<(Language, Dialect)>;
#[test]
fn extensions_and_shebangs() {
assert_eq!(
detect_language(Some(Path::new("x.tsx")), b"")
.unwrap()
.dialect,
Dialect::Tsx
);
assert_eq!(
detect_language(None, b"#!/usr/bin/env python3\n")
.unwrap()
.language,
Language::Python
);
}
#[test]
fn shebang_uses_only_the_executable_basename() {
let cases: &[(&[u8], ExpectedDetection)] = &[
(
b"#!/opt/python/bin/ruby -w\n",
Some((Language::Ruby, Dialect::Standard)),
),
(
b"#!/usr/share/swift/usr/bin/swift\n",
Some((Language::Swift, Dialect::Standard)),
),
(
b"#!/usr/bin/python3.12 -I\n",
Some((Language::Python, Dialect::Standard)),
),
(b"#!/opt/python/bin/custom\n", None),
(b"#!/usr/bin/custom ruby python node\n", None),
(b"#!/usr/bin/myenv python3\n", None),
(b"#!/usr/bin/python-wrapper\n", None),
(b"#!/usr/bin/python3.\n", None),
(b"#!/usr/bin/bashful\n", None),
];
for (line, expected) in cases {
let actual = detect_language(None, line)
.map(|detection| (detection.language, detection.dialect));
assert_eq!(
actual,
*expected,
"shebang: {}",
String::from_utf8_lossy(line)
);
}
}
#[test]
fn env_options_assignments_and_separator_reach_only_the_command() {
let cases: &[(&[u8], Language)] = &[
(
b"#!/usr/bin/env -i LANG=C -- python3 -I\n",
Language::Python,
),
(b"#!/usr/bin/env -u python -- ruby -w\n", Language::Ruby),
(
b"#!/usr/bin/env --unset=python LUA=perl lua\n",
Language::Lua,
),
(b"#!/usr/bin/env -C /python -- node\n", Language::JavaScript),
(b"#!/usr/bin/env PYTHON=python perl -w\n", Language::Perl),
(
b"#!/usr/bin/env --argv0=python dotnet-script\n",
Language::CSharp,
),
];
for (line, expected) in cases {
let detection = detect_language(None, line)
.unwrap_or_else(|| panic!("did not detect {}", String::from_utf8_lossy(line)));
assert_eq!(
detection.language,
*expected,
"shebang: {}",
String::from_utf8_lossy(line)
);
}
for line in [
b"#!/usr/bin/env PYTHON=python custom ruby\n".as_slice(),
b"#!/usr/bin/env -u python custom node\n",
b"#!/usr/bin/env --unknown python\n",
] {
assert!(
detect_language(None, line).is_none(),
"an env value or argument was mistaken for a command: {}",
String::from_utf8_lossy(line)
);
}
}
#[test]
fn env_split_string_finds_its_first_command_word() {
let cases: &[(&[u8], Language)] = &[
(b"#!/usr/bin/env -S python3 -I\n", Language::Python),
(b"#!/usr/bin/env --split-string=ruby -w\n", Language::Ruby),
(
b"#!/usr/bin/env --split-string=node --no-warnings\n",
Language::JavaScript,
),
(
b"#!/usr/bin/env -S LANG=C -- scala-cli shebang\n",
Language::Scala,
),
];
for (line, expected) in cases {
let detection = detect_language(None, line)
.unwrap_or_else(|| panic!("did not detect {}", String::from_utf8_lossy(line)));
assert_eq!(
detection.language,
*expected,
"shebang: {}",
String::from_utf8_lossy(line)
);
}
}
}