use super::spec::{ArgSpec, CommandSpec, ValueHint};
use super::{join_lines, style};
use rich::table::Table;
use rich::{Console, ConsoleOptions, Renderable, Segment, Text};
use std::fmt::Write as _;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Shell {
Bash,
Zsh,
Fish,
PowerShell,
}
impl Shell {
pub const ALL: [Shell; 4] = [Shell::Bash, Shell::Zsh, Shell::Fish, Shell::PowerShell];
}
impl std::fmt::Display for Shell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
Shell::Fish => "fish",
Shell::PowerShell => "powershell",
})
}
}
impl std::str::FromStr for Shell {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"bash" => Ok(Shell::Bash),
"zsh" => Ok(Shell::Zsh),
"fish" => Ok(Shell::Fish),
"powershell" | "pwsh" => Ok(Shell::PowerShell),
_ => Err(format!(
"unknown shell '{s}' (expected bash, zsh, fish, powershell or pwsh)"
)),
}
}
}
pub fn generate(spec: &CommandSpec, shell: Shell) -> String {
let nodes = nodes(spec);
match shell {
Shell::Bash => bash(spec, &nodes),
Shell::Zsh => zsh(spec, &nodes),
Shell::Fish => fish(spec, &nodes),
Shell::PowerShell => powershell(spec, &nodes),
}
}
struct Node<'a> {
id: String,
spec: &'a CommandSpec,
path: Vec<String>,
children: Vec<(String, String)>,
args: Vec<&'a ArgSpec>,
}
impl<'a> Node<'a> {
fn options(&self) -> impl Iterator<Item = &'a ArgSpec> + '_ {
self.args.iter().copied().filter(|a| !a.positional)
}
fn positionals(&self) -> impl Iterator<Item = &'a ArgSpec> + '_ {
self.args.iter().copied().filter(|a| a.positional)
}
fn grouped(&self) -> bool {
let first = self.options().next().map(ArgSpec::group);
self.options().any(|arg| Some(arg.group()) != first)
}
fn describe(&self, arg: &ArgSpec) -> String {
let help = one_line(&arg.help);
if self.grouped() {
if help.is_empty() {
arg.group().to_string()
} else {
format!("{}: {help}", arg.group())
}
} else {
help
}
}
}
fn nodes(root: &CommandSpec) -> Vec<Node<'_>> {
let mut out = vec![Node {
id: "n0".into(),
spec: root,
path: Vec::new(),
children: Vec::new(),
args: root.visible_args().collect(),
}];
let mut i = 0;
while i < out.len() {
let spec = out[i].spec;
let globals: Vec<&ArgSpec> = out[i].options().filter(|a| a.global).collect();
for child in spec.visible_subcommands() {
let id = format!("n{}", out.len());
for word in std::iter::once(&child.name).chain(&child.aliases) {
out[i].children.push((word.clone(), id.clone()));
}
let mut path = out[i].path.clone();
path.push(child.name.clone());
let mut args: Vec<&ArgSpec> = child.visible_args().collect();
for global in &globals {
if !child.args.iter().any(|a| a.id == global.id) {
args.push(global);
}
}
out.push(Node {
id,
spec: child,
path,
children: Vec::new(),
args,
});
}
i += 1;
}
out
}
fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn command_word(name: &str) -> String {
name.chars()
.filter(|c| !c.is_whitespace() && !c.is_control())
.collect()
}
fn ident(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn bash_words<'a>(words: impl IntoIterator<Item = &'a str>) -> String {
words
.into_iter()
.map(sh_quote)
.collect::<Vec<_>>()
.join(" ")
}
fn bash_value(hint: &ValueHint, f: &str) -> String {
match hint {
ValueHint::Choices(choices) => format!(
"{f}__match \"$cur\" {}",
bash_words(choices.iter().map(|c| c.value.as_str()))
),
ValueHint::File | ValueHint::Path => format!("{f}__compgen -f \"$cur\""),
ValueHint::Dir => format!("{f}__compgen -d \"$cur\""),
ValueHint::Command => format!("{f}__compgen -c \"$cur\""),
ValueHint::None | ValueHint::Any | ValueHint::Url => ":".into(),
}
}
fn bash(root: &CommandSpec, nodes: &[Node<'_>]) -> String {
let name = command_word(root.display_name());
let f = format!("_{}", ident(&name));
let mut s = String::new();
let _ = writeln!(s, "# bash completion for {name}");
let _ = writeln!(
s,
"# Generated by rich_ext::cli_doc; source it or install it under"
);
let _ = writeln!(s, "# ~/.local/share/bash-completion/completions/.");
let _ = writeln!(
s,
r#"
{f}__match() {{
# Add the words that start with the current word.
local cur="$1" word
shift
for word in "$@"; do
if [[ "$word" == "$cur"* ]]; then COMPREPLY+=("$word"); fi
done
}}
{f}__compgen() {{
# Add compgen's matches, one per line, so names with spaces survive.
local line
compopt -o filenames 2>/dev/null
while IFS= read -r line; do COMPREPLY+=("$line"); done < <(compgen "$1" -- "$2")
}}
{f}() {{
local cur="${{COMP_WORDS[COMP_CWORD]}}" prev="" node=n0 prefix="" i
COMPREPLY=()
if (( COMP_CWORD > 0 )); then prev="${{COMP_WORDS[COMP_CWORD-1]}}"; fi
# `--option=value`: bash splits at `=` when it is in COMP_WORDBREAKS...
if [[ "$cur" == "=" ]]; then cur=""; fi
if [[ "$prev" == "=" ]] && (( COMP_CWORD > 1 )); then prev="${{COMP_WORDS[COMP_CWORD-2]}}"; fi
# ...and keeps the word whole when it is not.
if [[ "$cur" == --*=* ]]; then prev="${{cur%%=*}}"; prefix="$prev="; cur="${{cur#*=}}"; fi
for (( i = 1; i < COMP_CWORD; i++ )); do
case "$node:${{COMP_WORDS[i]}}" in"#
);
for node in nodes {
for (word, child) in &node.children {
let _ = writeln!(
s,
" {}) node={child} ;;",
sh_quote(&format!("{}:{word}", node.id))
);
}
}
s.push_str(" esac\n done\n case \"$node\" in\n");
for node in nodes {
let _ = writeln!(s, " {})", node.id);
s.push_str(" case \"$prev\" in\n");
for arg in node.options().filter(|a| a.takes_value()) {
let _ = writeln!(
s,
" {}) {} ;;",
arg.switches()
.iter()
.map(|w| sh_quote(w))
.collect::<Vec<_>>()
.join("|"),
bash_value(&arg.value, &f)
);
}
s.push_str(" *)\n");
let switches: Vec<String> = node.options().flat_map(ArgSpec::switches).collect();
let options = if switches.is_empty() {
":".to_string()
} else {
format!(
"{f}__match \"$cur\" {}",
bash_words(switches.iter().map(String::as_str))
)
};
let _ = writeln!(
s,
" if [[ \"$cur\" == -* ]]; then\n {options}\n else"
);
let mut rest = Vec::new();
if !node.children.is_empty() {
rest.push(format!(
"{f}__match \"$cur\" {}",
bash_words(node.children.iter().map(|(w, _)| w.as_str()))
));
}
let mut seen = Vec::new();
for arg in node.positionals() {
let line = bash_value(&arg.value, &f);
if line != ":" && !seen.contains(&line) {
seen.push(line.clone());
rest.push(line);
}
}
if rest.is_empty() {
rest.push(":".into());
}
for line in rest {
let _ = writeln!(s, " {line}");
}
s.push_str(" fi ;;\n esac ;;\n");
}
let _ = writeln!(
s,
r#" esac
if [[ -n "$prefix" ]]; then
for i in "${{!COMPREPLY[@]}}"; do COMPREPLY[i]="$prefix${{COMPREPLY[i]}}"; done
fi
return 0
}}
complete -F {f} -o bashdefault -o default {}"#,
sh_quote(&name)
);
s
}
fn zsh_help(s: &str) -> String {
one_line(s)
.replace('\\', r"\\")
.replace('[', r"\[")
.replace(']', r"\]")
.replace(':', r"\:")
.replace('$', r"\$")
.replace('`', r"\`")
}
fn zsh_value(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii() && !c.is_ascii_alphanumeric() && !"-_./,+@".contains(c) {
out.push('\\');
}
out.push(c);
}
out
}
fn zsh_describe_entry(name: &str, help: &str) -> String {
let name = name.replace('\\', r"\\").replace(':', r"\:");
let help = one_line(help);
if help.is_empty() {
sh_quote(&name)
} else {
sh_quote(&format!("{name}:{help}"))
}
}
fn zsh_action(hint: &ValueHint) -> String {
match hint {
ValueHint::Choices(choices) if choices.iter().any(|c| !c.help.is_empty()) => {
let items: Vec<String> = choices
.iter()
.map(|c| {
let help = one_line(&c.help)
.replace('\\', r"\\")
.replace('"', "\\\"")
.replace('$', r"\$")
.replace('`', r"\`");
format!("{}\\:\"{help}\"", zsh_value(&c.value))
})
.collect();
format!("(({}))", items.join(" "))
}
ValueHint::Choices(choices) => {
let items: Vec<String> = choices.iter().map(|c| zsh_value(&c.value)).collect();
format!("({})", items.join(" "))
}
ValueHint::File | ValueHint::Path => "_files".into(),
ValueHint::Dir => "_files -/".into(),
ValueHint::Command => "_command_names -e".into(),
ValueHint::Url => "_urls".into(),
ValueHint::None | ValueHint::Any => " ".into(),
}
}
fn zsh_statement(hint: &ValueHint, message: &str) -> Option<String> {
Some(match hint {
ValueHint::Choices(choices) => {
let items: Vec<String> = choices
.iter()
.map(|c| zsh_describe_entry(&c.value, &c.help))
.collect();
format!(
"local -a values=({}); _describe -t values {} values && ret=0",
items.join(" "),
sh_quote(message)
)
}
ValueHint::File | ValueHint::Path => "_files && ret=0".into(),
ValueHint::Dir => "_files -/ && ret=0".into(),
ValueHint::Command => "_command_names -e && ret=0".into(),
ValueHint::Url => "_urls && ret=0".into(),
ValueHint::None | ValueHint::Any => return None,
})
}
fn slug(title: &str) -> String {
let mut out = String::new();
for c in title.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
out.trim_matches('-').to_string()
}
fn zsh(root: &CommandSpec, nodes: &[Node<'_>]) -> String {
let command = command_word(root.display_name());
let base = format!("_{}", ident(&command));
let fname = |node: &Node<'_>| {
let mut name = base.clone();
for part in &node.path {
name.push_str("__");
name.push_str(&ident(part));
}
if node.id != "n0" {
name.push_str("__");
name.push_str(&node.id);
}
name
};
let mut s = String::new();
let _ = writeln!(s, "#compdef {command}");
let _ = writeln!(
s,
"# zsh completion, generated by rich_ext::cli_doc. Put it on $fpath as"
);
let _ = writeln!(s, "# `{base}`, or source it after compinit.");
for node in nodes {
let name = fname(node);
let _ = writeln!(s, "\n{name}() {{");
s.push_str(" local cur=${words[CURRENT]} prev=${words[CURRENT-1]} ret=1\n");
let values: Vec<String> = node
.options()
.filter(|a| a.takes_value())
.flat_map(ArgSpec::switches)
.map(|w| sh_quote(&w))
.collect();
let _ = writeln!(s, " local -a value_options=({})", values.join(" "));
s.push_str(
" if [[ $cur == -* && $cur != *=* ]] && (( ! ${value_options[(Ie)$prev]} )); then\n",
);
s.push_str(" local -a group\n");
let mut groups: Vec<(&str, Vec<&ArgSpec>)> = Vec::new();
for arg in node.options() {
match groups.iter_mut().find(|(title, _)| *title == arg.group()) {
Some((_, members)) => members.push(arg),
None => groups.push((arg.group(), vec![arg])),
}
}
for (title, members) in &groups {
let entries: Vec<String> = members
.iter()
.flat_map(|arg| {
arg.switches()
.into_iter()
.map(|w| zsh_describe_entry(&w, &arg.help))
.collect::<Vec<_>>()
})
.collect();
let _ = writeln!(s, " group=({})", entries.join(" "));
let tag = slug(title);
let _ = writeln!(
s,
" _describe -t {} {} group && ret=0",
sh_quote(&tag),
sh_quote(title)
);
}
s.push_str(" return ret\n fi\n");
s.push_str(" local curcontext=$curcontext state state_descr line\n");
s.push_str(" typeset -A opt_args\n");
s.push_str(" _arguments -s -S -C");
let mut specs: Vec<String> = Vec::new();
for arg in node.options() {
let switches = arg.switches();
let exclusion = if arg.multiple {
"*".to_string()
} else {
format!("({})", switches.join(" "))
};
let help = zsh_help(&arg.help);
let desc = if help.is_empty() {
String::new()
} else {
format!("[{help}]")
};
for switch in &switches {
let spec = if arg.takes_value() {
let suffix = if switch.starts_with("--") { "=" } else { "+" };
let message = zsh_help(arg.value_name.as_deref().unwrap_or(&arg.id));
format!(
"{exclusion}{switch}{suffix}{desc}:{message}:{}",
zsh_action(&arg.value)
)
} else {
format!("{exclusion}{switch}{desc}")
};
specs.push(sh_quote(&spec));
}
}
let positionals: Vec<&ArgSpec> = node.positionals().collect();
let message = |arg: &ArgSpec| {
if arg.help.is_empty() {
zsh_help(arg.value_name.as_deref().unwrap_or(&arg.id))
} else {
zsh_help(&arg.help)
}
};
if node.children.is_empty() {
for arg in &positionals {
let lead = if arg.multiple {
"*:"
} else if arg.required {
":"
} else {
"::"
};
specs.push(sh_quote(&format!(
"{lead}{}:{}",
message(arg),
zsh_action(&arg.value)
)));
}
} else {
specs.push(sh_quote(": :->command"));
specs.push(sh_quote("*::: :->args"));
}
for spec in &specs {
let _ = write!(s, " \\\n {spec}");
}
s.push_str(" && ret=0\n");
if !node.children.is_empty() {
s.push_str(" case $state in\n (command)\n");
let commands: Vec<String> = node
.spec
.visible_subcommands()
.flat_map(|c| {
std::iter::once(&c.name)
.chain(&c.aliases)
.map(|w| zsh_describe_entry(w, &c.about))
.collect::<Vec<_>>()
})
.collect();
let heading = node
.spec
.subcommand_heading
.as_deref()
.unwrap_or("Commands");
let _ = writeln!(s, " local -a commands=({})", commands.join(" "));
let _ = writeln!(
s,
" _describe -t commands {} commands && ret=0",
sh_quote(heading)
);
if let Some(line) = positionals
.first()
.and_then(|a| zsh_statement(&a.value, &message(a)))
{
let _ = writeln!(s, " {line}");
}
s.push_str(" ;;\n (args)\n");
s.push_str(" words=($line[1] \"${words[@]}\")\n");
s.push_str(" (( CURRENT += 1 ))\n");
let _ = writeln!(
s,
" curcontext=\"${{curcontext%:*:*}}:{}-$line[1]:\"",
ident(&command)
);
s.push_str(" case $line[1] in\n");
for child in node.spec.visible_subcommands() {
let id = &node
.children
.iter()
.find(|(w, _)| *w == child.name)
.map(|(_, id)| id.clone())
.unwrap_or_default();
let target = nodes
.iter()
.find(|n| &n.id == id)
.map(fname)
.unwrap_or_default();
let words: Vec<String> = std::iter::once(&child.name)
.chain(&child.aliases)
.map(|w| sh_quote(w))
.collect();
let _ = writeln!(
s,
" ({}) {target} && ret=0 ;;",
words.join("|")
);
}
let rest = positionals
.get(1)
.or(positionals.first().filter(|a| a.multiple))
.and_then(|a| zsh_statement(&a.value, &message(a)));
if let Some(line) = rest {
let _ = writeln!(s, " (*) {line} ;;");
}
s.push_str(" esac\n ;;\n esac\n");
}
s.push_str(" return ret\n}\n");
}
let _ = writeln!(
s,
"\nif [[ $zsh_eval_context[-1] == loadautofunc ]]; then\n {base} \"$@\"\nelse\n compdef {base} {}\nfi",
sh_quote(&command)
);
s
}
fn fish_quote(s: &str) -> String {
format!(
"'{}'",
one_line(s).replace('\\', r"\\").replace('\'', r"\'")
)
}
fn fish(root: &CommandSpec, nodes: &[Node<'_>]) -> String {
let name = command_word(root.display_name());
let base = format!("__fish_{}", ident(&name));
let bin = fish_quote(&name);
let mut s = String::new();
let _ = writeln!(s, "# fish completion for {name}");
let _ = writeln!(s, "# Generated by rich_ext::cli_doc; install it under");
let _ = writeln!(s, "# ~/.config/fish/completions/.");
let _ = writeln!(s, "\nfunction {base}_node");
s.push_str(" # The subcommand node the command line is in.\n");
s.push_str(" set -l tokens (commandline -opc)\n set -e tokens[1]\n set -l node n0\n");
s.push_str(" for token in $tokens\n");
let mut first = true;
for node in nodes {
for (word, child) in &node.children {
let _ = writeln!(
s,
" {} test \"$node:$token\" = {}\n set node {child}",
if first { "if" } else { "else if" },
fish_quote(&format!("{}:{word}", node.id))
);
first = false;
}
}
if !first {
s.push_str(" end\n");
}
s.push_str(" end\n echo $node\nend\n");
let _ = writeln!(
s,
"\nfunction {base}_at\n test ({base}_node) = \"$argv[1]\"\nend"
);
let choice_fn = |node: &Node<'_>, index: usize| format!("{base}_{}_{index}", node.id);
let commands_fn = |node: &Node<'_>| format!("{base}_{}_commands", node.id);
for node in nodes {
let commands: Vec<String> = node
.spec
.visible_subcommands()
.flat_map(|command| {
let about = super::paragraphs(&command.about)
.into_iter()
.next()
.unwrap_or_default();
std::iter::once(&command.name)
.chain(&command.aliases)
.map(move |word| format!("{} {}", fish_quote(word), fish_quote(&about)))
.collect::<Vec<_>>()
})
.collect();
if !commands.is_empty() {
let _ = writeln!(
s,
"\nfunction {}\n printf '%s\\t%s\\n' {}\nend",
commands_fn(node),
commands.join(" ")
);
}
for (index, arg) in node.args.iter().enumerate() {
let choices = arg.choice_list();
if choices.is_empty() {
continue;
}
let items: Vec<String> = choices
.iter()
.map(|c| format!("{} {}", fish_quote(&c.value), fish_quote(&c.help)))
.collect();
let _ = writeln!(
s,
"\nfunction {}\n printf '%s\\t%s\\n' {}\nend",
choice_fn(node, index),
items.join(" ")
);
}
}
for node in nodes {
let condition = fish_quote(&format!("{base}_at {}", node.id));
let head = format!("complete -c {bin} -n {condition}");
s.push('\n');
let wants_files = node
.positionals()
.any(|a| matches!(a.value, ValueHint::File | ValueHint::Path | ValueHint::Any));
if !wants_files {
let _ = writeln!(s, "{head} -f");
}
for (index, arg) in node.args.iter().enumerate() {
let value = match &arg.value {
ValueHint::Choices(_) => format!(" -x -a '({})'", choice_fn(node, index)),
ValueHint::File | ValueHint::Path => " -r -F".into(),
ValueHint::Dir => " -x -a '(__fish_complete_directories)'".into(),
ValueHint::Command => " -x -a '(__fish_complete_command)'".into(),
ValueHint::Url => " -x".into(),
ValueHint::Any => " -r".into(),
ValueHint::None => String::new(),
};
if arg.positional {
if matches!(
arg.value,
ValueHint::Choices(_) | ValueHint::Dir | ValueHint::Command
) {
let value = value.replace(" -x", "");
let _ = writeln!(s, "{head}{value} -d {}", fish_quote(&node.describe(arg)));
}
continue;
}
let mut names = String::new();
for short in arg.short.iter().chain(&arg.short_aliases) {
let _ = write!(names, " -s {}", fish_quote(&short.to_string()));
}
for long in arg.long.iter().chain(&arg.aliases) {
let _ = write!(names, " -l {}", fish_quote(long));
}
let _ = writeln!(
s,
"{head}{names}{value} -d {}",
fish_quote(&node.describe(arg))
);
}
if node.spec.visible_subcommands().next().is_some() {
let _ = writeln!(s, "{head} -a '({})'", commands_fn(node));
}
}
s
}
fn ps_quote(s: &str) -> String {
let mut out = String::from("'");
for c in one_line(s).chars() {
if matches!(c, '\'' | '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}') {
out.push(c);
}
out.push(c);
}
out.push('\'');
out
}
fn ps_result(word: &str, kind: &str, tooltip: &str) -> String {
let tooltip = if tooltip.trim().is_empty() {
word
} else {
tooltip
};
format!(
"[CompletionResult]::new({w}, {w}, [CompletionResultType]::{kind}, {})",
ps_quote(tooltip),
w = ps_quote(word)
)
}
fn powershell(root: &CommandSpec, nodes: &[Node<'_>]) -> String {
let mut s = String::new();
let name = command_word(root.display_name());
let _ = writeln!(s, "# PowerShell completion for {name}");
s.push_str("# Generated by rich_ext::cli_doc; dot-source it from your $PROFILE.\n\n");
s.push_str("using namespace System.Management.Automation\n");
s.push_str("using namespace System.Management.Automation.Language\n\n");
let _ = writeln!(
s,
"Register-ArgumentCompleter -Native -CommandName {} -ScriptBlock {{",
ps_quote(&name)
);
s.push_str(" param($wordToComplete, $commandAst, $cursorPosition)\n\n");
s.push_str(" # Subcommand transitions, case-sensitive: 'node:word' -> node.\n");
s.push_str(
" $transitions = [System.Collections.Generic.Dictionary[string, string]]::new()\n",
);
for node in nodes {
for (word, child) in &node.children {
let _ = writeln!(
s,
" $transitions[{}] = '{child}'",
ps_quote(&format!("{}:{word}", node.id))
);
}
}
s.push_str(
r#" $node = 'n0'
$prev = ''
foreach ($element in @($commandAst.CommandElements | Select-Object -Skip 1)) {
if ($element.Extent.EndOffset -ge $cursorPosition) { break }
$text = $element.Extent.Text
if ($transitions.ContainsKey("${node}:$text")) { $node = $transitions["${node}:$text"] }
$prev = $text
}
$results = switch ($node) {
"#,
);
for node in nodes {
let _ = writeln!(s, " '{}' {{", node.id);
for arg in node.options().filter(|a| a.takes_value()) {
let test: Vec<String> = arg
.switches()
.iter()
.map(|w| format!("$prev -ceq {}", ps_quote(w)))
.collect();
let _ = writeln!(s, " if ({}) {{", test.join(" -or "));
match &arg.value {
ValueHint::Choices(choices) => {
for choice in choices {
let _ = writeln!(
s,
" {}",
ps_result(&choice.value, "ParameterValue", &choice.help)
);
}
s.push_str(" break\n");
}
_ => s.push_str(" return\n"),
}
s.push_str(" }\n");
}
s.push_str(" if ($wordToComplete.StartsWith('-')) {\n");
for arg in node.options() {
let description = node.describe(arg);
for switch in arg.switches() {
let _ = writeln!(
s,
" {}",
ps_result(&switch, "ParameterName", &description)
);
}
}
s.push_str(" break\n }\n");
for command in node.spec.visible_subcommands() {
let about = super::paragraphs(&command.about)
.into_iter()
.next()
.unwrap_or_default();
for word in std::iter::once(&command.name).chain(&command.aliases) {
let _ = writeln!(
s,
" {}",
ps_result(word, "ParameterValue", &about)
);
}
}
for arg in node.positionals() {
for choice in arg.choice_list() {
let _ = writeln!(
s,
" {}",
ps_result(&choice.value, "ParameterValue", &choice.help)
);
}
}
s.push_str(" break\n }\n");
}
s.push_str(
r#" }
$results | Where-Object { $_.CompletionText.StartsWith($wordToComplete, [System.StringComparison]::Ordinal) }
}
"#,
);
s
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CompletionKind {
Subcommand,
Option,
Value,
}
impl CompletionKind {
fn name(self) -> &'static str {
match self {
CompletionKind::Subcommand => "subcommand",
CompletionKind::Option => "option",
CompletionKind::Value => "value",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompletionItem {
pub path: Vec<String>,
pub word: String,
pub description: String,
pub group: String,
pub kind: CompletionKind,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CompletionCatalog {
pub name: String,
pub items: Vec<CompletionItem>,
}
impl CompletionCatalog {
pub fn from_spec(spec: &CommandSpec) -> Self {
let mut items = Vec::new();
collect(spec, &mut Vec::new(), &mut items);
CompletionCatalog {
name: spec.display_name().to_string(),
items,
}
}
}
fn collect(spec: &CommandSpec, path: &mut Vec<String>, items: &mut Vec<CompletionItem>) {
let mut push = |word: String, description: String, group: String, kind| {
items.push(CompletionItem {
path: path.clone(),
word,
description,
group,
kind,
})
};
for arg in spec.visible_args() {
if !arg.positional {
for switch in arg.switches() {
push(
switch,
one_line(&arg.help),
arg.group().to_string(),
CompletionKind::Option,
);
}
}
for choice in arg.choice_list() {
push(
choice.value.clone(),
one_line(&choice.help),
arg.names(),
CompletionKind::Value,
);
}
}
let heading = spec.subcommand_heading.as_deref().unwrap_or("Commands");
for command in spec.visible_subcommands() {
let about = super::paragraphs(&command.about)
.into_iter()
.next()
.unwrap_or_default();
for word in std::iter::once(&command.name).chain(&command.aliases) {
push(
word.clone(),
one_line(&about),
heading.to_string(),
CompletionKind::Subcommand,
);
}
}
for command in spec.visible_subcommands() {
path.push(command.name.clone());
collect(command, path, items);
path.pop();
}
}
impl Renderable for CompletionCatalog {
fn rich_render(&self, c: &Console, o: &ConsoleOptions) -> Vec<Segment> {
let mut table = Table::new();
for header in ["Command", "Word", "Kind", "Group", "Description"] {
table.add_column(header);
}
for item in &self.items {
let command = std::iter::once(self.name.as_str())
.chain(item.path.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" ");
let key = match item.kind {
CompletionKind::Subcommand => "help.command",
CompletionKind::Option => "help.option",
CompletionKind::Value => "help.metavar",
};
table.add_row_text(vec![
Text::new(command),
Text::styled(item.word.clone(), style(c, key)),
Text::new(item.kind.name()),
Text::new(item.group.clone()),
Text::new(item.description.clone()),
]);
}
join_lines(c.render_lines(&table, o, false))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nodes_are_numbered_breadth_first() {
let spec = CommandSpec::new("a")
.subcommand(CommandSpec::new("b").subcommand(CommandSpec::new("d")))
.subcommand(CommandSpec::new("c").alias("cc"));
let nodes = nodes(&spec);
let ids: Vec<(String, String)> = nodes
.iter()
.map(|n| (n.id.clone(), n.path.join(" ")))
.collect();
assert_eq!(
ids,
vec![
("n0".into(), "".into()),
("n1".into(), "b".into()),
("n2".into(), "c".into()),
("n3".into(), "b d".into()),
]
);
assert_eq!(
nodes[0].children,
vec![
("b".into(), "n1".into()),
("c".into(), "n2".into()),
("cc".into(), "n2".into())
]
);
}
#[test]
fn shells_parse_and_display() {
for shell in Shell::ALL {
assert_eq!(shell.to_string().parse::<Shell>(), Ok(shell));
}
assert_eq!("pwsh".parse::<Shell>(), Ok(Shell::PowerShell));
assert!("tcsh".parse::<Shell>().is_err());
}
#[test]
fn quoting() {
assert_eq!(sh_quote("it's"), r"'it'\''s'");
assert_eq!(fish_quote(r"a\b'c"), r"'a\\b\'c'");
assert_eq!(ps_quote("it’s"), "'it’’s'");
assert_eq!(zsh_help("a [b]: $x `y`"), r"a \[b\]\: \$x \`y\`");
}
}