usage-lib 3.3.0

Library for working with usage specs
Documentation
use crate::complete::CompleteOptions;
use heck::ToSnakeCase;

pub fn complete_fish(opts: &CompleteOptions) -> String {
    let usage_bin = &opts.usage_bin;
    let bin = &opts.bin;
    let bin_snake = bin.to_snake_case();
    let spec_variable = if let Some(cache_key) = &opts.cache_key {
        format!("_usage_spec_{bin_snake}_{}", cache_key.to_snake_case())
    } else {
        format!("_usage_spec_{bin_snake}")
    };
    let generated_comment = if let Some(source_file) = &opts.source_file {
        format!("# @generated by usage-cli from {source_file}")
    } else {
        "# @generated by usage-cli from usage spec".to_string()
    };
    let mut out = vec![
        generated_comment,
        format!(
            r#"
# if "{usage_bin}" is not installed show an error
if ! type -p {usage_bin} &> /dev/null
    echo >&2
    echo "Error: {usage_bin} CLI not found. This is required for completions to work in {bin}." >&2
    echo "See https://usage.jdx.dev for more information." >&2
    return 1
end"#
        ),
    ];

    if let Some(spec) = &opts.spec {
        let spec_escaped = spec.to_string().replace("'", r"\'");
        out.push(format!(
            r#"
set {spec_variable} '{spec_escaped}'"#
        ));
    }

    // Build logic to write spec directly to file without storing in shell variables
    let file_write_logic = if let Some(usage_cmd) = &opts.usage_cmd {
        if opts.cache_key.is_some() {
            format!(
                r#"if not test -f "$spec_file"
    {usage_cmd} | string collect > "$spec_file"
end"#
            )
        } else {
            format!(r#"{usage_cmd} | string collect > "$spec_file""#)
        }
    } else if let Some(_spec) = &opts.spec {
        if opts.cache_key.is_some() {
            format!(
                r#"if not test -f "$spec_file"
    echo ${spec_variable} > "$spec_file"
end"#
            )
        } else {
            format!(r#"echo ${spec_variable} > "$spec_file""#)
        }
    } else {
        String::new()
    };

    out.push(format!(
        r#"
set -l tmpdir (if set -q TMPDIR; echo $TMPDIR; else; echo /tmp; end)
set -l spec_file "$tmpdir/usage_{spec_variable}.spec"
{file_write_logic}

set -l tokens
if commandline -x >/dev/null 2>&1
    complete -xc {bin} -a "(command {usage_bin} complete-word --shell fish -f \"$spec_file\" -- (commandline -xpc) (commandline -t))"
else
    complete -xc {bin} -a "(command {usage_bin} complete-word --shell fish -f \"$spec_file\" -- (commandline -opc) (commandline -t))"
end
"#
    ).trim().to_string());

    out.join("\n")
}

/// Generates a fish "init" script that wires up tab-completion for any command
/// on `$PATH` whose first line is a `usage` shebang. The user sources this once
/// from `~/.config/fish/conf.d/`; per-script
/// `usage g completion fish <bin> -f <script>` generation is no longer required.
///
/// Mechanism: fish has no default-completer fallback equivalent to bash's
/// `complete -D` or zsh's `compdef -default-`. Instead, the init script scans
/// `$PATH` once at shell startup, identifies executables whose first line
/// contains `usage`, and registers `complete -c <name>` for each. This is a
/// one-time cost on shell startup proportional to the size of `$PATH`.
pub fn complete_fish_init(usage_bin: &str) -> String {
    format!(
        r##"# @generated by usage-cli — auto-completion for usage shebang scripts
# Source this file from ~/.config/fish/conf.d/ (or your fish config) to enable
# <Tab> completion for any command on $PATH whose first line is a `usage`
# shebang.

function __usage_register_shebang_completions
    if not type -q {usage_bin}
        return 0
    end
    # `commandline -x` (fish 3.4+) tokenizes quoted/complex arguments more
    # accurately than `-o`. Mirror the per-binary `complete_fish` detection so
    # init-registered completions behave identically on modern fish.
    set -l cmdline_pre_cmd 'commandline -opc'
    if commandline -x >/dev/null 2>&1
        set cmdline_pre_cmd 'commandline -xpc'
    end
    set -l registered
    for dir in $PATH
        test -d $dir; or continue
        for file in $dir/*
            test -f $file -a -x $file; or continue
            # `string replace` works on every supported fish version; `path
            # basename` requires fish 3.5+ which excludes Ubuntu 22.04 LTS.
            set -l name (string replace -r '^.*/' '' -- $file)
            contains -- $name $registered; and continue
            # Cap the read at 128 chars so we don't buffer entire binary
            # executables that have no newline.
            set -l first
            read -l -n 128 first <$file 2>/dev/null
            if string match -q -- '#!*usage*' "$first"
                complete -c $name -x -a "(command {usage_bin} complete-word --shell fish -f \"$file\" -- ($cmdline_pre_cmd) (commandline -t))"
                set -a registered $name
            end
        end
    end
end

__usage_register_shebang_completions
# vim: noet ci pi sts=0 sw=4 ts=4
"##
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test::SPEC_KITCHEN_SINK;
    use insta::assert_snapshot;

    #[test]
    fn test_complete_fish_init() {
        assert_snapshot!(complete_fish_init("usage"));
    }

    #[test]
    fn test_complete_fish() {
        assert_snapshot!(complete_fish(&CompleteOptions {
            usage_bin: "usage".to_string(),
            shell: "fish".to_string(),
            bin: "mycli".to_string(),
            cache_key: None,
            spec: None,
            usage_cmd: Some("mycli complete --usage".to_string()),
            include_bash_completion_lib: false,
            source_file: None,
        }));
        assert_snapshot!(complete_fish(&CompleteOptions {
            usage_bin: "usage".to_string(),
            shell: "fish".to_string(),
            bin: "mycli".to_string(),
            cache_key: Some("1.2.3".to_string()),
            spec: None,
            usage_cmd: Some("mycli complete --usage".to_string()),
            include_bash_completion_lib: false,
            source_file: None,
        }));
        assert_snapshot!(complete_fish(&CompleteOptions {
            usage_bin: "usage".to_string(),
            shell: "fish".to_string(),
            bin: "mycli".to_string(),
            cache_key: None,
            spec: Some(SPEC_KITCHEN_SINK.clone()),
            usage_cmd: None,
            include_bash_completion_lib: false,
            source_file: None,
        }));
    }
}