///|
fn collect_option_words(options : Array[CompletionOption]) -> String {
let words : Array[String] = []
for opt in options {
if opt.long is Some(name) {
words.push("--" + name)
}
if opt.short is Some(short) {
words.push("-" + short.to_string())
}
}
words.join(" ")
}
///|
fn collect_command_names(commands : Array[CompletionCommand]) -> String {
commands.map(fn(c) { c.name }).join(" ")
}
///|
fn escape_single_quote(s : String) -> String {
s.replace_all(old="'", new="'\\''")
}
///|
// Encode code points injectively: punctuation and Unicode never become shell
// identifiers, and distinct application names cannot overwrite each other.
fn completion_function(name : String) -> String {
let sb = StringBuilder()
sb.write_string("_cmder")
for c in name {
sb.write_string("_\{c.to_int()}")
}
sb.to_string()
}
///|
fn escape_fish_description(s : String) -> String {
s.replace_all(old="\\", new="\\\\").replace_all(old="'", new="\\'")
}
///|
fn escape_zsh_option_description(s : String) -> String {
s
.replace_all(old="\\", new="\\\\")
.replace_all(old="[", new="\\[")
.replace_all(old="]", new="\\]")
.replace_all(old=":", new="\\:")
}
///|
fn escape_zsh_command_description(s : String) -> String {
escape_single_quote(s.replace_all(old="\\", new="\\\\"))
}
///|
/// dollar sign as variable to avoid JS template literal collision
let dollar : String = "$"
// === Bash ===
///|
fn render_shell_scan(
sb : StringBuilder,
options : Array[CompletionOption],
commands : Array[CompletionCommand],
fn_name : String,
limit : String,
file_fallback~ : Bool = false,
) -> Unit {
sb.write_string(" local positional_seen=0\n")
sb.write_string(" while (( pos < " + limit + " )); do\n")
sb.write_string(" case \"" + dollar + "{words[pos]}\" in\n")
sb.write_string(
if file_fallback {
" --) _files; return ;;\n"
} else {
" --) return ;;\n"
},
)
for opt in options {
let patterns : Array[String] = []
if opt.long is Some(name) {
patterns.push("'--" + escape_single_quote(name) + "'")
}
if opt.short is Some(short) {
patterns.push("'-" + escape_single_quote(short.to_string()) + "'")
}
let increment = if opt.takes_value { 2 } else { 1 }
sb.write_string(
" " +
patterns.join("|") +
") ((pos += " +
increment.to_string() +
")) ;;\n",
)
if opt.takes_value && opt.long is Some(name) {
sb.write_string(
" '--" + escape_single_quote(name) + "'=*) ((pos += 1)) ;;\n",
)
}
}
for i, cmd in commands {
let sub_fn = fn_name + "_c" + i.to_string()
sb.write_string(
" '" +
escape_single_quote(cmd.name) +
"') ((pos += 1)); if (( !positional_seen )); then " +
sub_fn +
"; return; fi ;;\n",
)
}
let has_short_options = options.any(opt => opt.short is Some(_))
if has_short_options {
sb.write_string(" -[!-]*)\n")
sb.write_string(
" local rest=\"" + dollar + "{words[pos]#-}\" short\n",
)
sb.write_string(" ((pos += 1))\n")
sb.write_string(" while [[ -n \"" + dollar + "rest\" ]]; do\n")
sb.write_string(
" short=\"" +
dollar +
"{rest:0:1}\"; rest=\"" +
dollar +
"{rest:1}\"\n",
)
sb.write_string(" case \"" + dollar + "short\" in\n")
for opt in options {
if opt.short is Some(short) {
let action = if !opt.takes_value {
":"
} else {
"[[ -z \"" + dollar + "rest\" ]] && ((pos += 1)); break"
}
sb.write_string(
" '" +
escape_single_quote(short.to_string()) +
"') " +
action +
" ;;\n",
)
}
}
sb.write_string(
" *) break ;;\n esac\n done ;;\n",
)
}
sb.write_string(" *) positional_seen=1; ((pos += 1)) ;;\n")
sb.write_string(" esac\n done\n")
}
///|
fn render_bash_node(
sb : StringBuilder,
fn_name : String,
options : Array[CompletionOption],
commands : Array[CompletionCommand],
) -> Unit {
sb.write_string(fn_name + "() {\n")
render_shell_scan(sb, options, commands, fn_name, "cword")
sb.write_string(" (( pos > cword )) && return\n")
sb.write_string(
" local candidates='" +
escape_single_quote(collect_option_words(options)) +
"'\n",
)
sb.write_string(
" if (( !positional_seen )); then candidates='" +
escape_single_quote(collect_command_names(commands)) +
" '\"" +
dollar +
"candidates\"; fi\n",
)
sb.write_string(
" COMPREPLY=($(compgen -W \"" +
dollar +
"candidates" +
"\" -- \"" +
dollar +
"cur\"))\n",
)
sb.write_string("}\n\n")
for i, cmd in commands {
render_bash_node(
sb,
fn_name + "_c" + i.to_string(),
cmd.options,
cmd.subcommands,
)
}
}
///|
/// Generates a Bash completion script with Readline's default filename
/// fallback when no command or option matches, including values and `--`.
///
/// ```mbt check
/// test {
/// assert_true(
/// @cmder.cli(name="demo").render_bash_completion().contains("-o default -F"),
/// )
/// }
/// ```
pub fn CliApp::render_bash_completion(self : CliApp) -> String {
let sb = StringBuilder()
let fn_name = completion_function(self.root.name)
sb.write_string(fn_name + "() {\n")
sb.write_string(" COMPREPLY=()\n")
// Bash splits words at COMP_WORDBREAKS (notably '=' and ':'). Rejoin
// adjacent pieces using the original line, preserving actual whitespace.
// Never eval command-line text: quotes and substitutions remain inert.
sb.write_string(
(
#| local -a words=()
#| local cword=0 i word gap
#| local line="${COMP_LINE:0:COMP_POINT}"
#| for ((i = 0; i <= COMP_CWORD; i++)); do
#| word="${COMP_WORDS[i]}"
#| if (( i == COMP_CWORD && $# >= 2 )); then
#| word="$2"
#| fi
#| if [[ -n "$word" ]]; then
#| gap="${line%%"$word"*}"
#| line="${line#*"$word"}"
#| else
#| gap="$line"
#| fi
#| if (( i == 0 )); then
#| words[0]="$word"
#| elif [[ -n "$gap" ]]; then
#| ((cword += 1))
#| words[cword]="$word"
#| else
#| words[cword]+="$word"
#| fi
#| done
#|
),
)
sb.write_string(" local cur=\"" + dollar + "{words[cword]}\"\n")
sb.write_string(" local pos=1\n")
sb.write_string(" " + fn_name + "_root\n")
sb.write_string("}\n\n")
render_bash_node(
sb,
fn_name + "_root",
completion_options(self.root.options, version=true),
completion_commands(self.root.subcommands, self.root.options),
)
sb.write_string(
"complete -o bashdefault -o default -F " +
fn_name +
" '" +
escape_single_quote(self.root.name) +
"'\n",
)
sb.to_string()
}
// === Zsh ===
///|
fn render_zsh_options(
sb : StringBuilder,
options : Array[CompletionOption],
) -> Unit {
sb.write_string(" _arguments \\\n")
for opt in options {
let desc = escape_single_quote(
escape_zsh_option_description(opt.description),
)
let value_spec = if opt.takes_value { ":value:_files" } else { "" }
let spellings : Array[String] = []
if opt.short is Some(short) {
let suffix = if opt.takes_value { "+" } else { "" }
spellings.push("'-\{short}\{suffix}[\{desc}]\{value_spec}'")
}
if opt.long is Some(name) {
let suffix = if opt.takes_value { "=" } else { "" }
spellings.push("'--\{name}\{suffix}[\{desc}]\{value_spec}'")
}
let flag = spellings.join(" ")
sb.write_string(" \{flag} \\\n")
}
sb.write_string(" '*:path:_files'\n")
}
///|
fn render_zsh_command(
sb : StringBuilder,
cmd : CompletionCommand,
fn_name : String,
) -> Unit {
sb.write_string(fn_name + "() {\n")
sb.write_string(" local start=" + dollar + "((pos - 1))\n")
render_shell_scan(
sb,
cmd.options,
cmd.subcommands,
fn_name,
"CURRENT",
file_fallback=true,
)
sb.write_string(" if (( pos > CURRENT )); then _files; return; fi\n")
if cmd.subcommands.length() > 0 {
sb.write_string(" local -a commands\n")
sb.write_string(" commands=(\n")
for sub in cmd.subcommands {
let desc = escape_zsh_command_description(sub.description)
sb.write_string(" '" + sub.name + ":" + desc + "'\n")
}
sb.write_string(" )\n")
sb.write_string(" (( positional_seen )) || _describe 'command' commands\n")
}
if cmd.options.length() > 0 {
sb.write_string(
" local -a words=(\"" +
dollar +
"{words[@]:" +
dollar +
"((start - 1))}\")\n",
)
sb.write_string(" local CURRENT=" + dollar + "((CURRENT - start + 1))\n")
render_zsh_options(sb, cmd.options)
}
sb.write_string("}\n\n")
for i, sub in cmd.subcommands {
render_zsh_command(sb, sub, fn_name + "_c" + i.to_string())
}
}
///|
/// Generates a Zsh completion script with filename fallback for positionals,
/// values and tokens after `--`. Value options accept separated arguments,
/// `--name=value` and `-nvalue`; no application-specific value list is inferred.
///
/// ```mbt check
/// test {
/// assert_true(
/// @cmder.cli(name="demo").render_zsh_completion().contains("#compdef demo"),
/// )
/// }
/// ```
pub fn CliApp::render_zsh_completion(self : CliApp) -> String {
let sb = StringBuilder()
sb.write_string("#compdef " + self.root.name + "\n\n")
let fn_name = completion_function(self.root.name)
sb.write_string(fn_name + "() {\n")
sb.write_string(" local pos=2\n")
let root_options = completion_options(self.root.options, version=true)
let commands = completion_commands(self.root.subcommands, self.root.options)
render_shell_scan(
sb,
root_options,
commands,
fn_name,
"CURRENT",
file_fallback=true,
)
sb.write_string(" if (( pos > CURRENT )); then _files; return; fi\n")
sb.write_string(" local -a commands\n")
sb.write_string(" commands=(\n")
for cmd in commands {
let desc = escape_zsh_command_description(cmd.description)
sb.write_string(" '" + cmd.name + ":" + desc + "'\n")
}
sb.write_string(" )\n")
sb.write_string(" (( positional_seen )) || _describe 'command' commands\n")
render_zsh_options(sb, root_options)
sb.write_string("}\n\n")
for i, cmd in commands {
render_zsh_command(sb, cmd, fn_name + "_c" + i.to_string())
}
sb.write_string(
"compdef " + fn_name + " '" + escape_single_quote(self.root.name) + "'\n",
)
sb.to_string()
}
// === Fish ===
///|
fn render_fish_options(
sb : StringBuilder,
app_name : String,
condition : String,
options : Array[CompletionOption],
) -> Unit {
for opt in options {
let mut line = "complete -c " + app_name + " " + condition + " "
if opt.long is Some(name) {
line = line + " -l " + name
}
if opt.short is Some(short) {
line = line + " -s " + short.to_string()
}
if opt.description.length() > 0 {
line = line + " -d '" + escape_fish_description(opt.description) + "'"
}
if opt.takes_value {
line = line + " -r"
}
sb.write_string(line + "\n")
}
}
///|
// Emit one state per command path. Option values must not select subcommands.
fn render_fish_states(
sb : StringBuilder,
state : String,
options : Array[CompletionOption],
commands : Array[CompletionCommand],
) -> Unit {
sb.write_string(" case " + state + "\n switch \"$word\"\n")
for opt in options {
sb.write_string(" case")
if opt.long is Some(name) {
sb.write_string(" '--" + escape_single_quote(name) + "'")
}
if opt.short is Some(short) {
sb.write_string(" '-" + escape_single_quote(short.to_string()) + "'")
}
sb.write_string("\n")
if opt.takes_value {
sb.write_string(" set skip 1\n")
if opt.long is Some(name) {
sb.write_string(
" case '--" + escape_single_quote(name) + "=*'\n",
)
}
}
}
for i, cmd in commands {
sb.write_string(" case '" + escape_single_quote(cmd.name) + "'\n")
sb.write_string(
" if test $positional_seen -eq 0\n" +
" set state " +
state +
"_c" +
i.to_string() +
"\n" +
" end\n",
)
}
if options.any(opt => opt.short is Some(_)) {
sb.write_string(
(
#| case '-*'
#| set -l rest (string sub -s 2 -- "$word")
#| while test -n "$rest"
#| set -l short (string sub -l 1 -- "$rest")
#| set rest (string sub -s 2 -- "$rest")
#| switch "$short"
#|
),
)
for opt in options {
if opt.short is Some(short) {
sb.write_string(
" case '" +
escape_single_quote(short.to_string()) +
"'\n",
)
if opt.takes_value {
sb.write_string(
(
#| if test -z "$rest"
#| set skip 1
#| end
#| break
#|
),
)
}
}
}
sb.write_string(
" case '*'\n break\n end\n end\n",
)
}
sb.write_string(
" case '*'\n set positional_seen 1\n end\n",
)
for i, cmd in commands {
render_fish_states(
sb,
state + "_c" + i.to_string(),
cmd.options,
cmd.subcommands,
)
}
}
///|
fn render_fish_node(
sb : StringBuilder,
app_name : String,
context_fn : String,
state : String,
options : Array[CompletionOption],
commands : Array[CompletionCommand],
) -> Unit {
let condition = "-n '" + context_fn + " " + state + "'"
let command_condition = "-n '" + context_fn + " " + state + " commands'"
render_fish_options(sb, app_name, condition, options)
for i, cmd in commands {
let desc = escape_fish_description(cmd.description)
let line = "complete -c " +
app_name +
" " +
command_condition +
" -a " +
"'" +
escape_single_quote(cmd.name) +
"'" +
" -d '" +
desc +
"'"
sb.write_string(line + "\n")
render_fish_node(
sb,
app_name,
context_fn,
state + "_c" + i.to_string(),
cmd.options,
cmd.subcommands,
)
}
}
///|
/// Generates a Fish completion script for this application.
///
/// ```mbt check
/// test {
/// assert_true(
/// @cmder.cli(name="demo")
/// .render_fish_completion()
/// .contains("Fish completions"),
/// )
/// }
/// ```
pub fn CliApp::render_fish_completion(self : CliApp) -> String {
let sb = StringBuilder()
sb.write_string("# Fish completions for " + self.root.name + "\n")
let context_fn = completion_function(self.root.name) + "_context"
sb.write_string("function " + context_fn + "\n")
sb.write_string(
(
#| set -l words (commandline -opc)
#| set -l state root
#| set -l skip 0
#| set -l positional_seen 0
#| for word in $words[2..-1]
#| if test $skip -eq 1
#| set skip 0
#| continue
#| end
#| if test "$word" = --
#| return 1
#| end
#| switch $state
#|
),
)
let root_options = completion_options(self.root.options, version=true)
let commands = completion_commands(self.root.subcommands, self.root.options)
render_fish_states(sb, "root", root_options, commands)
sb.write_string(
(
#| end
#| end
#| if test (count $argv) -ge 2; and test "$argv[2]" = commands; and test $positional_seen -eq 1
#| return 1
#| end
#| test $skip -eq 0; and test "$state" = "$argv[1]"
#|end
#|
),
)
render_fish_node(
sb,
"'" + escape_fish_description(self.root.name) + "'",
context_fn,
"root",
root_options,
commands,
)
sb.to_string()
}