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}