From cffb490ae87c22bfaa34d23a6efddb7e7facc3f2 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Wed, 19 Aug 2026 17:33:19 +0300 Subject: [PATCH 1/2] - Refactor completions settings --- examples/completions-advanced/README.md | 2 +- examples/completions-advanced/settings.yml | 2 +- examples/completions/README.md | 4 +- examples/completions/settings.yml | 2 +- lib/bashly/libraries/settings/settings.yml | 9 ++- lib/bashly/settings.rb | 30 ++++++++-- .../views/command/completion_script.gtx | 14 +++-- lib/bashly/views/command/completions.gtx | 2 +- lib/bashly/views/command/master_script.gtx | 2 +- lib/bashly/views/command/start.gtx | 2 +- schemas/settings.json | 25 ++++---- .../completion_script_bash_spec.rb | 8 +-- .../integration/completion_script_zsh_spec.rb | 8 +-- .../integration/completion_settings_spec.rb | 50 ++++++++++++++++ .../integration/runtime_completions_spec.rb | 4 +- spec/bashly/settings_spec.rb | 58 +++++++++++++++++++ spec/fixtures/schemas_invalid/settings/3.yml | 1 + support/schema/settings.yml | 18 +++--- 18 files changed, 194 insertions(+), 47 deletions(-) create mode 100644 spec/bashly/integration/completion_settings_spec.rb create mode 100644 spec/fixtures/schemas_invalid/settings/3.yml diff --git a/examples/completions-advanced/README.md b/examples/completions-advanced/README.md index e3b00866..67f25a0f 100644 --- a/examples/completions-advanced/README.md +++ b/examples/completions-advanced/README.md @@ -74,7 +74,7 @@ commands: ## `settings.yml` ````yaml -enable_completions: always +completions: full ```` diff --git a/examples/completions-advanced/settings.yml b/examples/completions-advanced/settings.yml index c9337546..394ccc6d 100644 --- a/examples/completions-advanced/settings.yml +++ b/examples/completions-advanced/settings.yml @@ -1 +1 @@ -enable_completions: always +completions: full diff --git a/examples/completions/README.md b/examples/completions/README.md index e700dffb..468260c4 100644 --- a/examples/completions/README.md +++ b/examples/completions/README.md @@ -59,7 +59,7 @@ commands: ## `settings.yml` ````yaml -enable_completions: always +completions: full ```` @@ -104,5 +104,3 @@ download ```` - - diff --git a/examples/completions/settings.yml b/examples/completions/settings.yml index c9337546..394ccc6d 100644 --- a/examples/completions/settings.yml +++ b/examples/completions/settings.yml @@ -1 +1 @@ -enable_completions: always +completions: full diff --git a/lib/bashly/libraries/settings/settings.yml b/lib/bashly/libraries/settings/settings.yml index 38f84811..3aa87c61 100644 --- a/lib/bashly/libraries/settings/settings.yml +++ b/lib/bashly/libraries/settings/settings.yml @@ -158,13 +158,20 @@ env: development # - never # do not render this feature enable_header_comment: always enable_bash_version_bouncer: always -enable_completions: never enable_view_markers: development enable_inspect_args: development enable_deps_array: always enable_env_var_names_array: always 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 +# - full # generate the runtime engine and all available shell adapters +completions: ~ + #------------------------------------------------------------------------------- # DEVELOPER OPTIONS diff --git a/lib/bashly/settings.rb b/lib/bashly/settings.rb index 38694d75..b7143047 100644 --- a/lib/bashly/settings.rb +++ b/lib/bashly/settings.rb @@ -1,5 +1,7 @@ module Bashly class Settings + COMPLETION_SHELLS = %w[bash zsh].freeze + class << self include AssetHelper @@ -7,10 +9,10 @@ class << self :argfile_var, :commands_dir, :compact_short_flags, + :completions, :conjoined_flag_args, :config_path, :enable_bash_version_bouncer, - :enable_completions, :enable_deps_array, :enable_env_var_names_array, :enable_header_comment, @@ -51,6 +53,28 @@ def compact_short_flags @compact_short_flags ||= get :compact_short_flags end + def completions + @completions ||= get :completions + end + + def completions? + completions == 'minimal' || completion_shells.any? + end + + def completion_shells + value = completions + return [] if value.nil? || value == false || value == 'minimal' + return COMPLETION_SHELLS if value == 'full' + + shells = value.split(',', -1).map(&:strip) if value.is_a? String + valid = shells&.any? && shells.all? { |shell| COMPLETION_SHELLS.include? shell } + unique = shells&.uniq == shells + return shells if valid && unique + + raise ConfigurationError, + "completions must be false, minimal, full, or a comma-separated list of: #{COMPLETION_SHELLS.join ', '}" + end + def conjoined_flag_args @conjoined_flag_args ||= get :conjoined_flag_args end @@ -69,10 +93,6 @@ def enable_bash_version_bouncer @enable_bash_version_bouncer ||= get :enable_bash_version_bouncer end - def enable_completions - @enable_completions ||= get :enable_completions - end - def enable_deps_array @enable_deps_array ||= get :enable_deps_array end diff --git a/lib/bashly/views/command/completion_script.gtx b/lib/bashly/views/command/completion_script.gtx index 05410e52..3a604f15 100644 --- a/lib/bashly/views/command/completion_script.gtx +++ b/lib/bashly/views/command/completion_script.gtx @@ -1,11 +1,14 @@ = view_marker +completion_shells = Settings.completion_shells + > send_completions() { -> local completion_shell="${1:-bash}" +> local completion_shell="${1:-{{ completion_shells.first }}}" > > case "$completion_shell" in -> bash) send_completions_bash ;; -> zsh) send_completions_zsh ;; +completion_shells.each do |shell| + > {{ shell }}) send_completions_{{ shell }} ;; +end > *) > printf 'unsupported shell: %s\n' "$completion_shell" >&2 > return 1 @@ -13,5 +16,6 @@ > esac > } > -= render :completion_script_bash -= render :completion_script_zsh +completion_shells.each do |shell| + = render :"completion_script_#{shell}" +end diff --git a/lib/bashly/views/command/completions.gtx b/lib/bashly/views/command/completions.gtx index 72f345ea..5ffbf89a 100644 --- a/lib/bashly/views/command/completions.gtx +++ b/lib/bashly/views/command/completions.gtx @@ -74,7 +74,7 @@ > done <<<"$completion_output" > } > -= render :completion_script += render :completion_script if Settings.completion_shells.any? = render :completion_function deep_commands.each do |command| = command.render :completion_function diff --git a/lib/bashly/views/command/master_script.gtx b/lib/bashly/views/command/master_script.gtx index 49d09211..d0b353a2 100644 --- a/lib/bashly/views/command/master_script.gtx +++ b/lib/bashly/views/command/master_script.gtx @@ -8,7 +8,7 @@ = render :user_lib if user_lib.any? = render :command_functions = render :parse_requirements -= render :completions if Settings.enabled? :completions += render :completions if Settings.completions? = render :user_hooks = render :initialize = render :run diff --git a/lib/bashly/views/command/start.gtx b/lib/bashly/views/command/start.gtx index c28ca4b4..342eaa85 100644 --- a/lib/bashly/views/command/start.gtx +++ b/lib/bashly/views/command/start.gtx @@ -1,7 +1,7 @@ = view_marker > command_line_args=("$@") -if Settings.enabled? :completions +if Settings.completions? > if [[ "${command_line_args[0]:-}" == "__complete" ]]; then > completion_run "${command_line_args[@]:1}" > else diff --git a/schemas/settings.json b/schemas/settings.json index 36c8b138..3fa7d42c 100644 --- a/schemas/settings.json +++ b/schemas/settings.json @@ -190,17 +190,22 @@ ], "default": "always" }, - "enable_completions": { - "title": "enable_completions", - "description": "Whether to include runtime completion functions in the generated script\nhttps://bashly.dev/usage/settings/#enable_completions", - "type": "string", - "enum": [ - "development", - "production", - "always", - "never" + "completions": { + "title": "completions", + "description": "Which runtime completion functions and shell adapters to include in the generated script.\nUse minimal for the runtime engine only, full for all available adapters, or a comma-separated shell list.\nhttps://bashly.dev/usage/settings/#completions", + "oneOf": [ + { + "type": "null" + }, + { + "const": false + }, + { + "type": "string", + "pattern": "^(minimal|full|(bash|zsh)(\\s*,\\s*(bash|zsh))*)$" + } ], - "default": "never" + "default": null }, "enable_view_markers": { "title": "enable_view_markers", diff --git a/spec/bashly/integration/completion_script_bash_spec.rb b/spec/bashly/integration/completion_script_bash_spec.rb index 99134338..07fab158 100644 --- a/spec/bashly/integration/completion_script_bash_spec.rb +++ b/spec/bashly/integration/completion_script_bash_spec.rb @@ -23,14 +23,14 @@ def complete_with_bash(*words, trace_options: false) let(:cli) { File.expand_path 'spec/tmp/cli' } before(:context) do - Settings.enable_completions = 'always' + 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.enable_completions = 'never' + Settings.completions = nil end it 'prints Bash completions by default' do @@ -68,7 +68,7 @@ def complete_with_bash(*words, trace_options: false) context 'configured options' do before(:context) do - Settings.enable_completions = 'always' + Settings.completions = 'full' reset_tmp_dir FileUtils.cp_r Dir['spec/fixtures/completions/configured/*'], 'spec/tmp' Commands::Generate.new.execute %w[generate --quiet] @@ -77,7 +77,7 @@ def complete_with_bash(*words, trace_options: false) end after(:context) do - Settings.enable_completions = 'never' + Settings.completions = nil end it 'adds files when requested' do diff --git a/spec/bashly/integration/completion_script_zsh_spec.rb b/spec/bashly/integration/completion_script_zsh_spec.rb index bebe61ff..af3a922f 100644 --- a/spec/bashly/integration/completion_script_zsh_spec.rb +++ b/spec/bashly/integration/completion_script_zsh_spec.rb @@ -48,14 +48,14 @@ def complete_with_zsh(*words, trace_files: false) let(:cli) { File.expand_path 'spec/tmp/cli' } before(:context) do - Settings.enable_completions = 'always' + 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.enable_completions = 'never' + Settings.completions = nil end it 'prints Zsh completions' do @@ -89,14 +89,14 @@ def complete_with_zsh(*words, trace_files: false) context 'configured options' do before(:context) do - Settings.enable_completions = 'always' + Settings.completions = 'full' 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' + Settings.completions = nil end it 'preserves literal candidates' do diff --git a/spec/bashly/integration/completion_settings_spec.rb b/spec/bashly/integration/completion_settings_spec.rb new file mode 100644 index 00000000..3c0b781f --- /dev/null +++ b/spec/bashly/integration/completion_settings_spec.rb @@ -0,0 +1,50 @@ +describe 'Completion settings', :slow do + def generate_with_completions(value) + Settings.completions = value + reset_tmp_dir + FileUtils.cp_r Dir['spec/fixtures/completions/core/*'], 'spec/tmp' + Commands::Generate.new.execute %w[generate --quiet] + File.read 'spec/tmp/cli' + end + + after do + Settings.completions = nil + end + + it 'omits the runtime engine and adapters by default' do + script = generate_with_completions nil + + expect(script).not_to include 'completion_run() {' + expect(script).not_to include 'send_completions() {' + end + + it 'generates only the runtime engine for minimal' do + script = generate_with_completions 'minimal' + + expect(script).to include 'completion_run() {' + expect(script).not_to include 'send_completions() {' + end + + it 'generates only the Bash adapter when Bash is selected' do + script = generate_with_completions 'bash' + + expect(script).to include 'send_completions_bash() {' + expect(script).not_to include 'send_completions_zsh() {' + expect(script).to include 'local completion_shell="${1:-bash}"' + 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).to include 'send_completions_zsh() {' + expect(script).to include 'local completion_shell="${1:-zsh}"' + end + + it 'generates every adapter for full' do + script = generate_with_completions 'full' + + expect(script).to include 'send_completions_bash() {' + expect(script).to include 'send_completions_zsh() {' + end +end diff --git a/spec/bashly/integration/runtime_completions_spec.rb b/spec/bashly/integration/runtime_completions_spec.rb index 9d8fa51e..b96e0a3c 100644 --- a/spec/bashly/integration/runtime_completions_spec.rb +++ b/spec/bashly/integration/runtime_completions_spec.rb @@ -7,14 +7,14 @@ cli = File.expand_path 'spec/tmp/cli' before(:context) do - Settings.enable_completions = 'always' + Settings.completions = 'minimal' reset_tmp_dir FileUtils.cp_r Dir["#{workspace}/*"], 'spec/tmp' Commands::Generate.new.execute %w[generate --quiet] end after(:context) do - Settings.enable_completions = 'never' + Settings.completions = nil end examples.each do |name, example| diff --git a/spec/bashly/settings_spec.rb b/spec/bashly/settings_spec.rb index 9af15d9c..025d92a7 100644 --- a/spec/bashly/settings_spec.rb +++ b/spec/bashly/settings_spec.rb @@ -129,6 +129,64 @@ end end + describe '::completion_shells' do + it 'disables completions by default' do + expect(subject.completions?).to be false + expect(subject.completion_shells).to be_empty + end + + it 'disables completions when set to false' do + subject.completions = false + + expect(subject.completions?).to be false + expect(subject.completion_shells).to be_empty + end + + it 'enables only the runtime engine when set to minimal' do + subject.completions = 'minimal' + + expect(subject.completions?).to be true + expect(subject.completion_shells).to be_empty + end + + it 'enables every supported shell when set to full' do + subject.completions = 'full' + + expect(subject.completion_shells).to eq %w[bash zsh] + end + + it 'accepts one shell' do + subject.completions = 'zsh' + + expect(subject.completion_shells).to eq %w[zsh] + end + + it 'accepts comma-separated shells with optional whitespace' do + subject.completions = 'bash, zsh' + + expect(subject.completion_shells).to eq %w[bash zsh] + end + + it 'accepts the setting from the environment' do + original_value = ENV['BASHLY_COMPLETIONS'] + ENV['BASHLY_COMPLETIONS'] = 'zsh,bash' + subject.completions = nil + + expect(subject.completion_shells).to eq %w[zsh bash] + ensure + ENV['BASHLY_COMPLETIONS'] = original_value + end + + it 'rejects invalid values' do + invalid_values = [true, '', 'all', 'bash+zsh', 'fish', 'bash,', 'bash,,zsh', 'bash,bash'] + + invalid_values.each do |value| + subject.completions = value + expect { subject.completion_shells }.to raise_error ConfigurationError + end + end + end + describe '::production?' do it 'returns false by default' do expect(subject.production?).to be false diff --git a/spec/fixtures/schemas_invalid/settings/3.yml b/spec/fixtures/schemas_invalid/settings/3.yml new file mode 100644 index 00000000..10ca8f1e --- /dev/null +++ b/spec/fixtures/schemas_invalid/settings/3.yml @@ -0,0 +1 @@ +completions: bash+zsh diff --git a/support/schema/settings.yml b/support/schema/settings.yml index a2141a91..0441def6 100644 --- a/support/schema/settings.yml +++ b/support/schema/settings.yml @@ -169,14 +169,18 @@ properties: type: string enum: *feature_toggles default: always - enable_completions: - title: enable_completions + completions: + title: completions description: |- - Whether to include runtime completion functions in the generated script - https://bashly.dev/usage/settings/#enable_completions - type: string - enum: *feature_toggles - default: never + Which runtime completion functions and shell adapters to include in the generated script. + Use minimal for the runtime engine only, full for all available adapters, or a comma-separated shell list. + https://bashly.dev/usage/settings/#completions + oneOf: + - type: "null" + - const: false + - type: string + pattern: '^(minimal|full|(bash|zsh)(\s*,\s*(bash|zsh))*)$' + default: null enable_view_markers: title: enable_view_markers description: |- From f3e6505f42d458e61309374926addc0e2f01a4e8 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Wed, 19 Aug 2026 17:35:08 +0300 Subject: [PATCH 2/2] - Make `inspect_args` sorting deterministic --- lib/bashly/views/command/inspect_args.gtx | 6 +++--- spec/approvals/examples/commands | 2 +- spec/approvals/examples/validations | 2 +- spec/approvals/examples/whitelist | 8 ++++---- spec/approvals/fixtures/default-validations | 2 +- spec/approvals/fixtures/required-args-order | 6 +++--- spec/approvals/fixtures/whitelist-optional | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/bashly/views/command/inspect_args.gtx b/lib/bashly/views/command/inspect_args.gtx index 07a7d6b0..e371b65b 100644 --- a/lib/bashly/views/command/inspect_args.gtx +++ b/lib/bashly/views/command/inspect_args.gtx @@ -4,7 +4,7 @@ > local k > > if ((${#args[@]})); then -> readarray -t sorted_keys < <(printf '%s\n' "${!args[@]}" | sort) +> readarray -t sorted_keys < <(printf '%s\n' "${!args[@]}" | LC_ALL=C sort) > echo args: > for k in "${sorted_keys[@]}"; do > echo "- \${args[$k]} = ${args[$k]}" @@ -28,7 +28,7 @@ end if Settings.enabled? :deps_array > if ((${#deps[@]})); then - > readarray -t sorted_keys < <(printf '%s\n' "${!deps[@]}" | sort) + > readarray -t sorted_keys < <(printf '%s\n' "${!deps[@]}" | LC_ALL=C sort) > echo > echo deps: > for k in "${sorted_keys[@]}"; do @@ -40,7 +40,7 @@ end if Settings.enabled? :env_var_names_array > if ((${#env_var_names[@]})); then - > readarray -t sorted_names < <(printf '%s\n' "${env_var_names[@]}" | sort) + > readarray -t sorted_names < <(printf '%s\n' "${env_var_names[@]}" | LC_ALL=C sort) > echo > echo "environment variables:" > for k in "${sorted_names[@]}"; do diff --git a/spec/approvals/examples/commands b/spec/approvals/examples/commands index e0953a0e..b295ac13 100644 --- a/spec/approvals/examples/commands +++ b/spec/approvals/examples/commands @@ -119,8 +119,8 @@ missing required flag: --user, -u USER # The code you write here will be wrapped by a function named 'cli_upload_command()'. # Feel free to edit this file; your changes will persist when regenerating. args: -- ${args[source]} = sourcefile - ${args[--user]} = username +- ${args[source]} = sourcefile environment variables: - $API_KEY = diff --git a/spec/approvals/examples/validations b/spec/approvals/examples/validations index 22966367..58489137 100644 --- a/spec/approvals/examples/validations +++ b/spec/approvals/examples/validations @@ -16,8 +16,8 @@ run ./validate --help to test your bash script # The code you write here will be wrapped by a function named 'validate_calc_command()'. # Feel free to edit this file; your changes will persist when regenerating. args: -- ${args[first]} = 1 - ${args[--save]} = README.md +- ${args[first]} = 1 - ${args[second]} = 2 + ./validate calc A validation error in FIRST: diff --git a/spec/approvals/examples/whitelist b/spec/approvals/examples/whitelist index 9ec3343a..3bfa88ff 100644 --- a/spec/approvals/examples/whitelist +++ b/spec/approvals/examples/whitelist @@ -52,10 +52,10 @@ region must be one of: eu, us # The code you write here will be wrapped by a function named 'root_command()'. # Feel free to edit this file; your changes will persist when regenerating. args: -- ${args[environment]} = development - ${args[--protocol]} = ssh -- ${args[region]} = eu - ${args[--user]} = admin +- ${args[environment]} = development +- ${args[region]} = eu + ./login us --user user --protocol icmp --protocol must be one of: ftp, ssh, http + ./login eu production --user admin --protocol ssh @@ -64,7 +64,7 @@ args: # The code you write here will be wrapped by a function named 'root_command()'. # Feel free to edit this file; your changes will persist when regenerating. args: -- ${args[environment]} = production - ${args[--protocol]} = ssh -- ${args[region]} = eu - ${args[--user]} = admin +- ${args[environment]} = production +- ${args[region]} = eu diff --git a/spec/approvals/fixtures/default-validations b/spec/approvals/fixtures/default-validations index a31b30b7..8d32a687 100644 --- a/spec/approvals/fixtures/default-validations +++ b/spec/approvals/fixtures/default-validations @@ -18,5 +18,5 @@ must be an existing file # The code you write here will be wrapped by a function named 'root_command()'. # Feel free to edit this file; your changes will persist when regenerating. args: -- ${args[file]} = README.md - ${args[--template]} = cli +- ${args[file]} = README.md diff --git a/spec/approvals/fixtures/required-args-order b/spec/approvals/fixtures/required-args-order index 64049618..401583fe 100644 --- a/spec/approvals/fixtures/required-args-order +++ b/spec/approvals/fixtures/required-args-order @@ -27,9 +27,9 @@ args: # Feel free to edit this file; your changes will persist when regenerating. args: - ${args[--method]} = GET +- ${args[--role]} = admin - ${args[port]} = 3000 - ${args[protocol]} = http -- ${args[--role]} = admin + ./download http --role admin --method GET 3000 # This file is located at 'src/root_command.sh'. # It contains the implementation for the 'download' command. @@ -37,9 +37,9 @@ args: # Feel free to edit this file; your changes will persist when regenerating. args: - ${args[--method]} = GET +- ${args[--role]} = admin - ${args[port]} = 3000 - ${args[protocol]} = http -- ${args[--role]} = admin + ./download --role admin --method GET http 3000 # This file is located at 'src/root_command.sh'. # It contains the implementation for the 'download' command. @@ -47,6 +47,6 @@ args: # Feel free to edit this file; your changes will persist when regenerating. args: - ${args[--method]} = GET +- ${args[--role]} = admin - ${args[port]} = 3000 - ${args[protocol]} = http -- ${args[--role]} = admin diff --git a/spec/approvals/fixtures/whitelist-optional b/spec/approvals/fixtures/whitelist-optional index fd04d487..5b28ee2a 100644 --- a/spec/approvals/fixtures/whitelist-optional +++ b/spec/approvals/fixtures/whitelist-optional @@ -43,5 +43,5 @@ action must be one of: push, commit # The code you write here will be wrapped by a function named 'root_command()'. # Feel free to edit this file; your changes will persist when regenerating. args: -- ${args[action]} = push - ${args[--notify]} = slack +- ${args[action]} = push