diff --git a/PLAN.md b/PLAN.md index 506503286..6e9ec08d3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -452,8 +452,14 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and carries doc-comment or explicit help, hidden canonical values, hidden `alias`, visible `visible_alias`, and case-insensitive matching through its static metadata and lossless KDL emission. -- [ ] **`infer_subcommands` / `infer_long_args`** — unambiguous prefixes. We - suggest what was probably meant but do not accept it. +- [x] **`infer_subcommands` / `infer_long_args`** — unambiguous prefixes of + command names, command aliases, long flags, and long aliases are accepted + only when one declaration matches. Both settings are inherited by child + commands and carried through KDL, usage-lib, generated Rust, generated Go, + and Go's runtime spec builder. `#[usage(infer_subcommands, infer_long_args)]` + is the typed spelling. clap exposes setters but no public getters for these + global settings, so `clap_usage` cannot recover them from an existing + `Command`; a migration declares them on the usage type. - [x] **`external_subcommand`** — an unmatched word is forwarded with the rest of argv. Spec `external_subcommand`, usage-lib, usage-argv, the derive (`#[usage(external_subcommand)]` on a catch-all `Vec` variant), and the diff --git a/argv/src/help.rs b/argv/src/help.rs index 8e62a1fa8..b6d0feb81 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -1240,6 +1240,7 @@ pub fn route_to<'t>( if route.is_empty() { route.push(root); } + let mut infer_subcommands = route.iter().any(|cmd| cmd.infer_subcommands); // Already there for `--help`, whose span is empty. For the `help` word the parse stopped at // the command that *saw* it, and the words naming the one being asked about are exactly the @@ -1260,8 +1261,13 @@ pub fn route_to<'t>( // that reached here did. Matching on name and alias together instead answered with // whichever subcommand came first, which for a colliding word is a different command // than the one the parser selected. - let next = crate::find_named(here, word)?; + let next = if infer_subcommands { + crate::find_prefixed(here, word)? + } else { + crate::find_named(here, word)? + }; route.push(next); + infer_subcommands |= next.infer_subcommands; } // Only if the walk actually arrived: a caller should fall back rather than be handed a // page about some other command. diff --git a/argv/src/lib.rs b/argv/src/lib.rs index 5bf922584..0c2583a45 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -175,6 +175,12 @@ pub struct Command<'a> { /// unmatched word is taken, remaining tokens — including `--help` — are not parsed /// as this command's flags. pub external_subcommand: bool, + /// Accept an unambiguous prefix of a subcommand name or alias. + /// Inherited by nested commands. + pub infer_subcommands: bool, + /// Accept an unambiguous prefix of a long flag or alias. + /// Inherited by nested commands. + pub infer_long_args: bool, /// What an unrecognized flag-like token means here, or `None` to keep whatever the /// enclosing command said. See [`UnknownFlags`]. /// @@ -216,6 +222,8 @@ impl Command<'_> { subcommands: &[], default_subcommand: ::core::option::Option::None, external_subcommand: false, + infer_subcommands: false, + infer_long_args: false, unknown_flags: ::core::option::Option::None, version: false, key: 0, @@ -789,6 +797,41 @@ pub(crate) fn find_named<'t>(cmd: &'t Command<'t>, name: &[u8]) -> Option<&'t Co .or_else(|| subcommands().find(|c| c.aliases.iter().any(|a| a.as_bytes() == name))) } +fn find_prefixed<'t>(cmd: &'t Command<'t>, name: &[u8]) -> Option<&'t Command<'t>> { + if let Some(exact) = find_named(cmd, name) { + return Some(exact); + } + if name.is_empty() { + return None; + } + let mut found = None; + for command in cmd.subcommands { + if !command.name.as_bytes().starts_with(name) + && !command + .aliases + .iter() + .any(|alias| alias.as_bytes().starts_with(name)) + { + continue; + } + if found.is_some() { + return None; + } + found = Some(*command); + } + found +} + +fn has_prefixed_subcommand(cmd: &Command<'_>, name: &[u8]) -> bool { + cmd.subcommands.iter().any(|command| { + command.name.as_bytes().starts_with(name) + || command + .aliases + .iter() + .any(|alias| alias.as_bytes().starts_with(name)) + }) +} + /// What a caller should print for a parse failure, and what to exit with. /// /// The one entry point a generated `parse()` reaches for, and the reason it exists here rather @@ -993,6 +1036,8 @@ pub struct Parser<'t, 'a, 'v> { /// nothing keeps what the enclosing one said, and walking back up the ancestors on /// every unrecognized token would pay for the inheritance at the wrong moment. unknown_flags: UnknownFlags, + infer_subcommands: bool, + infer_long_args: bool, /// The chain above `cmd`, used to find inherited global flags. Fixed size so /// that nothing is allocated. ancestors: [Option<&'t Command<'t>>; MAX_DEPTH], @@ -1059,6 +1104,8 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { // Nothing above the root to inherit from, so the default stands. ::core::option::Option::None => UnknownFlags::Value, }, + infer_subcommands: root.infer_subcommands, + infer_long_args: root.infer_long_args, ancestors: [None; MAX_DEPTH], depth: 0, bundle: &[], @@ -1294,8 +1341,8 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { None => (body, None), }; - if let Some(flag) = self.find_long(name) { - let value = if flag.takes_value { + if let Some((flag, negated)) = self.find_long_form(name) { + let value = if flag.takes_value && !negated { Some(match attached { Some(v) => v, None => self.take_detached_value(flag)?, @@ -1303,21 +1350,13 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { } else { None }; - if flag.variadic { + if flag.variadic && !negated { self.start_collecting(flag, value.unwrap_or(b""))?; } return Ok(Event::Flag { flag, value, - negated: false, - }); - } - - if let Some(flag) = self.find_negation(name) { - return Ok(Event::Flag { - flag, - value: None, - negated: true, + negated, }); } @@ -1439,7 +1478,20 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { // positional of this command has taken a word, a later word that happens // to equal a subcommand name is just a value. if !self.arg_filled && !self.flags_stopped { - if let Some(sub) = self.find_subcommand(token) { + // Exact declared commands outrank the built-in help word. In inferred mode the + // built-in participates as a real candidate, so `he` reaches help only when a + // sibling such as `helper` does not make the prefix ambiguous. + if let Some(sub) = find_named(self.cmd, token) { + self.descend(sub)?; + return Ok(Event::Command(sub)); + } + let help_prefix = + !token.is_empty() && b"help".starts_with(token) && !self.cmd.subcommands.is_empty(); + let inferred = self + .infer_subcommands + .then(|| find_prefixed(self.cmd, token)) + .flatten(); + if let Some(sub) = inferred.filter(|_| !help_prefix) { self.descend(sub)?; return Ok(Event::Command(sub)); } @@ -1455,14 +1507,25 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { // // The words after it name a command, resolved here rather than descended into: // descending would bind them, and they are a question rather than an invocation. - if token == b"help" && !self.cmd.subcommands.is_empty() { + if (token == b"help" + || (self.infer_subcommands + && help_prefix + && !has_prefixed_subcommand(self.cmd, token))) + && !self.cmd.subcommands.is_empty() + { let mut cmd = self.cmd; + let mut infer_subcommands = self.infer_subcommands; let from = self.pos; while let Some(next) = self.argv.get(self.pos) { - let Some(sub) = find_named(cmd, bytes(next)) else { + let Some(sub) = (if infer_subcommands { + find_prefixed(cmd, bytes(next)) + } else { + find_named(cmd, bytes(next)) + }) else { break; }; cmd = sub; + infer_subcommands |= sub.infer_subcommands; self.pos += 1; } // Kept for `help::route_to`: which mount was asked about is not recoverable @@ -1563,6 +1626,8 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { self.starts[self.depth] = self.cmd_start; self.depth += 1; self.cmd = sub; + self.infer_subcommands |= sub.infer_subcommands; + self.infer_long_args |= sub.infer_long_args; // Only a command that says something changes it, which is what inheriting means. if let ::core::option::Option::Some(mode) = sub.unknown_flags { self.unknown_flags = mode; @@ -1624,14 +1689,89 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { own.chain(inherited) } - fn find_long(&self, name: &[u8]) -> Option<&'t Flag<'t>> { - self.in_scope() - .find(|f| f.longs.iter().any(|l| l.as_bytes() == name)) + fn find_long_form(&self, name: &[u8]) -> Option<(&'t Flag<'t>, bool)> { + if let Some(flag) = self + .in_scope() + .find(|f| f.longs.iter().any(|long| long.as_bytes() == name)) + { + return Some((flag, false)); + } + if let Some(flag) = self + .in_scope() + .find(|f| f.negate.is_some_and(|negate| negate.as_bytes() == name)) + { + return Some((flag, true)); + } + // Built-ins are exact entries too, below an authored spelling and above inference. + if name == b"help" { + return Some((&HELP_LONG, false)); + } + if name == b"version" && self.cmd.version { + return Some((&VERSION_LONG, false)); + } + if !self.infer_long_args || name.is_empty() { + return None; + } + + let mut found: Option<(&Flag<'_>, bool)> = None; + for flag in self.in_scope() { + let positive = flag.longs.iter().any(|long| { + long.as_bytes().starts_with(name) + && !self.long_form_is_shadowed(flag, long.as_bytes()) + }); + let negative = !positive + && flag.negate.is_some_and(|negate| { + negate.as_bytes().starts_with(name) + && !self.long_form_is_shadowed(flag, negate.as_bytes()) + }); + if !positive && !negative { + continue; + } + if found.is_some_and(|(prior, _)| !::core::ptr::eq(prior, flag)) { + return None; + } + found = Some((flag, negative)); + } + for (spelling, flag) in [ + (b"help".as_slice(), &HELP_LONG), + (b"version".as_slice(), &VERSION_LONG), + ] { + if (spelling == b"version" && !self.cmd.version) + || !spelling.starts_with(name) + || self.in_scope().any(|candidate| { + candidate + .longs + .iter() + .any(|long| long.as_bytes() == spelling) + }) + { + continue; + } + if found.is_some_and(|(prior, _)| !::core::ptr::eq(prior, flag)) { + return None; + } + found = Some((flag, false)); + } + found } - fn find_negation(&self, name: &[u8]) -> Option<&'t Flag<'t>> { - self.in_scope() - .find(|f| f.negate.is_some_and(|n| n.as_bytes() == name)) + /// Whether a nearer declaration already owns this exact long spelling. + /// + /// `in_scope` is ordered by precedence. Prefix inference must apply the same + /// shadowing as exact lookup: redeclaring `--verbose` on a child does not make + /// `--verb` ambiguous merely because an inherited global also spells it that way. + fn long_form_is_shadowed(&self, flag: &Flag<'_>, form: &[u8]) -> bool { + for prior in self.in_scope() { + if ::core::ptr::eq(prior, flag) { + return false; + } + if prior.longs.iter().any(|long| long.as_bytes() == form) + || prior.negate.is_some_and(|negate| negate.as_bytes() == form) + { + return true; + } + } + false } fn find_short(&self, byte: u8) -> Option<&'t Flag<'t>> { @@ -1647,12 +1787,6 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> { None }) } - - fn find_subcommand(&self, name: &[u8]) -> Option<&'t Command<'t>> { - // Shared with `help` rather than spelled out again, so descending into a command and - // asking about one cannot drift apart. - find_named(self.cmd, name) - } } /// View a token as bytes. @@ -1931,6 +2065,39 @@ mod tests { assert_eq!(value, Some(&b"-1"[..])); } + #[test] + fn negation_of_value_flag_does_not_consume_a_value() { + static MODE: Flag = Flag { + key: 9, + name: "mode", + longs: &["mode"], + negate: Some("no-mode"), + ..Flag::VALUE + }; + static NEGATED_VALUE: Command = Command { + name: "ex", + flags: &[&MODE], + args: &[&FILE], + ..Command::EMPTY + }; + + let a = argv(["--no-mode", "input"]); + assert_eq!( + parse(&NEGATED_VALUE, &a).unwrap(), + vec![ + Event::Flag { + flag: &MODE, + value: None, + negated: true + }, + Event::Arg { + arg: &FILE, + value: b"input" + } + ] + ); + } + #[test] fn no_abbreviation() { // A prefix names no flag, so by default it is a value like any other word. @@ -2369,6 +2536,81 @@ mod tests { assert_unique_subcommand_names(&[&INSTALL, &ADD]); } + #[test] + #[cfg(feature = "spec")] + fn inferred_help_keeps_the_route_it_resolved() { + let root = Command { + name: "ex", + subcommands: &[&INSTALL], + infer_subcommands: true, + ..Command::EMPTY + }; + let a = argv(["help", "insta"]); + let Err(Error::Help { cmd, .. }) = parse(&root, &a) else { + panic!("expected inferred help") + }; + let route = crate::help::route_to(&root, &a, cmd).expect("the inferred route"); + assert_eq!(route.len(), 2); + assert!(::core::ptr::eq(route[1], &INSTALL)); + } + + #[test] + fn inferred_builtins_participate_in_exactness_and_ambiguity() { + static HELP_ALL: Flag = Flag { + key: 380, + name: "help-all", + longs: &["help-all"], + ..Flag::BOOL + }; + static HELPER: Command = Command { + key: 381, + name: "helper", + ..Command::EMPTY + }; + let flags = Command { + name: "ex", + flags: &[&HELP_ALL], + infer_long_args: true, + unknown_flags: Some(UnknownFlags::Error), + ..Command::EMPTY + }; + let exact = argv(["--help"]); + assert!(matches!( + parse(&flags, &exact), + Ok(events) if matches!(events.as_slice(), [Event::Flag { flag, .. }] if is_help_flag(flag)) + )); + let ambiguous = argv(["--he"]); + assert!(matches!( + parse(&flags, &ambiguous), + Err(Error::UnknownFlag { .. }) + )); + + let lone = Command { + name: "ex", + infer_long_args: true, + ..Command::EMPTY + }; + let inferred = argv(["--he"]); + assert!(matches!( + parse(&lone, &inferred), + Ok(events) if matches!(events.as_slice(), [Event::Flag { flag, .. }] if is_help_flag(flag)) + )); + + let commands = Command { + name: "ex", + subcommands: &[&HELPER], + infer_subcommands: true, + ..Command::EMPTY + }; + let exact = argv(["help"]); + assert!(matches!(parse(&commands, &exact), Err(Error::Help { .. }))); + let ambiguous = argv(["he"]); + assert!(matches!( + parse(&commands, &ambiguous), + Err(Error::UnexpectedArg { .. }) + )); + } + #[test] fn the_word_is_re_examined_against_the_command_it_reached() { // The reason the cursor steps back rather than the token being consumed: `lint` names diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 2c10e4c91..9dab4777c 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -1073,6 +1073,12 @@ impl Spec<'_> { if self.root.cmd.external_subcommand { writeln!(out, "external_subcommand #true")?; } + if self.root.cmd.infer_subcommands { + writeln!(out, "infer_subcommands #true")?; + } + if self.root.cmd.infer_long_args { + writeln!(out, "infer_long_args #true")?; + } // A `complete` block for every completer this CLI declares, naming the command that // asks the binary itself. Written rather than declared, so there is one place a // completer is said to exist: the Rust function. Everything that reads a spec — the @@ -1313,6 +1319,12 @@ fn write_command<'a>( if meta.cmd.external_subcommand { out.push_str(" external_subcommand=#true"); } + if meta.cmd.infer_subcommands { + out.push_str(" infer_subcommands=#true"); + } + if meta.cmd.infer_long_args { + out.push_str(" infer_long_args=#true"); + } out.push_str(" {\n"); let inner = depth + 1; diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 4c6e656dd..e6b21e66a 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -109,6 +109,8 @@ pub fn build( version: false, unknown_flags, external_subcommand: cmd.external_subcommand, + infer_subcommands: cmd.infer_subcommands, + infer_long_args: cmd.infer_long_args, key: 0, })); diff --git a/conformance/tests/infer_prefixes.rs b/conformance/tests/infer_prefixes.rs new file mode 100644 index 000000000..d9f11d097 --- /dev/null +++ b/conformance/tests/infer_prefixes.rs @@ -0,0 +1,128 @@ +//! Unambiguous long-flag and subcommand prefixes, through both Rust parsers. + +use std::ffi::OsStr; + +use usage::Spec as LibSpec; +use usage_derive::{Args, Cli, Subcommands}; + +#[derive(Cli)] +#[usage( + name = "ex", + unknown_flags = "error", + infer_subcommands, + infer_long_args +)] +struct Ex { + #[usage(long, global)] + verbose: bool, + #[usage(long)] + verify: bool, + #[usage(long, negate = "--no-color")] + color: bool, + #[usage(subcommand)] + command: Option, +} + +#[derive(Subcommands)] +enum Commands { + Install(Install), + Inspect(Inspect), + #[usage(alias = "uninstall")] + Remove(Remove), +} + +#[derive(Args)] +struct Install { + #[usage(long)] + forceful: bool, + #[usage(long)] + verbose: bool, +} + +#[derive(Args)] +struct Inspect; + +#[derive(Args)] +struct Remove; + +fn argv(tokens: [&str; N]) -> [&OsStr; N] { + tokens.map(OsStr::new) +} + +fn words(tokens: [&str; N]) -> Vec { + tokens.iter().map(|token| token.to_string()).collect() +} + +#[test] +fn the_typed_parser_accepts_only_unique_prefixes() { + let parsed = Ex::parse_from(&argv(["insta", "--for"])) + .expect("root inference should reach the child and stay enabled there"); + let Some(Commands::Install(install)) = parsed.command else { + panic!("expected install") + }; + assert!(install.forceful); + + let parsed = Ex::parse_from(&argv(["install", "--verb"])) + .expect("a child redeclaration should shadow the inherited global for prefixes"); + let Some(Commands::Install(install)) = parsed.command else { + panic!("expected install") + }; + assert!(install.verbose); + assert!(!parsed.verbose); + + assert!( + matches!( + Ex::parse_from(&argv(["uni"])) + .expect("an alias prefix should route") + .command, + Some(Commands::Remove(_)) + ), + "aliases participate in inference" + ); + + assert!(Ex::parse_from(&argv(["ins"])).is_err()); + assert!(Ex::parse_from(&argv(["--ver"])).is_err()); + + let verbose = Ex::parse_from(&argv(["--verb"])).expect("a unique long prefix should bind"); + assert!(verbose.verbose); + assert!(!verbose.verify); + + let color = Ex::parse_from(&argv(["--col"])).expect("a positive prefix should enable"); + assert!(color.color); + let color = Ex::parse_from(&argv(["--no-col"])).expect("a negated prefix should disable"); + assert!(!color.color); + + let exact = Ex::parse_from(&argv(["install"])).expect("exact names still outrank prefixes"); + assert!(matches!(exact.command, Some(Commands::Install(_)))); +} + +#[test] +fn emitted_kdl_gives_usage_lib_the_same_policy() { + let kdl = Ex::to_kdl(); + assert!(kdl.contains("infer_subcommands #true"), "{kdl}"); + assert!(kdl.contains("infer_long_args #true"), "{kdl}"); + let spec: LibSpec = kdl.parse().expect("valid spec"); + + let parsed = usage::parse::parse(&spec, &words(["ex", "insta", "--for"])) + .expect("usage-lib should accept the same unique prefixes"); + assert_eq!(parsed.cmd.name, "install"); + + assert!(usage::parse::parse(&spec, &words(["ex", "ins"])).is_err()); + assert!(usage::parse::parse(&spec, &words(["ex", "--ver"])).is_err()); + + let positive = usage::parse::parse(&spec, &words(["ex", "--col"])) + .expect("a positive prefix should enable"); + assert!(matches!( + positive + .flags + .values() + .find(|value| matches!(value, usage::parse::ParseValue::Bool(true))), + Some(usage::parse::ParseValue::Bool(true)) + )); + let negative = usage::parse::parse(&spec, &words(["ex", "--no-col"])) + .expect("a negated prefix should disable"); + assert!(negative + .flags + .values() + .any(|value| matches!(value, usage::parse::ParseValue::Bool(false)))); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index c6b821cbe..854f6f90c 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -133,6 +133,8 @@ pub fn emit(cli: &Cli) -> TokenStream { // version resolved it here and wrote `Value` for every silent command, which made the // root's declaration reach the root alone. let unknown_flags = unknown_flags_tokens(cli); + let infer_subcommands = cli.infer_subcommands; + let infer_long_args = cli.infer_long_args; let default_subcommand = option_str(cli.default_subcommand.as_deref()); let multicall = cli.multicall; @@ -416,6 +418,8 @@ pub fn emit(cli: &Cli) -> TokenStream { // `--version` that answers with nothing is worse than one that is not there. version: #has_version, unknown_flags: #unknown_flags, + infer_subcommands: #infer_subcommands, + infer_long_args: #infer_long_args, name: #name, key: #root_key, flags: #flag_table_ref, @@ -2948,6 +2952,8 @@ pub fn emit_args(cli: &Cli) -> TokenStream { ) }); let unknown_flags = unknown_flags_tokens(cli); + let infer_subcommands = cli.infer_subcommands; + let infer_long_args = cli.infer_long_args; let before_help = option_expr(cli.before_help.as_ref()); let before_long_help = option_expr(cli.before_long_help.as_ref()); let after_help = option_expr(cli.after_help.as_ref()); @@ -3056,6 +3062,8 @@ pub fn emit_args(cli: &Cli) -> TokenStream { aliases: &[#(#aliases),*], key: #command_key, unknown_flags: #unknown_flags, + infer_subcommands: #infer_subcommands, + infer_long_args: #infer_long_args, flags: #flag_table_ref, args: #arg_table_ref, #sub_commands diff --git a/derive/src/lib.rs b/derive/src/lib.rs index ce6ad7757..f1d543d44 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -202,6 +202,8 @@ //! On the struct itself: `bin`, `version`, `about`, `long_about`, `before_help`, `after_help`, //! `verbatim_doc_comment` — preserve doc-comment line breaks and whitespace — //! `default_subcommand`, `multicall` — argv[0]'s basename selects a subcommand — +//! `infer_subcommands`, `infer_long_args` — accept unambiguous prefixes and inherit that +//! policy through nested commands — //! `min_usage_version` — the oldest `usage` that can read the emitted //! spec, declared rather than worked out — `effect` — what running this command does to the world, on an `Args` //! rather than on the root, which does nothing itself — `completion`, which adds the hidden command a generated shell diff --git a/derive/src/model.rs b/derive/src/model.rs index 73b7e9668..c789c2844 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -88,6 +88,10 @@ pub struct Cli { /// Whether a flag-like token that names no flag is a value or an error. Unset /// means the spec's default, which is `value`. pub unknown_flags: Option, + /// Accept unambiguous prefixes of subcommands, inherited by children. + pub infer_subcommands: bool, + /// Accept unambiguous prefixes of long flags, inherited by children. + pub infer_long_args: bool, /// The command a bare invocation means: `mise build` is `mise run build`. /// /// Only the root has one, and it is what mise sets by hand on the emitted spec today. @@ -505,6 +509,8 @@ impl Cli { about: None, long_about: None, unknown_flags: None, + infer_subcommands: false, + infer_long_args: false, default_subcommand: None, multicall: false, no_binary_name: false, @@ -643,6 +649,8 @@ impl Cli { } "multicall" => cli.multicall = flag_value(&meta)?, "no_binary_name" => cli.no_binary_name = flag_value(&meta)?, + "infer_subcommands" => cli.infer_subcommands = flag_value(&meta)?, + "infer_long_args" => cli.infer_long_args = flag_value(&meta)?, "restart_token" => cli.restart_token = Some(string_value(&meta)?), "mount" => cli.mount = Some(string_value(&meta)?), "group" => cli.groups.push(group_decl(&meta)?), @@ -652,7 +660,8 @@ impl Cli { format!( "unknown option `{other}` on a struct; usage::Cli takes \ `name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `usage`, `verbatim_doc_comment`, `unknown_flags`, \ - `default_subcommand`, `multicall`, `no_binary_name`, `restart_token`, `mount` and \ + `default_subcommand`, `multicall`, `no_binary_name`, `infer_subcommands`, \ + `infer_long_args`, `restart_token`, `mount` and \ `group` here, and the description comes from the doc \ comment" ), diff --git a/docs/rust/clap-compatibility.md b/docs/rust/clap-compatibility.md index eb370851a..61b9dffa0 100644 --- a/docs/rust/clap-compatibility.md +++ b/docs/rust/clap-compatibility.md @@ -70,7 +70,7 @@ not only from generated KDL, when a row says **usage only**. | `allow_external_subcommands` | Supported | Supported | Use an `#[usage(external_subcommand)]` catch-all variant. | | `multicall` | Supported | Supported | `parse()` routes on the executable basename. | | `no_binary_name` | Supported | Supported | `parse_from` is words-only; `try_parse_from` and `parse_from_argv` honor the clap-shaped command policy. | -| `infer_subcommands`, `infer_long_args` | Unsupported | Unsupported | Diagnostics suggest likely names but do not accept prefixes. | +| `infer_subcommands`, `infer_long_args` | Supported | Unsupported | Unambiguous prefixes and aliases are inherited; clap exposes no public getter for the bridge. | | `arg_required_else_help` and subcommand/argument policies | Unsupported | Unsupported | No command-policy vocabulary yet. | | unknown flags | Different | Different | Parsing is permissive by default. Opt into clap-like rejection with `#[usage(unknown_flags = "error")]`. | diff --git a/docs/rust/migrating-from-clap.md b/docs/rust/migrating-from-clap.md index 7df352b00..949c97987 100644 --- a/docs/rust/migrating-from-clap.md +++ b/docs/rust/migrating-from-clap.md @@ -65,6 +65,10 @@ enum Command { Unknown flags are values by default, which is useful for wrapper CLIs. Add `unknown_flags = "error"` on each command where unknown flag-like words must be rejected. +Clap's global prefix settings migrate as +`#[usage(infer_subcommands, infer_long_args)]`; they are inherited by nested commands. Because +clap exposes setters but not getters for these settings, a `clap::Command` bridge cannot infer +that declaration for you. ## Fields diff --git a/docs/spec/reference/cmd.md b/docs/spec/reference/cmd.md index 6d95ec45f..002e3139c 100644 --- a/docs/spec/reference/cmd.md +++ b/docs/spec/reference/cmd.md @@ -156,6 +156,27 @@ as this command's flags. See [the argv grammar](../argv.md#external-subcommands). +### Inferred prefixes + +A command can accept an unambiguous prefix of a subcommand name or alias, a long +flag, or a long flag alias: + +```kdl +infer_subcommands #true +infer_long_args #true + +cmd "install" { + alias "add" +} +flag "--verbose" +``` + +Here `mycli insta`, `mycli a`, and `mycli --verb` resolve to their full +declarations. A prefix matching two different commands or flags is not accepted; +an exact spelling always wins. Both settings are inherited by nested commands and +may also be enabled for one subtree with `infer_subcommands=#true` or +`infer_long_args=#true` on its `cmd` node. + ### Global flags and mounted commands A mounted command describes a different program, so the flags of the commands it is mounted under diff --git a/go/argv/argv.go b/go/argv/argv.go index a1b937bc2..5b74173fa 100644 --- a/go/argv/argv.go +++ b/go/argv/argv.go @@ -68,6 +68,10 @@ type Command struct { // DefaultSubcommand still catches first. Once the unmatched word is taken, // remaining tokens — including --help — are not parsed as this command's flags. ExternalSubcommand bool + // InferSubcommands accepts unambiguous prefixes of names and aliases. + InferSubcommands bool + // InferLongArgs accepts unambiguous prefixes of long flag names and aliases. + InferLongArgs bool // UnknownFlags is what an unrecognized flag-like token means here. Already // resolved: inheritance is a question for whoever builds the tables. UnknownFlags UnknownFlags diff --git a/go/argv/parser.go b/go/argv/parser.go index 6228b852a..5b0e6ffd9 100644 --- a/go/argv/parser.go +++ b/go/argv/parser.go @@ -1,5 +1,7 @@ package argv +import "strings" + // Parser reads a command line once, left to right, against static tables. // // There is no backtracking, no reordering, and no second pass: what a token binds @@ -35,6 +37,9 @@ type Parser struct { pos int // cmd is the command currently in scope. cmd *Command + // Prefix policies are inherited as the parser descends. + inferSubcommands bool + inferLongArgs bool // ancestors is the chain above cmd, used to find inherited global flags. // Fixed size so that nothing is allocated. ancestors [MaxDepth]*Command @@ -87,7 +92,12 @@ type Parser struct { // New begins parsing argv against root. argv excludes the program name. func New(root *Command, argv []string) Parser { - return Parser{argv: argv, cmd: root} + return Parser{ + argv: argv, + cmd: root, + inferSubcommands: root.InferSubcommands, + inferLongArgs: root.InferLongArgs, + } } // Next reads the next event, reporting false when argv is exhausted or the parse @@ -285,10 +295,10 @@ func (p *Parser) longFlag(token string) bool { } } - if flag := p.findLong(name); flag != nil { + if flag, negated := p.findLongForm(name); flag != nil { value := "" hasValue := false - if flag.TakesValue { + if flag.TakesValue && !negated { hasValue = true if hasAttached { value = attached @@ -303,14 +313,10 @@ func (p *Parser) longFlag(token string) bool { value = v } } - if flag.Variadic { + if flag.Variadic && !negated { p.startCollecting(flag) } - return p.emit(Event{Kind: KindFlag, Flag: flag, Value: value, HasValue: hasValue}) - } - - if flag := p.findNegation(name); flag != nil { - return p.emit(Event{Kind: KindFlag, Flag: flag, Negated: true}) + return p.emit(Event{Kind: KindFlag, Flag: flag, Value: value, HasValue: hasValue, Negated: negated}) } // Where the CLI declared a version, --version answers with it — asked after the @@ -445,12 +451,20 @@ func (p *Parser) word(token string) bool { // positional of this command has taken a word, a later word that happens to // equal a subcommand name is just a value. if !p.argFilled && !p.flagsStopped { - if sub := p.findSubcommand(token); sub != nil { + if sub := findNamed(p.cmd, token); sub != nil { if !p.descend(sub) { return false } return p.emit(Event{Kind: KindCommand, Command: sub}) } + helpPrefix := token != "" && strings.HasPrefix("help", token) && len(p.cmd.Subcommands) > 0 + inferred := p.findSubcommand(token) + if inferred != nil && !helpPrefix { + if !p.descend(inferred) { + return false + } + return p.emit(Event{Kind: KindCommand, Command: inferred}) + } // `ex help config ls` — the line every page with a Commands section prints. // Asked after the subcommand lookup, so a CLI that declares a help of its own @@ -459,14 +473,16 @@ func (p *Parser) word(token string) bool { // The words after it name a command, resolved here rather than descended // into: descending would bind them, and they are a question rather than an // invocation. - if token == "help" && len(p.cmd.Subcommands) > 0 { + if (token == "help" || (p.inferSubcommands && helpPrefix && !hasPrefixedSubcommand(p.cmd, token))) && len(p.cmd.Subcommands) > 0 { cmd := p.cmd + inferSubcommands := p.inferSubcommands for p.pos < len(p.argv) { - sub := findNamed(cmd, p.argv[p.pos]) + sub := findNamedWithPrefix(cmd, p.argv[p.pos], inferSubcommands) if sub == nil { break } cmd = sub + inferSubcommands = inferSubcommands || sub.InferSubcommands p.pos++ } // The long form, as `ex config --help` gives: someone who typed a whole word @@ -544,6 +560,8 @@ func (p *Parser) descend(sub *Command) bool { p.starts[p.depth] = p.cmdStart p.depth++ p.cmd = sub + p.inferSubcommands = p.inferSubcommands || sub.InferSubcommands + p.inferLongArgs = p.inferLongArgs || sub.InferLongArgs // Where this command's own words start, which is what lets a completion hand a // callback the half-parsed struct of the command it was declared on rather than // of the root. @@ -615,21 +633,94 @@ func (p *Parser) FlagsInScope(fn func(*Flag) bool) { p.eachInScope(fn) } -func (p *Parser) findLong(name string) *Flag { - return p.eachInScope(func(f *Flag) bool { +func (p *Parser) findLongForm(name string) (*Flag, bool) { + if found := p.eachInScope(func(f *Flag) bool { for _, l := range f.Longs { if l == name { return true } } return false + }); found != nil { + return found, false + } + if found := p.eachInScope(func(f *Flag) bool { + return f.Negate != "" && f.Negate == name + }); found != nil { + return found, true + } + if name == "help" { + return HelpLong, false + } + if name == "version" && p.cmd.Version { + return VersionLong, false + } + if !p.inferLongArgs || name == "" { + return nil, false + } + var found *Flag + negated := false + ambiguous := false + p.eachInScope(func(f *Flag) bool { + positive := false + for _, long := range f.Longs { + if len(long) >= len(name) && long[:len(name)] == name && !p.longFormIsShadowed(f, long) { + positive = true + break + } + } + negative := !positive && f.Negate != "" && len(f.Negate) >= len(name) && f.Negate[:len(name)] == name && !p.longFormIsShadowed(f, f.Negate) + if !positive && !negative { + return false + } + if found != nil && found != f { + ambiguous = true + return true + } + found, negated = f, negative + return false }) + for _, builtin := range []struct { + name string + flag *Flag + ok bool + }{{"help", HelpLong, true}, {"version", VersionLong, p.cmd.Version}} { + if !builtin.ok || !strings.HasPrefix(builtin.name, name) || p.longFormIsShadowed(builtin.flag, builtin.name) { + continue + } + if found != nil && found != builtin.flag { + ambiguous = true + break + } + found, negated = builtin.flag, false + } + if ambiguous { + return nil, false + } + return found, negated } -func (p *Parser) findNegation(name string) *Flag { - return p.eachInScope(func(f *Flag) bool { - return f.Negate != "" && f.Negate == name +// longFormIsShadowed reports whether a nearer declaration already owns this +// exact spelling. Prefix lookup must preserve exact lookup's shadowing rule. +func (p *Parser) longFormIsShadowed(flag *Flag, form string) bool { + shadowed := false + p.eachInScope(func(prior *Flag) bool { + if prior == flag { + return true + } + for _, long := range prior.Longs { + if long == form { + shadowed = true + return true + } + } + if prior.Negate == form { + shadowed = true + return true + } + return false }) + return shadowed } func (p *Parser) findShort(b byte) *Flag { @@ -656,7 +747,42 @@ func (p *Parser) findShort(b byte) *Flag { } func (p *Parser) findSubcommand(name string) *Command { - return findNamed(p.cmd, name) + return findNamedWithPrefix(p.cmd, name, p.inferSubcommands) +} + +func findNamedWithPrefix(cmd *Command, name string, infer bool) *Command { + if exact := findNamed(cmd, name); exact != nil || !infer || name == "" { + return exact + } + var found *Command + for _, sub := range cmd.Subcommands { + matches := len(sub.Name) >= len(name) && sub.Name[:len(name)] == name + for _, alias := range sub.Aliases { + matches = matches || len(alias) >= len(name) && alias[:len(name)] == name + } + if !matches { + continue + } + if found != nil { + return nil + } + found = sub + } + return found +} + +func hasPrefixedSubcommand(cmd *Command, name string) bool { + for _, sub := range cmd.Subcommands { + if strings.HasPrefix(sub.Name, name) { + return true + } + for _, alias := range sub.Aliases { + if strings.HasPrefix(alias, name) { + return true + } + } + } + return false } // findNamed resolves a word against a command's subcommands, by name or alias. diff --git a/go/argv/parser_test.go b/go/argv/parser_test.go index dada3160e..11a331576 100644 --- a/go/argv/parser_test.go +++ b/go/argv/parser_test.go @@ -136,6 +136,14 @@ func TestBinding(t *testing.T) { } } +func TestNegatedValueFlagDoesNotConsumeTheNextWord(t *testing.T) { + mode := &Flag{Key: 20, Name: "mode", Longs: []string{"mode"}, Negate: "no-mode", TakesValue: true} + cmd := &Command{Name: "ex", Flags: []*Flag{mode}, Args: []*Arg{file}} + if got := collect(cmd, "--no-mode", "input"); got != "flag:mode! arg:file=input" { + t.Fatalf("negation consumed the positional: %s", got) + } +} + // TestBundleIsRejectedWhole pins the rule that costs the parser a second scan. // // A token containing an unrecognized letter is not a bundle at all, so none of its @@ -392,6 +400,78 @@ func TestExternalSubcommand(t *testing.T) { } } +func TestInferredPrefixes(t *testing.T) { + verify := &Flag{Key: 21, Name: "verify", Longs: []string{"verify"}} + forceful := &Flag{Key: 22, Name: "forceful", Longs: []string{"forceful"}} + install := &Command{Name: "install", Flags: []*Flag{forceful}} + inspect := &Command{Name: "inspect"} + remove := &Command{Name: "remove", Aliases: []string{"uninstall"}} + cmd := &Command{ + Name: "ex", + Flags: []*Flag{verbose, verify}, + Subcommands: []*Command{install, inspect, remove}, + InferSubcommands: true, + InferLongArgs: true, + UnknownFlags: UnknownFlagsError, + } + + if got := collect(cmd, "insta", "--for"); got != "cmd:install flag:forceful" { + t.Errorf("unique prefixes: got %s", got) + } + if got := collect(cmd, "uni"); got != "cmd:remove" { + t.Errorf("alias prefix: got %s", got) + } + if got := collect(cmd, "ins"); got != "err:unexpected_arg" { + t.Errorf("ambiguous subcommand: got %s", got) + } + if got := collect(cmd, "--ver"); got != "err:unknown_flag" { + t.Errorf("ambiguous long: got %s", got) + } + + helpAll := &Flag{Key: 25, Name: "help-all", Longs: []string{"help-all"}} + withHelpAll := &Command{Name: "ex", Flags: []*Flag{helpAll}, InferLongArgs: true, UnknownFlags: UnknownFlagsError} + if got := collect(withHelpAll, "--help"); got != "flag:help" { + t.Errorf("exact built-in help: got %s", got) + } + if got := collect(withHelpAll, "--he"); got != "err:unknown_flag" { + t.Errorf("help prefix should be ambiguous: got %s", got) + } + if got := collect(&Command{Name: "ex", InferLongArgs: true}, "--he"); got != "flag:help" { + t.Errorf("unique built-in help prefix: got %s", got) + } + + helper := &Command{Name: "helper"} + withHelper := &Command{Name: "ex", Subcommands: []*Command{helper}, InferSubcommands: true} + if got := collect(withHelper, "help"); got != "err:help" { + t.Errorf("exact help command: got %s", got) + } + if got := collect(withHelper, "he"); got != "err:unexpected_arg" { + t.Errorf("help command prefix should be ambiguous: got %s", got) + } + + global := &Flag{Key: 23, Name: "verbose", Longs: []string{"verbose"}, Global: true} + local := &Flag{Key: 24, Name: "verbose", Longs: []string{"verbose"}} + run := &Command{Name: "run", Flags: []*Flag{local}} + shadowed := &Command{Name: "ex", Flags: []*Flag{global}, Subcommands: []*Command{run}, InferLongArgs: true} + if got := collect(shadowed, "run", "--verb"); got != "cmd:run flag:verbose" { + t.Errorf("redeclared global prefix: got %s", got) + } + + nestedInstall := &Command{Name: "install"} + nested := &Command{Name: "nested", Subcommands: []*Command{nestedInstall}, InferSubcommands: true} + nestedRoot := &Command{Name: "ex", Subcommands: []*Command{nested}} + if got := collect(nestedRoot, "nested", "insta"); got != "cmd:nested cmd:install" { + t.Errorf("nested command prefix: got %s", got) + } + p := New(nestedRoot, []string{"help", "nested", "insta"}) + for p.Next() { + } + err, ok := p.Err().(*Error) + if !ok || err.Code != CodeHelp || err.Cmd != nestedInstall { + t.Errorf("nested help prefix: got %v", p.Err()) + } +} + func BenchmarkParse(b *testing.B) { args := []string{"install", "--verbose", "-f", "a", "b", "c"} b.ReportAllocs() diff --git a/go/internal/spec/spec.go b/go/internal/spec/spec.go index 5cf1c66af..bfe5bf8b0 100644 --- a/go/internal/spec/spec.go +++ b/go/internal/spec/spec.go @@ -98,6 +98,8 @@ type Cmd struct { Flags []Flag `json:"flags"` UnknownFlags *string `json:"unknown_flags"` ExternalSubcommand bool `json:"external_subcommand"` + InferSubcommands bool `json:"infer_subcommands"` + InferLongArgs bool `json:"infer_long_args"` } // Subcommands is a command's children, in the order the spec declared them. @@ -412,7 +414,7 @@ func (s *Spec) Build() (*argv.Command, argv.Metadata) { // pass for the same reason the first two were. func (s *Spec) BuildAll() (*argv.Command, argv.Metadata, argv.HelpTable) { b := &builder{complete: s.Complete} - root := b.command(&s.Cmd, unknownFlags(s.UnknownFlags, argv.UnknownFlagsValue)) + root := b.command(&s.Cmd, unknownFlags(s.UnknownFlags, argv.UnknownFlagsValue), false, false) // default_subcommand is a property of the spec rather than of a command, so it // is resolved once, here, against the root's own subcommands. A name that @@ -570,7 +572,7 @@ func (b *builder) next() uint64 { return b.key } -func (b *builder) command(c *Cmd, inherited argv.UnknownFlags) *argv.Command { +func (b *builder) command(c *Cmd, inherited argv.UnknownFlags, inferSubcommands, inferLongArgs bool) *argv.Command { unknown := inherited if c.UnknownFlags != nil { unknown = unknownFlags(*c.UnknownFlags, inherited) @@ -580,6 +582,8 @@ func (b *builder) command(c *Cmd, inherited argv.UnknownFlags) *argv.Command { Name: c.Name, UnknownFlags: unknown, ExternalSubcommand: c.ExternalSubcommand, + InferSubcommands: inferSubcommands || c.InferSubcommands, + InferLongArgs: inferLongArgs || c.InferLongArgs, Key: b.next(), } examples := make([]argv.Example, 0, len(c.Examples)) @@ -626,7 +630,12 @@ func (b *builder) command(c *Cmd, inherited argv.UnknownFlags) *argv.Command { // In scope for everything below, and out of scope again afterwards. b.scope = append(b.scope, out) for i := range c.Subcommands { - out.Subcommands = append(out.Subcommands, b.command(&c.Subcommands[i].Cmd, unknown)) + out.Subcommands = append(out.Subcommands, b.command( + &c.Subcommands[i].Cmd, + unknown, + out.InferSubcommands, + out.InferLongArgs, + )) } b.scope = b.scope[:len(b.scope)-1] return out diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index 8c800cd20..5eef50356 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -493,6 +493,8 @@ impl From<&crate::SpecCommand> for SpecCommand { // Presentational output does not describe relationships between flags, the // way it already does not describe `conflicts`. groups: _, + infer_subcommands: _, + infer_long_args: _, } = cmd; Self { diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index ddcbb07ba..1d21639f5 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -374,6 +374,12 @@ impl<'a> Emitter<'a> { if e.cmd.external_subcommand { lines.push(Line::Field("ExternalSubcommand".into(), "true".into())); } + if effective_command_setting(commands, i, |cmd| cmd.infer_subcommands) { + lines.push(Line::Field("InferSubcommands".into(), "true".into())); + } + if effective_command_setting(commands, i, |cmd| cmd.infer_long_args) { + lines.push(Line::Field("InferLongArgs".into(), "true".into())); + } if e.root { if let Some(var) = &default_subcommand { @@ -1090,6 +1096,19 @@ fn effective_unknown_flags(spec: &Spec, commands: &[Emitted], at: usize) -> Unkn spec.unknown_flags.unwrap_or_default() } +fn effective_command_setting( + commands: &[Emitted], + at: usize, + enabled: impl Fn(&SpecCommand) -> bool, +) -> bool { + let path = &commands[at].cmd.full_cmd; + commands.iter().any(|candidate| { + candidate.cmd.full_cmd.len() <= path.len() + && candidate.cmd.full_cmd[..] == path[..candidate.cmd.full_cmd.len()] + && enabled(&candidate.cmd) + }) +} + fn flag_literal(flag: &SpecFlag, named: &Named) -> String { let mut fields = vec![ format!("Key: {}", named.key), @@ -1711,6 +1730,40 @@ cmd "exec" external_subcommand=#true ); } + #[test] + fn inferred_prefix_settings_are_emitted_and_inherited() { + let out = go(r#" +name "ex" +bin "ex" +infer_subcommands #true +infer_long_args #true +cmd "install" {} +"#); + let block = |var: &str| { + let start = out + .find(&format!("var {var} =")) + .unwrap_or_else(|| panic!("{var} should be emitted, got:\n{out}")); + let rest = &out[start..]; + let end = rest[1..] + .find("\nvar ") + .map(|i| i + 1) + .unwrap_or(rest.len()); + &rest[..end] + }; + for var in ["Root", "cmdInstall"] { + let has_true = |field: &str| { + block(var) + .lines() + .any(|line| line.contains(field) && line.ends_with("true,")) + }; + assert!( + has_true("InferSubcommands:") && has_true("InferLongArgs:"), + "{var} should carry the effective inherited settings:\n{}", + block(var) + ); + } + } + /// A subcommand actually named `root` wants the constant the root has. #[test] fn a_subcommand_named_root_does_not_collide_with_the_root() { diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 0ee7b55dc..00519ee85 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -247,6 +247,101 @@ fn get_flag_key(word: &str) -> &str { } } +fn resolve_long_flag( + available: &BTreeMap>, + word: &str, + infer: bool, + help_enabled: bool, +) -> Option<(Arc, bool)> { + let key = get_flag_key(word); + if let Some(exact) = available.get(key) { + return Some((Arc::clone(exact), exact.negate.as_deref() == Some(key))); + } + if !infer || !key.starts_with("--") || key.len() == 2 { + return None; + } + let built_in_help = + help_enabled && "--help".starts_with(key) && !available.contains_key("--help"); + let mut found: Option<(Arc, bool)> = None; + for (candidate, flag) in available { + if !candidate.starts_with("--") { + continue; + } + if !candidate.starts_with(key) { + continue; + } + let negated = flag.negate.as_deref() == Some(candidate.as_str()); + if let Some((prior, prior_negated)) = &mut found { + if !Arc::ptr_eq(prior, flag) { + return None; + } + // Several aliases of one declaration are one match. If both a positive and + // negative spelling share the prefix, the positive form wins, matching the + // static Rust and Go parsers. + *prior_negated &= negated; + } else { + found = Some((Arc::clone(flag), negated)); + } + } + if built_in_help { + // The built-in is a candidate too. With a declared match this prefix is + // ambiguous; without one the caller recognizes the built-in itself. + return None; + } + found +} + +fn resolve_subcommand_with_builtins<'a>( + cmd: &'a SpecCommand, + name: &str, + infer: bool, + help_enabled: bool, +) -> Option<&'a SpecCommand> { + if let Some(exact) = cmd.find_subcommand(name) { + return Some(exact); + } + if help_enabled + && !cmd.subcommands.is_empty() + && (name == "help" || (infer && !name.is_empty() && "help".starts_with(name))) + { + return None; + } + cmd.find_subcommand_with_prefix(name, infer) +} + +fn inferred_long_help( + spec: &Spec, + available: &BTreeMap>, + word: &str, + infer: bool, +) -> bool { + let key = get_flag_key(word); + spec.disable_help != Some(true) + && infer + && key.len() > 2 + && "--help".starts_with(key) + && !available + .keys() + .any(|candidate| candidate.starts_with("--") && candidate.starts_with(key)) +} + +fn inferred_help_word(spec: &Spec, cmd: &SpecCommand, word: &str, infer: bool) -> bool { + spec.disable_help != Some(true) + && !word.is_empty() + && !cmd.subcommands.is_empty() + && (word == "help" + || (infer + && "help".starts_with(word) + && !cmd.subcommands.values().any(|sub| { + sub.name.starts_with(word) + || sub.aliases.iter().any(|alias| alias.starts_with(word)) + || sub + .hidden_aliases + .iter() + .any(|alias| alias.starts_with(word)) + }))) +} + pub struct ParseOutput { pub cmd: SpecCommand, pub cmds: Vec, @@ -740,11 +835,18 @@ fn parse_partial_with_env( // once per task invocation. let default_catches_it = spec.default_subcommand.is_some() && !out.cmd.mounts.iter().any(|m| m.overrides_default); + let infer_subcommands = out.cmds.iter().any(|cmd| cmd.infer_subcommands); if !mounts_resolved && !out.cmd.mounts.is_empty() && !default_catches_it && is_command_word(&input[idx]) - && out.cmd.find_subcommand(&input[idx]).is_none() + && resolve_subcommand_with_builtins( + &out.cmd, + &input[idx], + infer_subcommands, + spec.disable_help != Some(true), + ) + .is_none() { mounts_resolved = true; let mut mounted = out.cmd.clone(); @@ -755,7 +857,12 @@ fn parse_partial_with_env( } out.cmd = mounted; } - if let Some(subcommand) = out.cmd.find_subcommand(&input[idx]) { + if let Some(subcommand) = resolve_subcommand_with_builtins( + &out.cmd, + &input[idx], + infer_subcommands, + spec.disable_help != Some(true), + ) { let mut subcommand = subcommand.clone(); // Pass prefix words (global flags before this subcommand) to mount subcommand.mount(&mount_prefix_words(&prefix_flags))?; @@ -788,11 +895,19 @@ fn parse_partial_with_env( // never named it. let is_bundle = word.starts_with("--") || short_bundle_is_known(&out.available_flags, &word); - if let Some(f) = out - .available_flags - .get(flag_key) - .cloned() - .filter(|_| is_bundle) + let infer_long_args = out.cmds.iter().any(|cmd| cmd.infer_long_args); + if let Some(f) = (if word.starts_with("--") { + resolve_long_flag( + &out.available_flags, + &word, + infer_long_args, + spec.disable_help != Some(true), + ) + .map(|(flag, _)| flag) + } else { + out.available_flags.get(flag_key).cloned() + }) + .filter(|_| is_bundle) { // Skip the flag and keep scanning. Both global and non-global flags may precede // a subcommand (`mycli --verbose run task`, `mycli run --force task`), and @@ -1030,7 +1145,17 @@ fn parse_partial_with_env( // the flag entirely. let split = w.split_once('='); let word = split.map(|(word, _)| word).unwrap_or(&w); - if let Some(f) = binding.as_ref().or_else(|| out.available_flags.get(word)) { + // Resolve even when Phase 1 already carried the flag binding forward: a binding + // identifies the declaration, but an inferred negation also has to preserve which + // spelling matched. + let resolved = resolve_long_flag( + &out.available_flags, + word, + out.cmds.iter().any(|cmd| cmd.infer_long_args), + spec.disable_help != Some(true), + ); + let resolved_flag = resolved.as_ref().map(|(flag, _)| flag); + if let Some(f) = binding.as_ref().or(resolved_flag) { parsed_flag_spellings .entry(Arc::as_ptr(f) as usize) .or_default() @@ -1084,17 +1209,26 @@ fn parse_partial_with_env( .unwrap(); arr.push(true); } else { - let negate = f.negate.clone().unwrap_or_default(); - // Which form was typed is a question about the name, so it is - // asked of `word` rather than the whole token: the attached value - // is dropped just above, and comparing `--no-color=yes` against - // `--no-color` would take the negation down with it. - out.flags - .insert(Arc::clone(f), ParseValue::Bool(word != negate)); + let negated = resolved + .as_ref() + .filter(|(resolved, _)| Arc::ptr_eq(resolved, f)) + .map(|(_, negated)| *negated) + .unwrap_or_else(|| f.negate.as_deref() == Some(word)); + // Exact bindings can compare the typed form. Inferred bindings carry + // which form matched, because a prefix is deliberately not equal to the + // full negation spelling. + out.flags.insert(Arc::clone(f), ParseValue::Bool(!negated)); } continue; } - if is_help_arg(spec, &w) { + if is_help_arg(spec, &w) + || inferred_long_help( + spec, + &out.available_flags, + &w, + out.cmds.iter().any(|cmd| cmd.infer_long_args), + ) + { out.errors .push(render_help_err(spec, &out.cmd, w.len() > 2)); record_cursor(&mut out, next_arg_idx, seen_double_dash); @@ -1305,9 +1439,17 @@ fn parse_partial_with_env( } continue; } - if is_help_arg(spec, &w) { + let help_word = inferred_help_word( + spec, + &out.cmd, + &w, + out.cmds.iter().any(|cmd| cmd.infer_subcommands), + ); + if is_help_arg(spec, &w) || help_word { + // A help *word* is clap's long help action even when inferred from `h` or `he`. + // The length distinction belongs only to the `-h` / `--help` flag spellings. out.errors - .push(render_help_err(spec, &out.cmd, w.len() > 2)); + .push(render_help_err(spec, &out.cmd, help_word || w.len() > 2)); record_cursor(&mut out, next_arg_idx, seen_double_dash); return Ok((out, overridden_flags)); } @@ -2943,6 +3085,84 @@ mount run="exit 1" assert_eq!(out.cmd.name, "ex"); } + #[test] + fn inferred_builtins_are_exact_and_participate_in_ambiguity() { + let with_help_all: Spec = r#" +name "ex" +bin "ex" +infer_long_args #true +unknown_flags "error" +flag "--help-all" +"# + .parse() + .unwrap(); + let exact = parse(&with_help_all, &input(&["ex", "--help"])) + .unwrap_err() + .to_string(); + assert!(exact.contains("Usage:"), "{exact}"); + let ambiguous = parse(&with_help_all, &input(&["ex", "--he"])) + .unwrap_err() + .to_string(); + assert!(!ambiguous.contains("Usage:"), "{ambiguous}"); + + let lone: Spec = r#" +name "ex" +bin "ex" +infer_long_args #true +"# + .parse() + .unwrap(); + let inferred = parse(&lone, &input(&["ex", "--he"])) + .unwrap_err() + .to_string(); + assert!(inferred.contains("Usage:"), "{inferred}"); + + let disabled: Spec = r#" +name "ex" +bin "ex" +infer_long_args #true +disable_help #true +unknown_flags "error" +flag "--hello" +"# + .parse() + .unwrap(); + parse(&disabled, &input(&["ex", "--he"])) + .expect("disabled built-in help must not make a real flag prefix ambiguous"); + + let with_helper: Spec = r#" +name "ex" +bin "ex" +infer_subcommands #true +cmd "helper" +"# + .parse() + .unwrap(); + let exact = parse(&with_helper, &input(&["ex", "help"])) + .unwrap_err() + .to_string(); + assert!(exact.contains("Usage:"), "{exact}"); + let ambiguous = parse(&with_helper, &input(&["ex", "he"])) + .unwrap_err() + .to_string(); + assert!(!ambiguous.contains("Usage:"), "{ambiguous}"); + + let help_word: Spec = r#" +name "ex" +bin "ex" +about "short description" +long_about "long description marker" +infer_subcommands #true +cmd "run" +"# + .parse() + .unwrap(); + let inferred = parse(&help_word, &input(&["ex", "he"])) + .unwrap_err() + .to_string(); + assert!(inferred.contains("long description marker"), "{inferred}"); + } + #[cfg(unix)] #[test] fn a_declared_subcommand_does_not_run_the_mount() { diff --git a/lib/src/spec/builder.rs b/lib/src/spec/builder.rs index 186e944c7..fbada8bd8 100644 --- a/lib/src/spec/builder.rs +++ b/lib/src/spec/builder.rs @@ -692,6 +692,18 @@ impl SpecCommandBuilder { self } + /// Accept unambiguous prefixes of subcommand names and aliases. + pub fn infer_subcommands(mut self, enabled: bool) -> Self { + self.inner.infer_subcommands = enabled; + self + } + + /// Accept unambiguous prefixes of long flag names and aliases. + pub fn infer_long_args(mut self, enabled: bool) -> Self { + self.inner.infer_long_args = enabled; + self + } + /// Set what running this command does to the world pub fn effect(mut self, effect: SpecCommandEffect) -> Self { self.inner.effect = Some(effect); diff --git a/lib/src/spec/cmd.rs b/lib/src/spec/cmd.rs index f3b08e495..2a9ef620c 100644 --- a/lib/src/spec/cmd.rs +++ b/lib/src/spec/cmd.rs @@ -102,6 +102,12 @@ pub struct SpecCommand { /// as this command's flags. #[serde(skip_serializing_if = "is_false")] pub external_subcommand: bool, + /// Accept unambiguous prefixes of subcommand names and aliases, inherited by children. + #[serde(skip_serializing_if = "is_false")] + pub infer_subcommands: bool, + /// Accept unambiguous prefixes of long flag names and aliases, inherited by children. + #[serde(skip_serializing_if = "is_false")] + pub infer_long_args: bool, /// Token that resets argument parsing, allowing multiple command invocations. /// e.g., `mise run lint ::: test ::: check` with restart_token=":::" #[serde(skip_serializing_if = "Option::is_none")] @@ -171,6 +177,8 @@ impl Default for SpecCommand { flags_from_mount: false, subcommand_required: false, external_subcommand: false, + infer_subcommands: false, + infer_long_args: false, restart_token: None, help: None, help_long: None, @@ -277,6 +285,8 @@ impl SpecCommand { "after_help_md" => cmd.after_help_md = Some(v.ensure_string()?), "subcommand_required" => cmd.subcommand_required = v.ensure_bool()?, "external_subcommand" => cmd.external_subcommand = v.ensure_bool()?, + "infer_subcommands" => cmd.infer_subcommands = v.ensure_bool()?, + "infer_long_args" => cmd.infer_long_args = v.ensure_bool()?, "hide" => cmd.hide = v.ensure_bool()?, "unknown_flags" => { let raw = v.ensure_string()?; @@ -401,6 +411,12 @@ impl SpecCommand { "external_subcommand" => { cmd.external_subcommand = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()? } + "infer_subcommands" => { + cmd.infer_subcommands = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()? + } + "infer_long_args" => { + cmd.infer_long_args = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()? + } "hide" => cmd.hide = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()?, "effect" => { let arg = child.ensure_arg_len(1..=1)?.arg(0)?; @@ -511,6 +527,8 @@ impl SpecCommand { hide, subcommand_required, external_subcommand, + infer_subcommands, + infer_long_args, restart_token, subcommands, complete, @@ -584,6 +602,8 @@ impl SpecCommand { self.hide = hide; self.subcommand_required = subcommand_required; self.external_subcommand = external_subcommand; + self.infer_subcommands |= infer_subcommands; + self.infer_long_args |= infer_long_args; if effect.is_some() { self.effect = effect; } @@ -637,6 +657,36 @@ impl SpecCommand { self.subcommands.get(name) } + pub(crate) fn find_subcommand_with_prefix( + &self, + name: &str, + infer: bool, + ) -> Option<&SpecCommand> { + if let Some(exact) = self.find_subcommand(name) { + return Some(exact); + } + if !infer || name.is_empty() { + return None; + } + let mut found = None; + for command in self.subcommands.values() { + if !command.name.starts_with(name) + && !command.aliases.iter().any(|alias| alias.starts_with(name)) + && !command + .hidden_aliases + .iter() + .any(|alias| alias.starts_with(name)) + { + continue; + } + if found.is_some() { + return None; + } + found = Some(command); + } + found + } + pub(crate) fn mount(&mut self, global_flag_args: &[String]) -> Result<(), UsageErr> { for mount in self.mounts.iter().cloned().collect_vec() { let cmd = if global_flag_args.is_empty() { @@ -687,6 +737,8 @@ impl From<&SpecCommand> for KdlNode { hide, subcommand_required, external_subcommand, + infer_subcommands, + infer_long_args, restart_token, unknown_flags, aliases, @@ -729,6 +781,14 @@ impl From<&SpecCommand> for KdlNode { node.entries_mut() .push(KdlEntry::new_prop("external_subcommand", true)); } + if *infer_subcommands { + node.entries_mut() + .push(KdlEntry::new_prop("infer_subcommands", true)); + } + if *infer_long_args { + node.entries_mut() + .push(KdlEntry::new_prop("infer_long_args", true)); + } if let Some(restart_token) = &restart_token { node.entries_mut() .push(KdlEntry::new_prop("restart_token", restart_token.clone())); diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index 347942109..158c1428b 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -298,6 +298,12 @@ impl Spec { "external_subcommand" => { schema.cmd.external_subcommand = node.arg(0)?.ensure_bool()?; } + "infer_subcommands" => { + schema.cmd.infer_subcommands = node.arg(0)?.ensure_bool()?; + } + "infer_long_args" => { + schema.cmd.infer_long_args = node.arg(0)?.ensure_bool()?; + } "example" => { let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?; let mut example = SpecExample::new(code.trim().to_string()); @@ -576,6 +582,16 @@ impl Display for Spec { node.push(KdlEntry::new(true)); nodes.push(node); } + if self.cmd.infer_subcommands { + let mut node = KdlNode::new("infer_subcommands"); + node.push(KdlEntry::new(true)); + nodes.push(node); + } + if self.cmd.infer_long_args { + let mut node = KdlNode::new("infer_long_args"); + node.push(KdlEntry::new(true)); + nodes.push(node); + } if !self.usage.is_empty() { let mut node = KdlNode::new("usage"); node.push(string_entry(None, &self.usage));