Skip to main content

appcore_args/
parser.rs

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