agent_shell_parser/parse/
resolve.rs1use std::sync::LazyLock;
2
3use super::tokenize::{find_base_command, is_env_assignment, parse_command};
4use super::types::{
5 CommandConfig, IndirectExecution, ResolvedCommand, UnanalyzableCommand, WrapperSpec,
6};
7
8static DEFAULT_CONFIG: LazyLock<CommandConfig> = LazyLock::new(|| {
9 serde_json::from_str(include_str!("../../config/commands.json"))
10 .expect("embedded commands.json is invalid")
11});
12
13pub fn default_command_config() -> &'static CommandConfig {
15 &DEFAULT_CONFIG
16}
17
18pub fn resolve_command(words: &[String]) -> ResolvedCommand {
23 resolve_command_with(words, &DEFAULT_CONFIG)
24}
25
26const MAX_RESOLVE_DEPTH: usize = 32;
28
29pub fn resolve_command_with(words: &[String], config: &CommandConfig) -> ResolvedCommand {
34 resolve_command_impl(words, config, 0)
35}
36
37pub(crate) fn classify_surface(
43 base: &str,
44 words: &[String],
45 config: &CommandConfig,
46) -> Option<IndirectExecution> {
47 if base.starts_with('$') {
48 return Some(IndirectExecution::Eval);
49 }
50 if config.eval_commands.iter().any(|c| c == base) {
51 return Some(IndirectExecution::Eval);
52 }
53 if config.shells.iter().any(|s| s == base) {
54 let has_c_flag = words.iter().any(|w| w == "-c");
55 return Some(if has_c_flag {
56 IndirectExecution::ShellSpawn
57 } else {
58 IndirectExecution::SourceScript
59 });
60 }
61 if config.source_commands.iter().any(|c| c == base) {
62 return Some(IndirectExecution::SourceScript);
63 }
64 if config.wrappers.iter().any(|w| w.name == base) {
65 return Some(IndirectExecution::CommandWrapper);
66 }
67 None
68}
69
70fn resolve_command_impl(words: &[String], config: &CommandConfig, depth: usize) -> ResolvedCommand {
71 if depth >= MAX_RESOLVE_DEPTH {
72 return ResolvedCommand::Unanalyzable(UnanalyzableCommand {
73 command: find_base_command(words),
74 kind: IndirectExecution::CommandWrapper,
75 });
76 }
77
78 let base = find_base_command(words);
79
80 match classify_surface(&base, words, config) {
81 Some(IndirectExecution::CommandWrapper) => {}
82 Some(kind) => {
83 return ResolvedCommand::Unanalyzable(UnanalyzableCommand {
84 command: base,
85 kind,
86 });
87 }
88 None => return ResolvedCommand::Resolved(parse_command(&words.join(" "))),
89 }
90
91 let spec = config.wrappers.iter().find(|s| s.name == base).unwrap();
93 if !spec.unanalyzable_flags.is_empty()
94 && words.iter().any(|w| {
95 spec.unanalyzable_flags.iter().any(|f| {
96 w == f
97 || w.starts_with(&format!("{f}="))
98 || (f.starts_with('-')
99 && f.len() == 2
100 && w.starts_with('-')
101 && !w.starts_with("--")
102 && w.contains(f.chars().last().unwrap()))
103 })
104 })
105 {
106 return ResolvedCommand::Unanalyzable(UnanalyzableCommand {
107 command: base,
108 kind: IndirectExecution::Eval,
109 });
110 }
111 let inner_start = strip_with_spec_idx(spec, words);
112 match inner_start {
113 None => return ResolvedCommand::Resolved(parse_command("")),
114 Some(idx) => {
115 debug_assert_ne!(idx, 0, "wrapper should always advance past itself");
116 if idx > 0 {
117 return resolve_command_impl(&words[idx..], config, depth + 1);
118 }
119 }
120 }
121
122 ResolvedCommand::Resolved(parse_command(&words.join(" ")))
123}
124
125pub fn strip_with_spec(spec: &WrapperSpec, words: &[String]) -> Vec<String> {
130 match strip_with_spec_idx(spec, words) {
131 None => vec![],
132 Some(idx) => words[idx..].to_vec(),
133 }
134}
135
136fn strip_with_spec_idx(spec: &WrapperSpec, words: &[String]) -> Option<usize> {
146 let wrapper_idx = words.iter().position(|w| {
147 let base = match w.rsplit_once('/') {
148 Some((_, name)) => name,
149 None => w.as_str(),
150 };
151 base == spec.name
152 });
153 let start = wrapper_idx.map(|i| i + 1).unwrap_or(0);
154
155 let mut i = start;
156 let mut positionals_skipped = 0;
157 while i < words.len() {
158 let w = &words[i];
159
160 if spec.has_terminator && w == "--" {
161 i += 1;
162 break;
163 }
164
165 if spec.skip_env_assignments && is_env_assignment(w) {
166 i += 1;
167 continue;
168 }
169
170 if w.starts_with('-') && w.len() > 1 {
171 if spec.short_value_flags.iter().any(|f| w == f)
173 || spec.long_value_flags.iter().any(|f| w == f)
174 {
175 i += 2;
176 if i > words.len() {
177 return None;
178 }
179 continue;
180 }
181 if let Some((flag_part, _)) = w.split_once('=') {
183 if spec.long_value_flags.iter().any(|f| f == flag_part)
184 || spec.short_value_flags.iter().any(|f| f == flag_part)
185 {
186 i += 1;
187 continue;
188 }
189 }
190 if spec
193 .short_value_flags
194 .iter()
195 .any(|f| w.starts_with(f.as_str()) && w.len() > f.len())
196 {
197 i += 1;
198 continue;
199 }
200 i += 1;
202 continue;
203 }
204
205 if positionals_skipped < spec.skip_positionals {
206 positionals_skipped += 1;
207 i += 1;
208 continue;
209 }
210
211 break;
212 }
213
214 if i >= words.len() {
215 return None;
216 }
217 Some(i)
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn words(s: &str) -> Vec<String> {
225 shlex::split(s).unwrap_or_else(|| s.split_whitespace().map(String::from).collect())
226 }
227
228 fn spec(name: &str) -> WrapperSpec {
229 WrapperSpec {
230 name: name.to_string(),
231 short_value_flags: vec!["-v".to_string()],
232 long_value_flags: vec!["--val".to_string()],
233 unanalyzable_flags: vec![],
234 skip_env_assignments: false,
235 has_terminator: true,
236 skip_positionals: 0,
237 }
238 }
239
240 #[test]
241 fn strip_simple_wrapper() {
242 let s = spec("wrap");
243 let result = strip_with_spec(&s, &words("wrap inner cmd"));
244 assert_eq!(result, words("inner cmd"));
245 }
246
247 #[test]
248 fn strip_value_consuming_short_flag() {
249 let s = spec("wrap");
250 let result = strip_with_spec(&s, &words("wrap -v thing inner cmd"));
251 assert_eq!(result, words("inner cmd"));
252 }
253
254 #[test]
255 fn strip_value_consuming_long_flag() {
256 let s = spec("wrap");
257 let result = strip_with_spec(&s, &words("wrap --val thing inner cmd"));
258 assert_eq!(result, words("inner cmd"));
259 }
260
261 #[test]
262 fn strip_long_flag_equals_form() {
263 let s = spec("wrap");
264 let result = strip_with_spec(&s, &words("wrap --val=thing inner cmd"));
265 assert_eq!(result, words("inner cmd"));
266 }
267
268 #[test]
269 fn strip_terminator_stops_flag_processing() {
270 let s = spec("wrap");
271 let result = strip_with_spec(&s, &words("wrap -x -- -v notflag cmd"));
272 assert_eq!(result, words("-v notflag cmd"));
273 }
274
275 #[test]
276 fn strip_boolean_flag_skipped() {
277 let s = spec("wrap");
278 let result = strip_with_spec(&s, &words("wrap -x --verbose inner"));
279 assert_eq!(result, words("inner"));
280 }
281
282 #[test]
283 fn strip_env_assignments_when_configured() {
284 let s = WrapperSpec {
285 name: "wrap".to_string(),
286 short_value_flags: vec![],
287 long_value_flags: vec![],
288 unanalyzable_flags: vec![],
289 skip_env_assignments: true,
290 has_terminator: false,
291 skip_positionals: 0,
292 };
293 let result = strip_with_spec(&s, &words("wrap FOO=bar BAZ=qux inner cmd"));
294 assert_eq!(result, words("inner cmd"));
295 }
296
297 #[test]
298 fn strip_truncated_value_flag_returns_empty() {
299 let s = spec("wrap");
300 let result = strip_with_spec(&s, &words("wrap -v"));
301 assert!(result.is_empty());
302 }
303
304 #[test]
305 fn strip_no_inner_command_returns_empty() {
306 let s = spec("wrap");
307 let result = strip_with_spec(&s, &words("wrap -x --verbose"));
308 assert!(result.is_empty());
309 }
310
311 #[test]
312 fn strip_path_prefixed_wrapper() {
313 let s = spec("wrap");
314 let result = strip_with_spec(&s, &words("/usr/bin/wrap inner cmd"));
315 assert_eq!(result, words("inner cmd"));
316 }
317
318 #[test]
319 fn resolve_with_custom_config() {
320 let config = CommandConfig {
321 wrappers: vec![WrapperSpec {
322 name: "mywrap".to_string(),
323 short_value_flags: vec!["-x".to_string()],
324 long_value_flags: vec![],
325 unanalyzable_flags: vec![],
326 skip_env_assignments: false,
327 has_terminator: false,
328 skip_positionals: 0,
329 }],
330 shells: vec!["mysh".to_string()],
331 eval_commands: vec!["myeval".to_string()],
332 source_commands: vec!["mysource".to_string()],
333 };
334
335 match resolve_command_with(&words("mywrap -x val inner"), &config) {
336 ResolvedCommand::Resolved(p) => assert_eq!(p.command, "inner"),
337 _ => panic!("expected Resolved"),
338 }
339
340 assert!(matches!(
341 resolve_command_with(&words("mysh -c 'code'"), &config),
342 ResolvedCommand::Unanalyzable(_)
343 ));
344
345 assert!(matches!(
346 resolve_command_with(&words("myeval 'code'"), &config),
347 ResolvedCommand::Unanalyzable(_)
348 ));
349
350 assert!(matches!(
351 resolve_command_with(&words("mysource file.sh"), &config),
352 ResolvedCommand::Unanalyzable(_)
353 ));
354 }
355}