use heck::ToKebabCase;
use std::string::String;
use std::vec::Vec;
use crate::schema::{ArgLevelSchema, ArgSchema, NamedValueMode, Schema, Subcommand};
#[derive(Debug, Clone, Copy, PartialEq, Eq, facet::Facet)]
#[repr(u8)]
pub enum Shell {
Bash,
Zsh,
Fish,
}
impl Shell {
pub fn detect() -> Option<Self> {
use std::env::var;
let shell = var("SHELL").ok().unwrap_or_default();
if shell.ends_with("bash") || var("BASH_VERSION").is_ok_and(|ver| !ver.is_empty()) {
Some(Self::Bash)
} else if shell.ends_with("fish") || var("FISH_VERSION").is_ok_and(|ver| !ver.is_empty()) {
Some(Self::Fish)
} else if shell.ends_with("zsh") || var("ZSH_VERSION").is_ok_and(|ver| !ver.is_empty()) {
Some(Self::Zsh)
} else {
None
}
}
}
pub fn generate_completions_for_shape(
shape: &'static facet_core::Shape,
shell: Shell,
program_name: &str,
) -> String {
let schema = match Schema::from_shape(shape) {
Ok(s) => s,
Err(_) => {
return format!("# Could not generate completions for {program_name}\n");
}
};
generate_completions_for_schema(&schema, shell, program_name)
}
pub fn generate_completions_for_schema(
schema: &Schema,
shell: Shell,
program_name: &str,
) -> String {
match shell {
Shell::Bash => generate_bash(schema.args(), program_name),
Shell::Zsh => generate_zsh(schema.args(), program_name),
Shell::Fish => generate_fish(schema.args(), program_name),
}
}
fn generate_bash(args: &ArgLevelSchema, program_name: &str) -> String {
let mut out = String::new();
generate_bash_function(&mut out, args, program_name, &[]);
generate_bash_subcommand_functions(&mut out, args, program_name, &[]);
out.push_str(&format!("complete -F _{program_name} {program_name}\n"));
out
}
fn generate_bash_function(
out: &mut String,
args: &ArgLevelSchema,
program_name: &str,
path: &[&str],
) {
let func_name = if path.is_empty() {
program_name.to_string()
} else {
format!("{}_{}", program_name, path.join("_").replace('-', "_"))
};
let (flags, subcommands) = collect_options(args);
out.push_str(&format!(
r#"_{func_name}() {{
local cur prev words cword
_init_completion || return
"#
));
if !flags.is_empty() {
out.push_str(" local flags=\"");
let mut first = true;
for flag in &flags {
for long_name in std::iter::once(&flag.long).chain(flag.aliases.iter()) {
if !first {
out.push(' ');
}
first = false;
out.push_str(&format!("--{long_name}"));
}
if let Some(short) = flag.short {
if !first {
out.push(' ');
}
first = false;
out.push_str(&format!("-{short}"));
}
}
out.push_str("\"\n");
} else {
out.push_str(" local flags=\"\"\n");
}
if !subcommands.is_empty() {
out.push_str(" local commands=\"");
let command_names: Vec<&str> = subcommands.iter().flat_map(|cmd| cmd.all_names()).collect();
for (i, cmd) in command_names.iter().enumerate() {
if i > 0 {
out.push(' ');
}
out.push_str(cmd);
}
out.push_str("\"\n");
} else {
out.push_str(" local commands=\"\"\n");
}
let value_flags: Vec<_> = flags
.iter()
.filter(|f| matches!(f.value_mode, NamedValueMode::RequiredValue))
.collect();
if !value_flags.is_empty() {
out.push_str("\n case \"$prev\" in\n");
for flag in &value_flags {
let mut cases = std::iter::once(&flag.long)
.chain(flag.aliases.iter())
.map(|long_name| format!("--{long_name}"))
.collect::<Vec<_>>();
if let Some(short) = flag.short {
cases.push(format!("-{short}"));
}
out.push_str(&format!(
" {})\n # Value expected, provide default file completion\n return\n ;;\n",
cases.join("|")
));
}
out.push_str(" esac\n");
}
if !subcommands.is_empty() {
out.push_str(&format!(
r#"
# Find the subcommand
local cmd_idx={}
local cmd=""
for ((i=1; i < cword; i++)); do
case "${{words[i]}}" in
-*)
# Skip flags
"#,
path.len() + 1
));
if !value_flags.is_empty() {
out.push_str(" case \"${words[i]}\" in\n");
for flag in &value_flags {
let mut cases = std::iter::once(&flag.long)
.chain(flag.aliases.iter())
.map(|long_name| format!("--{long_name}"))
.collect::<Vec<_>>();
if let Some(short) = flag.short {
cases.push(format!("-{short}"));
}
out.push_str(&format!(
" {}) ((i++)) ;;\n",
cases.join("|")
));
}
out.push_str(" esac\n");
}
out.push_str(
r#" ;;
*)
cmd="${words[i]}"
cmd_idx=$i
break
;;
esac
done
# If we're past the subcommand, dispatch to subcommand completer
if [[ -n "$cmd" && $cword -gt $cmd_idx ]]; then
case "$cmd" in
"#,
);
for cmd in &subcommands {
let sub_func = if path.is_empty() {
format!("{}_{}", program_name, cmd.name.replace('-', "_"))
} else {
format!("{}_{}", func_name, cmd.name.replace('-', "_"))
};
let case_patterns = cmd.all_names().collect::<Vec<_>>().join("|");
out.push_str(&format!(
" {})\n _{sub_func}\n return\n ;;\n",
case_patterns
));
}
out.push_str(
r#" esac
fi
# Complete flags or subcommands
if [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "$flags" -- "$cur"))
else
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
fi
}
"#,
);
} else {
out.push_str(
r#"
if [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "$flags" -- "$cur"))
fi
}
"#,
);
}
}
fn generate_bash_subcommand_functions(
out: &mut String,
args: &ArgLevelSchema,
program_name: &str,
path: &[&str],
) {
for (_, sub) in args.subcommands() {
let mut new_path = path.to_vec();
new_path.push(sub.cli_name());
generate_bash_function(out, sub.args(), program_name, &new_path);
generate_bash_subcommand_functions(out, sub.args(), program_name, &new_path);
}
}
fn generate_zsh(args: &ArgLevelSchema, program_name: &str) -> String {
let mut out = String::new();
out.push_str(&format!("#compdef {program_name}\n\n"));
generate_zsh_function(&mut out, args, program_name, program_name);
generate_zsh_subcommand_helpers(&mut out, args, program_name);
out.push_str(&format!(
r#"
if [ "$funcstack[1]" = "_{program_name}" ]; then
_{program_name} "$@"
else
compdef _{program_name} {program_name}
fi
"#
));
out
}
fn generate_zsh_function(
out: &mut String,
args: &ArgLevelSchema,
func_name: &str,
_program_name: &str,
) {
out.push_str(&format!(
r#"_{func_name}() {{
local -a options
local -a commands
local ret=1
"#
));
let (flags, subcommands) = collect_options(args);
out.push_str(" options=(\n");
for flag in &flags {
let desc = flag.doc.as_deref().unwrap_or("");
let escaped_desc = escape_zsh_description(desc);
let value_spec = match flag.value_mode {
NamedValueMode::RequiredValue => ":value:_default",
NamedValueMode::OptionalValue => "::value:_default",
NamedValueMode::BoolFlag | NamedValueMode::CountedFlag => "",
};
let long_names: Vec<&str> = std::iter::once(flag.long.as_str())
.chain(flag.aliases.iter().map(|alias| alias.as_str()))
.collect();
let synonymous = flag
.short
.into_iter()
.map(|short| format!("-{short}"))
.chain(long_names.iter().map(|long_name| format!("--{long_name}")))
.collect::<Vec<_>>();
let exclusion_group = if synonymous.len() > 1 {
Some(format!("'({})'", synonymous.join(" ")))
} else {
None
};
if let Some(short) = flag.short {
if let Some(exclusion_group) = exclusion_group.as_deref() {
out.push_str(&format!(
" {exclusion_group}-{short}'[{escaped_desc}]{value_spec}'\n",
));
} else {
out.push_str(&format!(
" '-{short}[{escaped_desc}]{value_spec}'\n",
));
}
}
for long_name in long_names {
if let Some(exclusion_group) = exclusion_group.as_deref() {
out.push_str(&format!(
" {exclusion_group}--{long_name}'[{escaped_desc}]{value_spec}'\n",
));
} else {
out.push_str(&format!(
" '--{long_name}[{escaped_desc}]{value_spec}'\n",
));
}
}
}
out.push_str(" )\n\n");
if !subcommands.is_empty() {
out.push_str(" commands=(\n");
for cmd in &subcommands {
let desc = cmd.doc.as_deref().unwrap_or("");
let escaped_desc = escape_zsh_description(desc);
for name in cmd.all_names() {
out.push_str(&format!(
" '{name}:{escaped_desc}'\n",
name = name
));
}
}
out.push_str(" )\n\n");
out.push_str(&format!(
r#" _arguments -C \
$options \
"1: :->command" \
"*::arg:->args" \
&& ret=0
case $state in
command)
_describe -t commands '{func_name} commands' commands && ret=0
;;
args)
case $words[1] in
"#
));
for cmd in &subcommands {
let sub_func = format!("{}_{}", func_name, cmd.name.replace('-', "_"));
let case_patterns = cmd.all_names().collect::<Vec<_>>().join("|");
out.push_str(&format!(
" {name})\n _{sub_func} && ret=0\n ;;\n",
name = case_patterns,
));
}
out.push_str(
r#" esac
;;
esac
return ret
}
"#,
);
} else {
out.push_str(
r#" _arguments $options && ret=0
return ret
}
"#,
);
}
}
fn generate_zsh_subcommand_helpers(out: &mut String, args: &ArgLevelSchema, parent_func: &str) {
for (_, sub) in args.subcommands() {
let func_name = format!("{}_{}", parent_func, sub.cli_name().replace('-', "_"));
generate_zsh_function(out, sub.args(), &func_name, parent_func);
generate_zsh_subcommand_helpers(out, sub.args(), &func_name);
}
}
fn escape_zsh_description(s: &str) -> String {
s.replace('\'', "'\\''")
.replace('[', "\\[")
.replace(']', "\\]")
.replace(':', "\\:")
}
fn generate_fish(args: &ArgLevelSchema, program_name: &str) -> String {
let mut out = String::new();
out.push_str(&format!("# Fish completion for {program_name}\n\n"));
generate_fish_level(&mut out, args, program_name, &[]);
generate_fish_subcommands(&mut out, args, program_name, &[]);
out
}
fn generate_fish_level(out: &mut String, args: &ArgLevelSchema, program_name: &str, path: &[&str]) {
let (flags, subcommands) = collect_options(args);
let condition = if path.is_empty() {
"__fish_use_subcommand".to_string()
} else {
let seen_checks: Vec<String> = path
.iter()
.map(|cmd| format!("__fish_seen_subcommand_from {cmd}"))
.collect();
seen_checks.join("; and ")
};
if !path.is_empty() {
out.push_str(&format!("\n# {} subcommand\n", path.join(" ")));
}
for flag in &flags {
let desc = flag.doc.as_deref().unwrap_or("");
out.push_str(&format!("complete -c {program_name}"));
if !path.is_empty() {
out.push_str(&format!(" -n '{condition}'"));
}
if let Some(short) = flag.short {
out.push_str(&format!(" -s {short}"));
}
for long_name in std::iter::once(&flag.long).chain(flag.aliases.iter()) {
out.push_str(&format!(" -l {long_name}"));
}
if matches!(flag.value_mode, NamedValueMode::RequiredValue | NamedValueMode::OptionalValue) {
out.push_str(" -r");
}
if !desc.is_empty() {
let escaped_desc = desc.replace('\'', "'\\''");
out.push_str(&format!(" -d '{escaped_desc}'"));
}
out.push('\n');
}
if !subcommands.is_empty() {
out.push_str(&format!(
"\n# {prefix}subcommands\n",
prefix = if path.is_empty() { "" } else { "Nested " }
));
let sub_names: Vec<&str> = subcommands.iter().flat_map(|s| s.all_names()).collect();
let no_sub_condition = if path.is_empty() {
"__fish_use_subcommand".to_string()
} else {
format!(
"{}; and not __fish_seen_subcommand_from {}",
condition,
sub_names.join(" ")
)
};
for cmd in &subcommands {
let desc = cmd.doc.as_deref().unwrap_or("");
let names = cmd.all_names().collect::<Vec<_>>();
for name in names {
out.push_str(&format!(
"complete -c {program_name} -n '{no_sub_condition}' -f -a {name}"
));
let display_desc = if name == cmd.name {
desc.to_string()
} else {
format!("Alias for {}", cmd.name)
};
if !display_desc.is_empty() {
let escaped_desc = display_desc.replace('\'', "'\\''");
out.push_str(&format!(" -d '{escaped_desc}'"));
}
out.push('\n');
}
}
}
}
fn generate_fish_subcommands(
out: &mut String,
args: &ArgLevelSchema,
program_name: &str,
path: &[&str],
) {
for (_, sub) in args.subcommands() {
let mut new_path = path.to_vec();
new_path.push(sub.cli_name());
generate_fish_level(out, sub.args(), program_name, &new_path);
generate_fish_subcommands(out, sub.args(), program_name, &new_path);
}
}
struct FlagInfo {
long: String,
aliases: Vec<String>,
short: Option<char>,
doc: Option<String>,
value_mode: NamedValueMode,
}
#[derive(Clone)]
struct SubcommandInfo {
name: String,
aliases: Vec<String>,
doc: Option<String>,
}
impl SubcommandInfo {
fn all_names(&self) -> impl Iterator<Item = &str> {
std::iter::once(self.name.as_str()).chain(self.aliases.iter().map(|alias| alias.as_str()))
}
}
fn collect_options(args: &ArgLevelSchema) -> (Vec<FlagInfo>, Vec<SubcommandInfo>) {
let mut flags = Vec::new();
let mut subcommands = Vec::new();
for (name, arg) in args.args() {
if !arg.kind().is_positional() {
flags.push(arg_to_flag(name, arg));
}
}
for sub in args.subcommands().values() {
subcommands.push(subcommand_to_info(sub));
}
(flags, subcommands)
}
fn arg_to_flag(name: &str, arg: &ArgSchema) -> FlagInfo {
let value_mode = arg
.named_value_mode()
.expect("arg_to_flag called on a non-named argument");
FlagInfo {
long: name.to_kebab_case(),
aliases: arg.aliases().to_vec(),
short: arg.kind().short(),
doc: arg.docs().summary().map(|s| s.trim().to_string()),
value_mode,
}
}
fn subcommand_to_info(sub: &Subcommand) -> SubcommandInfo {
SubcommandInfo {
name: sub.cli_name().to_string(),
aliases: sub.aliases().to_vec(),
doc: sub.docs().summary().map(|s| s.trim().to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use facet::Facet;
use figue_attrs as args;
#[derive(Facet)]
struct CommonArgs {
#[facet(args::named, crate::short = 'v')]
verbose: bool,
#[facet(args::named, crate::short = 'q')]
quiet: bool,
}
#[derive(Facet)]
struct ArgsWithFlatten {
#[facet(args::positional)]
input: String,
#[facet(flatten)]
common: CommonArgs,
}
#[test]
fn test_flatten_args_appear_in_completions() {
let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
assert!(
completions.contains("--verbose"),
"completions should contain --verbose from flattened CommonArgs"
);
assert!(
completions.contains("-v"),
"completions should contain -v short flag"
);
assert!(
completions.contains("--quiet"),
"completions should contain --quiet from flattened CommonArgs"
);
assert!(
completions.contains("-q"),
"completions should contain -q short flag"
);
assert!(
!completions.contains("--common"),
"completions should not show --common as a flag"
);
}
#[derive(Facet)]
struct ArgsWithRename {
#[facet(args::named, rename = "debug-mode")]
debug: bool,
#[facet(args::named, rename = "out")]
output_file: String,
}
#[test]
fn test_rename_respected_in_completions() {
let schema = Schema::from_shape(ArgsWithRename::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
assert!(
completions.contains("--debug-mode"),
"completions should contain --debug-mode (renamed from debug)"
);
assert!(
completions.contains("--out"),
"completions should contain --out (renamed from output_file)"
);
assert!(
!completions.contains("--debug ") && !completions.contains("--debug\n"),
"completions should not show --debug (was renamed to --debug-mode)"
);
assert!(
!completions.contains("--output-file"),
"completions should not show --output-file (was renamed to --out)"
);
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum CommandWithRename {
List,
#[facet(rename = "rm")]
Remove,
}
#[derive(Facet)]
struct ArgsWithRenamedSubcommand {
#[facet(args::subcommand)]
command: Option<CommandWithRename>,
}
#[test]
fn test_subcommand_rename_respected_in_completions() {
let schema = Schema::from_shape(ArgsWithRenamedSubcommand::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
assert!(
completions.contains("list"),
"completions should contain 'list' subcommand"
);
assert!(
completions.contains("rm"),
"completions should contain 'rm' subcommand (renamed from Remove)"
);
assert!(
!completions.contains("remove"),
"completions should not show 'remove' (was renamed to 'rm')"
);
}
#[test]
fn test_zsh_completions_with_docs() {
let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Zsh, "myapp");
assert!(
completions.contains("verbose output"),
"zsh completions should include doc for --verbose"
);
assert!(
completions.contains("quiet mode"),
"zsh completions should include doc for --quiet"
);
}
#[test]
fn test_fish_completions_with_docs() {
let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Fish, "myapp");
assert!(
completions.contains("verbose output"),
"fish completions should include doc for --verbose"
);
assert!(
completions.contains("quiet mode"),
"fish completions should include doc for --quiet"
);
}
#[derive(Facet)]
#[allow(dead_code)]
struct GitLikeArgs {
#[facet(args::named)]
version: bool,
#[facet(args::subcommand)]
command: Option<GitCommand>,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum GitCommand {
Clone {
#[facet(args::positional)]
url: String,
#[facet(default, args::named, args::short = 'b')]
branch: Option<String>,
},
Remote {
#[facet(args::subcommand)]
action: RemoteAction,
},
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum RemoteAction {
Add {
#[facet(args::positional)]
name: String,
},
#[facet(rename = "rm")]
Remove {
#[facet(args::positional)]
name: String,
},
}
#[test]
fn test_zsh_inline_sourcing_format() {
let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Zsh, "myapp");
assert!(
completions.contains("compdef _myapp myapp"),
"zsh completions should have compdef for inline sourcing"
);
assert!(
completions.contains(r#"if [ "$funcstack[1]" = "_myapp" ]"#),
"zsh completions should detect autoload vs inline sourcing"
);
}
#[test]
fn test_nested_subcommands_zsh() {
let schema = Schema::from_shape(GitLikeArgs::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Zsh, "git");
assert!(
completions.contains("'clone:"),
"zsh completions should have clone subcommand"
);
assert!(
completions.contains("'remote:"),
"zsh completions should have remote subcommand"
);
assert!(
completions.contains("_git_remote()"),
"zsh completions should generate function for remote subcommand"
);
assert!(
completions.contains("'rm:"),
"zsh completions should have 'rm' subcommand (renamed from Remove)"
);
}
#[test]
fn test_nested_subcommands_bash() {
let schema = Schema::from_shape(GitLikeArgs::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Bash, "git");
assert!(
completions.contains("clone"),
"bash completions should have clone subcommand"
);
assert!(
completions.contains("remote"),
"bash completions should have remote subcommand"
);
assert!(
completions.contains("_git_remote()"),
"bash completions should generate function for remote subcommand"
);
assert!(
completions.contains("rm"),
"bash completions should have 'rm' subcommand"
);
}
#[test]
fn test_nested_subcommands_fish() {
let schema = Schema::from_shape(GitLikeArgs::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Fish, "git");
assert!(
completions.contains("-a clone"),
"fish completions should have clone subcommand"
);
assert!(
completions.contains("-a remote"),
"fish completions should have remote subcommand"
);
assert!(
completions.contains("__fish_seen_subcommand_from remote"),
"fish completions should handle remote subcommand context"
);
assert!(
completions.contains("-a rm"),
"fish completions should have 'rm' subcommand"
);
}
#[test]
fn test_value_flags_distinguished_from_bool_flags() {
let schema = Schema::from_shape(ArgsWithRename::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Zsh, "myapp");
assert!(
!completions.contains("--debug-mode[Enable debug mode]:value"),
"bool flag should not require value"
);
assert!(
completions.contains("--out[Set output file]:value:_default"),
"string flag should require value"
);
}
#[derive(Facet)]
struct ArgsWithAlias {
#[facet(args::named, args::alias = "colour")]
color: bool,
}
#[derive(Facet)]
struct ArgsWithShortAlias {
#[facet(args::named, args::short = 'c', args::alias = "colour")]
color: bool,
}
#[derive(Facet)]
#[repr(u8)]
enum CommandWithAlias {
#[facet(args::alias = "profiles")]
Profile,
}
#[derive(Facet)]
struct ArgsWithAliasedSubcommand {
#[facet(args::subcommand)]
command: Option<CommandWithAlias>,
}
#[test]
fn test_aliases_appear_in_bash_completions() {
let schema = Schema::from_shape(ArgsWithAlias::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
assert!(completions.contains("--colour"), "expected alias in bash completions: {completions}");
}
#[test]
fn test_aliases_appear_in_fish_completions() {
let schema = Schema::from_shape(ArgsWithAlias::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Fish, "myapp");
assert!(completions.contains(" -l colour"), "expected alias in fish completions: {completions}");
}
#[test]
fn test_aliases_share_one_zsh_exclusion_group() {
let schema = Schema::from_shape(ArgsWithShortAlias::SHAPE).unwrap();
let completions = generate_completions_for_schema(&schema, Shell::Zsh, "myapp");
let group = "'(-c --color --colour)'";
assert!(
completions.contains(&format!("{group}-c")),
"expected short flag to use shared exclusion group: {completions}"
);
assert!(
completions.contains(&format!("{group}--color")),
"expected canonical long flag to use shared exclusion group: {completions}"
);
assert!(
completions.contains(&format!("{group}--colour")),
"expected alias long flag to use shared exclusion group: {completions}"
);
}
#[test]
fn test_subcommand_aliases_appear_in_completions() {
let schema = Schema::from_shape(ArgsWithAliasedSubcommand::SHAPE).unwrap();
let bash = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
let fish = generate_completions_for_schema(&schema, Shell::Fish, "myapp");
assert!(bash.contains("profiles"), "expected subcommand alias in bash completions: {bash}");
assert!(fish.contains("profiles"), "expected subcommand alias in fish completions: {fish}");
}
}