From 4c459e0fbf3e3e601171d2789d20d3ad23860de4 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Mon, 22 Jun 2026 15:37:10 +0300 Subject: [PATCH 1/2] tab complete root flags if - or -- provided, otherwise complete commands or command flags --- docs/docs/changelog.md | 1 + internal/cmd/completion.go | 96 ++++++++++++++++++++++++++++++++++---- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 3f5d27a7..8348855c 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -17,6 +17,7 @@ title: Changelog * `[Refactoring]` Use Go 1.26 `errors.AsType` for type-safe error unwrapping. * `[Testing]` Add golden-file tests for help and error rendering in `internal/cmd`, replacing bats format checks with snapshot comparisons. * `[Added]` Add a `theme` user setting with `default`, `ansi`, and `synthwave` themes for lets help and styled error output. +* `[Fixed]` zsh completion now handles root flags before command names, including `-c/--config`, and no longer emits `command -c not declared in config` while completing commands. ## [0.0.61](https://github.com/lets-cli/lets/releases/tag/v0.0.61) diff --git a/internal/cmd/completion.go b/internal/cmd/completion.go index bd9e4163..e796ef54 100644 --- a/internal/cmd/completion.go +++ b/internal/cmd/completion.go @@ -18,9 +18,17 @@ const zshCompletionText = `#compdef lets LETS_EXECUTABLE=lets function _lets { - local state + local state _arguments -C -s \ + "(-c --config)"{-c,--config}"[config file (default is lets.yaml)]:config file:_files" \ + "(-E --env)"{-E,--env}"[set env variable KEY=VALUE]:env var:" \ + "--only[run only specified command(s)]:command:" \ + "--exclude[run all but excluded command(s)]:command:" \ + "(-d --debug -dd)"{-d,--debug}"[show debug logs]" \ + "-dd[show very verbose debug logs]" \ + "--all[show all commands]" \ + "--init[create lets.yaml in current folder]" \ "1: :->cmds" \ '*::arg:->args' @@ -29,22 +37,86 @@ function _lets { _lets_commands ;; args) - _lets_command_options "${words[1]}" + local cmd=$(_lets_active_command) + _lets_command_options "${cmd}" ;; esac } -# Check if in folder with correct lets.yaml file _check_lets_config() { - ${LETS_EXECUTABLE} 1>/dev/null 2>/dev/null + ${LETS_EXECUTABLE} "$@" 1>/dev/null 2>/dev/null echo $? } +_lets_root_flags_before_command() { + local idx=1 + local -a prefix=() + + while [ $idx -lt $CURRENT ]; do + local token="${words[$idx]}" + + case "$token" in + -c|--config|-E|--env|--only|--exclude) + prefix+=("$token") + ((idx++)) + if [ $idx -lt $CURRENT ]; then + prefix+=("${words[$idx]}") + fi + ;; + --config=*|--env=*|--only=*|--exclude=*|-E*) + prefix+=("$token") + ;; + -d|-dd|--debug|--all|--init) + prefix+=("$token") + ;; + --) + break + ;; + -*) + prefix+=("$token") + ;; + *) + break + ;; + esac + + ((idx++)) + done + + reply=("${prefix[@]}") +} + +_lets_active_command() { + local idx=1 + + while [ $idx -le $#words ]; do + local token="${words[$idx]}" + + case "$token" in + -c|--config|-E|--env|--only|--exclude) + ((idx++)) + ;; + --config=*|--env=*|--only=*|--exclude=*|-E*|-d|-dd|--debug|--all|--init|--) + ;; + -*) + ;; + *) + echo "$token" + return + ;; + esac + + ((idx++)) + done +} + _lets_commands () { local cmds + _lets_root_flags_before_command + local -a root_flags=("${reply[@]}") - if [ $(_check_lets_config) -eq 0 ]; then - IFS=$'\n' cmds=($(${LETS_EXECUTABLE} completion --commands --verbose)) + if [ $(_check_lets_config "${root_flags[@]}") -eq 0 ]; then + IFS=$'\n' cmds=($(${LETS_EXECUTABLE} "${root_flags[@]}" completion --commands --verbose 2>/dev/null)) else cmds=() fi @@ -53,15 +125,21 @@ _lets_commands () { _lets_command_options () { local cmd=$1 + _lets_root_flags_before_command + local -a root_flags=("${reply[@]}") + + if [[ -z "$cmd" || "$cmd" == -* ]]; then + return 0 + fi - if [ $(_check_lets_config) -eq 0 ]; then + if [ $(_check_lets_config "${root_flags[@]}") -eq 0 ]; then IFS=$'\n' - _arguments -s $(${LETS_EXECUTABLE} completion --options=${cmd} --verbose) + _arguments -s $(${LETS_EXECUTABLE} "${root_flags[@]}" completion --options=${cmd} --verbose 2>/dev/null) fi } if ! command -v compinit >/dev/null; then - autoload -U compinit && compinit + autoload -U compinit && compinit fi compdef _lets lets From ec741cd5057a525453b7917adf0d97722297ffdd Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Mon, 22 Jun 2026 16:04:13 +0300 Subject: [PATCH 2/2] Fix -- treat in tab completion --- docs/docs/changelog.md | 1 + internal/cmd/completion.go | 79 ++++++++++++++++++++++++++------- internal/cmd/completion_test.go | 57 ++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 internal/cmd/completion_test.go diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 8348855c..1f7b055b 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -18,6 +18,7 @@ title: Changelog * `[Testing]` Add golden-file tests for help and error rendering in `internal/cmd`, replacing bats format checks with snapshot comparisons. * `[Added]` Add a `theme` user setting with `default`, `ansi`, and `synthwave` themes for lets help and styled error output. * `[Fixed]` zsh completion now handles root flags before command names, including `-c/--config`, and no longer emits `command -c not declared in config` while completing commands. +* `[Fixed]` zsh completion now treats `--` as the end of root flags and ignores non-flag command tokens when probing config validity. ## [0.0.61](https://github.com/lets-cli/lets/releases/tag/v0.0.61) diff --git a/internal/cmd/completion.go b/internal/cmd/completion.go index e796ef54..4d7aebaa 100644 --- a/internal/cmd/completion.go +++ b/internal/cmd/completion.go @@ -43,8 +43,58 @@ function _lets { esac } +_lets_root_token_kind() { + case "$1" in + -c|--config|-E|--env|--only|--exclude) + echo value + ;; + --config=*|--env=*|--only=*|--exclude=*|-E*) + echo flag + ;; + -d|-dd|--debug|--all|--init) + echo flag + ;; + --) + echo stop + ;; + -*) + echo other_flag + ;; + *) + echo command + ;; + esac +} + +# Accepts only root flags collected before a command, so config probes cannot +# accidentally execute or validate a command token. _check_lets_config() { - ${LETS_EXECUTABLE} "$@" 1>/dev/null 2>/dev/null + local idx=1 + local -a root_flags=("$@") + + while [ $idx -le ${#root_flags[@]} ]; do + local token="${root_flags[$idx]}" + + case "$(_lets_root_token_kind "$token")" in + value) + ((idx++)) + if [ $idx -gt ${#root_flags[@]} ]; then + echo 1 + return + fi + ;; + flag) + ;; + *) + echo 1 + return + ;; + esac + + ((idx++)) + done + + ${LETS_EXECUTABLE} "${root_flags[@]}" 1>/dev/null 2>/dev/null echo $? } @@ -55,29 +105,23 @@ _lets_root_flags_before_command() { while [ $idx -lt $CURRENT ]; do local token="${words[$idx]}" - case "$token" in - -c|--config|-E|--env|--only|--exclude) + case "$(_lets_root_token_kind "$token")" in + value) prefix+=("$token") ((idx++)) if [ $idx -lt $CURRENT ]; then prefix+=("${words[$idx]}") fi ;; - --config=*|--env=*|--only=*|--exclude=*|-E*) + flag) prefix+=("$token") ;; - -d|-dd|--debug|--all|--init) - prefix+=("$token") - ;; - --) + stop|command) break ;; - -*) + other_flag) prefix+=("$token") ;; - *) - break - ;; esac ((idx++)) @@ -92,15 +136,16 @@ _lets_active_command() { while [ $idx -le $#words ]; do local token="${words[$idx]}" - case "$token" in - -c|--config|-E|--env|--only|--exclude) + case "$(_lets_root_token_kind "$token")" in + value) ((idx++)) ;; - --config=*|--env=*|--only=*|--exclude=*|-E*|-d|-dd|--debug|--all|--init|--) + flag|other_flag) ;; - -*) + stop) + break ;; - *) + command) echo "$token" return ;; diff --git a/internal/cmd/completion_test.go b/internal/cmd/completion_test.go new file mode 100644 index 00000000..bf11995a --- /dev/null +++ b/internal/cmd/completion_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "bytes" + "os/exec" + "strings" + "testing" +) + +func TestZshActiveCommandStopsAtDoubleDash(t *testing.T) { + output := runZshCompletionHelper(t, ` +words=(-- foo) +_lets_active_command +`) + + if output != "" { + t.Fatalf("expected no active command after --, got %q", output) + } +} + +func TestZshCheckLetsConfigRejectsCommandTokens(t *testing.T) { + output := runZshCompletionHelper(t, ` +fake_lets() { return 0 } +LETS_EXECUTABLE=fake_lets +_check_lets_config foo +`) + + if output != "1" { + t.Fatalf("expected command token to be rejected, got %q", output) + } +} + +func runZshCompletionHelper(t *testing.T, body string) string { + t.Helper() + + if _, err := exec.LookPath("zsh"); err != nil { + t.Skip("zsh is not available") + } + + var completion bytes.Buffer + if err := genZshCompletion(&completion); err != nil { + t.Fatalf("generate zsh completion: %v", err) + } + + script := ` +compdef() { : } +compinit() { : } +` + completion.String() + body + + cmd := exec.Command("zsh", "-fc", script) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("zsh completion helper failed: %v\n%s", err, out) + } + + return strings.TrimSpace(string(out)) +}