Skip to main content

shuck_parser/
shebang.rs

1//! Helpers for reading interpreter names from shebang lines.
2
3use std::path::Path;
4
5/// Returns the interpreter command name from a shebang line.
6///
7/// For `/usr/bin/env` shebangs, this skips `env -S` and returns the first command
8/// in the split string, so `#!/usr/bin/env -S bash -e` reports `bash`.
9#[must_use]
10pub fn interpreter_name(line: &str) -> Option<&str> {
11    let line = line.trim();
12    let line = line.strip_prefix("#!")?.trim();
13
14    let mut parts = line.split_whitespace();
15    let first = parts.next()?;
16    let first_name = token_basename(first)?;
17    if first_name == "env" {
18        return env_interpreter_name(&mut parts);
19    }
20
21    Some(first_name)
22}
23
24fn env_interpreter_name<'a>(parts: &mut impl Iterator<Item = &'a str>) -> Option<&'a str> {
25    let mut skip_next = false;
26    while let Some(part) = parts.next() {
27        if skip_next {
28            skip_next = false;
29            continue;
30        }
31
32        if part == "-S" || part == "--split-string" {
33            return env_interpreter_name(parts);
34        }
35
36        if let Some(split_string) = part
37            .strip_prefix("-S")
38            .filter(|split_string| !split_string.is_empty())
39        {
40            return split_string_interpreter_name(split_string);
41        }
42
43        if let Some(split_string) = part.strip_prefix("--split-string=") {
44            return split_string_interpreter_name(split_string);
45        }
46
47        if looks_like_env_assignment(part) {
48            continue;
49        }
50
51        if env_option_consumes_value(part) {
52            skip_next = env_option_uses_separate_value(part);
53            continue;
54        }
55
56        if part.starts_with('-') {
57            continue;
58        }
59
60        return token_basename(part);
61    }
62
63    None
64}
65
66fn split_string_interpreter_name(split_string: &str) -> Option<&str> {
67    env_interpreter_name(&mut split_string.split_whitespace())
68}
69
70fn env_option_consumes_value(token: &str) -> bool {
71    matches!(token, "-u" | "-C" | "--unset" | "--chdir")
72        || token.starts_with("-u")
73        || token.starts_with("-C")
74        || token.starts_with("--unset=")
75        || token.starts_with("--chdir=")
76}
77
78fn env_option_uses_separate_value(token: &str) -> bool {
79    matches!(token, "-u" | "-C" | "--unset" | "--chdir")
80}
81
82fn looks_like_env_assignment(token: &str) -> bool {
83    let Some((name, _)) = token.split_once('=') else {
84        return false;
85    };
86    let mut chars = name.chars();
87    let Some(first) = chars.next() else {
88        return false;
89    };
90    (first == '_' || first.is_ascii_alphabetic())
91        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
92}
93
94fn token_basename(token: &str) -> Option<&str> {
95    Path::new(token).file_name()?.to_str()
96}
97
98#[cfg(test)]
99mod tests {
100    use super::interpreter_name;
101
102    #[test]
103    fn reads_direct_interpreter_name() {
104        assert_eq!(interpreter_name("#!/bin/dash"), Some("dash"));
105    }
106
107    #[test]
108    fn reads_env_interpreter_name() {
109        assert_eq!(interpreter_name("#!/usr/bin/env bash"), Some("bash"));
110    }
111
112    #[test]
113    fn skips_env_split_string_flag() {
114        assert_eq!(interpreter_name("#!/usr/bin/env -S bash -e"), Some("bash"));
115        assert_eq!(
116            interpreter_name("#!/usr/bin/env -S /bin/zsh -f"),
117            Some("zsh")
118        );
119    }
120
121    #[test]
122    fn skips_env_options_and_assignments() {
123        assert_eq!(
124            interpreter_name("#!/usr/bin/env -i FOO=1 -u BAR bash"),
125            Some("bash")
126        );
127        assert_eq!(
128            interpreter_name("#!/usr/bin/env -S FOO=1 bash -e"),
129            Some("bash")
130        );
131    }
132}