Repository: clap-rs/clap
Pull request #5691 (2024-08-21 19:03:00)

feat(complete): Provide ArgValueCompleter
This allows us to migrate away from `ValueHint`, as shown by `PathCompleter`


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 50ddf8d0..14e3b9d8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -482,7 +482,6 @@ dependencies = [
  "completest",
  "completest-pty",
  "is_executable",
- "pathdiff",
  "shlex",
  "snapbox",
  "trycmd",
diff --git a/clap_complete/Cargo.toml b/clap_complete/Cargo.toml
index cef7ec1d..091a431c 100644
--- a/clap_complete/Cargo.toml
+++ b/clap_complete/Cargo.toml
@@ -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]
diff --git a/clap_complete/src/command/mod.rs b/clap_complete/src/command/mod.rs
index b7fbbf5b..ed3ebeab 100644
--- a/clap_complete/src/command/mod.rs
+++ b/clap_complete/src/command/mod.rs
@@ -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.
diff --git a/clap_complete/src/engine/candidate.rs b/clap_complete/src/engine/candidate.rs
index 50f8b33a..ed0d0df1 100644
--- a/clap_complete/src/engine/candidate.rs
+++ b/clap_complete/src/engine/candidate.rs
@@ -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())
+    }
+}
diff --git a/clap_complete/src/engine/complete.rs b/clap_complete/src/engine/complete.rs
index 0c90b45c..24fc9cac 100644
--- a/clap_complete/src/engine/complete.rs
+++ b/clap_complete/src/engine/complete.rs
@@ -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
 }
 
diff --git a/clap_complete/src/engine/custom.rs b/clap_complete/src/engine/custom.rs
index b01de7c7..10c15f38 100644
--- a/clap_complete/src/engine/custom.rs
+++ b/clap_complete/src/engine/custom.rs
@@ -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()
+}
diff --git a/clap_complete/src/engine/mod.rs b/clap_complete/src/engine/mod.rs
index 937577eb..43a1f9f0 100644
--- a/clap_complete/src/engine/mod.rs
+++ b/clap_complete/src/engine/mod.rs
@@ -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;
diff --git a/clap_complete/src/env/mod.rs b/clap_complete/src/env/mod.rs
index 746549ab..69e0b1d7 100644
--- a/clap_complete/src/env/mod.rs
+++ b/clap_complete/src/env/mod.rs
@@ -20,6 +20,7 @@
 //! - [`ValueHint`][crate::ValueHint]
 //! - [`ValueEnum`][clap::ValueEnum]
 //! - [`ArgValueCandidates`][crate::ArgValueCandidates]
+//! - [`ArgValueCompleter`][crate::ArgValueCompleter]
 //!
 //! To source your completions:
 //!
diff --git a/clap_complete/src/lib.rs b/clap_complete/src/lib.rs
index 5e20c8a3..099ed692 100644
--- a/clap_complete/src/lib.rs
+++ b/clap_complete/src/lib.rs
@@ -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`]


Test patch:
diff --git a/clap_complete/tests/testsuite/engine.rs b/clap_complete/tests/testsuite/engine.rs
index 0fea4fb6..56410a22 100644
--- a/clap_complete/tests/testsuite/engine.rs
+++ b/clap_complete/tests/testsuite/engine.rs
@@ -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]
@@ -591,35 +542,155 @@ val3
     );
 }
 
+#[test]
+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);
+
+    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() {
-    #[derive(Debug)]
-    struct MyCustomCompleter {}
-
-    impl ValueCandidates for MyCustomCompleter {
-        fn candidates(&self) -> Vec<CompletionCandidate> {
-            vec![
-                CompletionCandidate::new("custom1"),
-                CompletionCandidate::new("custom2"),
-                CompletionCandidate::new("custom3"),
-            ]
-        }
+    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]