use crate::types::*;
pub fn generate(shell: &str, cmd: &CommandDef) -> Vec<u8> {
match shell {
"bash" => generate_bash(cmd),
"zsh" => generate_zsh(cmd),
"fish" => generate_fish(cmd),
"powershell" => generate_powershell(cmd),
_ => Vec::new(),
}
}
fn generate_bash(cmd: &CommandDef) -> Vec<u8> {
let name = cmd.name;
let mut out = String::new();
let subcmds: Vec<&str> = cmd.subcommands.iter().map(|s| s.name).collect();
let subcmd_list = subcmds.join(" ");
out.push_str(&format!(
r#"_deno() {{
local i cur prev opts cmds
COMPREPLY=()
cur="${{COMP_WORDS[COMP_CWORD]}}"
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
cmd=""
opts=""
for i in ${{COMP_WORDS[@]}}
do
case "${{cmd}},${{i}}" in
",${{COMP_WORDS[0]}}")
cmd="{name}"
;;
"#
));
for sub in cmd.subcommands {
out.push_str(&format!(
" \"{name},{}\")\n cmd=\"{name}__{}\"\n ;;\n",
sub.name,
sub.name.replace('-', "__"),
));
}
out
.push_str(" *)\n ;;\n esac\n done\n\n");
let root_flags: Vec<String> = cmd
.all_args()
.filter(|a| !a.hidden && !a.positional)
.filter_map(|a| a.long.map(|l| format!("--{l}")))
.collect();
out.push_str(&format!(
" case \"${{cmd}}\" in\n {name})\n opts=\"{} {}\"\n",
subcmd_list,
root_flags.join(" ")
));
out.push_str(
" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then\n",
);
out.push_str(
" COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )\n",
);
out.push_str(" return 0\n fi\n");
out.push_str(
" COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )\n",
);
out.push_str(" return 0\n ;;\n");
for sub in cmd.subcommands {
let sub_flags: Vec<String> = sub
.all_args()
.filter(|a| !a.hidden && !a.positional)
.filter_map(|a| a.long.map(|l| format!("--{l}")))
.collect();
out.push_str(&format!(
" {name}__{})\n opts=\"{}\"\n",
sub.name.replace('-', "__"),
sub_flags.join(" "),
));
out.push_str(
" COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )\n",
);
out.push_str(" return 0\n ;;\n");
}
out.push_str(" esac\n}\n\n");
out.push_str(&format!(
"complete -F _{name} -o bashdefault -o default {name}\n"
));
out.into_bytes()
}
fn zsh_description(help: &str) -> String {
let first_line = help.lines().next().unwrap_or_default().trim();
if first_line.is_empty() {
return String::new();
}
let mut escaped = String::with_capacity(first_line.len() + 2);
escaped.push('[');
for c in first_line.chars() {
match c {
'\\' | '[' | ']' => {
escaped.push('\\');
escaped.push(c);
}
'\'' => escaped.push_str("'\\''"),
_ => escaped.push(c),
}
}
escaped.push(']');
escaped
}
fn zsh_flag_spec(arg: &ArgDef, long: &str) -> String {
let desc = zsh_description(arg.help);
match arg.short {
Some(short) => {
format!("'(-{short} --{long})'{{-{short},--{long}}}'{desc}'")
}
None => format!("'--{long}{desc}'"),
}
}
fn generate_zsh(cmd: &CommandDef) -> Vec<u8> {
let name = cmd.name;
let mut out = String::new();
out.push_str(&format!("#compdef {name}\n\n"));
out.push_str("_deno_commands() {\n local commands; commands=(\n");
for sub in cmd.subcommands {
if sub.name == "help" {
continue;
}
let about = sub
.about
.lines()
.next()
.unwrap_or_default()
.trim()
.replace('\'', "'\\''");
out.push_str(&format!(" '{}:{}'\n", sub.name, about));
}
out.push_str(
" )\n _describe -t commands 'deno commands' commands\n}\n\n",
);
let default_sub = cmd
.default_subcommand
.and_then(|name| cmd.find_subcommand(name));
if default_sub.is_some() {
out.push_str(
"_deno_commands_or_files() {\n _alternative \\\n 'commands:command:_deno_commands' \\\n 'files:file:_files'\n}\n\n",
);
}
out.push_str(&format!("_{name}() {{\n"));
out.push_str(" local line state\n\n");
out.push_str(" _arguments -C \\\n");
let mut seen = std::collections::HashSet::new();
let root_args = cmd
.all_args()
.chain(default_sub.into_iter().flat_map(|sub| sub.all_args()));
for arg in root_args.filter(|a| !a.hidden && !a.positional) {
if let Some(long) = arg.long
&& seen.insert(long)
{
out.push_str(&format!(" {} \\\n", zsh_flag_spec(arg, long)));
}
}
if default_sub.is_some() {
out.push_str(" \":: :_deno_commands_or_files\" \\\n");
} else {
out.push_str(" \":: :_deno_commands\" \\\n");
}
out.push_str(" \"*::arg:->args\" \\\n");
out.push_str(" && ret=0\n\n");
out.push_str(" case $state in\n (args)\n");
out.push_str(" case $line[1] in\n");
for sub in cmd.subcommands {
if sub.name == "help" {
continue;
}
out.push_str(&format!(
" {})\n _arguments \\\n",
sub.name
));
for arg in sub.all_args().filter(|a| !a.hidden && !a.positional) {
if let Some(long) = arg.long {
out.push_str(&format!(
" {} \\\n",
zsh_flag_spec(arg, long)
));
}
}
out.push_str(" '*:file:_files'\n ;;\n");
}
if default_sub.is_some() {
out.push_str(
" *)\n (( CURRENT > 1 )) && _files\n ;;\n",
);
}
out.push_str(" esac\n ;;\n esac\n}\n\n");
out.push_str(&format!("_{name} \"$@\"\n"));
out.into_bytes()
}
fn generate_fish(cmd: &CommandDef) -> Vec<u8> {
let name = cmd.name;
let mut out = String::new();
out.push_str(&format!("complete -c {name} -e\n\n"));
out.push_str(&format!(
"function __fish_{name}_no_subcommand\n for i in (commandline -opc)\n"
));
for sub in cmd.subcommands {
out.push_str(&format!(
" if test $i = '{}'\n return 1\n end\n",
sub.name
));
}
out.push_str(" end\n return 0\nend\n\n");
for sub in cmd.subcommands {
if sub.name == "help" {
continue;
}
out.push_str(&format!(
"complete -c {name} -n __fish_{name}_no_subcommand -a {} -d '{}'\n",
sub.name,
sub.about.replace('\'', "\\'"),
));
}
out.push('\n');
for arg in cmd.all_args().filter(|a| !a.hidden && !a.positional) {
if let Some(long) = arg.long {
let desc = arg.help.replace('\'', "\\'");
if let Some(short) = arg.short {
out.push_str(&format!(
"complete -c {name} -s {short} -l {long} -d '{desc}'\n"
));
} else {
out.push_str(&format!("complete -c {name} -l {long} -d '{desc}'\n"));
}
}
}
out.push('\n');
for sub in cmd.subcommands {
if sub.name == "help" {
continue;
}
for arg in sub.all_args().filter(|a| !a.hidden && !a.positional) {
if let Some(long) = arg.long {
let desc = arg.help.replace('\'', "\\'");
out.push_str(&format!(
"complete -c {name} -n '__fish_seen_subcommand_from {}' -l {long} -d '{desc}'\n",
sub.name
));
}
}
}
out.into_bytes()
}
fn generate_powershell(cmd: &CommandDef) -> Vec<u8> {
let name = cmd.name;
let mut out = String::new();
out.push_str(&format!(
r#"Register-ArgumentCompleter -Native -CommandName '{name}' -ScriptBlock {{
param($wordToComplete, $commandAst, $cursorPosition)
$commandElements = $commandAst.CommandElements
$command = @(
'{name}'
for ($i = 1; $i -lt $commandElements.Count; $i++) {{
$element = $commandElements[$i]
if ($element -isnot [StringConstantExpressionAst] -or
$element.StringConstantType -ne [StringConstantType]::BareWord -or
$element.Value.StartsWith('-') -or
$element.Value -eq $wordToComplete) {{
break
}}
$element.Value
}}
) -join ';'
$completions = @(switch ($command) {{
"#
));
out.push_str(&format!(" '{name}' {{\n"));
for sub in cmd.subcommands {
if sub.name == "help" {
continue;
}
let about = sub.about.replace('\'', "''");
out.push_str(&format!(
" [CompletionResult]::new('{}', '{}', [CompletionResultType]::ParameterValue, '{}')\n",
sub.name, sub.name, about
));
}
out.push_str(" }\n");
for sub in cmd.subcommands {
if sub.name == "help" {
continue;
}
out.push_str(&format!(" '{name};{}' {{\n", sub.name));
for arg in sub.all_args().filter(|a| !a.hidden && !a.positional) {
if let Some(long) = arg.long {
let help = arg.help.replace('\'', "''");
out.push_str(&format!(
" [CompletionResult]::new('--{long}', '--{long}', [CompletionResultType]::ParameterName, '{help}')\n"
));
}
}
out.push_str(" }\n");
}
out.push_str(" })\n\n");
out.push_str(
" $completions.Where{ $_.CompletionText -like \"$wordToComplete*\" } |\n",
);
out.push_str(" Sort-Object -Property ListItemText\n}\n");
out.into_bytes()
}
pub const COMPLETE_ENV_VAR: &str = "COMPLETE";
pub fn generate_dynamic(shell: &str, bin: &str, completer: &str) -> Vec<u8> {
let escaped_name = bin.replace('-', "_");
let script = match shell {
"bash" => BASH_REGISTRATION
.replace("NAME", &escaped_name)
.replace("COMPLETER", completer)
.replace("VAR", COMPLETE_ENV_VAR)
.replace("BIN", bin),
"zsh" => ZSH_REGISTRATION
.replace("NAME", &escaped_name)
.replace("COMPLETER", completer)
.replace("VAR", COMPLETE_ENV_VAR)
.replace("BIN", bin),
"fish" => format!(
"complete --keep-order --exclusive --command {bin} --arguments \
\"({var}=fish {completer} -- (commandline --current-process --tokenize \
--cut-at-cursor) (commandline --current-token))\"\n",
var = COMPLETE_ENV_VAR,
),
_ => String::new(),
};
script.into_bytes()
}
const BASH_REGISTRATION: &str = r#"
_clap_complete_NAME() {
local IFS=$'\013'
local _CLAP_COMPLETE_INDEX=${COMP_CWORD}
local _CLAP_COMPLETE_COMP_TYPE=${COMP_TYPE}
if compopt +o nospace 2> /dev/null; then
local _CLAP_COMPLETE_SPACE=false
else
local _CLAP_COMPLETE_SPACE=true
fi
local words=("${COMP_WORDS[@]}")
if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
words[COMP_CWORD]="$2"
fi
COMPREPLY=( $( \
_CLAP_IFS="$IFS" \
_CLAP_COMPLETE_INDEX="$_CLAP_COMPLETE_INDEX" \
_CLAP_COMPLETE_COMP_TYPE="$_CLAP_COMPLETE_COMP_TYPE" \
_CLAP_COMPLETE_SPACE="$_CLAP_COMPLETE_SPACE" \
VAR="bash" \
"COMPLETER" -- "${words[@]}" \
) )
if [[ $? != 0 ]]; then
unset COMPREPLY
elif [[ $_CLAP_COMPLETE_SPACE == false ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then
compopt -o nospace
fi
}
if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then
complete -o nospace -o bashdefault -o nosort -F _clap_complete_NAME BIN
else
complete -o nospace -o bashdefault -F _clap_complete_NAME BIN
fi
"#;
const ZSH_REGISTRATION: &str = r#"#compdef BIN
function _clap_dynamic_completer_NAME() {
local _CLAP_COMPLETE_INDEX=$(expr $CURRENT - 1)
local _CLAP_IFS=$'\n'
local completions=("${(@f)$( \
_CLAP_IFS="$_CLAP_IFS" \
_CLAP_COMPLETE_INDEX="$_CLAP_COMPLETE_INDEX" \
VAR="zsh" \
COMPLETER -- "${words[@]}" 2>/dev/null \
)}")
if [[ -n $completions ]]; then
local -a dirs=()
local -a other=()
local completion
for completion in $completions; do
local value="${completion%%:*}"
if [[ "$value" == */ ]]; then
local dir_no_slash="${value%/}"
if [[ "$completion" == *:* ]]; then
local desc="${completion#*:}"
dirs+=("$dir_no_slash:$desc")
else
dirs+=("$dir_no_slash")
fi
else
other+=("$completion")
fi
done
[[ -n $dirs ]] && _describe -V 'values' dirs -o nosort -S '/' -r '/'
[[ -n $other ]] && _describe -V 'values' other -o nosort
fi
}
compdef _clap_dynamic_completer_NAME BIN"#;
pub type Candidate = (String, Option<String>);
pub type PositionalCompleter<'a> =
dyn Fn(&CommandDef, &str) -> Vec<Candidate> + 'a;
#[allow(clippy::disallowed_methods, reason = "reads completion index from env")]
pub fn try_complete(
cmd: &CommandDef,
args: &[String],
shell: &str,
positional_completer: &PositionalCompleter,
) -> bool {
let index = std::env::var("_CLAP_COMPLETE_INDEX")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(args.len().saturating_sub(1));
let words: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let current = words.get(index).copied().unwrap_or("");
let (active_cmd, _) = find_active_command(cmd, &words);
let candidates = get_candidates(
active_cmd,
cmd,
current,
&words,
index,
positional_completer,
);
let stdout = std::io::stdout();
let mut out = std::io::BufWriter::new(stdout.lock());
use std::io::Write;
let bash_ifs = std::env::var("_CLAP_IFS").ok();
for (i, (value, help)) in candidates.iter().enumerate() {
match shell {
"zsh" => {
if let Some(h) = help {
let _ = writeln!(out, "{}:{}", value, h);
} else {
let _ = writeln!(out, "{}", value);
}
}
"fish" => {
if let Some(h) = help {
let _ = writeln!(out, "{}\t{}", value, first_line(h));
} else {
let _ = writeln!(out, "{}", value);
}
}
_ => {
if i > 0 {
let _ = write!(out, "{}", bash_ifs.as_deref().unwrap_or("\n"));
}
let _ = write!(out, "{}", value);
}
}
}
true
}
fn first_line(s: &str) -> &str {
s.lines().next().unwrap_or_default()
}
fn find_active_command<'a>(
root: &'a CommandDef,
words: &[&str],
) -> (&'a CommandDef, Option<usize>) {
for (i, word) in words.iter().enumerate().skip(1) {
if word.starts_with('-') {
continue;
}
if let Some(sub) = root.find_subcommand(word) {
return (sub, Some(i));
}
break;
}
(root, None)
}
fn get_candidates(
active_cmd: &CommandDef,
root: &CommandDef,
current: &str,
words: &[&str],
index: usize,
positional_completer: &PositionalCompleter,
) -> Vec<Candidate> {
let mut candidates = Vec::new();
if index >= 2 {
let prev = words.get(index - 1).copied().unwrap_or("");
if prev.starts_with('-') && !prev.contains('=') {
let flag_name = prev.trim_start_matches('-');
let takes_value = active_cmd
.all_args()
.chain(root.all_args().filter(|a| a.global))
.any(|a| {
(a.long == Some(flag_name)
|| a.short.is_some_and(|c| c.to_string() == flag_name))
&& !matches!(a.action, ArgAction::SetTrue | ArgAction::Count)
&& a.num_args != NumArgs::Exact(0)
});
if takes_value {
return candidates; }
}
}
let want_flag = current.starts_with('-');
let in_subcommand = words
.iter()
.skip(1)
.any(|w| !w.starts_with('-') && root.find_subcommand(w).is_some());
if !want_flag {
let mut values: Vec<Candidate> = if !in_subcommand {
root
.subcommands
.iter()
.filter(|s| s.name != "help" && s.name != "json_reference")
.filter(|s| s.name.starts_with(current))
.map(|s| {
let help = (!s.about.is_empty()).then(|| s.about.to_string());
(s.name.to_string(), help)
})
.collect()
} else {
positional_completer(active_cmd, current)
.into_iter()
.filter(|(value, _)| value.starts_with(current))
.collect()
};
values.sort_by(|a, b| a.0.cmp(&b.0));
candidates.extend(values);
}
let mut flags: Vec<Candidate> = Vec::new();
for arg in active_cmd
.all_args()
.chain(root.all_args().filter(|a| a.global))
{
if arg.hidden || arg.positional {
continue;
}
if let Some(long) = arg.long {
let flag = format!("--{long}");
if flag.starts_with(current) {
let help = (!arg.help.is_empty()).then(|| arg.help.to_string());
flags.push((flag, help));
}
}
if want_flag && let Some(short) = arg.short {
let flag = format!("-{short}");
if flag.starts_with(current) && current.len() <= 2 {
flags.push((flag, None));
}
}
}
flags.sort_by(|a, b| a.0.cmp(&b.0));
candidates.extend(flags);
candidates
}
#[cfg(test)]
mod tests {
use super::*;
use crate::defs::DENO_ROOT;
fn no_positional(_: &CommandDef, _: &str) -> Vec<Candidate> {
Vec::new()
}
fn complete(
words: &[&str],
index: usize,
pc: &PositionalCompleter,
) -> Vec<Candidate> {
let current = words.get(index).copied().unwrap_or("");
let (active, _) = find_active_command(&DENO_ROOT, words);
get_candidates(active, &DENO_ROOT, current, words, index, pc)
}
fn values(c: &[Candidate]) -> Vec<&str> {
c.iter().map(|(v, _)| v.as_str()).collect()
}
#[test]
fn completes_subcommands_at_root() {
let c = complete(&["deno", ""], 1, &no_positional);
let v = values(&c);
assert!(v.contains(&"run"), "{v:?}");
assert!(v.contains(&"task"), "{v:?}");
assert!(v.contains(&"test"), "{v:?}");
assert!(!v.contains(&"json_reference"), "{v:?}");
}
#[test]
fn completes_subcommand_prefix() {
let c = complete(&["deno", "ru"], 1, &no_positional);
let v = values(&c);
assert!(v.contains(&"run"), "{v:?}");
assert!(!v.contains(&"task"), "{v:?}");
}
#[test]
fn completes_flags_in_subcommand() {
let c = complete(&["deno", "run", "--no-r"], 2, &no_positional);
let v = values(&c);
assert!(v.iter().all(|f| f.starts_with("--no-r")), "{v:?}");
assert!(v.contains(&"--no-remote"), "{v:?}");
}
#[test]
fn hidden_permission_flags_are_not_offered() {
let c = complete(&["deno", "run", "--allow-r"], 2, &no_positional);
assert!(c.is_empty(), "{:?}", values(&c));
}
#[test]
fn value_taking_flag_defers_to_file_completion() {
let c = complete(&["deno", "run", "--config", ""], 3, &no_positional);
assert!(c.is_empty(), "{:?}", values(&c));
}
#[test]
fn positional_completer_supplies_task_names_then_flags() {
fn tasks(cmd: &CommandDef, _: &str) -> Vec<Candidate> {
assert_eq!(cmd.name, "task");
vec![
("build".to_string(), Some("Build".to_string())),
("test".to_string(), None),
]
}
let c = complete(&["deno", "task", ""], 2, &tasks);
let v = values(&c);
assert_eq!(&v[0..2], &["build", "test"], "{v:?}");
assert!(v.iter().any(|f| f.starts_with("--")), "{v:?}");
}
#[test]
fn generate_bash_uses_real_newlines() {
let s = String::from_utf8(generate("bash", &DENO_ROOT)).unwrap();
assert!(
s.contains("\"deno,run\")\n cmd=\"deno__run\""),
"{s}"
);
assert!(s.contains(" *)\n ;;"), "{s}");
}
#[test]
fn generate_zsh_short_flags_keep_their_dash() {
let s =
String::from_utf8(generate("zsh", &crate::defs::DENO_ROOT)).unwrap();
assert!(s.contains("'(-h --help)'{-h,--help}'"), "{s}");
assert!(!s.contains("'(-h --help)'{h,--help}'"), "{s}");
for line in s.lines() {
let line = line.trim();
assert!(
!line.starts_with("'(-") || line.contains("'{-"),
"short flag lost its dash: {line}"
);
}
}
#[test]
fn generate_zsh_completes_files_for_bare_run() {
let s =
String::from_utf8(generate("zsh", &crate::defs::DENO_ROOT)).unwrap();
assert!(s.contains("_deno_commands_or_files() {"), "{s}");
assert!(s.contains("'files:file:_files'"), "{s}");
assert!(s.contains("\":: :_deno_commands_or_files\""), "{s}");
assert!(
s.contains(" *)\n (( CURRENT > 1 )) && _files\n"),
"{s}"
);
let root = &s[s.find("_deno() {").unwrap()..s.find("case $state").unwrap()];
assert!(root.contains("'--watch["), "{root}");
assert_eq!(root.matches("'--env-file[").count(), 1, "{root}");
}
#[test]
fn generate_zsh_descriptions_are_single_line_and_escaped() {
let s =
String::from_utf8(generate("zsh", &crate::defs::DENO_ROOT)).unwrap();
for line in s.lines() {
let line = line.trim();
if line.starts_with('\'') {
assert!(line.ends_with("' \\") || line.ends_with('\''), "{line}");
}
}
assert!(s.contains("'(-h --help)'{-h,--help}'' \\"), "{s}");
}
#[test]
fn zsh_description_escapes_and_truncates() {
assert_eq!(zsh_description(""), "");
assert_eq!(zsh_description("Print version"), "[Print version]");
assert_eq!(zsh_description("first\nsecond"), "[first]");
assert_eq!(
zsh_description("[possible values: a, b]"),
"[\\[possible values: a, b\\]]"
);
assert_eq!(zsh_description("don't"), "[don'\\''t]");
}
#[test]
fn generate_dynamic_bash_uses_callback_protocol() {
let s =
String::from_utf8(generate_dynamic("bash", "deno", "/bin/deno")).unwrap();
assert!(s.contains("_clap_complete_deno"), "{s}");
assert!(s.contains("_CLAP_COMPLETE_INDEX"), "{s}");
assert!(s.contains("/bin/deno"), "{s}");
assert!(s.contains("-F _clap_complete_deno deno"), "{s}");
}
#[test]
fn generate_dynamic_fish_is_exclusive_keep_order() {
let s =
String::from_utf8(generate_dynamic("fish", "deno", "/bin/deno")).unwrap();
assert!(s.contains("complete --keep-order --exclusive"), "{s}");
assert!(s.contains("COMPLETE=fish"), "{s}");
}
#[test]
fn generate_dynamic_zsh_preserves_order() {
let s =
String::from_utf8(generate_dynamic("zsh", "deno", "/bin/deno")).unwrap();
assert!(s.contains("#compdef deno"), "{s}");
assert!(s.contains("_clap_dynamic_completer_deno"), "{s}");
assert!(s.contains("-o nosort"), "{s}");
}
}