1use std::path::Path;
4
5#[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}