From c2ca076b3b686d57a63214c562d624f211a13f05 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:11:01 +0530 Subject: [PATCH] bug: align CLI completion with parser boundaries (#337) --- CHANGELOG.md | 2 ++ lib/bash/cli/lib_cli.sh | 40 +++++++++++++++++++--- lib/bash/cli/tests/lib_cli.bats | 60 +++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d39ce..55a8300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and versions are tracked in the repo-root `VERSION` file. - Enforced required repeatable positional tails based on values consumed by the repeatable itself, independent of earlier fixed positional arguments. +- Made CLI completion consume option values and stop command and option + interpretation after `--`, matching the runtime parser's path semantics. - Made table-driven CLI declaration transactional so any late semantic error restores an existing model exactly or removes a partial new model. - Rejected unreachable CLI command routes and ancestor/child option collisions, diff --git a/lib/bash/cli/lib_cli.sh b/lib/bash/cli/lib_cli.sh index d88c412..3b03089 100644 --- a/lib/bash/cli/lib_cli.sh +++ b/lib/bash/cli/lib_cli.sh @@ -1366,7 +1366,10 @@ __base_bash_libs_cli_completion_add__() { # base_cli_complete - Prints completion candidates, one per line. # Usage: base_cli_complete model -- [complete argv including the current prefix] base_cli_complete() { - local model="${1-}" current prefix path="" word token option_path name index + local model="${1-}" current prefix path="" word token option_path name type child_path index + # shellcheck disable=SC2034 # Option lookup path is intentionally unused while resolving completion state. + local found_name found_path found_type + local parse_options=1 pending_value=0 inline_value=0 local -a words=() completed=() children=() __base_bash_libs_cli_option_tokens=() if (($# < 2)) || [[ "$2" != -- ]]; then @@ -1379,10 +1382,39 @@ base_cli_complete() { if ((${#words[@]} == 0)); then prefix=""; else prefix="${words[${#words[@]} - 1]}"; fi if ((${#words[@]} > 1)); then completed=("${words[@]:0:${#words[@]}-1}"); fi for word in "${completed[@]+${completed[@]}}"; do - [[ "$word" == -* ]] && continue - token="$(__base_bash_libs_cli_command_child__ "$model" "$path" "$word")" - [[ -n "$token" ]] && path="$token" + if ((pending_value)); then + pending_value=0 + continue + fi + if ((parse_options)) && [[ "$word" == -- ]]; then + parse_options=0 + continue + fi + ((parse_options)) || continue + if [[ "$word" == -* && "$word" != - ]]; then + token="$word" + inline_value=0 + if [[ "$word" == --*=* ]]; then + token="${word%%=*}" + inline_value=1 + fi + if __base_bash_libs_cli_option_lookup__ "$model" "$path" "$token" \ + found_name found_path found_type; then + [[ "$found_type" == flag || "$inline_value" -eq 1 ]] || pending_value=1 + fi + continue + fi + child_path="$(__base_bash_libs_cli_command_child__ "$model" "$path" "$word")" + [[ -n "$child_path" ]] && path="$child_path" done + ((parse_options && !pending_value)) || return 0 + if [[ "$prefix" == --*=* ]]; then + token="${prefix%%=*}" + if __base_bash_libs_cli_option_lookup__ "$model" "$path" "$token" \ + found_name found_path found_type && [[ "$found_type" != flag ]]; then + return 0 + fi + fi __base_bash_libs_cli_completion_candidates=() if [[ "$prefix" == -* ]]; then __base_bash_libs_cli_collect_options__ "$model" "$path" diff --git a/lib/bash/cli/tests/lib_cli.bats b/lib/bash/cli/tests/lib_cli.bats index 1d02d04..7582839 100644 --- a/lib/bash/cli/tests/lib_cli.bats +++ b/lib/bash/cli/tests/lib_cli.bats @@ -403,6 +403,66 @@ EOF [[ "$output" == *"complete -F _demo_complete demo"* ]] } +@test "completion consumes option values and honors the double-dash boundary" { + base_cli_model_init complete name=complete + base_cli_command complete admin "Administration" aliases=a + base_cli_command complete admin/user "User" aliases=u + base_cli_option complete '' verbose flag --verbose -v + base_cli_option complete '' config value --config + base_cli_option complete '' tag repeatable --tag + + bats_run base_cli_complete complete -- --config admin + [ "$status" -eq 0 ] + [ -z "$output" ] + bats_run base_cli_complete complete -- --config admin "" + [ "$status" -eq 0 ] + [[ "$output" == *"admin"* ]] + [[ "$output" != *"user"* ]] + + bats_run base_cli_complete complete -- --tag admin + [ "$status" -eq 0 ] + [ -z "$output" ] + bats_run base_cli_complete complete -- --config=admin "" + [ "$status" -eq 0 ] + [[ "$output" == *"admin"* ]] + bats_run base_cli_complete complete -- --config -not-an-option + [ "$status" -eq 0 ] + [ -z "$output" ] + bats_run base_cli_complete complete -- --config "" "" + [ "$status" -eq 0 ] + [[ "$output" == *"admin"* ]] + + bats_run base_cli_complete complete -- --verbose "" + [ "$status" -eq 0 ] + [[ "$output" == *"admin"* ]] + bats_run base_cli_complete complete -- a u + [ "$status" -eq 0 ] + [[ "$output" == *"user"* ]] + [[ "$output" == *"u"* ]] + + bats_run base_cli_complete complete -- -- admin "" + [ "$status" -eq 0 ] + [ -z "$output" ] + bats_run base_cli_complete complete -- -- "" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "completion keeps partial candidates unique and one per line" { + base_cli_model_init unique_complete name=unique-complete + base_cli_command unique_complete admin "Administration" aliases=adm + base_cli_option unique_complete '' verbose flag --verbose -v + + bats_run base_cli_complete unique_complete -- ad + [ "$status" -eq 0 ] + [ "$output" = $'admin\nadm' ] + [ "$(printf '%s\n' "$output" | LC_ALL=C sort | uniq -d)" = "" ] + + bats_run base_cli_complete unique_complete -- --v + [ "$status" -eq 0 ] + [ "$output" = --verbose ] +} + @test "public CLI paths preserve caller variables that match internal scratch names" { local __base_bash_libs_cli_option_name=caller-name local __base_bash_libs_cli_option_path=caller-path