diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b9fa3542..6ca925c3 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 zsh + run: sudo apt-get -y install mandoc zsh fish # Rush needed for easy installation of stuff - name: Install rush diff --git a/README.md b/README.md index 97a402b9..c16f5f1a 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Bashly is responsible for: - **Color output**. - **Config file management** (INI format). - **YAML parsing**. - - **Bash completions**. + - **Shell completions** (bash, fish and zsh). - *and more*. - Auto-generating **markdown and man page documentation** for your script. diff --git a/examples/completions-advanced/README.md b/examples/completions-advanced/README.md index 67f25a0f..a7611934 100644 --- a/examples/completions-advanced/README.md +++ b/examples/completions-advanced/README.md @@ -15,6 +15,11 @@ source <(cli completions) source <(cli completions zsh) ``` +```fish +# Fish +cli completions fish | source +``` + ----- @@ -32,7 +37,7 @@ commands: args: - name: shell help: Shell to generate completions for - allowed: [bash, zsh] + allowed: [bash, fish, zsh] default: bash - name: deploy diff --git a/examples/completions-advanced/src/bashly.yml b/examples/completions-advanced/src/bashly.yml index ac2e980d..f65f9d64 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, zsh] + allowed: [bash, fish, zsh] default: bash - name: deploy diff --git a/examples/completions/README.md b/examples/completions/README.md index 468260c4..023ee54f 100644 --- a/examples/completions/README.md +++ b/examples/completions/README.md @@ -13,6 +13,11 @@ source <(cli completions) source <(cli completions zsh) ``` +```fish +# Fish +cli completions fish | source +``` + This example was generated with: ```bash @@ -41,7 +46,7 @@ commands: args: - name: shell help: Shell to generate completions for - allowed: [bash, zsh] + allowed: [bash, fish, zsh] default: bash - name: download diff --git a/examples/completions/src/bashly.yml b/examples/completions/src/bashly.yml index fce8126e..fc9eb387 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, zsh] + allowed: [bash, fish, zsh] default: bash - name: download diff --git a/lib/bashly/concerns/settings_completions.rb b/lib/bashly/concerns/settings_completions.rb index cca36d3a..b7eac73e 100644 --- a/lib/bashly/concerns/settings_completions.rb +++ b/lib/bashly/concerns/settings_completions.rb @@ -1,6 +1,6 @@ module Bashly module SettingsCompletions - COMPLETION_SHELLS = %w[bash zsh].freeze + COMPLETION_SHELLS = %w[bash fish zsh].freeze def completions @completions ||= get :completions diff --git a/lib/bashly/libraries/settings/settings.yml b/lib/bashly/libraries/settings/settings.yml index 34086c2c..bd22e15a 100644 --- a/lib/bashly/libraries/settings/settings.yml +++ b/lib/bashly/libraries/settings/settings.yml @@ -167,8 +167,8 @@ enable_sourcing: development # Generate native runtime completions. Supported values: # - ~ or false # disable completions (default) # - minimal # generate the runtime completion engine without shell adapters -# - bash or zsh # generate the runtime engine and one shell adapter -# - bash,zsh # generate the runtime engine and selected shell adapters +# - bash|fish|zsh # generate the runtime engine and one shell adapter +# - bash,fish # generate the runtime engine and selected shell adapters # - full # generate the runtime engine and all available shell adapters completions: ~ diff --git a/lib/bashly/views/command/completion_script_fish.gtx b/lib/bashly/views/command/completion_script_fish.gtx new file mode 100644 index 00000000..f7fb4015 --- /dev/null +++ b/lib/bashly/views/command/completion_script_fish.gtx @@ -0,0 +1,69 @@ += view_marker + +> send_completions_fish() { +> echo $'function _{{ name.to_underscore }}_completions' +> echo $' set --local completion_tokens (commandline --current-process --tokenize --cut-at-cursor)' +> echo $' set --local completion_current (commandline --current-token --cut-at-cursor)' +> echo $' set --local completion_command $completion_tokens[1]' +> echo $' set --local completion_words' +> echo $'' +> echo $' if test (count $completion_tokens) -gt 1' +> echo $' set completion_words $completion_tokens[2..-1]' +> echo $' end' +> echo $'' +> echo $' set --local completion_response ($completion_command __complete $completion_words "$completion_current")' +> echo $' set --query completion_response[1]; or return' +> echo $'' +> echo $' set --local completion_directive $completion_response[-1]' +> echo $' set --local completion_options' +> echo $'' +> echo $' if string match --quiet --regex "^:options=" -- $completion_directive' +> echo $' set --erase completion_response[-1]' +> echo $' set completion_options (string split --no-empty , -- (string replace --regex "^:options=" "" -- $completion_directive))' +> echo $' end' +> echo $'' +> echo $' set --local completion_files false' +> echo $' set --local completion_directories false' +> echo $' set --local completion_no_space false' +> echo $'' +> echo $' for completion_option in $completion_options' +> echo $' switch $completion_option' +> echo $' case files' +> echo $' set completion_files true' +> echo $' case directories' +> echo $' set completion_directories true' +> echo $' case no-space' +> echo $' set completion_no_space true' +> echo $' end' +> echo $' end' +> echo $'' +> echo $' set --local completion_candidates $completion_response' +> echo $' set --local completion_paths' +> echo $'' +> echo $' if test $completion_files = true' +> echo $' set completion_paths (__fish_complete_path "$completion_current")' +> echo $' else if test $completion_directories = true' +> echo $' set completion_paths (__fish_complete_directories "$completion_current")' +> echo $' end' +> echo $'' +> echo $' # path candidates carry a tab-separated description, and older fish' +> echo $' # versions do not deduplicate candidates on their own' +> echo $' for completion_path in $completion_paths' +> printf '%s\n' $' if not contains -- (string replace --regex \'\\t.*\' \'\' -- $completion_path) $completion_response' +> echo $' set --append completion_candidates $completion_path' +> echo $' end' +> echo $' end' +> echo $'' +> echo $' # fish has no no-space directive, but it appends a space only to a' +> echo $' # unique candidate, so a second candidate suppresses it' +> echo $' if test $completion_no_space = true; and test (count $completion_candidates) -eq 1' +> echo $' set --append completion_candidates "$completion_candidates[1] "' +> echo $' end' +> echo $'' +> echo $' if set --query completion_candidates[1]' +> printf '%s\n' $' printf \'%s\\n\' $completion_candidates' +> echo $' end' +> echo $'end' +> echo $'' +> echo $'complete --command {{ name }} --no-files --arguments "(_{{ name.to_underscore }}_completions)"' +> } diff --git a/schemas/settings.json b/schemas/settings.json index db20967c..792a7035 100644 --- a/schemas/settings.json +++ b/schemas/settings.json @@ -202,7 +202,7 @@ }, { "type": "string", - "pattern": "^(minimal|full|(bash|zsh)(\\s*,\\s*(bash|zsh))*)$" + "pattern": "^(minimal|full|(bash|fish|zsh)(\\s*,\\s*(bash|fish|zsh))*)$" } ], "default": null diff --git a/spec/bashly/integration/completion_script_bash_spec.rb b/spec/bashly/integration/completion_script_bash_spec.rb index 84306627..256423a1 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 fish" + 'bash', '-c', "source #{cli}; send_completions ksh" ) expect(status).not_to be_success expect(stdout).to be_empty - expect(stderr).to eq "unsupported shell: fish\n" + expect(stderr).to eq "unsupported shell: ksh\n" end end diff --git a/spec/bashly/integration/completion_script_fish_spec.rb b/spec/bashly/integration/completion_script_fish_spec.rb new file mode 100644 index 00000000..c73e3ff3 --- /dev/null +++ b/spec/bashly/integration/completion_script_fish_spec.rb @@ -0,0 +1,106 @@ +describe 'Fish completion script', :slow do + def complete_with_fish(*words) + commandline = ['./cli', *words].map { |word| word.empty? ? '' : Shellwords.shellescape(word) }.join ' ' + + stdout, stderr, status = Open3.capture3( + 'fish', '--no-config', '--private', '-c', <<~FISH + cd ./spec/tmp + eval (bash -c 'source ./cli; send_completions fish' | string collect) + complete -C #{Shellwords.shellescape commandline} + FISH + ) + + # Fish candidates may carry a tab-separated description + candidates = stdout.lines(chomp: true).map { |line| line.split("\t").first } + [candidates, stderr, status] + end + + context 'when generating the script' do + let(:cli) { File.expand_path 'spec/tmp/cli' } + + before(:context) do + Settings.completions = 'full' + 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.completions = nil + end + + it 'prints Fish completions' do + stdout, stderr, status = Open3.capture3( + 'bash', '-c', "source #{cli}; send_completions fish" + ) + + expect(status).to be_success + expect(stderr).to be_empty + expect(stdout).to start_with "function _cli_completions\n" + expect(stdout).to end_with %(complete --command cli --no-files --arguments "(_cli_completions)"\n) + end + + it 'completes commands' do + candidates, stderr, status = complete_with_fish 'd' + + expect(status).to be_success + expect(stderr).to be_empty + expect(candidates).to eq %w[deploy] + end + + it 'preserves an empty current word' do + candidates, stderr, status = complete_with_fish 'server', '' + + expect(status).to be_success + expect(stderr).to be_empty + expect(candidates).to include 'start' + end + end + + context 'with configured options' do + before(:context) do + Settings.completions = 'full' + reset_tmp_dir + FileUtils.cp_r Dir['spec/fixtures/completions/configured/*'], 'spec/tmp' + Commands::Generate.new.execute %w[generate --quiet] + FileUtils.touch 'spec/tmp/apple.txt' + FileUtils.mkdir 'spec/tmp/apricot' + end + + after(:context) do + Settings.completions = nil + end + + it 'preserves literal candidates' do + candidates, stderr, status = complete_with_fish 'static', '' + + expect(status).to be_success + expect(stderr).to be_empty + expect(candidates).to include 'two words', '$literal', ':options=files' + end + + it 'adds files when requested' do + candidates, stderr, status = complete_with_fish 'files', 'a' + + expect(status).to be_success + expect(stderr).to be_empty + expect(candidates).to contain_exactly 'apple.txt', 'apricot/' + end + + it 'adds only directories when requested' do + candidates, stderr, status = complete_with_fish 'directories', 'a' + + expect(status).to be_success + expect(stderr).to be_empty + expect(candidates).to eq %w[apricot/] + end + + it 'adds a second candidate to apply no-space to a unique candidate' do + candidates, stderr, status = complete_with_fish 'flag', '--target', 'pro' + + expect(status).to be_success + expect(stderr).to be_empty + expect(candidates).to eq ['production', 'production '] + end + end +end diff --git a/spec/bashly/integration/completion_settings_spec.rb b/spec/bashly/integration/completion_settings_spec.rb index 3c0b781f..efa5d60e 100644 --- a/spec/bashly/integration/completion_settings_spec.rb +++ b/spec/bashly/integration/completion_settings_spec.rb @@ -29,14 +29,25 @@ def generate_with_completions(value) script = generate_with_completions 'bash' expect(script).to include 'send_completions_bash() {' + expect(script).not_to include 'send_completions_fish() {' expect(script).not_to include 'send_completions_zsh() {' expect(script).to include 'local completion_shell="${1:-bash}"' end + it 'generates only the Fish adapter when Fish is selected' do + script = generate_with_completions 'fish' + + expect(script).not_to include 'send_completions_bash() {' + expect(script).to include 'send_completions_fish() {' + expect(script).not_to include 'send_completions_zsh() {' + expect(script).to include 'local completion_shell="${1:-fish}"' + end + it 'generates only the Zsh adapter when Zsh is selected' do script = generate_with_completions 'zsh' expect(script).not_to include 'send_completions_bash() {' + expect(script).not_to include 'send_completions_fish() {' expect(script).to include 'send_completions_zsh() {' expect(script).to include 'local completion_shell="${1:-zsh}"' end @@ -45,6 +56,7 @@ def generate_with_completions(value) script = generate_with_completions 'full' expect(script).to include 'send_completions_bash() {' + expect(script).to include 'send_completions_fish() {' expect(script).to include 'send_completions_zsh() {' end end diff --git a/spec/bashly/settings_spec.rb b/spec/bashly/settings_spec.rb index 025d92a7..dcda46c3 100644 --- a/spec/bashly/settings_spec.rb +++ b/spec/bashly/settings_spec.rb @@ -152,7 +152,7 @@ it 'enables every supported shell when set to full' do subject.completions = 'full' - expect(subject.completion_shells).to eq %w[bash zsh] + expect(subject.completion_shells).to eq %w[bash fish zsh] end it 'accepts one shell' do @@ -162,9 +162,9 @@ end it 'accepts comma-separated shells with optional whitespace' do - subject.completions = 'bash, zsh' + subject.completions = 'bash, fish, zsh' - expect(subject.completion_shells).to eq %w[bash zsh] + expect(subject.completion_shells).to eq %w[bash fish zsh] end it 'accepts the setting from the environment' do @@ -178,7 +178,7 @@ end it 'rejects invalid values' do - invalid_values = [true, '', 'all', 'bash+zsh', 'fish', 'bash,', 'bash,,zsh', 'bash,bash'] + invalid_values = [true, '', 'all', 'bash+zsh', 'ksh', 'bash,', 'bash,,zsh', 'bash,bash'] invalid_values.each do |value| subject.completions = value