Repository: clap-rs/clap
Pull request #3656 (2022-04-22 21:52:37)

feat(complete): Skeleton for Rust-driven completions
This implements the rudiments of completions within Rust.

What this can do for Bash
- Nest into subcommands
- Provide single-value positional value completions
  - `PossibleValues`
  - Some of `ValueHint`
- Provide short and long flag completions
- Provide subcommand completions

What this does not do
- Flag values
- Multiple values per argument
- Removing options based on conflicts
- Handle all of the random App and Arg settings
- Correctly append spaces when needed
- Shells other than Bash

Part of #3166


Patch:
diff --git a/clap_complete/Cargo.toml b/clap_complete/Cargo.toml
index 02dcaac3..66913076 100644
--- a/clap_complete/Cargo.toml
+++ b/clap_complete/Cargo.toml
@@ -36,14 +36,27 @@ bench = false
 
 [dependencies]
 clap = { path = "../", version = "3.1.10", default-features = false, features = ["std"] }
+clap_lex = { path = "../clap_lex", version = "0.1.0", optional = true }
+is_executable = { version = "1.0.1", optional = true }
+os_str_bytes = { version = "6.0", default-features = false, features = ["raw_os_str"], optional = true }
+pathdiff = { version = "0.2.1", optional = true }
+shlex = { version = "1.1.0", optional = true }
+unicode-xid = { version = "0.2.2", optional = true }
 
 [dev-dependencies]
 pretty_assertions = "1.0"
 snapbox = { version = "0.2", features = ["diff"] }
+# Cutting out `filesystem` feature
+trycmd = { version = "0.13", default-features = false, features = ["color-auto", "diff", "examples"] }
 clap = { path = "../", version = "3.1.10", default-features = false, features = ["std", "derive"] }
 
+[[example]]
+name = "dynamic"
+required-features = ["unstable-dynamic"]
+
 [features]
 default = []
+unstable-dynamic = ["clap_lex", "shlex", "unicode-xid", "os_str_bytes", "clap/derive", "is_executable", "pathdiff"]
 debug = ["clap/debug"]
 
 [package.metadata.docs.rs]
