argx 0.1.1

Expressive command-line parsing and configuration for Rust.
Documentation
//! Thin shell adapters for the private dynamic-completion protocol.

use super::{
    PROTOCOL_COMMAND, PROTOCOL_ENV, PROTOCOL_LINE_ENV, PROTOCOL_VERSION, PROTOCOL_WORDS_ENV,
    ScriptError, Shell,
};

/// Renders one supported shell adapter after validating the executable name.
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),
    })
}

/// Reports whether one executable name is safe in every supported generated adapter.
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'+'))
}

/// Generates the Bash adapter.
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}'
"#,
    )
}

/// Generates the Fish adapter.
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})'
"#,
    )
}

/// Generates one collision-resistant Nushell identifier suffix from a safe command name.
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 {
            // Escape `_` as well so a literal `_x2d` cannot collide with an encoded `-`.
            identifier.push_str(&format!("_x{byte:02x}"));
        }
    }
    identifier
}

/// Generates the Nushell adapter.
///
/// Nushell's external completer already receives tokenized spans, so they travel to Argx as a
/// JSON array rather than being lossy-reconstructed into shell source. The generated closure also
/// chains the user's previous external completer instead of replacing completion for other tools.
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
    }}
}}
"#,
    )
}

/// Generates the Zsh adapter.
///
/// The completion function intentionally follows Zsh's `_{command}` convention. A file named
/// `_{command}` in `$fpath` is autoloaded as that exact function by `compinit`; the tail also
/// registers the same function when the generated script is sourced directly.
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()"));
    }
}