usage-lib 3.5.2

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 three tab-separated columns per match:
/// raw value, description, and shell-quoted insert. We build the display
/// string from value+description (descriptions aligned to the longest value)
/// and pass insert to `compadd`. `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 {
    // `_describe` groups matches that share a `\:`-escaped prefix and only
    // surfaces one per group, so tasks like `release:create`, `release:docs-sync`,
    // `release:pr`, `release:update` collapse to a single entry. `complete-word
    // --shell zsh` emits three tab-separated columns — raw value, description,
    // shell-quoted insert — and we build the formatted display ourselves and
    // call `compadd` directly so every match is offered, with descriptions
    // aligned to the longest value.
    let template = r#"local -a values=() descs=() inserts=()
local needs_menu=0 line
while IFS= read -r line; do
  local -a parts=("${(@ps:\t:)line}")
  values+=("${parts[1]}")
  descs+=("${parts[2]}")
  inserts+=("${parts[3]}")
  [[ "${parts[3]}" == "'"* ]] && needs_menu=1
done < <(command __USAGE_BIN__ complete-word --shell zsh __CW_EXTRA__ -- "${(Q)words[@]}")
(( needs_menu )) && compstate[insert]=menu
if (( ${#inserts[@]} )); then
  local -a _usage_display=()
  local _usage_i _usage_max=0 _usage_v _usage_pad
  for _usage_v in "${values[@]}"; do
    (( ${#_usage_v} > _usage_max )) && _usage_max=${#_usage_v}
  done
  for ((_usage_i=1; _usage_i<=${#values[@]}; _usage_i++)); do
    if [[ -n "${descs[_usage_i]}" ]]; then
      _usage_pad=$(( _usage_max - ${#values[_usage_i]} ))
      _usage_display+=("${values[_usage_i]}${(l:_usage_pad:: :)}  -- ${descs[_usage_i]}")
    else
      _usage_display+=("${values[_usage_i]}")
    fi
  done
  compadd -l -d _usage_display -U -Q -S '' -a inserts
fi"#;
    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}() {{
  emulate -L zsh
  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() {{
    emulate -L zsh
    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,
        }));
    }
}