Skip to main content

agent_shell_parser/parse/
resolve.rs

1use 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
14/// Return the embedded default command configuration.
15pub fn default_command_config() -> &'static CommandConfig {
16    &DEFAULT_CONFIG
17}
18
19/// Resolve a command through the indirection layer using the default config.
20///
21/// Recursively strips transparent wrappers and classifies unanalyzable
22/// patterns (eval, shell spawn, source) based on the embedded command config.
23pub fn resolve_command(words: &[Word]) -> ResolvedCommand {
24    resolve_command_with(words, &DEFAULT_CONFIG)
25}
26
27/// Maximum recursion depth for wrapper resolution to prevent unbounded loops.
28const MAX_RESOLVE_DEPTH: usize = 32;
29
30/// Resolve a command through the indirection layer using a custom config.
31///
32/// Same as [`resolve_command`] but accepts caller-provided [`CommandConfig`],
33/// allowing consumers to extend or replace the default command knowledge.
34pub fn resolve_command_with(words: &[Word], config: &CommandConfig) -> ResolvedCommand {
35    resolve_command_impl(words, config, 0)
36}
37
38/// Classify the surface-level command without recursing into wrappers.
39///
40/// Returns `Some(kind)` if the command is an indirect execution pattern,
41/// `None` if it's a plain command. This is O(1) in wrapper depth — it only
42/// looks at the outermost command.
43pub(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    // It's a wrapper — check for unanalyzable flags, then strip and recurse.
95    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
124/// Strip a wrapper command using its spec and return the remaining arguments.
125///
126/// Correctly handles value-consuming flags, env assignments, and `--`
127/// terminators as specified by the [`WrapperSpec`].
128pub 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
135/// Strip a wrapper command using its spec and return the index where the inner
136/// command starts, or `None` if no inner command was found.
137///
138/// This avoids allocating a new `Vec<Word>` — callers can slice the original
139/// word list directly.
140///
141/// Correctly handles value-consuming flags (including combined short forms like
142/// `-uroot`), env assignments, and `--` terminators as specified by the
143/// [`WrapperSpec`].
144fn 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            // Exact match for value-consuming flags (e.g., `-u` consuming next token)
171            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            // Long flags with `=` form (e.g., `--user=root`)
181            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            // Combined short flags (e.g., `-uroot` where `-u` is a value flag)
190            // The value is embedded in the token — consume it and continue.
191            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            // Boolean flag — skip it
200            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;