agent_shell_parser/parse/
resolve.rs1use std::sync::LazyLock;
2
3use super::tokenize::{find_base_command, is_env_assignment};
4use super::types::{
5 CommandConfig, IndirectExecution, ParsedCommand, ResolvedCommand, UnanalyzableCommand, Word,
6 WrapperSpec,
7};
8
9static DEFAULT_CONFIG: LazyLock<CommandConfig> = LazyLock::new(|| {
10 serde_json::from_str(include_str!("../../config/commands.json"))
11 .expect("embedded commands.json is invalid")
12});
13
14pub fn default_command_config() -> &'static CommandConfig {
16 &DEFAULT_CONFIG
17}
18
19pub fn resolve_command(words: &[Word]) -> ResolvedCommand {
24 resolve_command_with(words, &DEFAULT_CONFIG)
25}
26
27const MAX_RESOLVE_DEPTH: usize = 32;
29
30pub fn resolve_command_with(words: &[Word], config: &CommandConfig) -> ResolvedCommand {
35 resolve_command_impl(words, config, 0)
36}
37
38pub(crate) fn classify_surface(
44 base: &str,
45 words: &[Word],
46 config: &CommandConfig,
47) -> Option<IndirectExecution> {
48 if base.starts_with('$') {
49 return Some(IndirectExecution::Eval);
50 }
51 if config.eval_commands.iter().any(|c| c == base) {
52 return Some(IndirectExecution::Eval);
53 }
54 if config.shells.iter().any(|s| s == base) {
55 let has_c_flag = words.iter().any(|w| w == "-c");
56 return Some(if has_c_flag {
57 IndirectExecution::ShellSpawn
58 } else {
59 IndirectExecution::SourceScript
60 });
61 }
62 if config.source_commands.iter().any(|c| c == base) {
63 return Some(IndirectExecution::SourceScript);
64 }
65 if config.wrappers.iter().any(|w| w.name == base) {
66 return Some(IndirectExecution::CommandWrapper);
67 }
68 None
69}
70
71fn resolve_command_impl(words: &[Word], config: &CommandConfig, depth: usize) -> ResolvedCommand {
72 if depth >= MAX_RESOLVE_DEPTH {
73 return ResolvedCommand::Unanalyzable(UnanalyzableCommand {
74 command: find_base_command(words),
75 kind: IndirectExecution::CommandWrapper,
76 });
77 }
78
79 let base = find_base_command(words);
80
81 match classify_surface(&base, words, config) {
82 Some(IndirectExecution::CommandWrapper) => {}
83 Some(kind) => {
84 return ResolvedCommand::Unanalyzable(UnanalyzableCommand {
85 command: base,
86 kind,
87 });
88 }
89 None => {
90 return ResolvedCommand::Resolved(ParsedCommand::from_words(words));
91 }
92 }
93
94 let spec = config.wrappers.iter().find(|s| s.name == base).unwrap();
96 if !spec.unanalyzable_flags.is_empty()
97 && words.iter().any(|w| {
98 spec.unanalyzable_flags.iter().any(|f| {
99 w == f
100 || w.starts_with(&format!("{f}="))
101 || (f.starts_with('-')
102 && f.len() == 2
103 && w.starts_with('-')
104 && !w.starts_with("--")
105 && w.contains(f.chars().last().unwrap()))
106 })
107 })
108 {
109 return ResolvedCommand::Unanalyzable(UnanalyzableCommand {
110 command: base,
111 kind: IndirectExecution::Eval,
112 });
113 }
114 let inner_start = strip_with_spec_idx(spec, words);
115 match inner_start {
116 None => ResolvedCommand::Resolved(ParsedCommand::from_words(&[])),
117 Some(idx) => {
118 debug_assert_ne!(idx, 0, "wrapper should always advance past itself");
119 resolve_command_impl(&words[idx..], config, depth + 1)
120 }
121 }
122}
123
124pub fn strip_with_spec(spec: &WrapperSpec, words: &[Word]) -> Vec<Word> {
129 match strip_with_spec_idx(spec, words) {
130 None => vec![],
131 Some(idx) => words[idx..].to_vec(),
132 }
133}
134
135fn strip_with_spec_idx(spec: &WrapperSpec, words: &[Word]) -> Option<usize> {
145 let wrapper_idx = words.iter().position(|w| {
146 let base = match w.rsplit_once('/') {
147 Some((_, name)) => name,
148 None => w.as_str(),
149 };
150 base == spec.name
151 });
152 let start = wrapper_idx.map(|i| i + 1).unwrap_or(0);
153
154 let mut i = start;
155 let mut positionals_skipped = 0;
156 while i < words.len() {
157 let w = &words[i];
158
159 if spec.has_terminator && w == "--" {
160 i += 1;
161 break;
162 }
163
164 if spec.skip_env_assignments && is_env_assignment(w) {
165 i += 1;
166 continue;
167 }
168
169 if w.starts_with('-') && w.len() > 1 {
170 if spec.short_value_flags.iter().any(|f| w == f)
172 || spec.long_value_flags.iter().any(|f| w == f)
173 {
174 i += 2;
175 if i > words.len() {
176 return None;
177 }
178 continue;
179 }
180 if let Some((flag_part, _)) = w.split_once('=') {
182 if spec.long_value_flags.iter().any(|f| f == flag_part)
183 || spec.short_value_flags.iter().any(|f| f == flag_part)
184 {
185 i += 1;
186 continue;
187 }
188 }
189 if spec
192 .short_value_flags
193 .iter()
194 .any(|f| w.starts_with(f.as_str()) && w.len() > f.len())
195 {
196 i += 1;
197 continue;
198 }
199 i += 1;
201 continue;
202 }
203
204 if positionals_skipped < spec.skip_positionals {
205 positionals_skipped += 1;
206 i += 1;
207 continue;
208 }
209
210 break;
211 }
212
213 if i >= words.len() {
214 return None;
215 }
216 Some(i)
217}
218
219#[cfg(test)]
220#[path = "resolve_tests.rs"]
221mod resolve_tests;