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