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