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;
17use crate::value_parser::{option_value, validate_value};
18use std::collections::HashSet;
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct ParsedCli {
22    command_path: Vec<String>,
23    options: Vec<ParsedOption>,
24    positionals: Vec<String>,
25    passthrough: Vec<String>,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct ParsedOption {
30    name: String,
31    value: Option<String>,
32}
33
34pub struct CliParser<'a> {
35    spec: &'a CliSpec,
36}
37
38impl ParsedCli {
39    pub fn command_path(&self) -> &[String] {
40        &self.command_path
41    }
42    pub fn options(&self) -> &[ParsedOption] {
43        &self.options
44    }
45    pub fn positionals(&self) -> &[String] {
46        &self.positionals
47    }
48    pub fn passthrough(&self) -> &[String] {
49        &self.passthrough
50    }
51    pub fn has_flag(&self, name: &str) -> bool {
52        self.options.iter().any(|option| option.name == name)
53    }
54    pub fn option_value(&self, name: &str) -> Option<&str> {
55        self.options
56            .iter()
57            .rev()
58            .find(|option| option.name == name)
59            .and_then(ParsedOption::value)
60    }
61    pub fn option_values<'b>(&'b self, name: &'b str) -> impl Iterator<Item = &'b str> + 'b {
62        self.options
63            .iter()
64            .filter(move |option| option.name == name)
65            .filter_map(ParsedOption::value)
66    }
67    pub fn option_occurrences(&self, name: &str) -> usize {
68        self.options
69            .iter()
70            .filter(|option| option.name == name)
71            .count()
72    }
73}
74
75impl ParsedOption {
76    pub fn name(&self) -> &str {
77        &self.name
78    }
79    pub fn value(&self) -> Option<&str> {
80        self.value.as_deref()
81    }
82}
83
84impl<'a> CliParser<'a> {
85    pub fn new(spec: &'a CliSpec) -> Self {
86        Self { spec }
87    }
88
89    pub fn parse(&self, args: &RawArgs) -> Result<ParsedCli, CliError> {
90        self.spec.validate().map_err(|error| {
91            CliError::new(CliErrorKind::InvalidSpecification, error.to_string())
92        })?;
93        let mut state = ParseState::new(self.spec);
94        let mut index = 0usize;
95        while index < args.words().len() {
96            let word = &args.words()[index];
97            if word == "--" {
98                state
99                    .passthrough
100                    .extend_from_slice(&args.words()[index + 1..]);
101                break;
102            }
103            index = if word.starts_with("--") {
104                parse_long_option(args.words(), index, &mut state)?
105            } else if is_short_option(word) && !state.accepts_negative_positional(word) {
106                parse_short_options(args.words(), index, &mut state)?
107            } else if state.positionals.is_empty() && state.enter_command(word) {
108                index + 1
109            } else {
110                state.push_positional(word)?;
111                index + 1
112            };
113        }
114        state.finish()
115    }
116}
117
118struct ParseState<'a> {
119    spec: &'a CliSpec,
120    commands: Vec<&'a CommandSpec>,
121    command_path: Vec<String>,
122    options: Vec<ParsedOption>,
123    positionals: Vec<String>,
124    passthrough: Vec<String>,
125}
126
127impl<'a> ParseState<'a> {
128    fn new(spec: &'a CliSpec) -> Self {
129        Self {
130            spec,
131            commands: Vec::new(),
132            command_path: Vec::new(),
133            options: Vec::new(),
134            positionals: Vec::new(),
135            passthrough: Vec::new(),
136        }
137    }
138
139    fn enter_command(&mut self, word: &str) -> bool {
140        if let Some(command) = self
141            .available_commands()
142            .iter()
143            .find(|command| command.matches(word))
144        {
145            self.command_path.push(command.name().to_string());
146            self.commands.push(command);
147            true
148        } else {
149            false
150        }
151    }
152
153    fn available_commands(&self) -> &'a [CommandSpec] {
154        self.commands
155            .last()
156            .map(|command| command.commands())
157            .unwrap_or_else(|| self.spec.commands())
158    }
159
160    fn active_arguments(&self) -> &'a [ArgumentSpec] {
161        self.commands
162            .last()
163            .map(|command| command.arguments())
164            .unwrap_or_else(|| self.spec.arguments())
165    }
166
167    fn visible_options(&self) -> Vec<&'a OptionSpec> {
168        let mut options = self.spec.options().iter().collect::<Vec<_>>();
169        for command in &self.commands {
170            options.extend(command.options());
171        }
172        options
173    }
174
175    fn find_long_option(&self, name: &str) -> Option<&'a OptionSpec> {
176        self.visible_options()
177            .into_iter()
178            .rev()
179            .find(|option| option.long() == name)
180    }
181
182    fn find_short_option(&self, short: char) -> Option<&'a OptionSpec> {
183        self.visible_options()
184            .into_iter()
185            .rev()
186            .find(|option| option.short_name() == Some(short))
187    }
188
189    fn push_option(&mut self, option: &OptionSpec, value: Option<String>) -> Result<(), CliError> {
190        if !option.is_repeatable()
191            && self
192                .options
193                .iter()
194                .any(|parsed| parsed.name == option.long())
195        {
196            return Err(CliError::new(
197                CliErrorKind::DuplicateOption,
198                format!("option `--{}` cannot be repeated", option.long()),
199            ));
200        }
201        if let Some(value) = value.as_deref() {
202            validate_value(
203                value,
204                option.value_type_kind(),
205                option.possible_values(),
206                &format!("--{}", option.long()),
207            )?;
208        }
209        self.options.push(ParsedOption {
210            name: option.long().to_string(),
211            value,
212        });
213        Ok(())
214    }
215
216    fn push_positional(&mut self, value: &str) -> Result<(), CliError> {
217        let argument = self.next_argument();
218        if argument.is_none()
219            && self.positionals.is_empty()
220            && !self.available_commands().is_empty()
221        {
222            let candidates = self
223                .available_commands()
224                .iter()
225                .filter(|command| !command.is_hidden())
226                .flat_map(|command| {
227                    std::iter::once(command.name())
228                        .chain(command.aliases().iter().map(String::as_str))
229                });
230            let message = suggestion::append(
231                format!("unknown command `{value}`"),
232                suggestion::closest(value, candidates),
233                "",
234            );
235            return Err(CliError::new(CliErrorKind::UnexpectedArgument, message));
236        }
237        let argument = argument.ok_or_else(|| {
238            CliError::new(
239                CliErrorKind::UnexpectedArgument,
240                format!("unexpected argument `{value}`"),
241            )
242        })?;
243        validate_value(
244            value,
245            argument.value_type_kind(),
246            argument.possible_values(),
247            argument.name(),
248        )?;
249        self.positionals.push(value.to_string());
250        Ok(())
251    }
252
253    fn next_argument(&self) -> Option<&'a ArgumentSpec> {
254        let arguments = self.active_arguments();
255        arguments
256            .get(self.positionals.len())
257            .or_else(|| arguments.last().filter(|argument| argument.is_multiple()))
258    }
259
260    fn accepts_negative_positional(&self, value: &str) -> bool {
261        let Some(argument) = self.next_argument() else {
262            return false;
263        };
264        argument.value_type_kind() == ValueType::I64
265            && value.parse::<i64>().is_ok()
266            && value
267                .chars()
268                .nth(1)
269                .is_none_or(|short| self.find_short_option(short).is_none())
270    }
271
272    fn finish(self) -> Result<ParsedCli, CliError> {
273        if !has_terminal_option(&self) {
274            validate_required_command(&self)?;
275            validate_required_arguments(&self)?;
276            validate_option_rules(&self)?;
277        }
278        Ok(ParsedCli {
279            command_path: self.command_path,
280            options: self.options,
281            positionals: self.positionals,
282            passthrough: self.passthrough,
283        })
284    }
285}
286
287fn has_terminal_option(state: &ParseState<'_>) -> bool {
288    state.visible_options().iter().any(|option| {
289        option.is_terminal()
290            && state
291                .options
292                .iter()
293                .any(|parsed| parsed.name == option.long())
294    })
295}
296
297fn parse_long_option(
298    words: &[String],
299    index: usize,
300    state: &mut ParseState<'_>,
301) -> Result<usize, CliError> {
302    let raw = words[index].strip_prefix("--").unwrap_or_default();
303    let (name, inline) = raw
304        .split_once('=')
305        .map_or((raw, None), |(name, value)| (name, Some(value.to_string())));
306    if name.is_empty() {
307        return Err(CliError::new(
308            CliErrorKind::UnknownOption,
309            "empty long option is not accepted",
310        ));
311    }
312    let option = state.find_long_option(name).ok_or_else(|| {
313        let candidates = state
314            .visible_options()
315            .into_iter()
316            .filter(|option| !option.is_hidden())
317            .map(OptionSpec::long);
318        let message = suggestion::append(
319            format!("unknown option `--{name}`"),
320            suggestion::closest(name, candidates),
321            "--",
322        );
323        CliError::new(CliErrorKind::UnknownOption, message)
324    })?;
325    let (value, next) = option_value(words, index, inline, option, false)?;
326    state.push_option(option, value)?;
327    Ok(next)
328}
329
330fn parse_short_options(
331    words: &[String],
332    index: usize,
333    state: &mut ParseState<'_>,
334) -> Result<usize, CliError> {
335    let raw = words[index].strip_prefix('-').unwrap_or_default();
336    let mut chars = raw.char_indices().peekable();
337    while let Some((_, short)) = chars.next() {
338        let option = state.find_short_option(short).ok_or_else(|| {
339            CliError::new(
340                CliErrorKind::UnknownOption,
341                format!("unknown option `-{short}`"),
342            )
343        })?;
344        let remainder = chars.peek().map(|(offset, _)| {
345            raw[*offset..]
346                .strip_prefix('=')
347                .unwrap_or(&raw[*offset..])
348                .to_string()
349        });
350        let (value, next) = option_value(words, index, remainder, option, true)?;
351        state.push_option(option, value)?;
352        if option.value_mode() != ValueMode::Forbidden {
353            return Ok(next);
354        }
355    }
356    Ok(index + 1)
357}
358
359fn validate_required_command(state: &ParseState<'_>) -> Result<(), CliError> {
360    let required = state
361        .commands
362        .last()
363        .map(|command| command.is_command_required())
364        .unwrap_or_else(|| state.spec.is_command_required());
365    if required && !state.available_commands().is_empty() {
366        return Err(CliError::new(
367            CliErrorKind::MissingCommand,
368            format!(
369                "a command is required after `{}`",
370                if state.command_path.is_empty() {
371                    state.spec.name().to_string()
372                } else {
373                    state.command_path.join(" ")
374                }
375            ),
376        ));
377    }
378    Ok(())
379}
380
381fn validate_required_arguments(state: &ParseState<'_>) -> Result<(), CliError> {
382    let provided = state.positionals.len();
383    for (index, argument) in state.active_arguments().iter().enumerate() {
384        if argument.is_required() && index >= provided {
385            return Err(CliError::new(
386                CliErrorKind::MissingValue,
387                format!("missing required argument `<{}>`", argument.name()),
388            ));
389        }
390    }
391    Ok(())
392}
393
394fn validate_option_rules(state: &ParseState<'_>) -> Result<(), CliError> {
395    let present = state
396        .options
397        .iter()
398        .map(|option| option.name.as_str())
399        .collect::<HashSet<_>>();
400    for option in state.visible_options() {
401        if option.is_required() && !present.contains(option.long()) {
402            return Err(CliError::new(
403                CliErrorKind::MissingOption,
404                format!("required option `--{}` was not provided", option.long()),
405            ));
406        }
407        if !present.contains(option.long()) {
408            continue;
409        }
410        if let Some(conflict) = option
411            .conflicts()
412            .iter()
413            .find(|name| present.contains(name.as_str()))
414        {
415            return Err(CliError::new(
416                CliErrorKind::OptionConflict,
417                format!("option `--{}` conflicts with `--{conflict}`", option.long()),
418            ));
419        }
420        if let Some(required) = option
421            .requirements()
422            .iter()
423            .find(|name| !present.contains(name.as_str()))
424        {
425            return Err(CliError::new(
426                CliErrorKind::MissingRequirement,
427                format!("option `--{}` requires `--{required}`", option.long()),
428            ));
429        }
430    }
431    Ok(())
432}
433
434fn is_short_option(word: &str) -> bool {
435    word.starts_with('-') && !word.starts_with("--") && word.len() > 1
436}