diff --git a/clap_complete/examples/bash_completion.rs b/clap_complete/examples/bash_completion.rs
deleted file mode 100644
index d7c71da5..00000000
--- a/clap_complete/examples/bash_completion.rs
+++ /dev/null
@@ -1,11 +0,0 @@
-use clap::Command;
-use clap_complete::{generate, shells::Bash};
-use std::io;
-
-fn main() {
-    let mut cmd = Command::new("myapp")
-        .subcommand(Command::new("test").subcommand(Command::new("config")))
-        .subcommand(Command::new("hello"));
-
-    generate(Bash, &mut cmd, "myapp", &mut io::stdout());
-}
diff --git a/clap_complete/examples/value_hints_derive.rs b/clap_complete/examples/completion-derive.rs
similarity index 100%
rename from clap_complete/examples/value_hints_derive.rs
rename to clap_complete/examples/completion-derive.rs
diff --git a/clap_complete/examples/value_hints.rs b/clap_complete/examples/completion.rs
similarity index 100%
rename from clap_complete/examples/value_hints.rs
rename to clap_complete/examples/completion.rs
diff --git a/clap_complete/examples/dynamic.rs b/clap_complete/examples/dynamic.rs
new file mode 100644
index 00000000..5b945cf0
--- /dev/null
+++ b/clap_complete/examples/dynamic.rs
@@ -0,0 +1,39 @@
+use clap::FromArgMatches;
+use clap::Subcommand;
+
+fn command() -> clap::Command<'static> {
+    let cmd = clap::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')
+                .possible_values(["json", "yaml", "toml"]),
+        )
+        .args_conflicts_with_subcommands(true);
+    let cmd = clap_complete::dynamic::bash::CompleteCommand::augment_subcommands(cmd);
+    cmd
+}
+
+fn main() {
+    let cmd = command();
+    let matches = cmd.get_matches();
+    if let Ok(completions) =
+        clap_complete::dynamic::bash::CompleteCommand::from_arg_matches(&matches)
+    {
+        completions.complete(&mut command());
+    } else {
+        println!("{:#?}", matches);
+    }
+}
+
+#[test]
+fn verify_cli() {
+    use clap::CommandFactory;
+    command().command().debug_assert();
+}
diff --git a/clap_complete/src/dynamic.rs b/clap_complete/src/dynamic.rs
new file mode 100644
index 00000000..a8efd161
--- /dev/null
+++ b/clap_complete/src/dynamic.rs
@@ -0,0 +1,554 @@
+//! Complete commands within shells
+
+/// Complete commands within bash
+pub mod bash {
+    use std::ffi::OsString;
+    use std::io::Write;
+
+    use unicode_xid::UnicodeXID;
+
+    #[derive(clap::Subcommand)]
+    #[clap(hide = true)]
+    #[allow(missing_docs)]
+    #[derive(Clone, Debug)]
+    pub enum CompleteCommand {
+        /// Register shell completions for this program
+        Complete(CompleteArgs),
+    }
+
+    #[derive(clap::Args)]
+    #[clap(group = clap::ArgGroup::new("complete").multiple(true).conflicts_with("register"))]
+    #[allow(missing_docs)]
+    #[derive(Clone, Debug)]
+    pub struct CompleteArgs {
+        /// Path to write completion-registration to
+        #[clap(long, required = true, parse(from_os_str))]
+        register: Option<std::path::PathBuf>,
+
+        #[clap(
+            long,
+            required = true,
+            value_name = "COMP_CWORD",
+            hide_short_help = true,
+            group = "complete"
+        )]
+        index: Option<usize>,
+
+        #[clap(long, hide_short_help = true, group = "complete")]
+        ifs: Option<String>,
+
+        #[clap(
+            long = "type",
+            required = true,
+            arg_enum,
+            hide_short_help = true,
+            group = "complete"
+        )]
+        comp_type: Option<CompType>,
+
+        #[clap(long, hide_short_help = true, group = "complete")]
+        space: bool,
+
+        #[clap(
+            long,
+            conflicts_with = "space",
+            hide_short_help = true,
+            group = "complete"
+        )]
+        no_space: bool,
+
+        #[clap(raw = true, hide_short_help = true, group = "complete")]
+        comp_words: Vec<OsString>,
+    }
+
+    impl CompleteCommand {
+        /// Process the completion request
+        pub fn complete(&self, cmd: &mut clap::Command) -> std::convert::Infallible {
+            self.try_complete(cmd).unwrap_or_else(|e| e.exit());
+            std::process::exit(0)
+        }
+
+        /// Process the completion request
+        pub fn try_complete(&self, cmd: &mut clap::Command) -> clap::Result<()> {
+            debug!("CompleteCommand::try_complete: {:?}", self);
+            let CompleteCommand::Complete(args) = self;
+            if let Some(out_path) = args.register.as_deref() {
+                let mut buf = Vec::new();
+                let name = cmd.get_name();
+                let bin = cmd.get_bin_name().unwrap_or(cmd.get_name());
+                register(name, [bin], bin, &Behavior::default(), &mut buf)?;
+                if out_path == std::path::Path::new("-") {
+                    std::io::stdout().write(&buf)?;
+                } else {
+                    if out_path.is_dir() {
+                        let out_path = out_path.join(file_name(name));
+                        std::fs::write(out_path, buf)?;
+                    } else {
+                        std::fs::write(out_path, buf)?;
+                    }
+                }
+            } else {
+                let index = args.index.unwrap_or_default();
+                let comp_type = args.comp_type.unwrap_or_default();
+                let space = match (args.space, args.no_space) {
+                    (true, false) => Some(true),
+                    (false, true) => Some(false),
+                    (true, true) => {
+                        unreachable!("`--space` and `--no-space` set, clap should prevent this")
+                    }
+                    (false, false) => None,
+                }
+                .unwrap();
+                let current_dir = std::env::current_dir().ok();
+                let completions = complete(
+                    cmd,
+                    args.comp_words.clone(),
+                    index,
+                    comp_type,
+                    space,
+                    current_dir.as_deref(),
+                )?;
+
+                let mut buf = Vec::new();
+                for (i, completion) in completions.iter().enumerate() {
+                    if i != 0 {
+                        write!(&mut buf, "{}", args.ifs.as_deref().unwrap_or("\n"))?;
+                    }
+                    write!(&mut buf, "{}", completion.to_string_lossy())?;
+                }
+                std::io::stdout().write(&buf)?;
+            }
+
+            Ok(())
+        }
+    }
+
+    /// The recommended file name for the registration code
+    pub fn file_name(name: &str) -> String {
+        format!("{}.bash", name)
+    }
+
+    /// Define the completion behavior
+    pub enum Behavior {
+        /// Bare bones behavior
+        Minimal,
+        /// Fallback to readline behavior when no matches are generated
+        Readline,
+        /// Customize bash's completion behavior
+        Custom(String),
+    }
+
+    impl Default for Behavior {
+        fn default() -> Self {
+            Self::Readline
+        }
+    }
+
+    /// Generate code to register the dynamic completion
+    pub fn register(
+        name: &str,
+        executables: impl IntoIterator<Item = impl AsRef<str>>,
+        completer: &str,
+        behavior: &Behavior,
+        buf: &mut dyn Write,
+    ) -> Result<(), std::io::Error> {
+        let escaped_name = name.replace("-", "_");
+        debug_assert!(
+            escaped_name.chars().all(|c| c.is_xid_continue()),
+            "`name` must be an identifier, got `{}`",
+            escaped_name
+        );
+        let mut upper_name = escaped_name.clone();
+        upper_name.make_ascii_uppercase();
+
+        let executables = executables
+            .into_iter()
+            .map(|s| shlex::quote(s.as_ref()).into_owned())
+            .collect::<Vec<_>>()
+            .join(" ");
+
+        let options = match behavior {
+            Behavior::Minimal => "-o nospace -o bashdefault",
+            Behavior::Readline => "-o nospace -o default -o bashdefault",
+            Behavior::Custom(c) => c.as_str(),
+        };
+
+        let completer = shlex::quote(completer);
+
+        let script = r#"
+_clap_complete_NAME() {
+    local IFS=$'\013'
+    local SUPPRESS_SPACE=0
+    if compopt +o nospace 2> /dev/null; then
+        SUPPRESS_SPACE=1
+    fi
+    if [[ ${SUPPRESS_SPACE} == 1 ]]; then
+        SPACE_ARG="--no-space"
+    else
+        SPACE_ARG="--space"
+    fi
+    COMPREPLY=( $("COMPLETER" complete --index ${COMP_CWORD} --type ${COMP_TYPE} ${SPACE_ARG} --ifs="$IFS" -- "${COMP_WORDS[@]}") )
+    if [[ $? != 0 ]]; then
+        unset COMPREPLY
+    elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then
+        compopt -o nospace
+    fi
+}
+complete OPTIONS -F _clap_complete_NAME EXECUTABLES
+"#
+        .replace("NAME", &escaped_name)
+        .replace("EXECUTABLES", &executables)
+        .replace("OPTIONS", options)
+        .replace("COMPLETER", &completer)
+        .replace("UPPER", &upper_name);
+
+        writeln!(buf, "{}", script)?;
+        Ok(())
+    }
+
+    /// Type of completion attempted that caused a completion function to be called
+    #[derive(Copy, Clone, Debug, PartialEq, Eq)]
+    #[non_exhaustive]
+    pub enum CompType {
+        /// Normal completion
+        Normal,
+        /// List completions after successive tabs
+        Successive,
+        /// List alternatives on partial word completion
+        Alternatives,
+        /// List completions if the word is not unmodified
+        Unmodified,
+        /// Menu completion
+        Menu,
+    }
+
+    impl clap::ArgEnum for CompType {
+        fn value_variants<'a>() -> &'a [Self] {
+            &[
+                Self::Normal,
+                Self::Successive,
+                Self::Alternatives,
+                Self::Unmodified,
+                Self::Menu,
+            ]
+        }
+        fn to_possible_value<'a>(&self) -> ::std::option::Option<clap::PossibleValue<'a>> {
+            match self {
+                Self::Normal => {
+                    let value = "9";
+                    debug_assert_eq!(b'\t'.to_string(), value);
+                    Some(
+                        clap::PossibleValue::new(value)
+                            .alias("normal")
+                            .help("Normal completion"),
+                    )
+                }
+                Self::Successive => {
+                    let value = "63";
+                    debug_assert_eq!(b'?'.to_string(), value);
+                    Some(
+                        clap::PossibleValue::new(value)
+                            .alias("successive")
+                            .help("List completions after successive tabs"),
+                    )
+                }
+                Self::Alternatives => {
+                    let value = "33";
+                    debug_assert_eq!(b'!'.to_string(), value);
+                    Some(
+                        clap::PossibleValue::new(value)
+                            .alias("alternatives")
+                            .help("List alternatives on partial word completion"),
+                    )
+                }
+                Self::Unmodified => {
+                    let value = "64";
+                    debug_assert_eq!(b'@'.to_string(), value);
+                    Some(
+                        clap::PossibleValue::new(value)
+                            .alias("unmodified")
+                            .help("List completions if the word is not unmodified"),
+                    )
+                }
+                Self::Menu => {
+                    let value = "37";
+                    debug_assert_eq!(b'%'.to_string(), value);
+                    Some(
+                        clap::PossibleValue::new(value)
+                            .alias("menu")
+                            .help("Menu completion"),
+                    )
+                }
+            }
+        }
+    }
+
+    impl Default for CompType {
+        fn default() -> Self {
+            Self::Normal
+        }
+    }
+
+    /// Complete the command specified
+    pub fn complete(
+        cmd: &mut clap::Command,
+        args: Vec<std::ffi::OsString>,
+        arg_index: usize,
+        _comp_type: CompType,
+        _trailing_space: bool,
+        current_dir: Option<&std::path::Path>,
+    ) -> Result<Vec<std::ffi::OsString>, std::io::Error> {
+        cmd.build();
+
+        let raw_args = clap_lex::RawArgs::new(args.into_iter());
+        let mut cursor = raw_args.cursor();
+        let mut target_cursor = raw_args.cursor();
+        raw_args.seek(
+            &mut target_cursor,
+            clap_lex::SeekFrom::Start(arg_index as u64),
+        );
+        // As we loop, `cursor` will always be pointing to the next item
+        raw_args.next_os(&mut target_cursor);
+
+        // TODO: Multicall support
+        if !cmd.is_no_binary_name_set() {
+            raw_args.next_os(&mut cursor);
+        }
+
+        let mut current_cmd = &*cmd;
+        let mut pos_index = 1;
+        let mut is_escaped = false;
+        while let Some(arg) = raw_args.next(&mut cursor) {
+            if cursor == target_cursor {
+                return complete_arg(&arg, current_cmd, current_dir, pos_index, is_escaped);
+            }
+
+            debug!(
+                "complete::next: Begin parsing '{:?}' ({:?})",
+                arg.to_value_os(),
+                arg.to_value_os().as_raw_bytes()
+            );
+
+            if let Ok(value) = arg.to_value() {
+                if let Some(next_cmd) = current_cmd.find_subcommand(value) {
+                    current_cmd = next_cmd;
+                    pos_index = 0;
+                    continue;
+                }
+            }
+
+            if is_escaped {
+                pos_index += 1;
+            } else if arg.is_escape() {
+                is_escaped = true;
+            } else if let Some(_long) = arg.to_long() {
+            } else if let Some(_short) = arg.to_short() {
+            } else {
+                pos_index += 1;
+            }
+        }
+
+        Err(std::io::Error::new(
+            std::io::ErrorKind::Other,
+            "No completion generated",
+        ))
+    }
+
+    fn complete_arg(
+        arg: &clap_lex::ParsedArg<'_>,
+        cmd: &clap::Command,
+        current_dir: Option<&std::path::Path>,
+        pos_index: usize,
+        is_escaped: bool,
+    ) -> Result<Vec<std::ffi::OsString>, std::io::Error> {
+        debug!(
+            "complete_arg: arg={:?}, cmd={:?}, current_dir={:?}, pos_index={}, is_escaped={}",
+            arg,
+            cmd.get_name(),
+            current_dir,
+            pos_index,
+            is_escaped
+        );
+        let mut completions = Vec::new();
+
+        if !is_escaped {
+            if let Some((flag, value)) = arg.to_long() {
+                if let Ok(flag) = flag {
+                    if let Some(value) = value {
+                        if let Some(arg) = cmd.get_arguments().find(|a| a.get_long() == Some(flag))
+                        {
+                            completions.extend(
+                                complete_arg_value(value.to_str().ok_or(value), arg, current_dir)
+                                    .into_iter()
+                                    .map(|os| {
+                                        // HACK: Need better `OsStr` manipulation
+                                        format!("--{}={}", flag, os.to_string_lossy()).into()
+                                    }),
+                            )
+                        }
+                    } else {
+                        completions.extend(
+                            crate::generator::utils::longs_and_visible_aliases(cmd)
+                                .into_iter()
+                                .filter_map(|f| {
+                                    f.starts_with(flag).then(|| format!("--{}", f).into())
+                                }),
+                        );
+                    }
+                }
+            } else if arg.is_escape() || arg.is_stdio() || arg.is_empty() {
+                // HACK: Assuming knowledge of is_escape / is_stdio
+                completions.extend(
+                    crate::generator::utils::longs_and_visible_aliases(cmd)
+                        .into_iter()
+                        .map(|f| format!("--{}", f).into()),
+                );
+            }
+
+            if arg.is_empty() || arg.is_stdio() || arg.is_short() {
+                // HACK: Assuming knowledge of is_stdio
+                completions.extend(
+                    crate::generator::utils::shorts_and_visible_aliases(cmd)
+                        .into_iter()
+                        // HACK: Need better `OsStr` manipulation
+                        .map(|f| format!("{}{}", arg.to_value_os().to_str_lossy(), f).into()),
+                );
+            }
+        }
+
+        if let Some(positional) = cmd
+            .get_positionals()
+            .find(|p| p.get_index() == Some(pos_index))
+        {
+            completions.extend(complete_arg_value(arg.to_value(), positional, current_dir));
+        }
+
+        if let Ok(value) = arg.to_value() {
+            completions.extend(complete_subcommand(value, cmd));
+        }
+
+        Ok(completions)
+    }
+
+    fn complete_arg_value(
+        value: Result<&str, &clap_lex::RawOsStr>,
+        arg: &clap::Arg<'_>,
+        current_dir: Option<&std::path::Path>,
+    ) -> Vec<OsString> {
+        let mut values = Vec::new();
+        debug!("complete_arg_value: arg={:?}, value={:?}", arg, value);
+
+        if let Some(possible_values) = arg.get_possible_values() {
+            if let Ok(value) = value {
+                values.extend(possible_values.into_iter().filter_map(|p| {
+                    let name = p.get_name();
+                    name.starts_with(value).then(|| name.into())
+                }));
+            }
+        } else {
+            let value_os = match value {
+                Ok(value) => clap_lex::RawOsStr::from_str(value),
+                Err(value_os) => value_os,
+            };
+            match arg.get_value_hint() {
+                clap::ValueHint::Other => {
+                    // Should not complete
+                }
+                clap::ValueHint::Unknown | clap::ValueHint::AnyPath => {
+                    values.extend(complete_path(value_os, current_dir, |_| true));
+                }
+                clap::ValueHint::FilePath => {
+                    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()));
+                }
+                clap::ValueHint::ExecutablePath => {
+                    use is_executable::IsExecutable;
+                    values.extend(complete_path(value_os, current_dir, |p| p.is_executable()));
+                }
+                clap::ValueHint::CommandName
+                | clap::ValueHint::CommandString
+                | clap::ValueHint::CommandWithArguments
+                | clap::ValueHint::Username
+                | clap::ValueHint::Hostname
+                | clap::ValueHint::Url
+                | clap::ValueHint::EmailAddress => {
+                    // No completion implementation
+                }
+                _ => {
+                    // Safe-ish fallback
+                    values.extend(complete_path(value_os, current_dir, |_| true));
+                }
+            }
+            values.sort();
+        }
+
+        values
+    }
+
+    fn complete_path(
+        value_os: &clap_lex::RawOsStr,
+        current_dir: Option<&std::path::Path>,
+        is_wanted: impl Fn(&std::path::Path) -> bool,
+    ) -> Vec<OsString> {
+        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((clap_lex::RawOsStr::from_str(""), value_os));
+        let root = current_dir.join(existing.to_os_str());
+        debug!("complete_path: root={:?}, prefix={:?}", root, prefix);
+
+        for entry in std::fs::read_dir(&root)
+            .ok()
+            .into_iter()
+            .flatten()
+            .filter_map(Result::ok)
+        {
+            let raw_file_name = clap_lex::RawOsString::new(entry.file_name());
+            if !raw_file_name.starts_with_os(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(suggestion.as_os_str().to_owned());
+            } else {
+                let path = entry.path();
+                if is_wanted(&path) {
+                    let suggestion = pathdiff::diff_paths(&path, current_dir).unwrap_or(path);
+                    completions.push(suggestion.as_os_str().to_owned());
+                }
+            }
+        }
+
+        completions
+    }
+
+    fn complete_subcommand(value: &str, cmd: &clap::Command) -> Vec<OsString> {
+        debug!(
+            "complete_subcommand: cmd={:?}, value={:?}",
+            cmd.get_name(),
+            value
+        );
+
+        let mut scs = crate::generator::utils::all_subcommands(cmd)
+            .into_iter()
+            .filter(|x| x.0.starts_with(value))
+            .map(|x| OsString::from(&x.0))
+            .collect::<Vec<_>>();
+        scs.sort();
+        scs.dedup();
+        scs
+    }
+}
diff --git a/clap_complete/src/lib.rs b/clap_complete/src/lib.rs
index a22ff9ac..3da01ac8 100644
--- a/clap_complete/src/lib.rs
+++ b/clap_complete/src/lib.rs
@@ -68,3 +68,6 @@ pub use generator::generate;
 pub use generator::generate_to;
 pub use generator::Generator;
 pub use shells::Shell;
