use super::{
PROTOCOL_COMMAND, PROTOCOL_ENV, PROTOCOL_LINE_ENV, PROTOCOL_VERSION, PROTOCOL_WORDS_ENV,
ScriptError, Shell,
};
pub(super) fn render(command: &str, shell: Shell) -> Result<String, ScriptError> {
if !valid_command_name(command) {
return Err(ScriptError::InvalidCommandName { name: command.to_owned() });
}
Ok(match shell {
Shell::Bash => bash_script(command),
Shell::Fish => fish_script(command),
Shell::Nushell => nushell_script(command),
Shell::Zsh => zsh_script(command),
})
}
fn valid_command_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('-')
&& !matches!(name, "." | "..")
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+'))
}
fn bash_script(command: &str) -> String {
format!(
r#"# @generated by argx
_argx_complete_{command}() {{
local __argx_out __argx_line __argx_value
__argx_out="$({PROTOCOL_ENV}={PROTOCOL_VERSION} \
{PROTOCOL_LINE_ENV}="${{COMP_LINE:0:$COMP_POINT}}" \
command '{command}' {PROTOCOL_COMMAND} 2>/dev/null)" || return 0
COMPREPLY=()
while IFS= read -r __argx_line; do
[[ -z "$__argx_line" ]] && continue
__argx_value="${{__argx_line%%$'\t'*}}"
COMPREPLY+=("$__argx_value")
done <<< "$__argx_out"
}}
complete -F _argx_complete_{command} '{command}'
"#,
)
}
fn fish_script(command: &str) -> String {
format!(
r#"# @generated by argx
function __argx_complete_{command}
set -lx {PROTOCOL_ENV} {PROTOCOL_VERSION}
set -lx {PROTOCOL_LINE_ENV} (commandline -cp)
command '{command}' {PROTOCOL_COMMAND} 2>/dev/null
end
complete --command '{command}' --no-files --arguments '(__argx_complete_{command})'
"#,
)
}
fn nushell_identifier(command: &str) -> String {
let mut identifier = String::with_capacity(command.len());
for byte in command.bytes() {
if byte.is_ascii_alphanumeric() {
identifier.push(char::from(byte));
} else {
identifier.push_str(&format!("_x{byte:02x}"));
}
}
identifier
}
fn nushell_script(command: &str) -> String {
let identifier = nushell_identifier(command);
format!(
r#"# @generated by argx
def __argx_complete_{identifier} [spans: list<string>] {{
let out = (with-env {{
{PROTOCOL_ENV}: "{PROTOCOL_VERSION}",
{PROTOCOL_WORDS_ENV}: ($spans | to json -r),
}} {{
^{command} {PROTOCOL_COMMAND} | complete
}})
if $out.exit_code != 0 {{
return []
}}
$out.stdout
| lines
| where {{|line| $line != "" }}
| each {{|line|
let parts = ($line | split row (char tab))
{{
value: ($parts | get 0)
description: (if ($parts | length) > 1 {{ $parts | get 1 }} else {{ "" }})
}}
}}
}}
let __argx_previous_{identifier} = ($env.config.completions.external.completer? | default null)
$env.config.completions.external.completer = {{|spans|
if ($spans | get 0) == "{command}" {{
__argx_complete_{identifier} $spans
}} else if $__argx_previous_{identifier} != null {{
do $__argx_previous_{identifier} $spans
}} else {{
null
}}
}}
"#,
)
}
fn zsh_script(command: &str) -> String {
format!(
r#"#compdef {command}
# @generated by argx
_{command}() {{
local __argx_line __argx_value __argx_description
local -a __argx_values=() __argx_display=()
while IFS=$'\t' read -r __argx_value __argx_description; do
[[ -z "$__argx_value" ]] && continue
__argx_values+=("$__argx_value")
if [[ -n "$__argx_description" ]]; then
__argx_display+=("$__argx_value -- $__argx_description")
else
__argx_display+=("$__argx_value")
fi
done < <({PROTOCOL_ENV}={PROTOCOL_VERSION} \
{PROTOCOL_LINE_ENV}="${{BUFFER[1,CURSOR]}}" \
command '{command}' {PROTOCOL_COMMAND} 2>/dev/null)
(( ${{#__argx_values[@]}} )) || return 1
compadd -l -U -d __argx_display -a __argx_values
}}
if [ "$funcstack[1]" = "_{command}" ]; then
_{command} "$@"
else
compdef _{command} '{command}'
fi
"#,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scripts_use_the_private_protocol_and_register_the_requested_shell() {
let bash = render("tool", Shell::Bash).unwrap();
assert!(bash.contains("ARGX_COMPLETE=1"));
assert!(bash.contains("ARGX_COMPLETE_LINE="));
assert!(bash.contains("command 'tool' __argx_complete__"));
assert!(bash.contains("complete -F _argx_complete_tool 'tool'"));
let fish = render("tool", Shell::Fish).unwrap();
assert!(fish.contains("set -lx ARGX_COMPLETE 1"));
assert!(fish.contains("set -lx ARGX_COMPLETE_LINE (commandline -cp)"));
assert!(fish.contains("complete --command 'tool'"));
let nushell = render("tool", Shell::Nushell).unwrap();
assert!(nushell.contains("ARGX_COMPLETE: \"1\""));
assert!(nushell.contains("ARGX_COMPLETE_WORDS: ($spans | to json -r)"));
assert!(nushell.contains("^tool __argx_complete__ | complete"));
assert!(nushell.contains("$env.config.completions.external.completer"));
assert!(nushell.contains("let __argx_previous_tool ="));
assert!(nushell.contains("do $__argx_previous_tool $spans"));
let zsh = render("tool", Shell::Zsh).unwrap();
assert!(zsh.starts_with("#compdef tool\n"));
assert!(zsh.contains("ARGX_COMPLETE_LINE=\"${BUFFER[1,CURSOR]}\""));
assert!(zsh.contains("compdef _tool 'tool'"));
assert!(zsh.contains("if [ \"$funcstack[1]\" = \"_tool\" ]; then"));
}
#[test]
fn script_rejects_command_names_that_cannot_be_embedded_safely() {
for name in ["", ".", "..", "-tool", "two words", "tool'evil", "path/tool"] {
assert!(matches!(
render(name, Shell::Zsh),
Err(ScriptError::InvalidCommandName { .. })
));
}
assert!(render("tool-cli_2.0+dev", Shell::Zsh).is_ok());
assert!(render("tool-cli_2.0+dev", Shell::Nushell).is_ok());
}
#[test]
fn nushell_identifiers_do_not_collide_with_encoded_spellings() {
assert_eq!(nushell_identifier("foo-bar"), "foo_x2dbar");
assert_eq!(nushell_identifier("foo+bar"), "foo_x2bbar");
assert_ne!(nushell_identifier("foo_x2dbar"), nushell_identifier("foo-bar"));
}
#[test]
fn shell_function_names_preserve_distinct_safe_command_names() {
let dashed = render("tool-cli", Shell::Bash).unwrap();
let escaped_looking = render("tool_2dcli", Shell::Bash).unwrap();
assert!(dashed.contains("_argx_complete_tool-cli()"));
assert!(escaped_looking.contains("_argx_complete_tool_2dcli()"));
}
}