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