/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
libs/cli_parser/src/completions.rs
738 строк
23 KB
Bartek Iwańczuk
refactor: port dynamic shell completions off clap_complete (#36035)
07 авг 2026, 17:59
Не верифицирован
07 авг 2026, 17:59
753517f
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. //! Shell completion script generators. //! //! Generate completion scripts from static `CommandDef` definitions //! for bash, zsh, fish, and powershell. use crate::types::*; /// Generate a completion script for the given shell. 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(); // Collect all subcommand names 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}" ;; "# )); // Add subcommand detection 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", ); // Root command completions 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"); // Subcommand completions 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 generate_zsh(cmd: &CommandDef) -> Vec<u8> { let name = cmd.name; let mut out = String::new(); out.push_str(&format!("#compdef {name}\n\n")); // Subcommands out.push_str("_deno_commands() {\n local commands; commands=(\n"); for sub in cmd.subcommands { if sub.name == "help" { continue; } let about = sub.about.replace('\'', "'\\''"); out.push_str(&format!(" '{}:{}'\n", sub.name, about)); } out.push_str( " )\n _describe -t commands 'deno commands' commands\n}\n\n", ); // Main function out.push_str(&format!("_{name}() {{\n")); out.push_str(" local line state\n\n"); out.push_str(" _arguments -C \\\n"); // Global flags for arg in cmd.all_args().filter(|a| !a.hidden && !a.positional) { if let Some(long) = arg.long { let help = arg.help.replace('\'', "'\\''"); if let Some(short) = arg.short { out.push_str(&format!( " '(-{short} --{long})'{{{short},--{long}}}'[{help}]' \\\n" )); } else { out.push_str(&format!(" '--{long}[{help}]' \\\n")); } } } out.push_str(" \":: :_deno_commands\" \\\n"); out.push_str(" \"*::arg:->args\" \\\n"); out.push_str(" && ret=0\n\n"); // Subcommand handling 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 { let help = arg.help.replace('\'', "'\\''"); out.push_str(&format!(" '--{long}[{help}]' \\\n")); } } out.push_str(" '*:file:_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(); // Disable file completions by default out.push_str(&format!("complete -c {name} -e\n\n")); // Condition helpers 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"); // Subcommand completions 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'); // Global flags 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'); // Per-subcommand flags 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) {{ "# )); // Root completions 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"); // Subcommand completions 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() } // ============================================================ // Dynamic completions // ============================================================ /// The env var the registration scripts set (to the shell name) so that a /// callback invocation of the binary is recognized as a completion request. /// Mirrors clap_complete's env protocol. pub const COMPLETE_ENV_VAR: &str = "COMPLETE"; /// Generate the *dynamic* completion registration script for `shell` /// (bash/fish/zsh). Unlike [`generate`] (which bakes the full command tree into /// a static script), this emits a small shell function that calls back into the /// binary with `COMPLETE=<shell>` set, so completions are computed live by /// [`try_complete`]. `bin` is the command name (e.g. `deno`); `completer` is /// the shell-quoted path to the executable to invoke. The wire protocol /// (`_CLAP_COMPLETE_INDEX`, `_CLAP_IFS`, `VAR=COMPLETE`) matches the scripts /// clap_complete used to emit, so behavior is unchanged. 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() } // Reproduced from clap_complete 4.5's `env::Bash::write_registration` so the // callback protocol is identical after dropping the clap_complete dependency. 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 "#; // Reproduced from Deno's custom `ZshCompleterUnsorted` (a modified // clap_complete zsh script that preserves candidate order via `-o nosort`). 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"#; /// A completion candidate: the value to insert, and optional help text. pub type Candidate = (String, Option<String>); /// Produces completion candidates for a positional argument the built-in /// engine can't resolve on its own (e.g. `deno task <TAB>` -> task names read /// from `deno.json`). Called with the active (sub)command and the current /// partial word. Returning an empty vec falls back to the shell's default /// (file) completion. This lives CLI-side so the parser crate needs no /// config/workspace dependencies. pub type PositionalCompleter<'a> = dyn Fn(&CommandDef, &str) -> Vec<Candidate> + 'a; /// Handle dynamic shell completion. Called when `COMPLETE` env var is set. /// /// Reads the command line from `args`, determines what to complete based on /// cursor position, and writes completion candidates to stdout using the /// clap_complete env protocol (`_CLAP_COMPLETE_INDEX` for the cursor position, /// `_CLAP_IFS` for the bash output separator), so the registration scripts /// emitted by [`generate_dynamic`] drive it unchanged. /// /// Returns `true` if completions were handled. #[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; // bash consumes candidates split on `_CLAP_IFS` (the registration script sets // it to `\013`); zsh/fish are newline-separated with a per-shell help suffix. 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); } } _ => { // bash: separate values by _CLAP_IFS, no trailing separator. 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 the previous word is a flag that takes a value, defer entirely to the // shell's file completion (mirrors clap emitting no candidates there). 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; // file completion } } } let want_flag = current.starts_with('-'); let in_subcommand = words .iter() .skip(1) .any(|w| !w.starts_with('-') && root.find_subcommand(w).is_some()); // Value candidates come first, matching clap's ordering: subcommand names at // the root, or positional values inside a subcommand (e.g. `deno task <TAB>` // -> task names via the CLI-provided completer). Skipped when the user is // clearly typing a flag (`-...`). 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() }; // clap's completion engine sorts candidates; the shells' `--keep-order` / // `nosort` then preserve that order, so match it (the spec `.out` files // encode this alphabetical ordering). values.sort_by(|a, b| a.0.cmp(&b.0)); candidates.extend(values); } // Then flags: long flags are always offered (so `deno task <TAB>` also lists // `--config` etc.); short flags only when the user has typed a leading `-`. 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() } /// Drive the engine the way `try_complete` does, but return the candidates /// instead of writing them to stdout. 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:?}"); // internal-only subcommands are hidden 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() { // Permission args are `hidden` (they're documented in the after-help // "Permission options" section instead), and clap's `.hide(true)` kept // them out of clap_complete's candidates too — so stay consistent. 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() { // `run --config <TAB>`: --config takes a value, so no candidates (the shell // does 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); // task names come first... assert_eq!(&v[0..2], &["build", "test"], "{v:?}"); // ...then the subcommand's long flags (e.g. --config) are also offered. assert!(v.iter().any(|f| f.starts_with("--")), "{v:?}"); } #[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}"); } }