1#![doc = include_str!("../README.md")]
2
3use std::collections::HashSet;
4use std::fmt;
5use std::str::FromStr;
6
7use getopts::Fail;
8
9#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct SubcommandError {
14 command: Option<String>,
15 expected: Vec<String>,
16}
17
18impl SubcommandError {
19 pub fn missing(expected: Vec<String>) -> Self {
21 Self {
22 command: None,
23 expected,
24 }
25 }
26
27 pub fn unknown(command: String, expected: Vec<String>) -> Self {
29 Self {
30 command: Some(command),
31 expected,
32 }
33 }
34
35 pub fn command(&self) -> Option<&str> {
37 self.command.as_deref()
38 }
39
40 pub fn expected(&self) -> &[String] {
42 &self.expected
43 }
44}
45
46impl fmt::Display for SubcommandError {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 if let Some(command) = &self.command {
49 write!(f, "unknown subcommand {command:?}")?;
50 } else {
51 write!(f, "missing subcommand")?;
52 }
53 write_expected_subcommands(f, &self.expected)
54 }
55}
56
57impl std::error::Error for SubcommandError {}
58
59fn write_expected_subcommands(f: &mut fmt::Formatter<'_>, expected: &[String]) -> fmt::Result {
60 if expected.is_empty() {
61 return Ok(());
62 }
63 write!(f, "; expected one of: {}", expected.join(", "))
64}
65
66pub fn split_subcommand(
75 free: Vec<String>,
76 command_names: &[&str],
77) -> Result<(String, Vec<String>), SubcommandError> {
78 let mut free = free.into_iter();
79 let expected = command_names
80 .iter()
81 .map(|command| command.to_string())
82 .collect::<Vec<_>>();
83 let Some(command) = free.next() else {
84 return Err(SubcommandError::missing(expected));
85 };
86 if !command_names.iter().any(|name| *name == command) {
87 return Err(SubcommandError::unknown(command, expected));
88 }
89 Ok((command, free.collect()))
90}
91
92pub fn usage_for_subcommand(command: &str) -> String {
94 format!("Usage: {command} [OPTIONS]")
95}
96
97#[macro_export]
129macro_rules! dispatch_subcommands {
130 ($free:expr, { $($name:literal => $ty:ty as $cmd:ident, $rest:ident => $body:block),+ $(,)? }) => {{
131 $crate::__dispatch_subcommands!(from_arguments, $free, {
132 $($name => $ty as $cmd, $rest => $body),+
133 })
134 }};
135}
136
137#[macro_export]
141macro_rules! dispatch_subcommands_relaxed {
142 ($free:expr, { $($name:literal => $ty:ty as $cmd:ident, $rest:ident => $body:block),+ $(,)? }) => {{
143 $crate::__dispatch_subcommands!(from_arguments_relaxed, $free, {
144 $($name => $ty as $cmd, $rest => $body),+
145 })
146 }};
147}
148
149#[doc(hidden)]
150#[macro_export]
151macro_rules! __dispatch_subcommands {
152 ($parser:ident, $free:expr, { $($name:literal => $ty:ty as $cmd:ident, $rest:ident => $body:block),+ $(,)? }) => {{
153 match $crate::split_subcommand($free, &[$($name),+]) {
154 Ok((__arrrg_command, __arrrg_rest)) => {
155 match __arrrg_command.as_str() {
156 $(
157 $name => {
158 let __arrrg_args = __arrrg_rest
159 .iter()
160 .map(::std::string::String::as_str)
161 .collect::<::std::vec::Vec<_>>();
162 let __arrrg_usage = $crate::usage_for_subcommand($name);
163 let ($cmd, $rest) =
164 <$ty as $crate::CommandLine>::$parser(&__arrrg_usage, &__arrrg_args);
165 $body
166 }
167 )+
168 _ => unreachable!("split_subcommand returned an unexpected command"),
169 }
170 }
171 Err(__arrrg_err) => Err(__arrrg_err),
172 }
173 }};
174}
175
176pub trait CommandLine: Sized + Default + Eq + PartialEq {
182 fn add_opts(&self, prefix: Option<&str>, opts: &mut getopts::Options);
184
185 fn matches(&mut self, prefix: Option<&str>, matches: &getopts::Matches);
187
188 fn canonical_command_line(&self, prefix: Option<&str>) -> Vec<String>;
190
191 fn from_command_line(usage: &str) -> (Self, Vec<String>) {
194 let args: Vec<String> = std::env::args().collect();
195 let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
196 Self::from_arguments(usage, &args[1..])
197 }
198
199 fn from_command_line_relaxed(usage: &str) -> (Self, Vec<String>) {
202 let args: Vec<String> = std::env::args().collect();
203 let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
204 Self::from_arguments_relaxed(usage, &args[1..])
205 }
206
207 fn from_arguments(usage: &str, args: &[&str]) -> (Self, Vec<String>) {
210 let (command_line, free) = Self::from_arguments_relaxed(usage, args);
211 let mut reconstructed_args = command_line.canonical_command_line(None);
212 let mut free_p = free.clone();
213 reconstructed_args.append(&mut free_p);
214 let mut args = args.to_vec();
215 args.retain(|a| *a != "--");
216 reconstructed_args.retain(|a| *a != "--");
217 if args != reconstructed_args {
218 panic!(
219 "non-canonical commandline specified:
220provided: {:?}
221expected: {:?}
222check argument order amongst other differences",
223 &args, reconstructed_args
224 );
225 }
226 (command_line, free)
227 }
228
229 fn from_arguments_relaxed(usage: &str, args: &[&str]) -> (Self, Vec<String>) {
232 let mut command_line = Self::default();
233 let mut opts = getopts::Options::new();
234 opts.parsing_style(getopts::ParsingStyle::StopAtFirstFree);
235 opts.long_only(true);
236 opts.optflag("h", "help", "Print this help menu.");
237 opts.optflag(
238 "",
239 "completions",
240 "Print zsh completion script for this command.",
241 );
242 command_line.add_opts(None, &mut opts);
243
244 if args
245 .iter()
246 .any(|arg| arg == &"--completions" || arg == &"-completions")
247 {
248 Self::completions(
249 &mut command_line,
250 &opts,
251 usage,
252 &std::env::args()
253 .next()
254 .unwrap_or_else(|| "command".to_string()),
255 );
256 return (command_line, vec![]);
257 }
258
259 let matches = match opts.parse(args) {
260 Ok(matches) => matches,
261 Err(Fail::OptionMissing(which)) => {
262 Self::error(&mut command_line, format!("missing argument: --{which}"));
263 Self::usage(&mut command_line, opts, usage);
264 return (command_line, vec![]);
265 }
266 Err(err) => {
267 Self::error(
268 &mut command_line,
269 format!("could not parse command line: {err}"),
270 );
271 Self::exit(&mut command_line, 64);
272 return (command_line, vec![]);
273 }
274 };
275 if matches.opt_present("h") {
276 Self::usage(&mut command_line, opts, usage);
277 return (command_line, vec![]);
278 }
279 if matches.opt_present("completions") {
280 Self::completions(
281 &mut command_line,
282 &opts,
283 usage,
284 &std::env::args()
285 .next()
286 .unwrap_or_else(|| "command".to_string()),
287 );
288 return (command_line, vec![]);
289 }
290 command_line.matches(None, &matches);
291 let free: Vec<String> = matches.free.to_vec();
292 (command_line, free)
293 }
294
295 fn usage(&mut self, opts: getopts::Options, brief: &str) {
297 self.error(opts.usage(brief));
298 self.exit(1);
299 }
300
301 fn completions(&mut self, opts: &getopts::Options, usage: &str, command_path: &str) {
329 print!("{}", zsh_completions(opts, usage, command_path));
330 self.exit(0);
331 }
332
333 fn error(&mut self, msg: impl AsRef<str>) {
335 eprintln!("{}", msg.as_ref());
336 }
337
338 fn exit(&mut self, status: i32) {
340 std::process::exit(status);
341 }
342}
343
344fn zsh_completion_entries(opts: &getopts::Options, usage: &str) -> Vec<String> {
345 let usage = opts.usage(usage);
346 let mut options = Vec::new();
347 let mut collecting = false;
348 for line in usage.lines() {
349 if line.starts_with("Options:") {
350 collecting = true;
351 continue;
352 }
353 if !collecting {
354 continue;
355 }
356 let trimmed = line.trim();
357 if trimmed.is_empty() {
358 continue;
359 }
360 if !trimmed.starts_with('-') {
361 continue;
362 }
363 let mut split_idx = None;
364 for (i, b) in trimmed.bytes().enumerate() {
365 if b == b'\t' {
366 split_idx = Some(i);
367 break;
368 }
369 if b.is_ascii_whitespace()
370 && i + 1 < trimmed.len()
371 && trimmed.as_bytes()[i + 1].is_ascii_whitespace()
372 {
373 split_idx = Some(i);
374 break;
375 }
376 }
377 let flag_part = if let Some(i) = split_idx {
378 trimmed[..i].trim().to_string()
379 } else {
380 trimmed.to_string()
381 };
382 if flag_part.is_empty() {
383 continue;
384 }
385 let desc_part = if let Some(i) = split_idx {
386 trimmed[i..].trim()
387 } else {
388 ""
389 };
390 for name in parse_option_names(&flag_part) {
391 options.push(format_completion_option(&name, desc_part));
392 }
393 }
394 options
395}
396
397fn parse_option_names(flag_part: &str) -> Vec<String> {
398 let mut names = Vec::new();
399 for raw_name in flag_part.split(',') {
400 let mut pieces = raw_name.split_whitespace().filter(|x| !x.is_empty());
401 if let Some(name) = pieces.next()
402 && name.starts_with('-')
403 {
404 names.push(name.to_string());
405 }
406 }
407 names
408}
409
410fn format_completion_option(name: &str, desc: &str) -> String {
411 let desc = desc.replace('\'', "'\"'\"'");
412 format!("{}[{}]", name, desc)
413}
414
415fn zsh_completions(opts: &getopts::Options, usage: &str, command_path: &str) -> String {
416 let opts = zsh_completion_entries(opts, usage);
417 let command = command_path
418 .rsplit('/')
419 .next()
420 .unwrap_or(command_path)
421 .to_string();
422 let mut function: String = command
423 .chars()
424 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
425 .collect();
426 if function.chars().next().is_some_and(|c| c.is_ascii_digit()) {
427 function.insert(0, '_');
428 }
429
430 let mut entries = Vec::new();
431 let mut seen = HashSet::new();
432 for opt in opts {
433 if seen.insert(opt.clone()) {
434 entries.push(format!(" '{opt}'"));
435 }
436 }
437
438 let mut lines = vec![format!("#compdef {command}"), format!("_{function}() {{")];
439 if entries.is_empty() {
440 lines.push(" _arguments".to_string());
441 } else {
442 lines.push(" _arguments -s \\".to_string());
443 for (i, entry) in entries.iter().enumerate() {
444 let continuation = if i + 1 == entries.len() { "" } else { " \\" };
445 lines.push(format!("{entry}{continuation}"));
446 }
447 }
448 lines.extend_from_slice(&["}".to_string(), format!("compdef _{function} {command}")]);
449 lines.join("\n") + "\n"
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn zsh_completions_have_help_and_completions() {
458 let mut opts = getopts::Options::new();
459 opts.optflag("h", "help", "Print this help menu.");
460 opts.optflag(
461 "",
462 "completions",
463 "Print zsh completion script for this command.",
464 );
465 let script = zsh_completions(&opts, "Usage: test", "test-binary");
466 assert!(script.contains("#compdef test-binary"));
467 assert!(script.contains("_test_binary()"));
468 assert!(script.contains("'--help[Print this help menu.]'"));
469 assert!(script.contains("'--completions[Print zsh completion script for this command.]'"));
470 }
471
472 #[test]
473 fn zsh_completion_entries_include_short_and_long_names() {
474 let mut opts = getopts::Options::new();
475 opts.optflag("h", "help", "Print this help menu.");
476 opts.optflag("v", "verbose", "Print verbose output.");
477 let entries = zsh_completion_entries(&opts, "Usage: test");
478
479 assert!(entries.contains(&"-h[Print this help menu.]".to_string()));
480 assert!(entries.contains(&"--help[Print this help menu.]".to_string()));
481 assert!(entries.contains(&"-v[Print verbose output.]".to_string()));
482 assert!(entries.contains(&"--verbose[Print verbose output.]".to_string()));
483 }
484
485 #[test]
486 fn zsh_completion_entries_escapes_single_quotes() {
487 let mut opts = getopts::Options::new();
488 opts.optflag("", "with-space", "It'll parse correctly.");
489 let entries = zsh_completion_entries(&opts, "Usage: test");
490
491 assert!(entries.contains(&"--with-space[It'\"'\"'ll parse correctly.]".to_string()));
492 }
493
494 #[test]
495 fn zsh_completions_normalize_function_name() {
496 let mut opts = getopts::Options::new();
497 opts.optflag("h", "help", "Print this help menu.");
498 let script = zsh_completions(&opts, "Usage: test", "./bin/my-cmd");
499
500 assert!(script.contains("#compdef my-cmd"));
501 assert!(script.contains("_my_cmd()"));
502 assert!(script.contains("compdef _my_cmd my-cmd"));
503 }
504
505 #[derive(Default, Eq, PartialEq)]
506 struct TestCommandLine {
507 required: String,
508 }
509
510 impl CommandLine for TestCommandLine {
511 fn add_opts(&self, _prefix: Option<&str>, opts: &mut getopts::Options) {
512 opts.reqopt("", "chooser-mode", "required mode", "METHOD");
513 }
514
515 fn matches(&mut self, _prefix: Option<&str>, matches: &getopts::Matches) {
516 if let Some(mode) = matches.opt_str("chooser-mode") {
517 self.required = mode;
518 }
519 }
520
521 fn canonical_command_line(&self, _prefix: Option<&str>) -> Vec<String> {
522 let mut result = vec!["test".to_string()];
523 if !self.required.is_empty() {
524 result.push("--chooser-mode".to_string());
525 result.push(self.required.to_string());
526 }
527 result
528 }
529 }
530
531 #[test]
532 fn completions_bypass_missing_required_args() {
533 let (command_line, _) = NoExitCommandLine::<TestCommandLine>::from_arguments_relaxed(
534 "Usage: test",
535 &["--completions"],
536 );
537 let (_, _, status) = command_line.into_parts();
538 assert_eq!(status, 0);
539 }
540}
541
542#[derive(Default, Eq, PartialEq)]
547pub struct NoExitCommandLine<T: CommandLine>(T, Vec<String>, i32);
548
549impl<T: CommandLine> AsRef<T> for NoExitCommandLine<T> {
550 fn as_ref(&self) -> &T {
551 &self.0
552 }
553}
554
555impl<T: CommandLine> NoExitCommandLine<T> {
556 pub fn into_inner(self) -> T {
557 self.0
558 }
559
560 pub fn into_parts(self) -> (T, Vec<String>, i32) {
561 (self.0, self.1, self.2)
562 }
563}
564
565impl<T: CommandLine> CommandLine for NoExitCommandLine<T> {
566 fn add_opts(&self, prefix: Option<&str>, opts: &mut getopts::Options) {
567 self.0.add_opts(prefix, opts);
568 }
569
570 fn matches(&mut self, prefix: Option<&str>, matches: &getopts::Matches) {
571 self.0.matches(prefix, matches);
572 }
573
574 fn canonical_command_line(&self, prefix: Option<&str>) -> Vec<String> {
575 self.0.canonical_command_line(prefix)
576 }
577
578 fn error(&mut self, msg: impl AsRef<str>) {
579 self.1.push(msg.as_ref().to_string());
580 }
581
582 fn exit(&mut self, status: i32) {
583 self.2 = status;
584 }
585}
586
587#[doc(hidden)]
590pub fn getopt_str(prefix: Option<&str>, field_arg: &str) -> String {
591 match prefix {
592 Some(prefix) => {
593 format!("{prefix}-{field_arg}")
594 }
595 None => field_arg.to_string(),
596 }
597}
598
599#[doc(hidden)]
600pub fn dashed_str(prefix: Option<&str>, field_arg: &str) -> String {
601 format!("--{}", getopt_str(prefix, field_arg))
602}
603
604#[doc(hidden)]
605pub fn parse_field<T>(arg_str: &str, s: &str) -> T
606where
607 T: FromStr,
608 <T as FromStr>::Err: std::fmt::Display,
609{
610 match s.parse::<T>() {
611 Ok(t) => t,
612 Err(err) => {
613 panic!("field --{arg_str} is unparseable: {err}");
614 }
615 }
616}