Merge pull request #5691 from epage/custom

feat(complete): Provide ArgValueCompleter
This commit is contained in:
Ed Page 2024-08-21 14:34:26 -05:00 committed by GitHub
commit 3266c36abf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 432 additions and 151 deletions

1
Cargo.lock generated
View file

@ -482,7 +482,6 @@ dependencies = [
"completest",
"completest-pty",
"is_executable",
"pathdiff",
"shlex",
"snapbox",
"trycmd",

View file

@ -37,7 +37,6 @@ bench = false
clap = { path = "../", version = "4.5.15", default-features = false, features = ["std"] }
clap_lex = { path = "../clap_lex", version = "0.7.0", optional = true }
is_executable = { version = "1.0.1", optional = true }
pathdiff = { version = "0.2.1", optional = true }
shlex = { version = "1.1.0", optional = true }
unicode-xid = { version = "0.2.2", optional = true }
@ -57,8 +56,8 @@ required-features = ["unstable-dynamic", "unstable-command"]
[features]
default = []
unstable-doc = ["unstable-dynamic", "unstable-command"] # for docs.rs
unstable-dynamic = ["dep:clap_lex", "dep:shlex", "dep:is_executable", "dep:pathdiff", "clap/unstable-ext"]
unstable-command = ["unstable-dynamic", "dep:unicode-xid", "clap/derive", "dep:is_executable", "dep:pathdiff", "clap/unstable-ext"]
unstable-dynamic = ["dep:clap_lex", "dep:shlex", "dep:is_executable", "clap/unstable-ext"]
unstable-command = ["unstable-dynamic", "dep:unicode-xid", "clap/derive", "dep:is_executable", "clap/unstable-ext"]
debug = ["clap/debug"]
[lints]

View file

@ -50,6 +50,7 @@ pub use shells::*;
/// - [`ValueHint`][crate::ValueHint]
/// - [`ValueEnum`][clap::ValueEnum]
/// - [`ArgValueCandidates`][crate::ArgValueCandidates]
/// - [`ArgValueCompleter`][crate::ArgValueCompleter]
///
/// **Warning:** `stdout` should not be written to before [`CompleteCommand::complete`] has had a
/// chance to run.
@ -122,6 +123,7 @@ impl CompleteCommand {
/// - [`ValueHint`][crate::ValueHint]
/// - [`ValueEnum`][clap::ValueEnum]
/// - [`ArgValueCandidates`][crate::ArgValueCandidates]
/// - [`ArgValueCompleter`][crate::ArgValueCompleter]
///
/// **Warning:** `stdout` should not be written to before [`CompleteArgs::complete`] has had a
/// chance to run.

View file

@ -65,3 +65,9 @@ impl CompletionCandidate {
self.hidden
}
}
impl<S: Into<OsString>> From<S> for CompletionCandidate {
fn from(s: S) -> Self {
CompletionCandidate::new(s.into())
}
}

View file

@ -3,7 +3,9 @@ use std::ffi::OsString;
use clap_lex::OsStrExt as _;
use super::custom::complete_path;
use super::ArgValueCandidates;
use super::ArgValueCompleter;
use super::CompletionCandidate;
/// Complete the given command, shell-agnostic
@ -270,7 +272,9 @@ fn complete_arg_value(
Err(value_os) => value_os,
};
if let Some(completer) = arg.get::<ArgValueCandidates>() {
if let Some(completer) = arg.get::<ArgValueCompleter>() {
values.extend(completer.complete(value_os));
} else if let Some(completer) = arg.get::<ArgValueCandidates>() {
values.extend(complete_custom_arg_value(value_os, completer));
} else if let Some(possible_values) = possible_values(arg) {
if let Ok(value) = value {
@ -289,17 +293,17 @@ fn complete_arg_value(
// Should not complete
}
clap::ValueHint::Unknown | clap::ValueHint::AnyPath => {
values.extend(complete_path(value_os, current_dir, |_| true));
values.extend(complete_path(value_os, current_dir, &|_| true));
}
clap::ValueHint::FilePath => {
values.extend(complete_path(value_os, current_dir, |p| p.is_file()));
values.extend(complete_path(value_os, current_dir, &|p| p.is_file()));
}
clap::ValueHint::DirPath => {
values.extend(complete_path(value_os, current_dir, |p| p.is_dir()));
values.extend(complete_path(value_os, current_dir, &|p| p.is_dir()));
}
clap::ValueHint::ExecutablePath => {
use is_executable::IsExecutable;
values.extend(complete_path(value_os, current_dir, |p| p.is_executable()));
values.extend(complete_path(value_os, current_dir, &|p| p.is_executable()));
}
clap::ValueHint::CommandName
| clap::ValueHint::CommandString
@ -312,7 +316,7 @@ fn complete_arg_value(
}
_ => {
// Safe-ish fallback
values.extend(complete_path(value_os, current_dir, |_| true));
values.extend(complete_path(value_os, current_dir, &|_| true));
}
}
@ -341,69 +345,14 @@ fn rsplit_delimiter<'s, 'o>(
Some((Some(prefix), Ok(value)))
}
fn complete_path(
value_os: &OsStr,
current_dir: Option<&std::path::Path>,
is_wanted: impl Fn(&std::path::Path) -> bool,
) -> Vec<CompletionCandidate> {
let mut completions = Vec::new();
let current_dir = match current_dir {
Some(current_dir) => current_dir,
None => {
// Can't complete without a `current_dir`
return Vec::new();
}
};
let (existing, prefix) = value_os
.split_once("\\")
.unwrap_or((OsStr::new(""), value_os));
let root = current_dir.join(existing);
debug!("complete_path: root={root:?}, prefix={prefix:?}");
let prefix = prefix.to_string_lossy();
for entry in std::fs::read_dir(&root)
.ok()
.into_iter()
.flatten()
.filter_map(Result::ok)
{
let raw_file_name = entry.file_name();
if !raw_file_name.starts_with(&prefix) {
continue;
}
if entry.metadata().map(|m| m.is_dir()).unwrap_or(false) {
let path = entry.path();
let mut suggestion = pathdiff::diff_paths(&path, current_dir).unwrap_or(path);
suggestion.push(""); // Ensure trailing `/`
completions
.push(CompletionCandidate::new(suggestion.as_os_str().to_owned()).help(None));
} else {
let path = entry.path();
if is_wanted(&path) {
let suggestion = pathdiff::diff_paths(&path, current_dir).unwrap_or(path);
completions
.push(CompletionCandidate::new(suggestion.as_os_str().to_owned()).help(None));
}
}
}
completions
}
fn complete_custom_arg_value(
value: &OsStr,
completer: &ArgValueCandidates,
) -> Vec<CompletionCandidate> {
debug!("complete_custom_arg_value: completer={completer:?}, value={value:?}");
let mut values = Vec::new();
let custom_arg_values = completer.candidates();
values.extend(custom_arg_values);
let mut values = completer.candidates();
values.retain(|comp| comp.get_content().starts_with(&value.to_string_lossy()));
values
}

View file

@ -1,7 +1,9 @@
use std::any::type_name;
use std::ffi::OsStr;
use std::sync::Arc;
use clap::builder::ArgExt;
use clap_lex::OsStrExt as _;
use super::CompletionCandidate;
@ -68,3 +70,248 @@ where
self()
}
}
/// Extend [`Arg`][clap::Arg] with a completer
///
/// # Example
///
/// ```rust
/// use clap::Parser;
/// use clap_complete::engine::{ArgValueCompleter, CompletionCandidate};
///
/// fn custom_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
/// let mut completions = vec![];
/// let Some(current) = current.to_str() else {
/// return completions;
/// };
///
/// if "foo".starts_with(current) {
/// completions.push(CompletionCandidate::new("foo"));
/// }
/// if "bar".starts_with(current) {
/// completions.push(CompletionCandidate::new("bar"));
/// }
/// if "baz".starts_with(current) {
/// completions.push(CompletionCandidate::new("baz"));
/// }
/// completions
/// }
///
/// #[derive(Debug, Parser)]
/// struct Cli {
/// #[arg(long, add = ArgValueCompleter::new(custom_completer))]
/// custom: Option<String>,
/// }
/// ```
#[derive(Clone)]
pub struct ArgValueCompleter(Arc<dyn ValueCompleter>);
impl ArgValueCompleter {
/// Create a new `ArgValueCompleter` with a custom completer
pub fn new<C>(completer: C) -> Self
where
C: ValueCompleter + 'static,
{
Self(Arc::new(completer))
}
/// Candidates that match `current`
///
/// See [`CompletionCandidate`] for more information.
pub fn complete(&self, current: &OsStr) -> Vec<CompletionCandidate> {
self.0.complete(current)
}
}
impl std::fmt::Debug for ArgValueCompleter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(type_name::<Self>())
}
}
impl ArgExt for ArgValueCompleter {}
/// User-provided completion candidates for an [`Arg`][clap::Arg], see [`ArgValueCompleter`]
///
/// This is useful when predefined value hints are not enough.
pub trait ValueCompleter: Send + Sync {
/// All potential candidates for an argument.
///
/// See [`CompletionCandidate`] for more information.
fn complete(&self, current: &OsStr) -> Vec<CompletionCandidate>;
}
impl<F> ValueCompleter for F
where
F: Fn(&OsStr) -> Vec<CompletionCandidate> + Send + Sync,
{
fn complete(&self, current: &OsStr) -> Vec<CompletionCandidate> {
self(current)
}
}
/// Complete a value as a [`std::path::Path`]
///
/// # Example
///
/// ```rust
/// use clap::Parser;
/// use clap_complete::engine::{ArgValueCompleter, PathCompleter};
///
/// #[derive(Debug, Parser)]
/// struct Cli {
/// #[arg(long, add = ArgValueCompleter::new(PathCompleter::file()))]
/// custom: Option<String>,
/// }
/// ```
pub struct PathCompleter {
current_dir: Option<std::path::PathBuf>,
filter: Option<Box<dyn Fn(&std::path::Path) -> bool + Send + Sync>>,
stdio: bool,
}
impl PathCompleter {
/// Any path is allowed
pub fn any() -> Self {
Self {
filter: None,
current_dir: None,
stdio: false,
}
}
/// Complete only files
pub fn file() -> Self {
Self::any().filter(|p| p.is_file())
}
/// Complete only directories
pub fn dir() -> Self {
Self::any().filter(|p| p.is_dir())
}
/// Include stdio (`-`)
pub fn stdio(mut self) -> Self {
self.stdio = true;
self
}
/// Select which paths should be completed
pub fn filter(
mut self,
filter: impl Fn(&std::path::Path) -> bool + Send + Sync + 'static,
) -> Self {
self.filter = Some(Box::new(filter));
self
}
/// Override [`std::env::current_dir`]
pub fn current_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.current_dir = Some(path.into());
self
}
}
impl Default for PathCompleter {
fn default() -> Self {
Self::any()
}
}
impl ValueCompleter for PathCompleter {
fn complete(&self, current: &OsStr) -> Vec<CompletionCandidate> {
let filter = self.filter.as_deref().unwrap_or(&|_| true);
let mut current_dir_actual = None;
let current_dir = self.current_dir.as_deref().or_else(|| {
current_dir_actual = std::env::current_dir().ok();
current_dir_actual.as_deref()
});
let mut candidates = complete_path(current, current_dir, filter);
if self.stdio && current.is_empty() {
candidates.push(CompletionCandidate::new("-").help(Some("stdio".into())));
}
candidates
}
}
pub(crate) fn complete_path(
value_os: &OsStr,
current_dir: Option<&std::path::Path>,
is_wanted: &dyn Fn(&std::path::Path) -> bool,
) -> Vec<CompletionCandidate> {
let mut completions = Vec::new();
let mut potential = Vec::new();
let value_path = std::path::Path::new(value_os);
let (prefix, current) = split_file_name(value_path);
let current = current.to_string_lossy();
let search_root = if prefix.is_absolute() {
prefix.to_owned()
} else {
let current_dir = match current_dir {
Some(current_dir) => current_dir,
None => {
// Can't complete without a `current_dir`
return completions;
}
};
current_dir.join(prefix)
};
debug!("complete_path: search_root={search_root:?}, prefix={prefix:?}");
for entry in std::fs::read_dir(&search_root)
.ok()
.into_iter()
.flatten()
.filter_map(Result::ok)
{
let raw_file_name = entry.file_name();
if !raw_file_name.starts_with(&current) {
continue;
}
if entry.metadata().map(|m| m.is_dir()).unwrap_or(false) {
let mut suggestion = prefix.join(raw_file_name);
suggestion.push(""); // Ensure trailing `/`
let candidate = CompletionCandidate::new(suggestion.as_os_str().to_owned());
if is_wanted(&entry.path()) {
completions.push(candidate);
} else {
potential.push(candidate);
}
} else {
if is_wanted(&entry.path()) {
let suggestion = prefix.join(raw_file_name);
let candidate = CompletionCandidate::new(suggestion.as_os_str().to_owned());
completions.push(candidate);
}
}
}
completions.sort();
potential.sort();
completions.extend(potential);
completions
}
fn split_file_name(path: &std::path::Path) -> (&std::path::Path, &OsStr) {
// Workaround that `Path::new("name/").file_name()` reports `"name"`
if path_has_name(path) {
(
path.parent().unwrap_or_else(|| std::path::Path::new("")),
path.file_name().expect("not called with `..`"),
)
} else {
(path, Default::default())
}
}
fn path_has_name(path: &std::path::Path) -> bool {
let path_bytes = path.as_os_str().as_encoded_bytes();
let Some(trailing) = path_bytes.last() else {
return false;
};
let trailing = *trailing as char;
!std::path::is_separator(trailing) && path.file_name().is_some()
}