+
+#[cfg(feature = "unstable-dynamic")]
+pub mod dynamic;
diff --git a/clap_complete/src/macros.rs b/clap_complete/src/macros.rs
index def05143..bc697946 100644
--- a/clap_complete/src/macros.rs
+++ b/clap_complete/src/macros.rs
@@ -10,8 +10,8 @@ macro_rules! w {
 #[cfg(feature = "debug")]
 macro_rules! debug {
     ($($arg:tt)*) => {
-        print!("[{:>w$}] \t", module_path!(), w = 28);
-        println!($($arg)*)
+        eprint!("[{:>w$}] \t", module_path!(), w = 28);
+        eprintln!($($arg)*)
     }
 }
 
diff --git a/clap_lex/src/lib.rs b/clap_lex/src/lib.rs
index 4d9878aa..f1c8da35 100644
--- a/clap_lex/src/lib.rs
+++ b/clap_lex/src/lib.rs
@@ -275,6 +275,11 @@ impl<'s> ParsedArg<'s> {
         Self { inner, utf8 }
     }
 
+    /// Argument is length of 0
+    pub fn is_empty(&self) -> bool {
+        self.inner.as_ref().is_empty()
+    }
+
     /// Does the argument look like a stdio argument (`-`)
     pub fn is_stdio(&self) -> bool {
         self.inner.as_ref() == "-"
@@ -293,14 +298,14 @@ impl<'s> ParsedArg<'s> {
     }
 
     /// Treat as a long-flag
-    ///
-    /// **NOTE:** May return an empty flag.  Check [`ParsedArg::is_escape`] to separately detect `--`.
-    ///
-    /// **NOTE:** Will not match [`ParsedArg::is_stdio`], completion engines will need to check
-    /// that case.
     pub fn to_long(&self) -> Option<(Result<&str, &RawOsStr>, Option<&RawOsStr>)> {
         if let Some(raw) = self.utf8 {
             let remainder = raw.strip_prefix("--")?;
+            if remainder.is_empty() {
+                debug_assert!(self.is_escape());
+                return None;
+            }
+
             let (flag, value) = if let Some((p0, p1)) = remainder.split_once('=') {
                 (p0, Some(p1))
             } else {
@@ -312,6 +317,11 @@ impl<'s> ParsedArg<'s> {
         } else {
             let raw = self.inner.as_ref();
             let remainder = raw.strip_prefix("--")?;
+            if remainder.is_empty() {
+                debug_assert!(self.is_escape());
+                return None;
+            }
+
             let (flag, value) = if let Some((p0, p1)) = remainder.split_once('=') {
                 (p0, Some(p1))
             } else {
@@ -323,20 +333,18 @@ impl<'s> ParsedArg<'s> {
     }
 
     /// Can treat as a long-flag
-    ///
-    /// **NOTE:** May return an empty flag.  Check [`ParsedArg::is_escape`] to separately detect `--`.
     pub fn is_long(&self) -> bool {
-        self.inner.as_ref().starts_with("--")
+        self.inner.as_ref().starts_with("--") && !self.is_escape()
     }
 
     /// Treat as a short-flag
-    ///
-    /// **NOTE:** Maybe return an empty flag.  Check [`ParsedArg::is_stdio`] to separately detect
-    /// `-`.
     pub fn to_short(&self) -> Option<ShortFlags<'_>> {
         if let Some(remainder_os) = self.inner.as_ref().strip_prefix('-') {
             if remainder_os.starts_with('-') {
                 None
+            } else if remainder_os.is_empty() {
+                debug_assert!(self.is_stdio());
+                None
             } else {
                 let remainder = self.utf8.map(|s| &s[1..]);
                 Some(ShortFlags::new(remainder_os, remainder))
@@ -347,11 +355,10 @@ impl<'s> ParsedArg<'s> {
     }
 
     /// Can treat as a short-flag
-    ///
-    /// **NOTE:** Maybe return an empty flag.  Check [`ParsedArg::is_stdio`] to separately detect
-    /// `-`.
     pub fn is_short(&self) -> bool {
-        self.inner.as_ref().starts_with('-') && !self.is_long()
+        self.inner.as_ref().starts_with('-')
+            && !self.is_stdio()
+            && !self.inner.as_ref().starts_with("--")
     }
 
     /// Treat as a value
diff --git a/src/build/command.rs b/src/build/command.rs
index 7cd03ffc..5e273a0c 100644
--- a/src/build/command.rs
+++ b/src/build/command.rs
@@ -3510,8 +3510,7 @@ impl<'help> App<'help> {
     }
 
     /// Report whether [`Command::no_binary_name`] is set
-    #[allow(unused)]
-    pub(crate) fn is_no_binary_name_set(&self) -> bool {
+    pub fn is_no_binary_name_set(&self) -> bool {
         self.is_set(AppSettings::NoBinaryName)
     }
 
diff --git a/src/build/value_hint.rs b/src/build/value_hint.rs
index 647adcc5..7c35d1eb 100644
--- a/src/build/value_hint.rs
+++ b/src/build/value_hint.rs
@@ -24,7 +24,7 @@ use std::str::FromStr;
 ///
 /// [^1]: fish completions currently only support named arguments (e.g. -o or --opt), not
 ///       positional arguments.
-#[derive(Debug, PartialEq, Copy, Clone)]
+#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)]
 #[non_exhaustive]
 pub enum ValueHint {
     /// Default value if hint is not specified. Follows shell default behavior, which is usually
diff --git a/src/parse/parser.rs b/src/parse/parser.rs
index 030a5b91..7c62b2a3 100644
--- a/src/parse/parser.rs
+++ b/src/parse/parser.rs
@@ -190,7 +190,17 @@ impl<'help, 'cmd> Parser<'help, 'cmd> {
                     }
                 }
 
-                if let Some((long_arg, long_value)) = arg_os.to_long() {
+                if arg_os.is_escape() {
+                    if matches!(&parse_state, ParseState::Opt(opt) | ParseState::Pos(opt) if
+                        self.cmd[opt].is_allow_hyphen_values_set())
+                    {
+                        // ParseResult::MaybeHyphenValue, do nothing
+                    } else {
+                        debug!("Parser::get_matches_with: setting TrailingVals=true");
+                        trailing_values = true;
+                        continue;
+                    }
+                } else if let Some((long_arg, long_value)) = arg_os.to_long() {
                     let parse_result = self.parse_long_arg(
                         matcher,
                         long_arg,
@@ -205,9 +215,7 @@ impl<'help, 'cmd> Parser<'help, 'cmd> {
                     );
                     match parse_result {
                         ParseResult::NoArg => {
-                            debug!("Parser::get_matches_with: setting TrailingVals=true");
-                            trailing_values = true;
-                            continue;
+                            unreachable!("`to_long` always has the flag specified")
                         }
                         ParseResult::ValuesDone => {
                             parse_state = ParseState::ValuesDone;
@@ -661,14 +669,6 @@ impl<'help, 'cmd> Parser<'help, 'cmd> {
             // If allow hyphen, this isn't a new arg.
             debug!("Parser::is_new_arg: Allow hyphen");
             false
-        } else if next.is_escape() {
-            // Ensure we don't assuming escapes are long args
-            debug!("Parser::is_new_arg: -- found");
-            false
-        } else if next.is_stdio() {
-            // Ensure we don't assume stdio is a short arg
-            debug!("Parser::is_new_arg: - found");
-            false
         } else if next.is_long() {
             // If this is a long flag, this is a new arg.
             debug!("Parser::is_new_arg: --<something> found");
diff --git a/src/parse/validator.rs b/src/parse/validator.rs
index e39a39a4..4a709efc 100644
--- a/src/parse/validator.rs
+++ b/src/parse/validator.rs
@@ -605,7 +605,7 @@ impl Conflicts {
     }
 
     fn gather_conflicts(&mut self, cmd: &Command, matcher: &ArgMatcher, arg_id: &Id) -> Vec<Id> {
-        debug!("Conflicts::gather_conflicts");
+        debug!("Conflicts::gather_conflicts: arg={:?}", arg_id);
         let mut conflicts = Vec::new();
         for other_arg_id in matcher
             .arg_names()
@@ -628,6 +628,7 @@ impl Conflicts {
                 conflicts.push(other_arg_id.clone());
             }
         }
+        debug!("Conflicts::gather_conflicts: conflicts={:?}", conflicts);
         conflicts
     }
 


Test patch:
diff --git a/clap_complete/tests/bash.rs b/clap_complete/tests/bash.rs
index 7b6a40ab..a3e454de 100644
--- a/clap_complete/tests/bash.rs
+++ b/clap_complete/tests/bash.rs
@@ -83,3 +83,19 @@ fn value_hint() {
         name,
     );
 }
+
+#[cfg(feature = "unstable-dynamic")]
+#[test]
+fn register_minimal() {
+    let name = "my-app";
+    let executables = [name];
+    let completer = name;
+    let behavior = clap_complete::dynamic::bash::Behavior::Minimal;
+
+    let mut buf = Vec::new();
+    clap_complete::dynamic::bash::register(name, executables, completer, &behavior, &mut buf)
+        .unwrap();
+    snapbox::Assert::new()
+        .action_env("SNAPSHOTS")
+        .matches_path("tests/snapshots/register_minimal.bash", buf);
+}
diff --git a/clap_complete/tests/dynamic.rs b/clap_complete/tests/dynamic.rs
new file mode 100644
index 00000000..94254523
--- /dev/null
+++ b/clap_complete/tests/dynamic.rs
@@ -0,0 +1,150 @@
+#![cfg(feature = "unstable-dynamic")]
+
+#[test]
+fn suggest_subcommand_subset() {
+    let name = "test";
+    let mut cmd = clap::Command::new(name)
+        .subcommand(clap::Command::new("hello-world"))
+        .subcommand(clap::Command::new("hello-moon"))
+        .subcommand(clap::Command::new("goodbye-world"));
+
+    let args = [name, "he"];
+    let arg_index = 1;
+    let args = IntoIterator::into_iter(args)
+        .map(|s| std::ffi::OsString::from(s))
+        .collect::<Vec<_>>();
+    let comp_type = clap_complete::dynamic::bash::CompType::default();
+    let trailing_space = true;
+    let current_dir = None;
+
+    let completions = clap_complete::dynamic::bash::complete(
+        &mut cmd,
+        args,
+        arg_index,
+        comp_type,
+        trailing_space,
+        current_dir,
+    )
+    .unwrap();
+    let completions = completions
+        .into_iter()
+        .map(|s| s.to_string_lossy().into_owned())
+        .collect::<Vec<_>>();
+
+    assert_eq!(completions, ["hello-moon", "hello-world", "help"]);
+}
+
+#[test]
+fn suggest_long_flag_subset() {
+    let name = "test";
+    let mut cmd = clap::Command::new(name)
+        .arg(
+            clap::Arg::new("hello-world")
+                .long("--hello-world")
+                .multiple_occurrences(true),
+        )
+        .arg(
+            clap::Arg::new("hello-moon")
+                .long("--hello-moon")
+                .multiple_occurrences(true),
+        )
+        .arg(
+            clap::Arg::new("goodbye-world")
+                .long("--goodbye-world")
+                .multiple_occurrences(true),
+        );
+
+    let args = [name, "--he"];
+    let arg_index = 1;
+    let args = IntoIterator::into_iter(args)
+        .map(|s| std::ffi::OsString::from(s))
+        .collect::<Vec<_>>();
+    let comp_type = clap_complete::dynamic::bash::CompType::default();
+    let trailing_space = true;
+    let current_dir = None;
+
+    let completions = clap_complete::dynamic::bash::complete(
+        &mut cmd,
+        args,
+        arg_index,
+        comp_type,
+        trailing_space,
+        current_dir,
+    )
+    .unwrap();
+    let completions = completions
+        .into_iter()
+        .map(|s| s.to_string_lossy().into_owned())
+        .collect::<Vec<_>>();
+
+    assert_eq!(completions, ["--help", "--hello-world", "--hello-moon"]);
+}
+
+#[test]
+fn suggest_possible_value_subset() {
+    let name = "test";
+    let mut cmd = clap::Command::new(name).arg(clap::Arg::new("hello-world").possible_values([
+        "hello-world",
+        "hello-moon",
+        "goodbye-world",
+    ]));
+
+    let args = [name, "hello"];
+    let arg_index = 1;
+    let args = IntoIterator::into_iter(args)
+        .map(|s| std::ffi::OsString::from(s))
+        .collect::<Vec<_>>();
+    let comp_type = clap_complete::dynamic::bash::CompType::default();
+    let trailing_space = true;
+    let current_dir = None;
+
+    let completions = clap_complete::dynamic::bash::complete(
+        &mut cmd,
+        args,
+        arg_index,
+        comp_type,
+        trailing_space,
+        current_dir,
+    )
+    .unwrap();
+    let completions = completions
+        .into_iter()
+        .map(|s| s.to_string_lossy().into_owned())
+        .collect::<Vec<_>>();
+
+    assert_eq!(completions, ["hello-world", "hello-moon"]);
+}
+
+#[test]
+fn suggest_additional_short_flags() {
+    let name = "test";
+    let mut cmd = clap::Command::new(name)
+        .arg(clap::Arg::new("a").short('a').multiple_occurrences(true))
+        .arg(clap::Arg::new("b").short('b').multiple_occurrences(true))
+        .arg(clap::Arg::new("c").short('c').multiple_occurrences(true));
+
+    let args = [name, "-a"];
+    let arg_index = 1;
+    let args = IntoIterator::into_iter(args)
+        .map(|s| std::ffi::OsString::from(s))
+        .collect::<Vec<_>>();
+    let comp_type = clap_complete::dynamic::bash::CompType::default();
+    let trailing_space = true;
+    let current_dir = None;
+
+    let completions = clap_complete::dynamic::bash::complete(
+        &mut cmd,
+        args,
+        arg_index,
+        comp_type,
+        trailing_space,
+        current_dir,
+    )
+    .unwrap();
+    let completions = completions
+        .into_iter()
+        .map(|s| s.to_string_lossy().into_owned())
+        .collect::<Vec<_>>();
+
+    assert_eq!(completions, ["-ah", "-aa", "-ab", "-ac"]);
+}
diff --git a/clap_complete/tests/examples.rs b/clap_complete/tests/examples.rs
new file mode 100644
index 00000000..18b3218e
--- /dev/null
+++ b/clap_complete/tests/examples.rs
@@ -0,0 +1,13 @@
+#![cfg(not(tarpaulin))]
+
+#[test]
+fn example_tests() {
+    let t = trycmd::TestCases::new();
+    let features: &[&str] = &[
+        #[cfg(feature = "unstable-dynamic")]
+        "unstable-dynamic",
+    ];
+    let features = features.join(" ");
+    t.register_bins(trycmd::cargo::compile_examples(["--features", &features]).unwrap());
+    t.case("examples/**/*.md");
+}
diff --git a/clap_complete/tests/snapshots/register_minimal.bash b/clap_complete/tests/snapshots/register_minimal.bash
new file mode 100644
index 00000000..41ef0b10
--- /dev/null
+++ b/clap_complete/tests/snapshots/register_minimal.bash
@@ -0,0 +1,21 @@
+
+_clap_complete_my_app() {
+    local IFS=$'/013'
+    local SUPPRESS_SPACE=0
+    if compopt +o nospace 2> /dev/null; then
+        SUPPRESS_SPACE=1
+    fi
+    if [[ ${SUPPRESS_SPACE} == 1 ]]; then
+        SPACE_ARG="--no-space"
+    else
+        SPACE_ARG="--space"
+    fi
+    COMPREPLY=( $("my-app" complete --index ${COMP_CWORD} --type ${COMP_TYPE} ${SPACE_ARG} --ifs="$IFS" -- "${COMP_WORDS[@]}") )
+    if [[ $? != 0 ]]; then
+        unset COMPREPLY
+    elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then
+        compopt -o nospace
+    fi
+}
+complete -o nospace -o bashdefault -F _clap_complete_my_app my-app
+
diff --git a/clap_lex/tests/parsed.rs b/clap_lex/tests/parsed.rs
index 9789011c..77a1dfb9 100644
--- a/clap_lex/tests/parsed.rs
+++ b/clap_lex/tests/parsed.rs
@@ -13,17 +13,15 @@ fn to_long_stdio() {
 }
 
 #[test]
-fn to_long_escape() {
+fn to_long_no_escape() {
     let raw = clap_lex::RawArgs::new(["bin", "--"]);
     let mut cursor = raw.cursor();
     assert_eq!(raw.next_os(&mut cursor), Some(std::ffi::OsStr::new("bin")));
     let next = raw.next(&mut cursor).unwrap();
 
-    assert!(next.is_long());
+    assert!(!next.is_long());
 
-    let (key, value) = next.to_long().unwrap();
-    assert_eq!(key, Ok(""));
-    assert_eq!(value, None);
+    assert_eq!(next.to_long(), None);
 }
 
 #[test]
@@ -75,10 +73,9 @@ fn to_short_stdio() {
     assert_eq!(raw.next_os(&mut cursor), Some(std::ffi::OsStr::new("bin")));
     let next = raw.next(&mut cursor).unwrap();
 
-    assert!(next.is_short());
+    assert!(!next.is_short());
 
-    let mut shorts = next.to_short().unwrap();
-    assert_eq!(shorts.next_value_os(), None);
+    assert!(next.to_short().is_none());
 }
 
 #[test]
diff --git a/clap_lex/tests/shorts.rs b/clap_lex/tests/shorts.rs
index a583a883..3adc4e3d 100644
--- a/clap_lex/tests/shorts.rs
+++ b/clap_lex/tests/shorts.rs
@@ -87,20 +87,6 @@ fn advance_by_nothing() {
     assert_eq!(actual, "short");
 }
 
-#[test]
-fn advance_by_nothing_with_nothing() {
-    let raw = clap_lex::RawArgs::new(["bin", "-"]);
-    let mut cursor = raw.cursor();
-    assert_eq!(raw.next_os(&mut cursor), Some(std::ffi::OsStr::new("bin")));
-    let next = raw.next(&mut cursor).unwrap();
-    let mut shorts = next.to_short().unwrap();
-
-    assert_eq!(shorts.advance_by(0), Ok(()));
-
-    let actual: String = shorts.map(|s| s.unwrap()).collect();
-    assert_eq!(actual, "");
-}
-
 #[test]
 fn advance_by_something() {
     let raw = clap_lex::RawArgs::new(["bin", "-short"]);
@@ -129,17 +115,6 @@ fn advance_by_out_of_bounds() {
     assert_eq!(actual, "");
 }
 
-#[test]
-fn is_empty() {
-    let raw = clap_lex::RawArgs::new(["bin", "-"]);
-    let mut cursor = raw.cursor();
-    assert_eq!(raw.next_os(&mut cursor), Some(std::ffi::OsStr::new("bin")));
-    let next = raw.next(&mut cursor).unwrap();
-    let shorts = next.to_short().unwrap();
-
-    assert!(shorts.is_empty());
-}
-
 #[test]
 fn is_not_empty() {
     let raw = clap_lex::RawArgs::new(["bin", "-hello"]);
@@ -165,7 +140,7 @@ fn is_partial_not_empty() {
 
 #[test]
 fn is_exhausted_empty() {
-    let raw = clap_lex::RawArgs::new(["bin", "-"]);
+    let raw = clap_lex::RawArgs::new(["bin", "-hello"]);
     let mut cursor = raw.cursor();
     assert_eq!(raw.next_os(&mut cursor), Some(std::ffi::OsStr::new("bin")));
     let next = raw.next(&mut cursor).unwrap();