Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions argv/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
37 changes: 37 additions & 0 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Completer>,
/// 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Completer>,
/// 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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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<'_>,
Expand Down
43 changes: 43 additions & 0 deletions conformance/tests/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf>,
/// A directory to write
#[usage(long, value_hint = usage_argv::ValueHint::DirPath)]
dir: Option<std::path::PathBuf>,
}

fn ask_hinted(line: &str) -> String {
let argv: Vec<OsString> = ["__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");
Expand Down
4 changes: 4 additions & 0 deletions derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
50 changes: 49 additions & 1 deletion derive/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<syn::Path>,
/// A built-in completion class, in the spec's vocabulary (`path` or `dir`).
pub complete_type: Option<String>,
pub var_min: Option<usize>,
pub var_max: Option<usize>,
/// Flags this one displaces. Applied while parsing rather than after it: the
Expand Down Expand Up @@ -243,6 +245,35 @@ fn effect_value(meta: &Meta) -> syn::Result<proc_macro2::TokenStream> {
)))
}

/// usage's path-oriented `ValueHint`s lowered into the completion types the spec has.
fn value_hint(meta: &Meta) -> syn::Result<String> {
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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1048,6 +1081,7 @@ impl Field {
let mut is_arg = false;
let mut choices: Vec<String> = Vec::new();
let mut complete: Option<syn::Path> = None;
let mut complete_type: Option<String> = None;
let mut value_enum = false;
let mut var_min: Option<usize> = None;
let mut var_max: Option<usize> = None;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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`, \
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1689,6 +1736,7 @@ impl Field {
required_collection,
choices,
complete,
complete_type,
value_enum,
var_min,
var_max,
Expand Down
Loading