View file

@ -9,4 +9,7 @@ mod custom;
pub use candidate::CompletionCandidate;
pub use complete::complete;
pub use custom::ArgValueCandidates;
pub use custom::ArgValueCompleter;
pub use custom::PathCompleter;
pub use custom::ValueCandidates;
pub use custom::ValueCompleter;

View file

@ -20,6 +20,7 @@
//! - [`ValueHint`][crate::ValueHint]
//! - [`ValueEnum`][clap::ValueEnum]
//! - [`ArgValueCandidates`][crate::ArgValueCandidates]
//! - [`ArgValueCompleter`][crate::ArgValueCompleter]
//!
//! To source your completions:
//!

View file

@ -81,10 +81,14 @@ pub use command::CompleteCommand;
#[doc(inline)]
#[cfg(feature = "unstable-dynamic")]
pub use engine::ArgValueCandidates;
#[cfg(feature = "unstable-dynamic")]
pub use engine::ArgValueCompleter;
#[doc(inline)]
#[cfg(feature = "unstable-dynamic")]
pub use engine::CompletionCandidate;
#[cfg(feature = "unstable-dynamic")]
pub use engine::PathCompleter;
#[cfg(feature = "unstable-dynamic")]
pub use env::CompleteEnv;
/// Deprecated, see [`aot`]

