usage-lib 3.4.0

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

/// The completion loop that both `complete_zsh` (per-bin script) and
/// `complete_zsh_init` (shebang-fallback handler) need to emit.
///
/// `complete-word --shell zsh` emits two tab-separated columns per match:
/// the display string (`value:description` for `_describe`) and the
/// shell-quoted insert string. `usage complete-word` already filters by the
/// typed prefix, so `-U` tells `compadd` not to re-filter (which would
/// discard our pre-quoted matches whose literal text starts with `'`).
/// `compstate[insert]=menu` skips longest-common-prefix insertion when
/// values share a leading quote.
///
/// `cw_extra_args` is the extra `complete-word` arguments specific to each
/// caller (e.g. `-f "$spec_file"` vs `-f "$cmdpath" --cword=$((CURRENT - 1))`).
/// `indent` is prepended to every emitted line.
fn render_completion_loop(usage_bin: &str, indent: &str, cw_extra_args: &str) -> String {
    let template = r#"local -a completions=() inserts=()
local needs_menu=0 display insert
while IFS=$'\t' read -r display insert; do
  completions+=("$display")
  inserts+=("$insert")
  [[ "$insert" == "'"* ]] && needs_menu=1
done < <(command __USAGE_BIN__ complete-word --shell zsh __CW_EXTRA__ -- "${(Q)words[@]}")
(( needs_menu )) && compstate[insert]=menu
_describe 'completions' completions inserts -U -Q -S ''"#;
    template
        .replace("__USAGE_BIN__", usage_bin)
        .replace("__CW_EXTRA__", cw_extra_args)
        .lines()
        .map(|l| format!("{indent}{l}"))
        .collect::<Vec<_>>()
        .join("\n")
}

pub fn complete_zsh(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 bin_snake = bin.to_snake_case();
    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![format!(
        r#"#compdef {bin}
{generated_comment}
local curcontext="$curcontext""#
    )];

    if let Some(_usage_cmd) = &opts.usage_cmd {
        out.push(format!(
            r#"
# caching config
_usage_{bin_snake}_cache_policy() {{
  if [[ -z "${{lifetime}}" ]]; then
    lifetime=$((60*60*4)) # 4 hours
  fi
  local -a oldp
  oldp=( "$1"(Nms+${{lifetime}}) )
  (( $#oldp ))
}}"#
        ));
    }

    out.push(format!(
        r#"
_{bin_snake}() {{
  typeset -A opt_args
  local curcontext="$curcontext" cache_policy

  if ! type -p {usage_bin} &> /dev/null; then
      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
  fi"#,
    ));

    // 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 [[ ! -f "$spec_file" ]]; then
    {usage_cmd} >| "$spec_file"
  fi"#
            )
        } else {
            format!(r#"{usage_cmd} >| "$spec_file""#)
        }
    } else if let Some(spec) = &opts.spec {
        let heredoc = format!(
            r#"cat >| "$spec_file" <<'__USAGE_EOF__'
{spec}
__USAGE_EOF__"#,
            spec = spec.to_string().trim()
        );
        if opts.cache_key.is_some() {
            format!(
                r#"if [[ ! -f "$spec_file" ]]; then
  {heredoc}
fi"#
            )
        } else {
            heredoc.to_string()
        }
    } else {
        String::new()
    };

    let completion_loop = render_completion_loop(usage_bin, "  ", r#"-f "$spec_file""#);

    out.push(format!(
        r#"
  local spec_file="${{TMPDIR:-/tmp}}/usage_{spec_variable}.spec"
  {file_write_logic}
{completion_loop}
  return 0
}}

if [ "$funcstack[1]" = "_{bin_snake}" ]; then
    _{bin_snake} "$@"
else
    compdef _{bin_snake} {bin}
fi

# vim: noet ci pi sts=0 sw=4 ts=4"#,
    ));

    out.join("\n")
}

// fn render_args(cmds: &[&SchemaCmd]) -> String {
//     format!("XX")
// }

/// Generates a zsh "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 their shell rc; per-script `usage g completion zsh <bin> -f <script>`
/// generation is no longer required.
///
/// Mechanism: registers a `compdef -default-` fallback handler that fires for
/// any command without a more specific completion. The handler resolves the
/// command path, peeks the first line, and dispatches to `usage complete-word`
/// when it's a usage shebang. Otherwise it falls back to `_files`.
pub fn complete_zsh_init(usage_bin: &str) -> String {
    let completion_loop = render_completion_loop(
        usage_bin,
        "                ",
        r#"-f "$cmdpath" --cword=$((CURRENT - 1))"#,
    );
    format!(
        r##"# @generated by usage-cli — auto-completion for usage shebang scripts
# Source this file from your zshrc to enable <Tab> completion for any command
# on $PATH whose first line is a `usage` shebang.

_usage_default_complete() {{
    local cmd cmdpath
    cmd="${{words[1]}}"
    if [[ "$cmd" == */* ]]; then
        cmdpath="$cmd"
    elif (( ${{+commands[$cmd]}} )); then
        cmdpath="${{commands[$cmd]}}"
    fi

    if [[ -n "$cmdpath" && -f "$cmdpath" ]]; then
        local first
        if IFS= read -r first < "$cmdpath" 2>/dev/null && [[ "$first" == "#!"*"usage"* ]]; then
            if (( ${{+commands[{usage_bin}]}} )); then
{completion_loop}
                return $?
            fi
        fi
    fi

    # Not a usage shebang script — fall back to file completion
    _files
}}

compdef _usage_default_complete -default-
# 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_zsh_init() {
        assert_snapshot!(complete_zsh_init("usage"));
    }

    #[test]
    fn test_complete_zsh() {
        assert_snapshot!(complete_zsh(&CompleteOptions {
            usage_bin: "usage".to_string(),
            shell: "zsh".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_zsh(&CompleteOptions {
            usage_bin: "usage".to_string(),
            shell: "zsh".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_zsh(&CompleteOptions {
            usage_bin: "usage".to_string(),
            shell: "zsh".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,
        }));
    }
}