Skip to main content

ocomment_core/
detect.rs

1use crate::{Dialect, Language};
2use std::path::Path;
3
4/// What [`detect_language`] concluded about a file, and on what evidence.
5#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct Detection {
7    /// The language to scan the file as.
8    pub language: Language,
9    /// The dialect that goes with it, [`Dialect::Standard`] unless the
10    /// evidence named a more specific one.
11    pub dialect: Dialect,
12    /// What decided it: `extension`, `reserved-filename`, `shebang`, or
13    /// `content`.
14    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/// How an executable basename is compared with one interpreter name.
28#[derive(Clone, Copy, Eq, PartialEq)]
29enum Spelling {
30    /// The basename is the interpreter name, ignoring ASCII case.
31    Exact,
32    /// The name, or that basename followed only by a numeric version such as
33    /// `python3.12` or `lua5.4`.
34    NumericVersion,
35}
36
37/// The interpreter names a `#!` line is read for, in the order they are tried,
38/// with the language and dialect each one selects.
39///
40/// Only the executable basename is compared. Interpreter-looking parent
41/// directories and arguments are data, not evidence. `env` is handled before
42/// this table: its options and assignments are consumed until the executable
43/// it will launch is reached.
44const 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
102/// The executable one `#!` line actually launches.
103///
104/// A direct shebang contributes only its first token. When that token is
105/// `env`, options and assignments are consumed according to `env`'s command
106/// line instead. This deliberately never searches parent directories, option
107/// values, assignments, or arguments for an interpreter-looking substring.
108fn 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            /* NOTE: Guessing whether an unknown option consumes the next token can
184             * turn its value into an interpreter. Unknown syntax is therefore
185             * deliberately undetected. */
186            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
197/// Split the string accepted by `env -S`. This is the small shell-like part of
198/// `env`'s interface: ASCII whitespace separates words, quotes group it, and a
199/// backslash quotes the following character. An unfinished quote or escape is
200/// invalid and fails closed.
201fn 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
288/// Every interpreter name [`detect_language`] reads a `#!` line for, in the
289/// order it tries them.
290///
291/// This is the detector's own table rather than a copy of it, so a caller that
292/// documents or publishes the list — `spec/languages.toml` does, and
293/// `ocomment languages` prints it — can be checked against what the detector
294/// will actually answer to instead of against a second list that may have
295/// stopped agreeing.
296///
297/// These are executable basenames, not substrings to search for in an entire
298/// shebang. The Python and Lua entries also accept a numeric version suffix.
299///
300/// # Examples
301///
302/// ```
303/// use ocomment_core::{Language, detect_language, shebang_interpreters};
304///
305/// // Every published name really does select a language from a `#!` line.
306/// for interpreter in shebang_interpreters() {
307///     let line = format!("#!/usr/bin/env {interpreter}\n");
308///     let found = detect_language(None, line.as_bytes()).unwrap();
309///     assert_eq!(found.reason, "shebang");
310/// }
311/// // `bash` is met before `sh`, which it contains.
312/// let bash = detect_language(None, b"#!/bin/bash\n").unwrap();
313/// assert_eq!(bash.dialect, ocomment_core::Dialect::Bash53);
314/// ```
315pub fn shebang_interpreters() -> impl Iterator<Item = &'static str> {
316    SHEBANGS.iter().map(|(name, _, _, _)| *name)
317}
318
319/// Detect a built-in language from filename, shebang, then conservative content hints.
320///
321/// The evidence is weighed in that order and the first answer wins, so a
322/// `.py` file whose first line says `#!/bin/sh` is still Python. `path` is
323/// optional because a buffer in an editor may have no name yet; with no path
324/// and no shebang, only a handful of unmistakable content hints are left, and
325/// `None` means the caller has to name the language itself.
326///
327/// # Examples
328///
329/// ```
330/// use std::path::Path;
331/// use ocomment_core::{Dialect, Language, detect_language};
332///
333/// let found = detect_language(Some(Path::new("src/app.tsx")), b"").unwrap();
334/// assert_eq!(found.language, Language::TypeScript);
335/// assert_eq!(found.dialect, Dialect::Tsx);
336/// assert_eq!(found.reason, "extension");
337///
338/// // No name, so the shebang decides.
339/// let piped = detect_language(None, b"#!/usr/bin/env python3\n").unwrap();
340/// assert_eq!(piped.language, Language::Python);
341///
342/// // Nothing to go on.
343/// assert!(detect_language(None, b"x = 1\n").is_none());
344/// ```
345pub 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            /* NOTE: `.php5` and `.inc` are deliberately absent: the first is a
384             * migration-era suffix no supported PHP version installs a handler
385             * for, and the second names a file included by another language
386             * quite as often as by PHP. */
387            "php" | "phtml" | "phpt" => Some((Language::Php, Dialect::Standard)),
388            /* NOTE: Ruby owns more suffixes than any other language here, because
389             * a Ruby project writes so much of itself in Ruby: `.rake` for a
390             * Rake task file, `.gemspec` for a gem's own manifest, `.ru` for a
391             * Rack configuration, `.podspec` and `.jbuilder` and `.thor` for
392             * three more tools that read a Ruby script under a name of their
393             * own, and `.rbi` for a Sorbet interface. `.erb` is deliberately
394             * absent: an ERB template is text with Ruby in tags, which is a
395             * scanner of its own rather than this one. */
396            "rb" | "rbw" | "rake" | "gemspec" | "ru" | "podspec" | "jbuilder" | "thor" | "rbi" => {
397                Some((Language::Ruby, Dialect::Standard))
398            }
399            /* NOTE: `.zon` is Zig Object Notation, the data format `@import` and
400             * `build.zig.zon` are written in. It is the same lexer with the
401             * keywords taken away — the same comments, the same string and
402             * multiline string literals — so it is the same scanner, and a
403             * `build.zig.zon` is detected by that suffix rather than by name. */
404            "zig" | "zon" => Some((Language::Zig, Dialect::Standard)),
405            /* NOTE: R is written `.R` about as often as `.r`, and the suffix is
406             * folded before it is looked up here, so both reach the same
407             * scanner. `.Rmd` is R Markdown and is detected as Markdown, whose
408             * fenced-block scan reads its `{r}` chunks as R. */
409            "r" => Some((Language::R, Dialect::Standard)),
410            /* NOTE: `.dart` is the only suffix Dart owns. `.dart_tool` names the
411             * per-package build directory rather than a file, and a
412             * `pubspec.yaml` beside it is YAML and is detected as that. */
413            "dart" => Some((Language::Dart, Dialect::Standard)),
414            /* NOTE: `.swift` is the only suffix Swift owns, and `Package.swift`
415             * carries it, so the one file name a Swift package is required to
416             * spell exactly needs no reserved-name rule of its own.
417             * `.swiftinterface` is deliberately absent: it is a generated
418             * module interface rather than a checked-in source file, and
419             * `.swiftmodule` beside it is a binary. */
420            "swift" => Some((Language::Swift, Dialect::Standard)),
421            /* NOTE: `.csx` is a C# script, which `dotnet script` and the C#
422             * interactive window read: the same lexical rules with a `#!` line
423             * allowed at the first byte and statements at the top level.
424             * `.cshtml` and `.razor` are deliberately absent: a Razor page is
425             * markup with C# blocks in it, which is a scanner of its own, and
426             * `.csproj` beside them is XML. */
427            "cs" | "csx" => Some((Language::CSharp, Dialect::Standard)),
428            /* NOTE: `.scala` is the language's own suffix and `.sc` the script
429             * suffix scala-cli reads, which share the one scanner. `.sbt` is
430             * deliberately absent: a build definition is a file of its own
431             * with a leading-blank `//` convention that no source file shares,
432             * and `.scala.sc` carries `.sc` as its last suffix and is detected
433             * as that. */
434            "scala" | "sc" => Some((Language::Scala, Dialect::Standard)),
435            /* NOTE: `.vue` and `.svelte` are the suffixes of single-file
436             * components, whose templates are HTML with code in them and whose
437             * script and style bodies are scanned as their own languages.
438             * `.scss` and `.sass` are the two Sass syntaxes. They share
439             * interpolation and silent comments, but the latter is
440             * indentation-based and therefore has its own dialect. */
441            /* NOTE: `.md` and `.markdown` are Markdown, and so is `.Rmd` —
442             * an R Markdown document, whose `{r}` chunk headers name R for
443             * the fenced-block scan — which is what the note that once kept
444             * it from the R entry is now the record of. */
445            "md" | "markdown" | "rmd" => Some((Language::Markdown, Dialect::Standard)),
446            /* NOTE: `.pl`, `.pm` and `.t` are Perl — a program, a module and
447             * a test — and so is a `perl` `#!` line. `.pod` is deliberately
448             * absent: a POD document is documentation only, with no code to
449             * scan. */
450            "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            /* NOTE: A lock file has no extension of its own to go on, and only some
467             * of them are TOML: `Cargo.lock`, `Pipfile`, and the three Python
468             * resolvers below are, while `Pipfile.lock` beside `Pipfile` is
469             * JSON and is deliberately absent. */
470            "cargo.lock" | "pipfile" | "poetry.lock" | "uv.lock" | "pdm.lock" => {
471                Some((Language::Toml, Dialect::Standard))
472            }
473            /* NOTE: YAML owns two extensions, so only the configuration files
474             * written with none at all are named here. `.clang-format` and
475             * `.clang-tidy` are YAML documents that the LLVM tools read, and
476             * `.yamllint` is the linter's own; `.pre-commit-config.yaml` and
477             * `.gitlab-ci.yml` carry an extension and are detected by it. */
478            ".clang-format" | ".clang-tidy" | ".yamllint" => {
479                Some((Language::Yaml, Dialect::Standard))
480            }
481            /* NOTE: Every one of these is a Ruby script a tool loads by name and
482             * evaluates: Bundler's `Gemfile`, Rake's `Rakefile`, and the
483             * project files of Guard, Capistrano, Vagrant, Homebrew,
484             * CocoaPods, fastlane, Berkshelf, Thor and Danger, plus the two
485             * dot files `irb` and `pry` read at start-up. `.gemrc` is
486             * deliberately absent: it carries the same air of a Ruby dot file
487             * and is a YAML document. */
488            "gemfile" | "rakefile" | "guardfile" | "capfile" | "vagrantfile" | "brewfile"
489            | "podfile" | "fastfile" | "appfile" | "berksfile" | "thorfile" | "dangerfile"
490            | ".irbrc" | ".pryrc" => Some((Language::Ruby, Dialect::Standard)),
491            /* NOTE: `.Rprofile` is the R script an R session sources at start-up
492             * and the one R file that carries no suffix. `.Renviron` beside it
493             * is deliberately absent: it is a table of `name=value` lines that
494             * R reads without parsing as code, so a `#` in one means nothing to
495             * this scanner. `Rprofile.site` is absent for a second reason — it
496             * is the system-wide profile, which lives outside a project and not
497             * in a checkout. */
498            ".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}