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 == '\'' {
29 single = !single;
30 }
31 let opens = !single && i + 1 < chars.len() && matches!(c, '$' | '<' | '>') && chars[i + 1] == '(';
32 if opens || (!single && c == '`') {
33 let start = if c == '`' { i + 1 } else { i + 2 };
34 let mut depth = 1;
35 let mut j = start;
36 while j < chars.len() && depth > 0 {
37 match (c, chars[j]) {
38 ('`', '`') => depth = 0,
39 (_, '(') if c != '`' => depth += 1,
40 (_, ')') if c != '`' => depth -= 1,
41 _ => {}
42 }
43 j += 1;
44 }
45 let end = if depth == 0 { j - 1 } else { j };
46 inner.push(chars[start..end].iter().collect());
47 outer.push_str(" _sub_ ");
48 i = j;
49 continue;
50 }
51 outer.push(c);
52 i += 1;
53 }
54 (outer, inner)
55}
56
57fn segments(cmd: &str) -> Vec<String> {
59 let (mut out, mut cur) = (vec![], String::new());
60 let (mut single, mut double) = (false, false);
61 for c in cmd.chars() {
62 match c {
63 '\'' if !double => single = !single,
64 '"' if !single => double = !double,
65 ';' | '|' | '&' | '\n' if !single && !double => {
66 out.push(std::mem::take(&mut cur));
67 continue;
68 }
69 _ => {}
70 }
71 cur.push(c);
72 }
73 out.push(cur);
74 out
75}
76
77fn words(segment: &str) -> Vec<String> {
79 let (mut out, mut cur) = (vec![], String::new());
80 let (mut single, mut double) = (false, false);
81 for c in segment.chars() {
82 match c {
83 '\'' if !double => single = !single,
84 '"' if !single => double = !double,
85 c if c.is_whitespace() && !single && !double => {
86 if !cur.is_empty() {
87 out.push(std::mem::take(&mut cur));
88 }
89 }
90 c => cur.push(c),
91 }
92 }
93 if !cur.is_empty() {
94 out.push(cur);
95 }
96 out
97}
98
99fn is_assignment(w: &str) -> bool {
100 w.split_once('=').is_some_and(|(name, _)| {
101 !name.is_empty()
102 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
103 && !name.starts_with(|c: char| c.is_ascii_digit())
104 })
105}
106
107fn program(segment: &str) -> Option<String> {
109 let mut ws = words(segment).into_iter().peekable();
110 while let Some(w) = ws.next() {
111 if OPAQUE.contains(&w.as_str()) {
112 return None;
113 }
114 if LEADING.contains(&w.as_str()) || is_assignment(&w) {
115 continue;
116 }
117 if WRAPPERS.contains(&w.as_str()) {
118 while ws.peek().is_some_and(|n| {
119 n.starts_with('-')
120 || is_assignment(n)
121 || n.chars().all(|c| c.is_ascii_digit() || c == '.' || c == 's' || c == 'm')
122 }) {
123 ws.next();
124 }
125 continue;
126 }
127 if w == "_sub_" || w.contains(['$', '*', '?', '<', '>']) || BUILTINS.contains(&w.as_str()) {
128 return None;
129 }
130 return Some(w);
131 }
132 None
133}
134
135fn installed(root: &Path, prog: &str) -> bool {
136 if prog.contains('/') {
137 let p = Path::new(prog);
138 return if p.is_absolute() { p.exists() } else { root.join(p).exists() };
139 }
140 let Some(path) = std::env::var_os("PATH") else { return true };
141 std::env::split_paths(&path).any(|dir| is_executable(&dir.join(prog)))
142}
143
144#[cfg(unix)]
145fn is_executable(p: &Path) -> bool {
146 use std::os::unix::fs::PermissionsExt;
147 p.metadata().is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
148}
149
150#[cfg(not(unix))]
151fn is_executable(p: &Path) -> bool {
152 p.is_file()
153}
154
155pub fn missing_programs(root: &Path, cmd: &str) -> Vec<String> {
158 if cfg!(windows) {
159 return vec![];
160 }
161 let (outer, inner) = substitutions(cmd);
162 let mut out: Vec<String> =
163 segments(&outer).iter().filter_map(|s| program(s)).filter(|p| !installed(root, p)).collect();
164 for body in inner {
165 out.extend(missing_programs(root, &body));
166 }
167 out.sort();
168 out.dedup();
169 out
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 fn progs(cmd: &str) -> Vec<String> {
177 let (outer, inner) = substitutions(cmd);
178 let mut v: Vec<String> = segments(&outer).iter().filter_map(|s| program(s)).collect();
179 for b in inner {
180 v.extend(progs(&b));
181 }
182 v
183 }
184
185 #[test]
186 fn reads_the_programs_a_command_runs() {
187 assert_eq!(progs("cargo test -q && grep -c x f | wc -l"), ["cargo", "grep", "wc"]);
188 assert_eq!(progs("FOO=1 BAR=2 node scripts/check.mjs"), ["node"]);
189 assert_eq!(progs("env -i PATH=/bin jq . a.json"), ["jq"]);
190 assert_eq!(progs("diff <(jq -S . a) <(yq -o json b)"), ["diff", "jq", "yq"]);
191 assert_eq!(progs("test \"$(rg -c x f)\" = 0"), ["rg"]);
192 assert_eq!(progs("! grep -q 'a | b; c' file"), ["grep"]);
193 assert_eq!(progs("echo \"x && y\""), Vec::<String>::new());
194 assert_eq!(progs("$TOOL run"), Vec::<String>::new());
195 assert_eq!(progs("if [ -f a ]; then make; fi"), ["make"]);
196 assert_eq!(progs("timeout 30 cargo test"), ["cargo"]);
197 }
198
199 #[cfg(unix)]
200 #[test]
201 fn reports_only_missing_programs() {
202 let root = std::env::temp_dir();
203 assert!(missing_programs(&root, "sh -c true && cat /dev/null").is_empty());
204 assert_eq!(missing_programs(&root, "diff <(no-such-tool-xyz a) b"), ["no-such-tool-xyz"]);
205 assert_eq!(missing_programs(&root, "./no/such/script.sh"), ["./no/such/script.sh"]);
206 }
207}