use std::ffi::OsString;
use clap::ValueHint;
use clap_complete::env::{Bash, Elvish, EnvCompleter, Fish, Powershell, Shells};
const PATHFILES_SENTINEL: &str = "__CLAP_PATHFILES__";
pub(crate) const SHELLS: Shells<'static> =
Shells(&[&Bash, &Elvish, &Fish, &Powershell, &GroupedZsh]);
struct GroupedZsh;
impl EnvCompleter for GroupedZsh {
fn name(&self) -> &'static str {
"zsh"
}
fn is(&self, name: &str) -> bool {
name == "zsh"
}
fn write_registration(
&self,
var: &str,
name: &str,
bin: &str,
completer: &str,
buf: &mut dyn std::io::Write,
) -> Result<(), std::io::Error> {
let escaped_name = name.replace('-', "_");
let bin = shlex::try_quote(bin).unwrap_or(std::borrow::Cow::Borrowed(bin));
let completer =
shlex::try_quote(completer).unwrap_or(std::borrow::Cow::Borrowed(completer));
let script = include_str!("grouped.zsh")
.replace("{NAME}", &escaped_name)
.replace("{COMPLETER}", &completer)
.replace("{BIN}", &bin)
.replace("{VAR}", var);
writeln!(buf, "{script}")?;
Ok(())
}
fn write_complete(
&self,
cmd: &mut clap::Command,
args: Vec<OsString>,
current_dir: Option<&std::path::Path>,
buf: &mut dyn std::io::Write,
) -> Result<(), std::io::Error> {
let index: usize = std::env::var("_CLAP_COMPLETE_INDEX")
.ok()
.and_then(|i| i.parse().ok())
.unwrap_or_default();
let ifs: Option<String> = std::env::var("_CLAP_IFS").ok().and_then(|i| i.parse().ok());
let mut args = args;
if args.len() == index {
args.push(OsString::new());
}
if let Some(flags) = detect_path_files_flags(cmd, &args, index) {
write!(buf, "{PATHFILES_SENTINEL}\t{flags}")?;
return Ok(());
}
let completions = clap_complete::engine::complete(cmd, args, index, current_dir)?;
for (i, candidate) in completions.iter().enumerate() {
if i != 0 {
write!(buf, "{}", ifs.as_deref().unwrap_or("\n"))?;
}
let tag = candidate
.get_tag()
.map_or_else(|| "values".to_string(), ToString::to_string);
write!(
buf,
"{}\x1f{}",
tag,
candidate.get_value().to_string_lossy()
)?;
if let Some(help) = candidate.get_help() {
let raw = help.to_string();
let line = raw.lines().next().unwrap_or_default();
let stripped = strip_tag_prefix(line, &tag);
if !stripped.is_empty() {
write!(buf, "\t{stripped}")?;
}
}
}
Ok(())
}
}
fn strip_tag_prefix<'a>(help: &'a str, tag: &str) -> &'a str {
help.strip_prefix(tag)
.map_or(help, |rest| rest.strip_prefix(": ").unwrap_or(rest))
.trim()
}
fn detect_path_files_flags(
cmd: &clap::Command,
args: &[OsString],
index: usize,
) -> Option<&'static str> {
let current = args.get(index)?.to_string_lossy();
let chain = active_command_chain(cmd, args, index);
if let Some((flag, _value)) = current.split_once('=')
&& let Some(long) = flag.strip_prefix("--")
&& let Some(hint) = find_long_value_hint(&chain, long)
{
return zsh_files_flags(hint);
}
if let Some(rest) = current.strip_prefix('-')
&& !current.starts_with("--")
&& let Some(c) = rest.chars().next()
&& let Some(hint) = find_short_value_hint(&chain, c)
{
return zsh_files_flags(hint);
}
if index > 0 {
let prev = args[index - 1].to_string_lossy();
if let Some(long) = prev.strip_prefix("--")
&& !long.contains('=')
&& let Some(hint) = find_long_value_hint(&chain, long)
{
return zsh_files_flags(hint);
}
if prev.len() == 2
&& let Some(rest) = prev.strip_prefix('-')
&& !prev.starts_with("--")
&& let Some(c) = rest.chars().next()
&& let Some(hint) = find_short_value_hint(&chain, c)
{
return zsh_files_flags(hint);
}
}
None
}
fn active_command_chain<'a>(
root: &'a clap::Command,
args: &[OsString],
index: usize,
) -> Vec<&'a clap::Command> {
let mut chain = vec![root];
let mut current = root;
let mut i = 1;
let stop = index.min(args.len());
while i < stop {
let token = args[i].to_string_lossy();
if token == "--" {
break;
}
if token.starts_with("--") {
if !token.contains('=')
&& let Some(long) = token.strip_prefix("--")
&& long_flag_takes_value(&chain, long)
{
i += 2;
continue;
}
i += 1;
continue;
}
if token.starts_with('-') && token.len() > 1 {
if token.len() == 2
&& let Some(c) = token.chars().nth(1)
&& short_flag_takes_value(&chain, c)
{
i += 2;
continue;
}
i += 1;
continue;
}
if let Some(sub) = current.find_subcommand(token.as_ref()) {
chain.push(sub);
current = sub;
i += 1;
} else {
break;
}
}
chain
}
fn long_flag_takes_value(chain: &[&clap::Command], name: &str) -> bool {
chain
.iter()
.rev()
.find_map(|cmd| {
cmd.get_arguments()
.find(|arg| arg.get_long() == Some(name))
.map(action_takes_value)
})
.unwrap_or(false)
}
fn short_flag_takes_value(chain: &[&clap::Command], c: char) -> bool {
chain
.iter()
.rev()
.find_map(|cmd| {
cmd.get_arguments()
.find(|arg| arg.get_short() == Some(c))
.map(action_takes_value)
})
.unwrap_or(false)
}
fn action_takes_value(arg: &clap::Arg) -> bool {
!matches!(
arg.get_action(),
clap::ArgAction::SetTrue
| clap::ArgAction::SetFalse
| clap::ArgAction::Count
| clap::ArgAction::Help
| clap::ArgAction::Version
| clap::ArgAction::HelpShort
| clap::ArgAction::HelpLong
)
}
fn find_long_value_hint(chain: &[&clap::Command], name: &str) -> Option<ValueHint> {
for cmd in chain.iter().rev() {
for arg in cmd.get_arguments() {
if arg.get_long() == Some(name) {
return Some(arg.get_value_hint());
}
}
}
None
}
fn find_short_value_hint(chain: &[&clap::Command], c: char) -> Option<ValueHint> {
for cmd in chain.iter().rev() {
for arg in cmd.get_arguments() {
if arg.get_short() == Some(c) {
return Some(arg.get_value_hint());
}
}
}
None
}
const fn zsh_files_flags(hint: ValueHint) -> Option<&'static str> {
match hint {
ValueHint::DirPath => Some("-/"),
ValueHint::FilePath | ValueHint::AnyPath => Some(""),
ValueHint::ExecutablePath => Some("-g *(*)"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use clap::{Arg, Command, ValueHint};
use clap_complete::env::EnvCompleter as _;
use super::{GroupedZsh, detect_path_files_flags, strip_tag_prefix, zsh_files_flags};
fn dir_flag_cmd() -> Command {
Command::new("runner").arg(
Arg::new("dir")
.long("dir")
.value_hint(ValueHint::DirPath)
.num_args(1),
)
}
fn to_os(strings: &[&str]) -> Vec<OsString> {
strings.iter().map(|s| OsString::from(*s)).collect()
}
#[test]
fn detect_path_files_recognises_separated_dir_flag() {
let cmd = dir_flag_cmd();
let args = to_os(&["runner", "--dir", ""]);
assert_eq!(detect_path_files_flags(&cmd, &args, 2), Some("-/"));
}
#[test]
fn detect_path_files_recognises_inline_equals_dir_flag() {
let cmd = dir_flag_cmd();
let args = to_os(&["runner", "--dir=~/pro"]);
assert_eq!(detect_path_files_flags(&cmd, &args, 1), Some("-/"));
}
#[test]
fn detect_path_files_recognises_root_flag_after_subcommand() {
let cmd = Command::new("runner")
.arg(
Arg::new("dir")
.long("dir")
.value_hint(ValueHint::DirPath)
.num_args(1),
)
.subcommand(Command::new("run"));
let args = to_os(&["runner", "run", "--dir", ""]);
assert_eq!(detect_path_files_flags(&cmd, &args, 3), Some("-/"));
}
#[test]
fn detect_path_files_honours_boolean_shadow_on_subcommand() {
let cmd = Command::new("runner")
.arg(
Arg::new("flag")
.long("flag")
.value_hint(ValueHint::DirPath)
.num_args(1),
)
.subcommand(
Command::new("leaf").arg(
Arg::new("flag")
.long("flag")
.action(clap::ArgAction::SetTrue),
),
);
let args = to_os(&["runner", "leaf", "--flag", ""]);
assert_eq!(detect_path_files_flags(&cmd, &args, 3), None);
}
#[test]
fn detect_path_files_respects_active_subcommand() {
let cmd = Command::new("runner")
.subcommand(
Command::new("build").arg(
Arg::new("out")
.long("out")
.value_hint(ValueHint::DirPath)
.num_args(1),
),
)
.subcommand(
Command::new("deploy").arg(
Arg::new("out")
.long("out")
.value_hint(ValueHint::FilePath)
.num_args(1),
),
);
let build_args = to_os(&["runner", "build", "--out", ""]);
assert_eq!(
detect_path_files_flags(&cmd, &build_args, 3),
Some("-/"),
"build's DirPath should win in build context"
);
let deploy_args = to_os(&["runner", "deploy", "--out", ""]);
assert_eq!(
detect_path_files_flags(&cmd, &deploy_args, 3),
Some(""),
"deploy's FilePath should win in deploy context"
);
}
#[test]
fn detect_path_files_walks_subcommands() {
let cmd = Command::new("runner").subcommand(
Command::new("run").arg(
Arg::new("target")
.long("target")
.value_hint(ValueHint::FilePath)
.num_args(1),
),
);
let args = to_os(&["runner", "run", "--target", ""]);
assert_eq!(detect_path_files_flags(&cmd, &args, 3), Some(""));
}
#[test]
fn detect_path_files_ignores_non_path_flags() {
let cmd = Command::new("runner").arg(Arg::new("name").long("name").num_args(1));
let args = to_os(&["runner", "--name", ""]);
assert_eq!(detect_path_files_flags(&cmd, &args, 2), None);
}
#[test]
fn zsh_files_flags_map_each_path_hint() {
assert_eq!(zsh_files_flags(ValueHint::DirPath), Some("-/"));
assert_eq!(zsh_files_flags(ValueHint::FilePath), Some(""));
assert_eq!(zsh_files_flags(ValueHint::AnyPath), Some(""));
assert_eq!(zsh_files_flags(ValueHint::ExecutablePath), Some("-g *(*)"));
assert_eq!(zsh_files_flags(ValueHint::Username), None);
assert_eq!(zsh_files_flags(ValueHint::Unknown), None);
}
#[test]
fn detect_path_files_handles_short_value_flag_separated() {
let cmd = Command::new("runner").subcommand(
Command::new("completions").arg(
Arg::new("output")
.short('o')
.long("output")
.value_hint(ValueHint::FilePath)
.num_args(1),
),
);
let args = to_os(&["runner", "completions", "-o", ""]);
assert_eq!(detect_path_files_flags(&cmd, &args, 3), Some(""));
}
#[test]
fn detect_path_files_handles_short_value_flag_attached() {
let cmd = Command::new("runner").subcommand(
Command::new("completions").arg(
Arg::new("output")
.short('o')
.long("output")
.value_hint(ValueHint::FilePath)
.num_args(1),
),
);
let args = to_os(&["runner", "completions", "-ofoo"]);
assert_eq!(detect_path_files_flags(&cmd, &args, 2), Some(""));
}
#[test]
fn detect_path_files_ignores_boolean_short_flag() {
let cmd = Command::new("runner").arg(
Arg::new("yes")
.short('y')
.long("yes")
.action(clap::ArgAction::SetTrue),
);
let args = to_os(&["runner", "-y", ""]);
assert_eq!(detect_path_files_flags(&cmd, &args, 2), None);
}
#[test]
fn strip_tag_prefix_removes_matching_source() {
assert_eq!(
strip_tag_prefix("justfile: Format code", "justfile"),
"Format code"
);
}
#[test]
fn strip_tag_prefix_leaves_non_matching_help_unchanged() {
assert_eq!(strip_tag_prefix("Run a task", "Commands"), "Run a task");
}
#[test]
fn strip_tag_prefix_returns_empty_for_bare_source() {
assert_eq!(strip_tag_prefix("package.json", "package.json"), "");
}
#[test]
fn registration_script_uses_null_glob_and_extended_glob() {
let mut buf = Vec::new();
GroupedZsh
.write_registration("COMPLETE", "runner", "runner", "/bin/runner", &mut buf)
.expect("registration should succeed");
let script = String::from_utf8(buf).expect("script must be utf-8");
assert!(
script.contains("emulate -L zsh -o NULL_GLOB -o EXTENDED_GLOB"),
"completion function must enable both NULL_GLOB (for unmatched \
globs) and EXTENDED_GLOB (so `_files`'s `*(#q-/)` qualifier \
parses); got:\n{script}"
);
}
#[test]
fn registration_script_uses_noglob_precommand_not_setopt() {
let mut buf = Vec::new();
GroupedZsh
.write_registration("COMPLETE", "runner", "runner", "/bin/runner", &mut buf)
.expect("registration should succeed");
let script = String::from_utf8(buf).expect("script must be utf-8");
assert!(
script.contains("noglob _files"),
"path-hint delegation must use the `noglob` precommand modifier \
on the `_files` call so `*(*)` reaches `_files` literally while \
`_path_files`'s internal globbing still runs; got:\n{script}"
);
let offending = script.lines().find(|line| {
let trimmed = line.trim_start();
!trimmed.starts_with('#') && trimmed.contains("setopt noglob")
});
assert!(
offending.is_none(),
"`setopt noglob` disables globbing function-wide and blocks \
`_path_files` from expanding `*(-/)`, causing the literal \
pattern to leak as a completion candidate; offending line: \
{offending:?}\n\nfull script:\n{script}"
);
}
}