1use std::path::Path;
6
7const BUILTINS: &[&str] = &[
8 "cd", "echo", "printf", "test", "[", "[[", "true", "false", "exit", "export", "set", "unset", "read", "shift",
9 "return", "local", "eval", "source", ".", ":", "pwd", "type", "wait", "trap", "alias", "ulimit", "umask", "let",
10 "declare", "readonly", "break", "continue", "getopts", "hash", "kill",
11];
12const LEADING: &[&str] =
14 &["if", "then", "else", "elif", "while", "until", "do", "!", "{", "(", "exec", "command", "nohup"];
15const OPAQUE: &[&str] = &["for", "case", "select", "function", "fi", "done", "esac", "}", ")", "in"];
17const WRAPPERS: &[&str] = &["env", "time", "nice", "timeout", "xargs", "sudo"];
19
20fn substitutions(cmd: &str) -> (String, Vec<String>) {
23 let chars: Vec<char> = cmd.chars().collect();
24 let (mut outer, mut inner) = (String::new(), vec![]);
25 let (mut single, mut i) = (false, 0);
26 while i < chars.len() {
27 let c = chars[i];
28 if c == '\\' && !single && i + 1 < chars.len() {
29 outer.push(c);
30 outer.push(chars[i + 1]);
31 i += 2;
32 continue;
33 }
34 if c == '\'' {
35 single = !single;
36 }
37 let opens = !single && i + 1 < chars.len() && matches!(c, '$' | '<' | '>') && chars[i + 1] == '(';
38 if opens || (!single && c == '`') {
39 let start = if c == '`' { i + 1 } else { i + 2 };
40 let mut depth = 1;
41 let mut j = start;
42 while j < chars.len() && depth > 0 {
43 match (c, chars[j]) {
44 ('`', '`') => depth = 0,
45 (_, '(') if c != '`' => depth += 1,
46 (_, ')') if c != '`' => depth -= 1,
47 _ => {}
48 }
49 j += 1;
50 }
51 let end = if depth == 0 { j - 1 } else { j };
52 inner.push(chars[start..end].iter().collect());
53 outer.push_str(" _sub_ ");
54 i = j;
55 continue;
56 }
57 outer.push(c);
58 i += 1;
59 }
60 (outer, inner)
61}
62
63fn redirect_amp(chars: &[char], i: usize) -> bool {
65 let prev = i.checked_sub(1).map(|p| chars[p]);
66 let next = chars.get(i + 1).copied();
67 matches!(prev, Some('>' | '<')) || (next == Some('>') && prev != Some('&'))
68}
69
70fn segments(cmd: &str) -> Vec<String> {
72 let chars: Vec<char> = cmd.chars().collect();
73 let (mut out, mut cur) = (vec![], String::new());
74 let (mut single, mut double, mut escaped) = (false, false, false);
75 for (i, &c) in chars.iter().enumerate() {
76 if escaped {
77 escaped = false;
78 cur.push(c);
79 continue;
80 }
81 match c {
82 '\\' if !single => escaped = true,
83 '\'' if !double => single = !single,
84 '"' if !single => double = !double,
85 '&' if !single && !double && redirect_amp(&chars, i) => {}
86 ';' | '|' | '&' | '\n' if !single && !double => {
87 out.push(std::mem::take(&mut cur));
88 continue;
89 }
90 _ => {}
91 }
92 cur.push(c);
93 }
94 out.push(cur);
95 out
96}
97
98fn words(segment: &str) -> Vec<String> {
100 let (mut out, mut cur) = (vec![], String::new());
101 let (mut single, mut double, mut escaped) = (false, false, false);
102 for c in segment.chars() {
103 if escaped {
104 escaped = false;
105 cur.push(c);
106 continue;
107 }
108 match c {
109 '\\' if !single => escaped = true,
110 '\'' if !double => single = !single,
111 '"' if !single => double = !double,
112 c if c.is_whitespace() && !single && !double => {
113 if !cur.is_empty() {
114 out.push(std::mem::take(&mut cur));
115 }
116 }
117 c => cur.push(c),
118 }
119 }
120 if !cur.is_empty() {
121 out.push(cur);
122 }
123 out
124}
125
126fn is_assignment(w: &str) -> bool {
127 w.split_once('=').is_some_and(|(name, _)| {
128 !name.is_empty()
129 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
130 && !name.starts_with(|c: char| c.is_ascii_digit())
131 })
132}
133
134fn program(segment: &str) -> Option<String> {
136 let mut ws = words(segment).into_iter().peekable();
137 while let Some(w) = ws.next() {
138 let w = w.trim_start_matches(['(', '{']).to_string();
140 if w.is_empty() {
141 continue;
142 }
143 if OPAQUE.contains(&w.as_str()) {
144 return None;
145 }
146 if LEADING.contains(&w.as_str()) || is_assignment(&w) {
147 continue;
148 }
149 if WRAPPERS.contains(&w.as_str()) {
150 while ws.peek().is_some_and(|n| {
151 n.starts_with('-')
152 || is_assignment(n)
153 || n.chars().all(|c| c.is_ascii_digit() || c == '.' || c == 's' || c == 'm')
154 }) {
155 ws.next();
156 }
157 continue;
158 }
159 if w == "_sub_" || w.contains(['$', '*', '?', '<', '>']) || BUILTINS.contains(&w.as_str()) {
160 return None;
161 }
162 return Some(w);
163 }
164 None
165}
166
167fn installed(root: &Path, prog: &str) -> bool {
168 if prog.contains('/') {
169 let p = Path::new(prog);
170 return if p.is_absolute() { p.exists() } else { root.join(p).exists() };
171 }
172 let Some(path) = std::env::var_os("PATH") else { return true };
173 std::env::split_paths(&path).any(|dir| is_executable(&dir.join(prog)))
174}
175
176#[cfg(unix)]
177fn is_executable(p: &Path) -> bool {
178 use std::os::unix::fs::PermissionsExt;
179 p.metadata().is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
180}
181
182#[cfg(not(unix))]
183fn is_executable(p: &Path) -> bool {
184 p.is_file()
185}
186
187pub fn missing_programs(root: &Path, cmd: &str) -> Vec<String> {
190 if cfg!(windows) {
191 return vec![];
192 }
193 let (outer, inner) = substitutions(cmd);
194 let mut out: Vec<String> =
195 segments(&outer).iter().filter_map(|s| program(s)).filter(|p| !installed(root, p)).collect();
196 for body in inner {
197 out.extend(missing_programs(root, &body));
198 }
199 out.sort();
200 out.dedup();
201 out
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn progs(cmd: &str) -> Vec<String> {
209 let (outer, inner) = substitutions(cmd);
210 let mut v: Vec<String> = segments(&outer).iter().filter_map(|s| program(s)).collect();
211 for b in inner {
212 v.extend(progs(&b));
213 }
214 v
215 }
216
217 #[test]
218 fn reads_the_programs_a_command_runs() {
219 assert_eq!(progs("cargo test -q && grep -c x f | wc -l"), ["cargo", "grep", "wc"]);
220 assert_eq!(progs("FOO=1 BAR=2 node scripts/check.mjs"), ["node"]);
221 assert_eq!(progs("env -i PATH=/bin jq . a.json"), ["jq"]);
222 assert_eq!(progs("diff <(jq -S . a) <(yq -o json b)"), ["diff", "jq", "yq"]);
223 assert_eq!(progs("test \"$(rg -c x f)\" = 0"), ["rg"]);
224 assert_eq!(progs("! grep -q 'a | b; c' file"), ["grep"]);
225 assert_eq!(progs("echo \"x && y\""), Vec::<String>::new());
226 assert_eq!(progs("$TOOL run"), Vec::<String>::new());
227 assert_eq!(progs("if [ -f a ]; then make; fi"), ["make"]);
228 assert_eq!(progs("timeout 30 cargo test"), ["cargo"]);
229 }
230
231 #[test]
232 fn redirections_are_not_commands() {
233 assert_eq!(progs("[ -d a ] && ls a/*.toml >/dev/null 2>&1"), ["ls"]);
234 assert_eq!(progs("make &> build.log && cat build.log"), ["make", "cat"]);
235 assert_eq!(progs("echo x >&2; jq . a <&0"), ["jq"]);
236 assert_eq!(progs("sleep 1 & wait"), ["sleep"]);
237 }
238
239 #[test]
240 fn subshells_and_escaped_quotes_are_read() {
241 assert_eq!(progs("(cd app && bun run test)"), ["bun"]);
242 assert_eq!(progs(r#"test -z "$(grep -h x f | tr -d " \"" | sort -u)""#), ["grep", "tr", "sort"]);
243 assert_eq!(progs(r#"grep -q "a\` — " AGENTS.md && exit 1"#), ["grep"]);
244 }
245
246 #[cfg(unix)]
247 #[test]
248 fn reports_only_missing_programs() {
249 let root = std::env::temp_dir();
250 assert!(missing_programs(&root, "sh -c true && cat /dev/null").is_empty());
251 assert_eq!(missing_programs(&root, "diff <(no-such-tool-xyz a) b"), ["no-such-tool-xyz"]);
252 assert_eq!(missing_programs(&root, "./no/such/script.sh"), ["./no/such/script.sh"]);
253 }
254}