diff --git a/README.md b/README.md index 0bca602..cfac31a 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,46 @@ To activate it, either source the completion file or add it to the system-wide c In your `.bashrc` (or `.profile`) add ``` -source [...]/vendor/mglaman/drupalorg-cli/drupalorg-cli-completion.sh +source [...]/vendor/mglaman/drupalorg-cli/drupalorg-cli-completion.bash ``` +### Installing (Zsh) completion + +`drupalorg` comes with namespace-aware completion out of the box. If [`jq`](https://jqlang.org/) is installed, the Zsh completion script upgrades itself to use `drupalorg list --format=json` once per shell session and can complete: + +* commands and namespace-prefixed commands +* documented long and short options (i.e. flags) +* command aliases such as `is` and `pi` +* positional argument placeholders such as `` + +In those placeholders, angle brackets mean the argument is required, and square brackets mean it is optional. For example, `` is required and `[nid]` is optional. + +Without `jq`, the script falls back to the original command and namespace completion behavior. + +Copy the Zsh completion file to `~/.zsh/completions/_drupalorg`: + +```sh +mkdir -p ~/.zsh/completions +curl -L https://raw.githubusercontent.com/mglaman/drupalorg-cli/refs/heads/main/drupalorg-cli-completion.zsh -o ~/.zsh/completions/_drupalorg +``` + +In your `~/.zshrc` add (if not already present): + +```sh +fpath=(~/.zsh/completions $fpath) +autoload -Uz compinit +compinit +``` + +Restart your shell or run `source ~/.zshrc`. + ## Updating Automatic updating is not yet supported. You will need to manually download new releases. ## Usage -Use the 'list' command to see available commands. +Use the 'list' command to see available commands. ``` drupalorg list diff --git a/drupalorg-cli-completion.sh b/drupalorg-cli-completion.bash similarity index 100% rename from drupalorg-cli-completion.sh rename to drupalorg-cli-completion.bash diff --git a/drupalorg-cli-completion.zsh b/drupalorg-cli-completion.zsh new file mode 100644 index 0000000..6aad601 --- /dev/null +++ b/drupalorg-cli-completion.zsh @@ -0,0 +1,478 @@ +#compdef drupalorg + +# Enhanced completion data is cached in shell memory for the current session. +# After upgrading drupalorg, either start a new shell, call +# `_drupalorg_reset_completion_cache`, or set `DRUPALORG_COMPLETION_RESET=1` +# before the next completion request to force a rebuild. + +typeset -g _DRUPALORG_COMPLETION_CACHE_READY=0 +typeset -ga _DRUPALORG_TOP_LEVEL_MATCHES +typeset -ga _DRUPALORG_ALL_COMMANDS +typeset -gA _DRUPALORG_ALIAS_TO_COMMAND +typeset -gA _DRUPALORG_COMMAND_ARGUMENTS +typeset -gA _DRUPALORG_COMMAND_LONG_OPTIONS +typeset -gA _DRUPALORG_COMMAND_SHORT_OPTIONS +typeset -gA _DRUPALORG_COMMAND_OPTION_CANONICAL +typeset -gA _DRUPALORG_COMMAND_OPTION_TAKES_VALUE +typeset -gA _DRUPALORG_COMMAND_OPTION_REPEATABLE + +_drupalorg_reset_completion_cache() { + typeset -g _DRUPALORG_COMPLETION_CACHE_READY=0 + typeset -ga _DRUPALORG_TOP_LEVEL_MATCHES=() + typeset -ga _DRUPALORG_ALL_COMMANDS=() + typeset -gA _DRUPALORG_ALIAS_TO_COMMAND=() + typeset -gA _DRUPALORG_COMMAND_ARGUMENTS=() + typeset -gA _DRUPALORG_COMMAND_LONG_OPTIONS=() + typeset -gA _DRUPALORG_COMMAND_SHORT_OPTIONS=() + typeset -gA _DRUPALORG_COMMAND_OPTION_CANONICAL=() + typeset -gA _DRUPALORG_COMMAND_OPTION_TAKES_VALUE=() + typeset -gA _DRUPALORG_COMMAND_OPTION_REPEATABLE=() +} + +_drupalorg_append_unique_array_entry() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local array_name=$1 + local value=$2 + local -a target_array + + target_array=("${(@P)array_name}") + (( ${target_array[(I)$value]} )) && return + eval "${array_name}+=(\"\$value\")" +} + +_drupalorg_append_unique_assoc_list() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local assoc_name=$1 + local key=$2 + local value=$3 + local existing_values=() + local current_value="" + + current_value=${${(P)assoc_name}[$key]-} + + if [[ -n $current_value ]]; then + existing_values=("${(@s: :)current_value}") + (( ${existing_values[(I)$value]} )) && return + eval "${assoc_name}+=(\"\$key\" \"\$current_value \$value\")" + return + fi + + eval "${assoc_name}+=(\"\$key\" \"\$value\")" +} + +_drupalorg_load_json_completion_cache() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local json_payload=$1 + local jq_output + local kind command_name field_one field_two field_three field_four field_five + local record_separator=$'\x1f' + + _drupalorg_reset_completion_cache + + jq_output=$({ + printf '%s' "$json_payload" | jq -r --arg sep "$record_separator" '.namespaces[]? | select(.id != "_global") | ["NAMESPACE", .id] | join($sep)' + printf '%s' "$json_payload" | jq -r --arg sep "$record_separator" '.commands[]? | select(.hidden | not) | ["COMMAND", .name] | join($sep)' + printf '%s' "$json_payload" | jq -r ' + .commands[]? + | select(.hidden | not) as $command + | ($command.usage[1:][]? | select(type == "string" and test("^[^[:space:]]+$"))) + | ["ALIAS", ., $command.name] + | join("\u001f") + ' + printf '%s' "$json_payload" | jq -r ' + .commands[]? + | select(.hidden | not) as $command + | ($command.definition.options // {}) + | to_entries[] + | [ + "OPTION", + $command.name, + .key, + (.value.name // ""), + (.value.shortcut // ""), + (if .value.accept_value then "1" else "0" end), + (if .value.is_multiple then "1" else "0" end) + ] + | join("\u001f") + ' + printf '%s' "$json_payload" | jq -r ' + .commands[]? + | select(.hidden | not) as $command + | ( + if (($command.definition.arguments // []) | type) == "array" then + ($command.definition.arguments // []) + else + ($command.definition.arguments // {} | to_entries | map(.value)) + end + )[] + | [ + "ARGUMENT", + $command.name, + (.name // ""), + (if .is_required then "1" else "0" end), + (if .is_array then "1" else "0" end) + ] + | join("\u001f") + ' + } 2>/dev/null) || return 1 + + while IFS=$record_separator read -r kind command_name field_one field_two field_three field_four field_five; do + [[ -n $kind ]] || continue + + case $kind in + NAMESPACE) + _drupalorg_append_unique_array_entry _DRUPALORG_TOP_LEVEL_MATCHES "${command_name}:" + ;; + COMMAND) + _drupalorg_append_unique_array_entry _DRUPALORG_ALL_COMMANDS "$command_name" + if [[ $command_name != *:* ]]; then + _drupalorg_append_unique_array_entry _DRUPALORG_TOP_LEVEL_MATCHES "$command_name" + fi + ;; + ALIAS) + local alias_name=$command_name + command_name=$field_one + _DRUPALORG_ALIAS_TO_COMMAND[$alias_name]=$command_name + _drupalorg_append_unique_array_entry _DRUPALORG_TOP_LEVEL_MATCHES "$alias_name" + ;; + OPTION) + local option_key=$field_one + local long_name=$field_two + local shortcuts=$field_three + local accepts_value=$field_four + local is_multiple_flag=$field_five + + _DRUPALORG_COMMAND_OPTION_CANONICAL["$command_name:$long_name"]=$option_key + _DRUPALORG_COMMAND_OPTION_TAKES_VALUE["$command_name:$option_key"]=$accepts_value + _DRUPALORG_COMMAND_OPTION_REPEATABLE["$command_name:$option_key"]=$is_multiple_flag + _drupalorg_append_unique_assoc_list _DRUPALORG_COMMAND_LONG_OPTIONS "$command_name" "$long_name" + + if [[ -n $shortcuts ]]; then + local shortcut + for shortcut in "${(@s:|:)shortcuts}"; do + [[ -n $shortcut ]] || continue + _DRUPALORG_COMMAND_OPTION_CANONICAL["$command_name:$shortcut"]=$option_key + _drupalorg_append_unique_assoc_list _DRUPALORG_COMMAND_SHORT_OPTIONS "$command_name" "$shortcut" + done + fi + ;; + ARGUMENT) + local argument_name=$field_one + local is_required=$field_two + local is_array=$field_three + local record="${argument_name}|${is_required}|${is_array}" + if [[ -n ${_DRUPALORG_COMMAND_ARGUMENTS[$command_name]-} ]]; then + _DRUPALORG_COMMAND_ARGUMENTS[$command_name]+=$'\n'"$record" + else + _DRUPALORG_COMMAND_ARGUMENTS[$command_name]=$record + fi + ;; + esac + done <<< "$jq_output" + + _DRUPALORG_COMPLETION_CACHE_READY=1 +} + +_drupalorg_maybe_load_completion_cache() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local json_payload + + if [[ -n ${DRUPALORG_COMPLETION_RESET-} ]]; then + _drupalorg_reset_completion_cache + unset DRUPALORG_COMPLETION_RESET 2>/dev/null || true + fi + + (( _DRUPALORG_COMPLETION_CACHE_READY )) && return 0 + (( $+commands[jq] )) || return 1 + + json_payload=$(drupalorg list --format=json 2>/dev/null) || return 1 + [[ -n $json_payload ]] || return 1 + + _drupalorg_load_json_completion_cache "$json_payload" +} + +_drupalorg_resolve_command_name() { + emulate -L zsh + + local command_name=$1 + print -r -- "${_DRUPALORG_ALIAS_TO_COMMAND[$command_name]:-$command_name}" +} + +_drupalorg_collect_top_level_matches() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local prefix=$1 + local match + local results=() + + for match in "${_DRUPALORG_TOP_LEVEL_MATCHES[@]}"; do + [[ -z $prefix || $match == ${prefix}* ]] || continue + results+=("$match") + done + + print -r -- "${results[*]}" +} + +_drupalorg_collect_subcommand_matches() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local prefix=$1 + local namespace=${prefix%%:*} + local match + local results=() + + for match in "${_DRUPALORG_ALL_COMMANDS[@]}"; do + [[ $match == ${namespace}:* ]] || continue + [[ -z $prefix || $match == ${prefix}* ]] || continue + results+=("$match") + done + + print -r -- "${results[*]}" +} + +_drupalorg_collect_used_option_keys() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local command_name=$1 + shift + + local used_token + local option_key + local seen_keys=() + + for used_token in "$@"; do + option_key=${_DRUPALORG_COMMAND_OPTION_CANONICAL["$command_name:$used_token"]-} + [[ -n $option_key ]] || continue + (( ${seen_keys[(I)$option_key]} )) || seen_keys+=("$option_key") + done + + print -r -- "${seen_keys[*]}" +} + +_drupalorg_collect_option_matches() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local command_name + command_name=$(_drupalorg_resolve_command_name "$1") + shift + + local prefix=$1 + shift + + local option_token option_key + local candidate_options=() + local used_keys=("${(@s: :)$(_drupalorg_collect_used_option_keys "$command_name" "$@")}") + + if [[ $prefix == --* ]]; then + candidate_options=("${(@s: :)${_DRUPALORG_COMMAND_LONG_OPTIONS[$command_name]-}}") + else + candidate_options=("${(@s: :)${_DRUPALORG_COMMAND_SHORT_OPTIONS[$command_name]-}}") + fi + + local results=() + for option_token in "${candidate_options[@]}"; do + [[ -z $prefix || $option_token == ${prefix}* ]] || continue + + option_key=${_DRUPALORG_COMMAND_OPTION_CANONICAL["$command_name:$option_token"]-} + if [[ -n $option_key && ${_DRUPALORG_COMMAND_OPTION_REPEATABLE["$command_name:$option_key"]-0} != 1 ]]; then + (( ${used_keys[(I)$option_key]} )) && continue + fi + + results+=("$option_token") + done + + print -r -- "${results[*]}" +} + +_drupalorg_collect_argument_placeholders() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local command_name + command_name=$(_drupalorg_resolve_command_name "$1") + local position=$2 + local argument_records argument_record + local -a placeholders=() + + [[ -n ${_DRUPALORG_COMMAND_ARGUMENTS[$command_name]-} ]] || return 0 + + argument_records=("${(@f)${_DRUPALORG_COMMAND_ARGUMENTS[$command_name]}}") + local total=${#argument_records[@]} + (( total )) || return 0 + + if (( position > total )); then + local last_record=${argument_records[$total]} + local last_name last_required last_array + IFS='|' read -r last_name last_required last_array <<< "$last_record" + if [[ $last_array == 1 ]]; then + if [[ $last_required == 1 ]]; then + print -r -- "<${last_name}>..." + else + print -r -- "[${last_name}]..." + fi + fi + return 0 + fi + + argument_record=${argument_records[$position]} + local argument_name is_required is_array + IFS='|' read -r argument_name is_required is_array <<< "$argument_record" + if [[ $is_required == 1 ]]; then + placeholders+=("<${argument_name}>") + else + placeholders+=("[${argument_name}]") + fi + if [[ $is_array == 1 ]]; then + placeholders[-1]+='...' + fi + + print -r -- "${placeholders[*]}" +} + +_drupalorg_option_requires_value() { + emulate -L zsh + + local command_name + command_name=$(_drupalorg_resolve_command_name "$1") + local option_token=$2 + local option_key=${_DRUPALORG_COMMAND_OPTION_CANONICAL["$command_name:$option_token"]-} + + [[ -n $option_key && ${_DRUPALORG_COMMAND_OPTION_TAKES_VALUE["$command_name:$option_key"]-0} == 1 ]] +} + +_drupalorg_fallback_raw_completion() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local cur line full ns i + local -a top_matches sub_matches + local -A seen_ns + + local seen_command=0 + for (( i = 2; i < CURRENT; i++ )); do + if [[ ${words[i]} != -* ]]; then + seen_command=1 + break + fi + done + (( seen_command )) && return 1 + + cur=${words[CURRENT]} + + while IFS= read -r line; do + [[ -n $line ]] || continue + full="${line%%[[:space:]]*}" + + if [[ $cur == *:* ]]; then + ns="${cur%%:*}" + [[ $full == ${ns}:* ]] || continue + sub_matches+=("$full") + else + if [[ $full == *:* ]]; then + ns="${full%%:*}" + if [[ -z ${seen_ns[$ns]-} ]]; then + seen_ns[$ns]=1 + top_matches+=("${ns}:") + fi + else + top_matches+=("$full") + fi + fi + done < <(drupalorg list --raw 2>/dev/null) + + if [[ $cur == *:* ]]; then + (( ${#sub_matches[@]} )) || return 1 + compadd -Q -S '' -- "${sub_matches[@]}" + else + (( ${#top_matches[@]} )) || return 1 + compadd -Q -S '' -- "${top_matches[@]}" + fi +} + +_drupalorg() { + emulate -L zsh + setopt local_options no_sh_word_split typesetsilent + + local cur=${words[CURRENT]} + local command_name="" + local canonical_command="" + local token + local expecting_value=0 + local positional_count=0 + local i + local -a used_options matches + + _drupalorg_maybe_load_completion_cache || { + _drupalorg_fallback_raw_completion + return $? + } + + for (( i = 2; i < CURRENT; i++ )); do + token=${words[i]} + + if (( expecting_value )); then + expecting_value=0 + continue + fi + + if [[ -z $command_name ]]; then + [[ $token == -* ]] && continue + command_name=$token + canonical_command=$(_drupalorg_resolve_command_name "$command_name") + continue + fi + + if [[ $token == -* ]]; then + used_options+=("$token") + if _drupalorg_option_requires_value "$canonical_command" "$token"; then + expecting_value=1 + fi + continue + fi + + positional_count=$(( positional_count + 1 )) + done + + if [[ -z $command_name ]]; then + if [[ $cur == *:* ]]; then + matches=("${(@s: :)$(_drupalorg_collect_subcommand_matches "$cur")}") + else + matches=("${(@s: :)$(_drupalorg_collect_top_level_matches "$cur")}") + fi + + (( ${#matches[@]} )) || return 1 + compadd -Q -S '' -- "${matches[@]}" + return 0 + fi + + canonical_command=$(_drupalorg_resolve_command_name "$command_name") + + if (( CURRENT > 2 )) && _drupalorg_option_requires_value "$canonical_command" "${words[CURRENT-1]}"; then + return 1 + fi + + if [[ $cur == -* ]]; then + matches=("${(@s: :)$(_drupalorg_collect_option_matches "$canonical_command" "$cur" "${used_options[@]}")}") + (( ${#matches[@]} )) || return 1 + compadd -Q -S '' -- "${matches[@]}" + return 0 + fi + + matches=("${(@s: :)$(_drupalorg_collect_argument_placeholders "$canonical_command" $(( positional_count + 1 )))}") + (( ${#matches[@]} )) || return 1 + compadd -Q -S '' -- "${matches[@]}" +} + +(( $+functions[compdef] )) && compdef _drupalorg drupalorg diff --git a/tests/zsh-completion-tests.zsh b/tests/zsh-completion-tests.zsh new file mode 100755 index 0000000..659290a --- /dev/null +++ b/tests/zsh-completion-tests.zsh @@ -0,0 +1,245 @@ +#!/bin/zsh + +set -euo pipefail + +REPO_ROOT=${0:A:h:h} +function compdef() {} +source "$REPO_ROOT/drupalorg-cli-completion.zsh" + +function assert_eq() { + local expected=$1 + local actual=$2 + local message=$3 + + if [[ "$expected" != "$actual" ]]; then + print -u2 -- "ASSERTION FAILED: $message" + print -u2 -- "Expected: $expected" + print -u2 -- "Actual: $actual" + exit 1 + fi +} + +function assert_contains() { + local haystack=$1 + local needle=$2 + local message=$3 + + if [[ " $haystack " != *" $needle "* ]]; then + print -u2 -- "ASSERTION FAILED: $message" + print -u2 -- "Missing: $needle" + print -u2 -- "In: $haystack" + exit 1 + fi +} + +function assert_not_contains() { + local haystack=$1 + local needle=$2 + local message=$3 + + if [[ " $haystack " == *" $needle "* ]]; then + print -u2 -- "ASSERTION FAILED: $message" + print -u2 -- "Unexpected: $needle" + print -u2 -- "In: $haystack" + exit 1 + fi +} + +typeset json_fixture +json_fixture=$(cat <<'EOF' +{ + "application": { + "name": "Drupal.org CLI", + "version": "0.8.3" + }, + "commands": [ + { + "name": "list", + "hidden": false, + "usage": ["list [--format FORMAT] [--] []"], + "definition": { + "arguments": { + "namespace": { + "name": "namespace", + "is_required": false, + "is_array": false + } + }, + "options": { + "format": { + "name": "--format", + "shortcut": "", + "accept_value": true, + "is_multiple": false + }, + "help": { + "name": "--help", + "shortcut": "-h", + "accept_value": false, + "is_multiple": false + } + } + } + }, + { + "name": "issue:checkout", + "hidden": false, + "usage": ["issue:checkout [ []]"], + "definition": { + "arguments": { + "nid": { + "name": "nid", + "is_required": false, + "is_array": false + }, + "branch": { + "name": "branch", + "is_required": false, + "is_array": false + } + }, + "options": { + "help": { + "name": "--help", + "shortcut": "-h", + "accept_value": false, + "is_multiple": false + } + } + } + }, + { + "name": "issue:search", + "hidden": false, + "usage": [ + "issue:search [-s|--status [STATUS]] [--limit [LIMIT]] [-f|--format [FORMAT]] [--] [ []]", + "is" + ], + "definition": { + "arguments": { + "project": { + "name": "project", + "is_required": false, + "is_array": false + }, + "query": { + "name": "query", + "is_required": false, + "is_array": false + } + }, + "options": { + "status": { + "name": "--status", + "shortcut": "-s", + "accept_value": true, + "is_multiple": false + }, + "format": { + "name": "--format", + "shortcut": "-f", + "accept_value": true, + "is_multiple": false + } + } + } + }, + { + "name": "issue:show", + "hidden": false, + "usage": ["issue:show [-f|--format [FORMAT]] [--with-comments] [--] "], + "definition": { + "arguments": { + "nid": { + "name": "nid", + "is_required": true, + "is_array": false + } + }, + "options": { + "format": { + "name": "--format", + "shortcut": "-f", + "accept_value": true, + "is_multiple": false + }, + "with-comments": { + "name": "--with-comments", + "shortcut": "", + "accept_value": false, + "is_multiple": false + }, + "help": { + "name": "--help", + "shortcut": "-h", + "accept_value": false, + "is_multiple": false + } + } + } + } + ], + "namespaces": [ + { + "id": "_global", + "commands": ["is", "list"] + }, + { + "id": "issue", + "commands": ["issue:checkout", "issue:search", "issue:show"] + } + ] +} +EOF +) + +_drupalorg_reset_completion_cache +_drupalorg_load_json_completion_cache "$json_fixture" + +assert_eq "issue:search" "$(_drupalorg_resolve_command_name is)" "Alias should resolve to canonical command" +assert_eq "issue:show" "$(_drupalorg_resolve_command_name issue:show)" "Canonical command should resolve to itself" + +typeset issue_show_options +issue_show_options=$(_drupalorg_collect_option_matches issue:show "--" "--format") +assert_contains "$issue_show_options" "--with-comments" "issue:show should expose long options" +assert_not_contains "$issue_show_options" "--format" "Non-repeatable used option should not be re-suggested" + +typeset issue_show_short_options +issue_show_short_options=$(_drupalorg_collect_option_matches issue:show "-" "-f") +assert_contains "$issue_show_short_options" "-h" "issue:show should expose short options" +assert_not_contains "$issue_show_short_options" "-f" "Used short option should not be re-suggested" + +typeset issue_show_arguments +issue_show_arguments=$(_drupalorg_collect_argument_placeholders issue:show 1) +assert_contains "$issue_show_arguments" "" "Required positional argument should be exposed as placeholder" + +typeset issue_checkout_arguments +issue_checkout_arguments=$(_drupalorg_collect_argument_placeholders issue:checkout 2) +assert_contains "$issue_checkout_arguments" "[branch]" "Optional positional argument should be exposed as placeholder" + +typeset top_level_matches +top_level_matches=$(_drupalorg_collect_top_level_matches "") +assert_contains "$top_level_matches" "issue:" "Namespaces should be offered at top level" +assert_contains "$top_level_matches" "is" "Aliases should be offered at top level" +assert_contains "$top_level_matches" "list" "Top-level commands should be offered at top level" + +typeset invocation_log +invocation_log=$(mktemp) +function drupalorg() { + print -- "list --format=json" >> "$invocation_log" + if [[ "$1" == "list" && "$2" == "--format=json" ]]; then + print -r -- "$json_fixture" + return 0 + fi + + print -u2 -- "Unexpected drupalorg invocation: $*" + return 1 +} + +_drupalorg_reset_completion_cache +_drupalorg_maybe_load_completion_cache +_drupalorg_maybe_load_completion_cache +assert_eq "1" "$(wc -l < "$invocation_log" | tr -d ' ')" "Session cache should fetch JSON once" +rm -f "$invocation_log" + +print -- "zsh completion tests passed"