Skip to main content

appcore_args/
parser.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: parser.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/19 12:52:57 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/19 13:34:54 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Defines bounded parser contracts and behavior for this crate.
12
13use crate::error::{CliError, CliErrorKind};
14use crate::raw::RawArgs;
15use crate::spec::{ArgumentSpec, CliSpec, CommandSpec, OptionSpec, ValueMode, ValueType};
16use crate::suggestion;
17
18use std::collections::HashSet;
19
20pub struct CliParser<'a> {
21    spec: &'a CliSpec,
22}
23
24impl<'a> CliParser<'a> {
25    pub fn new(spec: &'a CliSpec) -> Self {
26        Self { spec }
27    }
28
29    pub fn parse(&self, args: &RawArgs) -> Result<crate::ParsedCli, CliError> {
30        self.parse_with_env(args, |name| std::env::var_os(name))
31    }
32    /// Parses with an injected environment lookup; argv wins over env and defaults.
33    pub fn parse_with_env(
34        &self,
35        args: &RawArgs,
36        env: impl FnMut(&str) -> Option<std::ffi::OsString>,
37    ) -> Result<crate::ParsedCli, CliError> {
38        let raw = crate::RawOsArgs::parse_with_limits(
39            args.words().iter().map(std::ffi::OsString::from),
40            args.limits(),
41        )?;
42        self.parse_os_with_env(&raw, env)?.into_text()
43    }
44    /// Preserves native path arguments and passthrough bytes without UTF-8 conversion.
45    pub fn parse_os(&self, args: &crate::RawOsArgs) -> Result<crate::ParsedOsCli, CliError> {
46        self.parse_os_with_env(args, |name| std::env::var_os(name))
47    }
48    /// Native parser with deterministic environment injection for hosts and tests.
49    pub fn parse_os_with_env(
50        &self,
51        args: &crate::RawOsArgs,
52        mut env: impl FnMut(&str) -> Option<std::ffi::OsString>,
53    ) -> Result<crate::ParsedOsCli, CliError> {
54        self.spec.validate().map_err(|error| {
55            CliError::new(CliErrorKind::InvalidSpecification, error.to_string())
56        })?;
57        let mut state = ParseState::new(self.spec);
58        let mut index = 0;
59        while index < args.words().len() {
60            let word = &args.words()[index];
61            let text = word.to_str();
62            if text == Some("--") {
63                state
64                    .passthrough
65                    .extend_from_slice(&args.words()[index + 1..]);
66                break;
67            }
68            let bytes = word.as_encoded_bytes();
69            index = if bytes.starts_with(b"--") {
70                parse_long_option(args.words(), index, &mut state)?
71            } else if bytes.starts_with(b"-")
72                && bytes.len() > 1
73                && !text.is_some_and(|s| state.accepts_negative_positional(s))
74            {
75                parse_short_options(args.words(), index, &mut state)?
76            } else if state.positionals.is_empty() && text.is_some_and(|s| state.enter_command(s)) {
77                index + 1
78            } else {
79                state.push_positional(word)?;
80                index + 1
81            };
82        }
83        if !has_terminal_option(&state) {
84            state.resolve_sources(args, &mut env)?;
85        }
86        state.finish()
87    }
88}
89
90struct ParseState<'a> {
91    spec: &'a CliSpec,
92    commands: Vec<&'a CommandSpec>,
93    command_path: Vec<String>,
94    options: Vec<crate::native::ParsedOsOption>,
95    positionals: Vec<std::ffi::OsString>,
96    passthrough: Vec<std::ffi::OsString>,
97}
98
99impl<'a> ParseState<'a> {
100    fn new(spec: &'a CliSpec) -> Self {
101        Self {
102            spec,
103            commands: Vec::new(),
104            command_path: Vec::new(),
105            options: Vec::new(),
106            positionals: Vec::new(),
107            passthrough: Vec::new(),
108        }
109    }
110
111    fn enter_command(&mut self, word: &str) -> bool {
112        if let Some(command) = self
113            .available_commands()
114            .iter()
115            .find(|command| command.matches(word))
116        {
117            self.command_path.push(command.name().to_string());
118            self.commands.push(command);
119            true
120        } else {
121            false
122        }
123    }
124
125    fn available_commands(&self) -> &'a [CommandSpec] {
126        self.commands
127            .last()
128            .map(|command| command.commands())
129            .unwrap_or_else(|| self.spec.commands())
130    }
131
132    fn active_arguments(&self) -> &'a [ArgumentSpec] {
133        self.commands
134            .last()
135            .map(|command| command.arguments())
136            .unwrap_or_else(|| self.spec.arguments())
137    }
138
139    fn visible_options(&self) -> Vec<&'a OptionSpec> {
140        let mut options = self.spec.options().iter().collect::<Vec<_>>();
141        for command in &self.commands {
142            options.extend(command.options());
143        }
144        options
145    }
146
147    fn find_long_option(&self, name: &str) -> Option<&'a OptionSpec> {
148        self.visible_options()
149            .into_iter()
150            .rev()
151            .find(|option| option.long() == name)
152    }
153
154    fn find_short_option(&self, short: char) -> Option<&'a OptionSpec> {
155        self.visible_options()
156            .into_iter()
157            .rev()
158            .find(|option| option.short_name() == Some(short))
159    }
160
161    fn push_option(
162        &mut self,
163        option: &OptionSpec,
164        value: Option<std::ffi::OsString>,
165    ) -> Result<(), CliError> {
166        if !option.is_repeatable()
167            && self
168                .options
169                .iter()
170                .any(|parsed| parsed.name == option.long())
171        {
172            return Err(CliError::new(
173                CliErrorKind::DuplicateOption,
174                format!("option `--{}` cannot be repeated", option.long()),
175            ));
176        }
177        if let Some(value) = value.as_deref() {
178            crate::native::validate_native(
179                value,
180                option.kind,
181                option.value_type_kind(),
182                option.possible_values(),
183                &format!("--{}", option.long()),
184            )?;
185        }
186        self.options.push(crate::native::ParsedOsOption {
187            name: option.long().to_string(),
188            value,
189        });
190        Ok(())
191    }
192
193    fn push_positional(&mut self, value: &std::ffi::OsStr) -> Result<(), CliError> {
194        let argument = self.next_argument();
195        if argument.is_none()
196            && self.positionals.is_empty()
197            && !self.available_commands().is_empty()
198        {
199            let candidates = self
200                .available_commands()
201                .iter()
202                .filter(|command| !command.is_hidden())
203                .flat_map(|command| {
204                    std::iter::once(command.name())
205                        .chain(command.aliases().iter().map(String::as_str))
206                });
207            let message = suggestion::append(
208                format!("unknown command `{}`", value.to_string_lossy()),
209                suggestion::closest(value.to_str().unwrap_or(""), candidates),
210                "",
211            );
212            return Err(CliError::new(CliErrorKind::UnexpectedArgument, message));
213        }
214        let argument = argument.ok_or_else(|| {
215            CliError::new(
216                CliErrorKind::UnexpectedArgument,
217                format!("unexpected argument `{}`", value.to_string_lossy()),
218            )
219        })?;
220        crate::native::validate_native(
221            value,
222            argument.kind,
223            argument.value_type_kind(),
224            argument.possible_values(),
225            argument.name(),
226        )?;
227        self.positionals.push(value.to_os_string());
228        Ok(())
229    }
230
231    fn next_argument(&self) -> Option<&'a ArgumentSpec> {
232        let arguments = self.active_arguments();
233        arguments
234            .get(self.positionals.len())
235            .or_else(|| arguments.last().filter(|argument| argument.is_multiple()))
236    }
237
238    fn accepts_negative_positional(&self, value: &str) -> bool {
239        let Some(argument) = self.next_argument() else {
240            return false;
241        };
242        argument.value_type_kind() == ValueType::I64
243            && value.parse::<i64>().is_ok()
244            && value
245                .chars()
246                .nth(1)
247                .is_none_or(|short| self.find_short_option(short).is_none())
248    }
249
250    fn resolve_sources(
251        &mut self,
252        args: &crate::RawOsArgs,
253        env: &mut impl FnMut(&str) -> Option<std::ffi::OsString>,
254    ) -> Result<(), CliError> {
255        let limits = args.limits();
256        let mut total = args
257            .words()
258            .iter()
259            .map(|w| w.as_encoded_bytes().len())
260            .sum::<usize>();
261        let mut count = args.words().len();
262        for option in self.visible_options() {
263            if self.options.iter().any(|o| o.name == option.long()) {
264                continue;
265            }
266            let value = option
267                .environment
268                .as_deref()
269                .and_then(&mut *env)
270                .or_else(|| option.default.clone());
271            if let Some(value) = value {
272                crate::native::validate_word(&value, count, total, limits)?;
273                total += value.as_encoded_bytes().len();
274                count += 1;
275                let value = if option.value_mode() == ValueMode::Forbidden {
276                    match value.to_str() {
277                        Some("true") => None,
278                        Some("false") => continue,
279                        _ => {
280                            return Err(CliError::new(
281                                CliErrorKind::InvalidValue,
282                                format!(
283                                    "--{} environment/default must be true or false",
284                                    option.long()
285                                ),
286                            ))
287                        }
288                    }
289                } else {
290                    Some(value)
291                };
292                self.push_option(option, value).map_err(|error| {
293                    CliError::new(
294                        error.kind(),
295                        format!("invalid environment/default value for --{}", option.long()),
296                    )
297                })?;
298            }
299        }
300        Ok(())
301    }
302    fn finish(self) -> Result<crate::ParsedOsCli, CliError> {
303        if !has_terminal_option(&self) {
304            validate_required_command(&self)?;
305            validate_required_arguments(&self)?;
306            validate_option_rules(&self)?;
307        }
308        Ok(crate::ParsedOsCli {
309            command_path: self.command_path,
310            options: self.options,
311            positionals: self.positionals,
312            passthrough: self.passthrough,
313        })
314    }
315}
316
317fn has_terminal_option(state: &ParseState<'_>) -> bool {
318    state.visible_options().iter().any(|option| {
319        option.is_terminal()
320            && state
321                .options
322                .iter()
323                .any(|parsed| parsed.name == option.long())
324    })
325}
326
327fn parse_long_option(
328    words: &[std::ffi::OsString],
329    index: usize,
330    state: &mut ParseState<'_>,
331) -> Result<usize, CliError> {
332    let (prefix, inline) = crate::native::split_attached(&words[index], b'=');
333    let name = prefix
334        .to_str()
335        .and_then(|s| s.strip_prefix("--"))
336        .filter(|s| !s.is_empty())
337        .ok_or_else(|| CliError::new(CliErrorKind::UnknownOption, "invalid long option name"))?;
338    let option = state.find_long_option(name).ok_or_else(|| {
339        let candidates = state
340            .visible_options()
341            .into_iter()
342            .filter(|o| !o.is_hidden())
343            .map(OptionSpec::long);
344        CliError::new(
345            CliErrorKind::UnknownOption,
346            suggestion::append(
347                format!("unknown option `--{name}`"),
348                suggestion::closest(name, candidates),
349                "--",
350            ),
351        )
352    })?;
353    let (value, next) = crate::native::option_value(words, index, inline, option, false)?;
354    state.push_option(option, value)?;
355    Ok(next)
356}
357
358fn parse_short_options(
359    words: &[std::ffi::OsString],
360    index: usize,
361    state: &mut ParseState<'_>,
362) -> Result<usize, CliError> {
363    let bytes = words[index].as_encoded_bytes();
364    let mut offset = 1;
365    while offset < bytes.len() {
366        let short = bytes[offset] as char;
367        let option = state.find_short_option(short).ok_or_else(|| {
368            CliError::new(
369                CliErrorKind::UnknownOption,
370                format!("unknown option `-{short}`"),
371            )
372        })?;
373        offset += 1;
374        let remainder = (offset < bytes.len()).then(|| {
375            let start = offset + usize::from(bytes[offset] == b'=');
376            crate::native::os_slice(&words[index], start, bytes.len()).to_os_string()
377        });
378        let (value, next) = crate::native::option_value(words, index, remainder, option, true)?;
379        state.push_option(option, value)?;
380        if option.value_mode() != ValueMode::Forbidden {
381            return Ok(next);
382        }
383    }
384    Ok(index + 1)
385}
386
387fn validate_required_command(state: &ParseState<'_>) -> Result<(), CliError> {
388    let required = state
389        .commands
390        .last()
391        .map(|command| command.is_command_required())
392        .unwrap_or_else(|| state.spec.is_command_required());
393    if required && !state.available_commands().is_empty() {
394        return Err(CliError::new(
395            CliErrorKind::MissingCommand,
396            format!(
397                "a command is required after `{}`",
398                if state.command_path.is_empty() {
399                    state.spec.name().to_string()
400                } else {
401                    state.command_path.join(" ")
402                }
403            ),
404        ));
405    }
406    Ok(())
407}
408
409fn validate_required_arguments(state: &ParseState<'_>) -> Result<(), CliError> {
410    let provided = state.positionals.len();
411    for (index, argument) in state.active_arguments().iter().enumerate() {
412        if argument.is_required() && index >= provided {
413            return Err(CliError::new(
414                CliErrorKind::MissingValue,
415                format!("missing required argument `<{}>`", argument.name()),
416            ));
417        }
418    }
419    Ok(())
420}
421
422fn validate_option_rules(state: &ParseState<'_>) -> Result<(), CliError> {
423    let present = state
424        .options
425        .iter()
426        .map(|option| option.name.as_str())
427        .collect::<HashSet<_>>();
428    let visible = state.visible_options();
429    let mut groups = HashSet::new();
430    for option in &visible {
431        if present.contains(option.long()) {
432            if let Some(group) = option.exclusive_group.as_deref() {
433                if !groups.insert(group) {
434                    return Err(CliError::new(
435                        CliErrorKind::OptionConflict,
436                        format!("group `{group}` accepts at most one option"),
437                    ));
438                }
439            }
440        }
441    }
442    for option in visible {
443        if option.is_required() && !present.contains(option.long()) {
444            return Err(CliError::new(
445                CliErrorKind::MissingOption,
446                format!("required option `--{}` was not provided", option.long()),
447            ));
448        }
449        if !present.contains(option.long()) {
450            continue;
451        }
452        if let Some(conflict) = option
453            .conflicts()
454            .iter()
455            .find(|name| present.contains(name.as_str()) || groups.contains(name.as_str()))
456        {
457            return Err(CliError::new(
458                CliErrorKind::OptionConflict,
459                format!("option `--{}` conflicts with `--{conflict}`", option.long()),
460            ));
461        }
462        if let Some(required) = option
463            .requirements()
464            .iter()
465            .find(|name| !present.contains(name.as_str()) && !groups.contains(name.as_str()))
466        {
467            return Err(CliError::new(
468                CliErrorKind::MissingRequirement,
469                format!("option `--{}` requires `--{required}`", option.long()),
470            ));
471        }
472    }
473    Ok(())
474}