View file

@ -4,7 +4,9 @@ use std::fs;
use std::path::Path;
use clap::{builder::PossibleValue, Command};
use clap_complete::engine::{ArgValueCandidates, CompletionCandidate, ValueCandidates};
use clap_complete::engine::{
ArgValueCandidates, ArgValueCompleter, CompletionCandidate, PathCompleter,
};
use snapbox::assert_data_eq;
macro_rules! complete {
@ -293,18 +295,18 @@ goodbye-world
#[test]
fn suggest_argument_value() {
let mut cmd = Command::new("dynamic")
.arg(
clap::Arg::new("input")
.long("input")
.short('i')
.value_hint(clap::ValueHint::FilePath),
)
.arg(
clap::Arg::new("format")
.long("format")
.short('F')
.value_parser(["json", "yaml", "toml"]),
)
.arg(
clap::Arg::new("stream")
.long("stream")
.short('S')
.value_parser(["stdout", "stderr"]),
)
.arg(
clap::Arg::new("count")
.long("count")
@ -314,44 +316,6 @@ fn suggest_argument_value() {
.arg(clap::Arg::new("positional").value_parser(["pos_a", "pos_b", "pos_c"]))
.args_conflicts_with_subcommands(true);
let testdir = snapbox::dir::DirRoot::mutable_temp().unwrap();
let testdir_path = testdir.path().unwrap();
fs::write(testdir_path.join("a_file"), "").unwrap();
fs::write(testdir_path.join("b_file"), "").unwrap();
fs::create_dir_all(testdir_path.join("c_dir")).unwrap();
fs::create_dir_all(testdir_path.join("d_dir")).unwrap();
assert_data_eq!(
complete!(cmd, "--input [TAB]", current_dir = Some(testdir_path)),
snapbox::str![[r#"
a_file
b_file
c_dir/
d_dir/
"#]],
);
assert_data_eq!(
complete!(cmd, "-i [TAB]", current_dir = Some(testdir_path)),
snapbox::str![[r#"
a_file
b_file
c_dir/
d_dir/
"#]],
);
assert_data_eq!(
complete!(cmd, "--input a[TAB]", current_dir = Some(testdir_path)),
snapbox::str!["a_file"],
);
assert_data_eq!(
complete!(cmd, "-i b[TAB]", current_dir = Some(testdir_path)),
snapbox::str!["b_file"],
);
assert_data_eq!(
complete!(cmd, "--format [TAB]"),
snapbox::str![[r#"
@ -388,14 +352,14 @@ toml
);
assert_data_eq!(
complete!(cmd, "--input a_file [TAB]"),
complete!(cmd, "--format toml [TAB]"),
snapbox::str![[r#"
--input
--format
--stream
--count
--help Print help
-i
-F
-S
-c
-h Print help
pos_a
@ -405,39 +369,26 @@ pos_c
);
assert_data_eq!(
complete!(cmd, "-ci[TAB]", current_dir = Some(testdir_path)),
complete!(cmd, "-cS[TAB]"),
snapbox::str![[r#"
-cia_file
-cib_file
-cic_dir/
-cid_dir/
-cSstdout
-cSstderr
"#]]
);
assert_data_eq!(
complete!(cmd, "-ci=[TAB]", current_dir = Some(testdir_path)),
complete!(cmd, "-cS=[TAB]"),
snapbox::str![[r#"
-ci=a_file
-ci=b_file
-ci=c_dir/
-ci=d_dir/
-cS=stdout
-cS=stderr
"#]]
);
assert_data_eq!(
complete!(cmd, "-ci=a[TAB]", current_dir = Some(testdir_path)),
snapbox::str!["-ci=a_file"]
);
assert_data_eq!(complete!(cmd, "-cS=stdo[TAB]"), snapbox::str!["-cS=stdout"]);
assert_data_eq!(
complete!(cmd, "-ciF[TAB]", current_dir = Some(testdir_path)),
snapbox::str![]
);
assert_data_eq!(complete!(cmd, "-cSF[TAB]"), snapbox::str![]);
assert_data_eq!(
complete!(cmd, "-ciF=[TAB]", current_dir = Some(testdir_path)),
snapbox::str![]
);
assert_data_eq!(complete!(cmd, "-cSF=[TAB]"), snapbox::str![]);
}
#[test]
@ -592,34 +543,154 @@ val3
}
#[test]
fn suggest_custom_arg_value() {
#[derive(Debug)]
struct MyCustomCompleter {}
fn suggest_value_hint_file_path() {
let mut cmd = Command::new("dynamic")
.arg(
clap::Arg::new("input")
.long("input")
.short('i')
.value_hint(clap::ValueHint::FilePath),
)
.args_conflicts_with_subcommands(true);
impl ValueCandidates for MyCustomCompleter {
fn candidates(&self) -> Vec<CompletionCandidate> {
vec![
CompletionCandidate::new("custom1"),
CompletionCandidate::new("custom2"),
CompletionCandidate::new("custom3"),
]
}
let testdir = snapbox::dir::DirRoot::mutable_temp().unwrap();
let testdir_path = testdir.path().unwrap();
fs::write(testdir_path.join("a_file"), "").unwrap();
fs::write(testdir_path.join("b_file"), "").unwrap();
fs::create_dir_all(testdir_path.join("c_dir")).unwrap();
fs::create_dir_all(testdir_path.join("d_dir")).unwrap();
assert_data_eq!(
complete!(cmd, "--input [TAB]", current_dir = Some(testdir_path)),
snapbox::str![[r#"
a_file
b_file
c_dir/
d_dir/
"#]],
);
assert_data_eq!(
complete!(cmd, "--input a[TAB]", current_dir = Some(testdir_path)),
snapbox::str!["a_file"],
);
}
#[test]
fn suggest_value_path_file() {
let testdir = snapbox::dir::DirRoot::mutable_temp().unwrap();
let testdir_path = testdir.path().unwrap();
fs::write(testdir_path.join("a_file"), "").unwrap();
fs::write(testdir_path.join("b_file"), "").unwrap();
fs::create_dir_all(testdir_path.join("c_dir")).unwrap();
fs::create_dir_all(testdir_path.join("d_dir")).unwrap();
let mut cmd = Command::new("dynamic")
.arg(
clap::Arg::new("input")
.long("input")
.short('i')
.add(ArgValueCompleter::new(
PathCompleter::file()
.stdio()
.current_dir(testdir_path.to_owned()),
)),
)
.args_conflicts_with_subcommands(true);
assert_data_eq!(
complete!(cmd, "--input [TAB]", current_dir = Some(testdir_path)),
snapbox::str![[r#"
a_file
b_file
c_dir/
d_dir/
- stdio
"#]],
);
assert_data_eq!(
complete!(cmd, "--input a[TAB]", current_dir = Some(testdir_path)),
snapbox::str!["a_file"],
);
}
#[test]
fn suggest_custom_arg_value() {
fn custom_completer() -> Vec<CompletionCandidate> {
vec![
CompletionCandidate::new("foo"),
CompletionCandidate::new("bar"),
CompletionCandidate::new("baz"),
]
}
let mut cmd = Command::new("dynamic").arg(
clap::Arg::new("custom")
.long("custom")
.add::<ArgValueCandidates>(ArgValueCandidates::new(MyCustomCompleter {})),
.add(ArgValueCandidates::new(custom_completer)),
);
assert_data_eq!(
complete!(cmd, "--custom [TAB]"),
snapbox::str![[r#"
custom1
custom2
custom3
foo
bar
baz
"#]],
);
assert_data_eq!(
complete!(cmd, "--custom b[TAB]"),
snapbox::str![[r#"
bar
baz
"#]],
);
}
#[test]
fn suggest_custom_arg_completer() {
fn custom_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
let mut completions = vec![];
let Some(current) = current.to_str() else {
return completions;
};
if "foo".starts_with(current) {
completions.push(CompletionCandidate::new("foo"));
}
if "bar".starts_with(current) {
completions.push(CompletionCandidate::new("bar"));
}
if "baz".starts_with(current) {
completions.push(CompletionCandidate::new("baz"));
}
completions
}
let mut cmd = Command::new("dynamic").arg(
clap::Arg::new("custom")
.long("custom")
.add(ArgValueCompleter::new(custom_completer)),
);
assert_data_eq!(
complete!(cmd, "--custom [TAB]"),
snapbox::str![[r#"
foo
bar
baz
"#]]
);
assert_data_eq!(
complete!(cmd, "--custom b[TAB]"),
snapbox::str![[r#"
bar
baz
"#]]
);
}
#[test]