diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 3a17ee7a3..b7f6f4725 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -428,19 +428,26 @@ pub fn complete<'a>(spec: &'a Spec<'a>, split: &Split) -> Completions<'a> { // The name a value here would have, which is what says whether paths belong, and whether // that value declares its own set. - let (named, declares_choices) = if let Some(flag) = position.awaiting_value { + let (named, declares_choices, complete_type) = if let Some(flag) = position.awaiting_value { let meta = flag_meta(spec.root, flag); ( meta.and_then(|m| m.value_name).or(Some(flag.name)), meta.is_some_and(|m| !m.choices.is_empty()), + meta.and_then(|m| m.complete_type), ) } else if let Some(arg) = at_cursor { let meta = arg_meta(spec.root, arg); - (Some(arg.name), meta.is_some_and(|m| !m.choices.is_empty())) + ( + Some(arg.name), + meta.is_some_and(|m| !m.choices.is_empty()), + meta.and_then(|m| m.complete_type), + ) } else { - (None, false) + (None, false, None) }; - let asked_for = named.and_then(files_for); + let asked_for = complete_type + .and_then(files_for) + .or_else(|| named.and_then(files_for)); // An argument that requires a separator is not fillable yet, so nothing else belongs here — // not even a path, which the parser would reject exactly as it rejects a value. diff --git a/argv/src/lib.rs b/argv/src/lib.rs index 5bf1850d3..fd077e237 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -83,6 +83,21 @@ use std::ffi::{OsStr, OsString}; +/// A value's filesystem completion class for `#[usage(value_hint = ...)]`. +/// +/// This lives in the runtime crate so a declaration never needs clap merely to describe what +/// kind of path a shell should offer. It is metadata only and adds no work to a successful +/// parse. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ValueHint { + /// A path to a file. + FilePath, + /// A path to either a file or a directory. + AnyPath, + /// A path to a directory. + DirPath, +} + #[cfg(feature = "complete")] pub mod complete; #[cfg(feature = "diagnostics")] diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 565eef04d..4335b5ac0 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -485,6 +485,8 @@ pub struct FlagMeta<'a> { /// that asks *this binary*, so a spec stays complete for every other consumer while the /// binary answers itself. pub complete: Option, + /// A built-in completion class such as `path` or `dir`. + pub complete_type: Option<&'a str>, /// Whether the flag may be given more than once. Distinct from /// [`Flag::variadic`], which is one occurrence taking several values. pub repeatable: bool, @@ -518,6 +520,7 @@ impl FlagMeta<'_> { /// Metadata for a flag with nothing declared, for struct update syntax. pub const EMPTY: FlagMeta<'static> = FlagMeta { complete: None, + complete_type: None, flag: &Flag::BOOL, help: None, long_help: None, @@ -561,12 +564,15 @@ pub struct ArgMeta<'a> { pub help_heading: Option<&'a str>, /// What answers for this argument when a shell asks. See [`FlagMeta::complete`]. pub complete: Option, + /// A built-in completion class such as `path` or `dir`. + pub complete_type: Option<&'a str>, } impl ArgMeta<'_> { /// Metadata for an argument with nothing declared, for struct update syntax. pub const EMPTY: ArgMeta<'static> = ArgMeta { complete: None, + complete_type: None, arg: &Arg::REQUIRED, help: None, long_help: None, @@ -785,6 +791,7 @@ fn write_body( ); write_arg(out, arg, depth)?; } + write_completion_types(out, meta, depth)?; #[cfg(feature = "complete")] write_completers(out, meta, bin, depth)?; for sub in meta.subcommands { @@ -793,6 +800,36 @@ fn write_body( Ok(()) } +/// Built-in completion types declared by this command, written in the spec's vocabulary. +fn write_completion_types( + out: &mut String, + meta: &CommandMeta<'_>, + depth: usize, +) -> core::fmt::Result { + for arg in meta.args { + if let Some(type_) = arg.complete_type { + indent(out, depth)?; + writeln!( + out, + "complete {} type={}", + quoted(&arg.arg.name.to_ascii_lowercase()), + quoted(type_) + )?; + } + } + for flag in meta.flags { + if let Some(type_) = flag.complete_type { + let name = flag + .value_name + .unwrap_or(flag.flag.name) + .to_ascii_lowercase(); + indent(out, depth)?; + writeln!(out, "complete {} type={}", quoted(&name), quoted(type_))?; + } + } + Ok(()) +} + fn write_command( out: &mut String, meta: &CommandMeta<'_>, diff --git a/conformance/tests/completion.rs b/conformance/tests/completion.rs index 988eaf086..ea18b0d1b 100644 --- a/conformance/tests/completion.rs +++ b/conformance/tests/completion.rs @@ -59,6 +59,49 @@ fn ask(shell: &str, line: &str) -> String { Ex::completion_request(&argv).expect("this is a completion request") } +#[derive(Cli)] +#[usage(bin = "hinted", completion)] +struct Hinted { + /// A file to read + #[usage(long, value_hint = usage_argv::ValueHint::FilePath)] + file: Option, + /// A directory to write + #[usage(long, value_hint = usage_argv::ValueHint::DirPath)] + dir: Option, +} + +fn ask_hinted(line: &str) -> String { + let argv: Vec = ["__complete_word__", "--shell", "bash", "--line", line] + .iter() + .map(OsString::from) + .collect(); + Hinted::completion_request(&argv).expect("this is a completion request") +} + +#[test] +fn usage_path_value_hints_reach_native_and_emitted_completions() { + assert_eq!( + ask_hinted("hinted --file "), + format!("{}\n", usage_argv::complete::FILES_MARKER) + ); + assert_eq!( + ask_hinted("hinted --dir "), + format!("{}\n", usage_argv::complete::DIRS_MARKER) + ); + + let kdl = Hinted::to_kdl(); + assert!(kdl.contains("complete \"file\" type=\"path\""), "{kdl}"); + assert!(kdl.contains("complete \"dir\" type=\"dir\""), "{kdl}"); + + let argv = [OsStr::new("--file"), OsStr::new("input.kdl")]; + let parsed = Hinted::parse_from(&argv).expect("the hinted flag still parses"); + assert_eq!( + parsed.file.as_deref(), + Some(std::path::Path::new("input.kdl")) + ); + assert!(parsed.dir.is_none()); +} + #[test] fn a_request_is_answered_from_the_same_tables_the_parse_uses() { assert_eq!(ask("bash", "ex "), "install\nrm\nrun\nuninstall\n"); diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index d72f07511..374af1784 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -802,6 +802,7 @@ fn flag_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { let env = option_str(field.env.as_deref()); let help_heading = option_str(field.help_heading.as_deref()); let value_name = option_str(field.value_name.as_deref()); + let complete_type = option_str(field.complete_type.as_deref()); let defaults = &field.default; let default = quote!(&[#(#defaults),*]); let hide = field.hide; @@ -836,6 +837,7 @@ fn flag_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { pub static #name: ::usage_argv::spec::FlagMeta = ::usage_argv::spec::FlagMeta { effect: #effect, complete: #completer, + complete_type: #complete_type, flag: &#table, help: #help, long_help: #long_help, @@ -867,6 +869,7 @@ fn arg_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { let long_help = option_str(field.long_help.as_deref()); let env = option_str(field.env.as_deref()); let help_heading = option_str(field.help_heading.as_deref()); + let complete_type = option_str(field.complete_type.as_deref()); let defaults = &field.default; let default = quote!(&[#(#defaults),*]); let hide = field.hide; @@ -882,6 +885,7 @@ fn arg_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { #completer_decl pub static #name: ::usage_argv::spec::ArgMeta = ::usage_argv::spec::ArgMeta { complete: #completer, + complete_type: #complete_type, arg: &#table, help: #help, long_help: #long_help, diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 8e7ab3e11..b6d99cb7b 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -208,6 +208,7 @@ //! | `double_dash = "…"` | how a positional relates to `--`: `optional` (the default), `required` (fillable only after one), `preserve` (the `--` is a value), `automatic` (filling it ends flag parsing, so a wrapper forwards) | //! | `complete = my_fn` | a function that answers for this value when a shell asks | //! | `value_enum` | the words come from the field's type, which derives [`ValueEnum`] | +//! | `value_hint = usage_argv::ValueHint::FilePath` | ask the shell for paths; `AnyPath` and `DirPath` are also supported | //! | `arg` | force a field to be positional | //! | `overrides = "--other"` | a flag this one displaces, the last given winning | //! | `conflicts = "--other"` | a flag this one cannot be given with | diff --git a/derive/src/model.rs b/derive/src/model.rs index 91cb47eec..8bbe8673a 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -163,6 +163,8 @@ pub struct Field { /// The counterpart of a spec's `run=`, and the source it is generated from: declaring the /// function is the only place a completer is said to exist. pub complete: Option, + /// A built-in completion class, in the spec's vocabulary (`path` or `dir`). + pub complete_type: Option, pub var_min: Option, pub var_max: Option, /// Flags this one displaces. Applied while parsing rather than after it: the @@ -243,6 +245,35 @@ fn effect_value(meta: &Meta) -> syn::Result { ))) } +/// usage's path-oriented `ValueHint`s lowered into the completion types the spec has. +fn value_hint(meta: &Meta) -> syn::Result { + let value = &meta.require_name_value()?.value; + let Expr::Path(path) = value else { + return Err(syn::Error::new_spanned( + value, + "`value_hint` takes a usage ValueHint variant, as in \ + `value_hint = usage_argv::ValueHint::FilePath`", + )); + }; + let Some(variant) = path.path.segments.last() else { + return Err(syn::Error::new_spanned( + value, + "`value_hint` needs a variant", + )); + }; + match variant.ident.to_string().as_str() { + "FilePath" | "AnyPath" => Ok("path".to_string()), + "DirPath" => Ok("dir".to_string()), + other => Err(syn::Error::new_spanned( + value, + format!( + "`ValueHint::{other}` has no usage completion type yet; supported hints are \ + `FilePath`, `AnyPath`, and `DirPath`" + ), + )), + } +} + /// Whether a field is a flag or a positional, and how it is addressed. pub enum Kind { Flag { @@ -887,6 +918,7 @@ impl Field { // place rather than a command. effect: None, complete: None, + complete_type: None, // A flattened field holds declarations, not a value, so none of what describes a // value applies — the same as a subcommand field. shape: Shape::Bool, @@ -979,6 +1011,7 @@ impl Field { kind: Kind::Subcommand { ty, optional }, effect: None, complete: None, + complete_type: None, // A subcommand field holds a command, not a value, so none of what // describes a value applies to it. shape: Shape::Bool, @@ -1048,6 +1081,7 @@ impl Field { let mut is_arg = false; let mut choices: Vec = Vec::new(); let mut complete: Option = None; + let mut complete_type: Option = None; let mut value_enum = false; let mut var_min: Option = None; let mut var_max: Option = None; @@ -1114,6 +1148,7 @@ impl Field { }; complete = Some(path.path.clone()); } + "value_hint" => complete_type = Some(value_hint(&meta)?), "choices" => { let Meta::List(list) = &meta else { return Err(syn::Error::new_spanned( @@ -1183,7 +1218,7 @@ impl Field { "unknown option `{other}`; a field takes `name`, `long`, \ `short`, `negate`, `global`, `var`, `variadic`, \ `count`, `hide`, `arg`, `env`, `default`, `choices`, \ - `var_min`, `var_max`, `value_enum`, `overrides`, \ + `var_min`, `var_max`, `value_enum`, `value_hint`, `overrides`, \ `conflicts`, `requires`, `required_if`, \ `required_unless`, `help_heading`, `value_name`, \ `verbatim_doc_comment`, \ @@ -1340,6 +1375,18 @@ impl Field { "a `bool` or counting field has no value to check against `choices`", )); } + if complete_type.is_some() && matches!(shape, Shape::Bool | Shape::Count) { + return Err(syn::Error::new( + span, + "`value_hint` describes a value to complete, and this field takes no value", + )); + } + if complete_type.is_some() && complete.is_some() { + return Err(syn::Error::new( + span, + "`value_hint` and `complete` both answer completion for this value; use one", + )); + } if let (Some(min), Some(max)) = (var_min, var_max) { if min > max { return Err(syn::Error::new( @@ -1689,6 +1736,7 @@ impl Field { required_collection, choices, complete, + complete_type, value_enum, var_min, var_max,