pub fn unknown_arg_is(err: &clap::Error, flag: &str) -> bool {
use clap::error::{ContextKind, ContextValue};
err.context().any(|(kind, value)| {
kind == ContextKind::InvalidArg && matches!(value, ContextValue::String(s) if s == flag)
})
}
pub fn suggest_subcommand_correction(args: &[String], cmd: &clap::Command) -> Option<String> {
args.first()?;
let value_flags: Vec<&str> = cmd
.get_arguments()
.filter(|a| a.get_num_args().is_some_and(|r| r.min_values() > 0))
.filter_map(|a| a.get_long())
.collect();
let top_level_names: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
let mut parent_name: Option<&str> = None;
let mut parent_pos: Option<usize> = None; let mut skip_next = false;
for (i, arg) in args.iter().enumerate().skip(1) {
if skip_next {
skip_next = false;
continue;
}
if arg == "--" {
break;
}
if let Some(flag) = arg.strip_prefix("--") {
if value_flags.contains(&flag) {
skip_next = true;
}
continue;
}
if arg.starts_with('-') {
continue;
}
if let Some(name) = top_level_names.iter().find(|&&n| n == arg.as_str()) {
parent_name = Some(name);
parent_pos = Some(i);
break;
}
}
let parent_name = parent_name?;
let parent_pos = parent_pos?;
let parent_cmd = cmd
.get_subcommands()
.find(|s| s.get_name() == parent_name)?;
let sub_names: Vec<&str> = parent_cmd
.get_subcommands()
.map(clap::Command::get_name)
.collect();
if sub_names.is_empty() {
return None;
}
let mut found_flag_pos: Option<usize> = None;
let mut found_sub_name: Option<&str> = None;
skip_next = false;
for (i, arg) in args.iter().enumerate().skip(parent_pos + 1) {
if skip_next {
skip_next = false;
continue;
}
if arg == "--" {
break;
}
if let Some(flag_value) = arg.strip_prefix("--") {
if let Some(name) = sub_names.iter().find(|&&n| n == flag_value) {
found_flag_pos = Some(i);
found_sub_name = Some(name);
break;
}
let parent_value_flags: Vec<&str> = parent_cmd
.get_arguments()
.filter(|a| a.get_num_args().is_some_and(|r| r.min_values() > 0))
.filter_map(|a| a.get_long())
.collect();
if parent_value_flags.contains(&flag_value) {
skip_next = true;
}
}
}
let flag_pos = found_flag_pos?;
let sub_name = found_sub_name?;
let mut corrected: Vec<String> = Vec::with_capacity(args.len());
for (i, arg) in args.iter().enumerate() {
if i == flag_pos {
continue;
}
corrected.push(crate::hints::shell_quote(arg));
if i == parent_pos {
corrected.push(sub_name.to_owned());
}
}
Some(corrected.join(" "))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cmd() -> clap::Command {
use clap::{Arg, Command};
Command::new("hyalo")
.arg(Arg::new("dir").short('d').long("dir").num_args(1))
.arg(Arg::new("format").long("format").num_args(1))
.subcommand(
Command::new("task")
.arg(Arg::new("file").short('f').long("file").num_args(1))
.arg(Arg::new("line").short('l').long("line").num_args(1))
.subcommand(Command::new("read"))
.subcommand(Command::new("toggle"))
.subcommand(Command::new("set-status")),
)
.subcommand(
Command::new("properties")
.subcommand(Command::new("summary"))
.subcommand(Command::new("rename")),
)
.subcommand(
Command::new("tags")
.subcommand(Command::new("summary"))
.subcommand(Command::new("rename")),
)
.subcommand(Command::new("find").arg(Arg::new("property").short('p').long("property")))
}
fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(str::to_owned).collect()
}
#[test]
fn toggle_before_file_flag() {
let cmd = make_cmd();
let result =
suggest_subcommand_correction(&args("hyalo task --toggle --file f --line 1"), &cmd);
assert_eq!(
result,
Some("hyalo task toggle --file f --line 1".to_owned())
);
}
#[test]
fn toggle_after_other_flags() {
let cmd = make_cmd();
let result =
suggest_subcommand_correction(&args("hyalo task --file f --line 1 --toggle"), &cmd);
assert_eq!(
result,
Some("hyalo task toggle --file f --line 1".to_owned())
);
}
#[test]
fn toggle_between_flags() {
let cmd = make_cmd();
let result =
suggest_subcommand_correction(&args("hyalo task --file f --toggle --line 1"), &cmd);
assert_eq!(
result,
Some("hyalo task toggle --file f --line 1".to_owned())
);
}
#[test]
fn set_status_hyphenated() {
let cmd = make_cmd();
let result = suggest_subcommand_correction(
&args("hyalo task --set-status --file f --line 1 --status ?"),
&cmd,
);
assert_eq!(
result,
Some("hyalo task set-status --file f --line 1 --status '?'".to_owned())
);
}
#[test]
fn properties_rename() {
let cmd = make_cmd();
let result =
suggest_subcommand_correction(&args("hyalo properties --rename --from a --to b"), &cmd);
assert_eq!(
result,
Some("hyalo properties rename --from a --to b".to_owned())
);
}
#[test]
fn properties_summary() {
let cmd = make_cmd();
let result = suggest_subcommand_correction(&args("hyalo properties --summary"), &cmd);
assert_eq!(result, Some("hyalo properties summary".to_owned()));
}
#[test]
fn tags_rename() {
let cmd = make_cmd();
let result =
suggest_subcommand_correction(&args("hyalo tags --rename --from a --to b"), &cmd);
assert_eq!(result, Some("hyalo tags rename --from a --to b".to_owned()));
}
#[test]
fn tags_summary() {
let cmd = make_cmd();
let result = suggest_subcommand_correction(&args("hyalo tags --summary"), &cmd);
assert_eq!(result, Some("hyalo tags summary".to_owned()));
}
#[test]
fn task_read() {
let cmd = make_cmd();
let result =
suggest_subcommand_correction(&args("hyalo task --read --file f --line 1"), &cmd);
assert_eq!(result, Some("hyalo task read --file f --line 1".to_owned()));
}
#[test]
fn unknown_flag_no_suggestion() {
let cmd = make_cmd();
let result =
suggest_subcommand_correction(&args("hyalo task --verbose --file f toggle"), &cmd);
assert_eq!(result, None);
}
#[test]
fn find_has_no_subcommands() {
let cmd = make_cmd();
let result =
suggest_subcommand_correction(&args("hyalo find --property status=done"), &cmd);
assert_eq!(result, None);
}
#[test]
fn short_flags_preserved() {
let cmd = make_cmd();
let result =
suggest_subcommand_correction(&args("hyalo task --toggle -f foo.md -l 28"), &cmd);
assert_eq!(result, Some("hyalo task toggle -f foo.md -l 28".to_owned()));
}
#[test]
fn dir_value_not_confused_with_subcommand() {
let cmd = make_cmd();
let result = suggest_subcommand_correction(
&args("hyalo --dir task --toggle --file f --line 1"),
&cmd,
);
assert_eq!(result, None);
}
#[test]
fn dir_value_with_real_subcommand_after() {
let cmd = make_cmd();
let result = suggest_subcommand_correction(
&args("hyalo --dir mydir task --toggle --file f --line 1"),
&cmd,
);
assert_eq!(
result,
Some("hyalo --dir mydir task toggle --file f --line 1".to_owned())
);
}
#[test]
fn no_parent_subcommand_at_all() {
let cmd = make_cmd();
let result = suggest_subcommand_correction(&args("hyalo --toggle"), &cmd);
assert_eq!(result, None);
}
#[test]
fn format_value_not_confused() {
let cmd = make_cmd();
let result = suggest_subcommand_correction(
&args("hyalo --format json task --toggle --file f --line 1"),
&cmd,
);
assert_eq!(
result,
Some("hyalo --format json task toggle --file f --line 1".to_owned())
);
}
#[test]
fn args_with_spaces_are_quoted() {
let cmd = make_cmd();
let input = vec![
"hyalo".to_owned(),
"task".to_owned(),
"--toggle".to_owned(),
"--file".to_owned(),
"My Notes.md".to_owned(),
"--line".to_owned(),
"1".to_owned(),
];
let result = suggest_subcommand_correction(&input, &cmd);
assert_eq!(
result,
Some("hyalo task toggle --file 'My Notes.md' --line 1".to_owned())
);
}
}