diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 35fc4177..b9fa3542 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install OS dependencies - run: sudo apt-get -y install mandoc + run: sudo apt-get -y install mandoc zsh # Rush needed for easy installation of stuff - name: Install rush diff --git a/examples/completions-advanced/README.md b/examples/completions-advanced/README.md index d6c6f3d4..e3b00866 100644 --- a/examples/completions-advanced/README.md +++ b/examples/completions-advanced/README.md @@ -5,10 +5,14 @@ dynamic external commands and internal functions, file and directory sources, and the `no-space` option. Runtime completions are enabled in `settings.yml`. Users can load the generated -Bash wrapper with: +wrapper for their shell with: ```bash +# Bash source <(cli completions) + +# Zsh +source <(cli completions zsh) ``` @@ -28,7 +32,7 @@ commands: args: - name: shell help: Shell to generate completions for - allowed: [bash] + allowed: [bash, zsh] default: bash - name: deploy diff --git a/examples/completions-advanced/src/bashly.yml b/examples/completions-advanced/src/bashly.yml index 53772230..ac2e980d 100644 --- a/examples/completions-advanced/src/bashly.yml +++ b/examples/completions-advanced/src/bashly.yml @@ -8,7 +8,7 @@ commands: args: - name: shell help: Shell to generate completions for - allowed: [bash] + allowed: [bash, zsh] default: bash - name: deploy diff --git a/examples/completions/README.md b/examples/completions/README.md index eb1a7920..e700dffb 100644 --- a/examples/completions/README.md +++ b/examples/completions/README.md @@ -3,10 +3,14 @@ Demonstrates how to expose the generated `send_completions` function through an application command. Runtime completions are enabled in `settings.yml`. -Users can load the Bash wrapper with: +Users can load the wrapper for their shell with: ```bash +# Bash source <(cli completions) + +# Zsh +source <(cli completions zsh) ``` This example was generated with: @@ -37,7 +41,7 @@ commands: args: - name: shell help: Shell to generate completions for - allowed: [bash] + allowed: [bash, zsh] default: bash - name: download @@ -102,4 +106,3 @@ download ```` - diff --git a/examples/completions/src/bashly.yml b/examples/completions/src/bashly.yml index 62f82689..fce8126e 100644 --- a/examples/completions/src/bashly.yml +++ b/examples/completions/src/bashly.yml @@ -8,7 +8,7 @@ commands: args: - name: shell help: Shell to generate completions for - allowed: [bash] + allowed: [bash, zsh] default: bash - name: download diff --git a/lib/bashly/views/command/completion_script.gtx b/lib/bashly/views/command/completion_script.gtx index 0ca9a265..05410e52 100644 --- a/lib/bashly/views/command/completion_script.gtx +++ b/lib/bashly/views/command/completion_script.gtx @@ -5,6 +5,7 @@ > > case "$completion_shell" in > bash) send_completions_bash ;; +> zsh) send_completions_zsh ;; > *) > printf 'unsupported shell: %s\n' "$completion_shell" >&2 > return 1 @@ -13,3 +14,4 @@ > } > = render :completion_script_bash += render :completion_script_zsh diff --git a/lib/bashly/views/command/completion_script_zsh.gtx b/lib/bashly/views/command/completion_script_zsh.gtx new file mode 100644 index 00000000..724eae6d --- /dev/null +++ b/lib/bashly/views/command/completion_script_zsh.gtx @@ -0,0 +1,58 @@ += view_marker + +> send_completions_zsh() { +> cat <<'BASHLY_COMPLETIONS' +> #compdef {{ name }} +> +> _{{ name.to_underscore }}_completions() { +> local completion_command="${words[1]}" +> local completion_current="${words[CURRENT]:-}" +> local completion_output +> local completion_directive +> local -a completion_words=() +> local -a completion_response=() +> local -a completion_options=() +> local -a completion_add_args=() +> +> if (( CURRENT > 2 )); then +> completion_words=("${words[2,$((CURRENT - 1))]}") +> fi +> +> completion_output="$( +> "$completion_command" __complete \ +> "${completion_words[@]}" \ +> "$completion_current" +> )" +> completion_response=("${(@f)completion_output}") +> +> completion_directive="${completion_response[-1]:-}" +> if [[ $completion_directive == :options=* ]]; then +> completion_response[-1]=() +> completion_options=("${(@s:,:)${completion_directive#:options=}}") +> fi +> +> local completion_option +> local completion_files=false +> local completion_directories=false +> for completion_option in "${completion_options[@]}"; do +> case "$completion_option" in +> files) completion_files=true ;; +> directories) completion_directories=true ;; +> no-space) completion_add_args=(-S '') ;; +> esac +> done +> +> if (( ${#completion_response[@]} > 0 )); then +> compadd "${completion_add_args[@]}" -- "${completion_response[@]}" +> fi +> +> if [[ $completion_files == true ]]; then +> _files "${completion_add_args[@]}" +> elif [[ $completion_directories == true ]]; then +> _files -/ "${completion_add_args[@]}" +> fi +> } +> +> compdef _{{ name.to_underscore }}_completions {{ name }} +> BASHLY_COMPLETIONS +> } diff --git a/spec/bashly/integration/completion_script_bash_spec.rb b/spec/bashly/integration/completion_script_bash_spec.rb index 34ed61a6..99134338 100644 --- a/spec/bashly/integration/completion_script_bash_spec.rb +++ b/spec/bashly/integration/completion_script_bash_spec.rb @@ -57,12 +57,12 @@ def complete_with_bash(*words, trace_options: false) it 'rejects unsupported shells' do stdout, stderr, status = Open3.capture3( - 'bash', '-c', "source #{cli}; send_completions zsh" + 'bash', '-c', "source #{cli}; send_completions fish" ) expect(status).not_to be_success expect(stdout).to be_empty - expect(stderr).to eq "unsupported shell: zsh\n" + expect(stderr).to eq "unsupported shell: fish\n" end end diff --git a/spec/bashly/integration/completion_script_zsh_spec.rb b/spec/bashly/integration/completion_script_zsh_spec.rb new file mode 100644 index 00000000..bebe61ff --- /dev/null +++ b/spec/bashly/integration/completion_script_zsh_spec.rb @@ -0,0 +1,140 @@ +describe 'Zsh completion script', :slow do + def complete_with_zsh(*words, trace_files: false) + completion_words = words.map { |word| Shellwords.shellescape word }.join ' ' + file_trace = if trace_files + <<~ZSH + _files() { + if [[ "$1" == "-/" ]]; then + shift + compadd "$@" -- apricot + else + compadd "$@" -- apple.txt apricot + fi + } + ZSH + end + + Open3.capture3( + 'zsh', '-f', '-c', <<~ZSH + cd ./spec/tmp + compdef() { true } + eval "$(bash -c 'source ./cli; send_completions zsh')" + typeset -A completion_test_seen=() + compadd() { + local no_space=false + if [[ "$1" == "-S" ]]; then + [[ -z "$2" ]] && no_space=true + shift 2 + fi + [[ "$1" == "--" ]] && shift + [[ $no_space == true ]] && print 'option no-space' + local candidate + for candidate in "$@"; do + if [[ -z "${completion_test_seen[$candidate]:-}" ]]; then + printf 'candidate %s\n' "$candidate" + completion_test_seen[$candidate]=1 + fi + done + } + #{file_trace} + words=(./cli #{completion_words}) + CURRENT=#{words.length + 1} + _cli_completions + ZSH + ) + end + + context 'generation' do + let(:cli) { File.expand_path 'spec/tmp/cli' } + + before(:context) do + Settings.enable_completions = 'always' + reset_tmp_dir + FileUtils.cp_r Dir['spec/fixtures/completions/core/*'], 'spec/tmp' + Commands::Generate.new.execute %w[generate --quiet] + end + + after(:context) do + Settings.enable_completions = 'never' + end + + it 'prints Zsh completions' do + stdout, stderr, status = Open3.capture3( + 'bash', '-c', "source #{cli}; send_completions zsh" + ) + + expect(status).to be_success + expect(stderr).to be_empty + expect(stdout).to start_with "#compdef cli\n" + expect(stdout).to include '_cli_completions() {' + expect(stdout).to end_with "compdef _cli_completions cli\n" + end + + it 'completes commands' do + stdout, stderr, status = complete_with_zsh 'd' + + expect(status).to be_success + expect(stderr).to be_empty + expect(stdout.lines(chomp: true)).to eq ['candidate deploy'] + end + + it 'preserves an empty current word' do + stdout, stderr, status = complete_with_zsh 'server', '' + + expect(status).to be_success + expect(stderr).to be_empty + expect(stdout.lines(chomp: true)).to include 'candidate start' + end + end + + context 'configured options' do + before(:context) do + Settings.enable_completions = 'always' + reset_tmp_dir + FileUtils.cp_r Dir['spec/fixtures/completions/configured/*'], 'spec/tmp' + Commands::Generate.new.execute %w[generate --quiet] + end + + after(:context) do + Settings.enable_completions = 'never' + end + + it 'preserves literal candidates' do + stdout, stderr, status = complete_with_zsh 'static', '', trace_files: true + + expect(status).to be_success + expect(stderr).to be_empty + expect(stdout.lines(chomp: true)).to include( + 'candidate two words', 'candidate $literal', 'candidate :options=files' + ) + end + + it 'adds files when requested' do + stdout, stderr, status = complete_with_zsh 'files', 'a', trace_files: true + + expect(status).to be_success + expect(stderr).to be_empty + expect(stdout.lines(chomp: true)).to contain_exactly( + 'candidate apple.txt', 'candidate apricot' + ) + end + + it 'adds only directories when requested' do + stdout, stderr, status = complete_with_zsh 'directories', 'a', trace_files: true + + expect(status).to be_success + expect(stderr).to be_empty + expect(stdout.lines(chomp: true)).to eq ['candidate apricot'] + end + + it 'applies no-space to candidates and files' do + stdout, stderr, status = complete_with_zsh 'combined', 'a', trace_files: true + + expect(status).to be_success + expect(stderr).to be_empty + expect(stdout.lines(chomp: true)).to contain_exactly( + 'option no-space', 'candidate apple.txt', 'candidate apricot' + ) + end + end +end