diff --git a/lib/bashly/completions/bashly-completions.bash b/lib/bashly/completions/bashly-completions.bash index 0f85d38c..1554ab5a 100644 --- a/lib/bashly/completions/bashly-completions.bash +++ b/lib/bashly/completions/bashly-completions.bash @@ -301,7 +301,7 @@ _bashly_completions() { return ;; 6:0) - while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "arg arg.allowed arg.completions arg.default arg.help arg.name arg.repeatable arg.required arg.validate command command.alias command.args command.catch_all command.commands command.completions command.default command.dependencies command.environment_variables command.examples command.expose command.extensible command.filename command.filters command.flags command.footer command.function command.group command.help command.help_header_override command.name command.private command.variables command.version environment_variable environment_variable.default environment_variable.help environment_variable.name environment_variable.private environment_variable.required environment_variable.validate flag flag.alias flag.allowed flag.arg flag.completions flag.conflicts flag.default flag.help flag.long flag.needs flag.private flag.repeatable flag.required flag.short flag.unique flag.validate variable variable.name variable.value" -- "$cur") + while read -r; do COMPREPLY+=("$REPLY"); done < <(compgen -W "arg arg.allowed arg.completions arg.default arg.help arg.name arg.repeatable arg.required arg.validate command command.alias command.args command.catch_all command.commands command.completions command.default command.dependencies command.environment_variables command.examples command.expose command.extensible command.filename command.filters command.flags command.footer command.function command.group command.help command.help_header_override command.name command.private command.variables command.version environment_variable environment_variable.default environment_variable.help environment_variable.name environment_variable.private environment_variable.required environment_variable.validate flag flag.alias flag.allowed flag.arg flag.completions flag.conflicts flag.default flag.help flag.long flag.needs flag.negatable flag.private flag.repeatable flag.required flag.short flag.unique flag.validate variable variable.name variable.value" -- "$cur") return ;; 8:0) diff --git a/lib/bashly/completions/completely.yaml b/lib/bashly/completions/completely.yaml index 08d0697e..cf8b09bd 100644 --- a/lib/bashly/completions/completely.yaml +++ b/lib/bashly/completions/completely.yaml @@ -127,6 +127,7 @@ tokens: - flag.help - flag.long - flag.needs + - flag.negatable - flag.private - flag.repeatable - flag.required diff --git a/lib/bashly/config_validator.rb b/lib/bashly/config_validator.rb index 1b4481db..6aac6fdb 100644 --- a/lib/bashly/config_validator.rb +++ b/lib/bashly/config_validator.rb @@ -130,6 +130,7 @@ def assert_flag(key, value) assert_string_or_array "#{key}.default", value['default'] assert_string_or_array "#{key}.validate", value['validate'] + assert_boolean "#{key}.negatable", value['negatable'] assert_boolean "#{key}.private", value['private'] assert_boolean "#{key}.repeatable", value['repeatable'] assert_boolean "#{key}.unique", value['unique'] @@ -148,6 +149,13 @@ def assert_flag(key, value) refute value['required'] && value['default'], "#{key} cannot have both nub`required` and nub`default`" + if value['negatable'] + assert value['long'], "#{key}.negatable requires nub`long`" + refute value['arg'], "#{key}.negatable does not make sense with nub`arg`" + refute value['repeatable'], "#{key}.negatable does not make sense with nub`repeatable`" + refute value['required'], "#{key}.negatable does not make sense with nub`required`" + end + if value['default'] assert value['arg'], "#{key}.default does not make sense without nub`arg`" end @@ -234,7 +242,7 @@ def assert_command(key, value) assert_array "#{key}.variables", value['variables'], of: :var assert_uniq "#{key}.commands", value['commands'], %w[name alias] - assert_uniq "#{key}.flags", value['flags'], %w[long short alias] + assert_uniq_flags key, value['flags'] assert_uniq "#{key}.args", value['args'], 'name' if value['function'] @@ -276,5 +284,23 @@ def assert_command(key, value) refute value['extensible'], "#{key}.extensible makes no sense" end end + + def assert_uniq_flags(key, flags) + return unless flags + + list = flags.flat_map do |flag| + [flag['long'], flag['short'], *Array(flag['alias']), negated_flag_name(flag)].compact + end + + nonuniqs = list.nonuniq + assert nonuniqs.empty?, + "#{key}.flags contains non-unique elements (#{nonuniqs.join ', '}) in long or short or alias" + end + + def negated_flag_name(flag) + return unless flag['negatable'] == true && flag['long'] + + "--no-#{flag['long'].delete_prefix '--'}" + end end end diff --git a/lib/bashly/docs/flag.yml b/lib/bashly/docs/flag.yml index f7178f5c..5a27049f 100644 --- a/lib/bashly/docs/flag.yml +++ b/lib/bashly/docs/flag.yml @@ -147,6 +147,15 @@ flag.needs: help: Where to add the alias needs: [--add] +flag.negatable: + help: Allow a boolean long flag to also be disabled with its `--no-` form. + url: https://bashly.dev/configuration/flag/#negatable + example: |- + flags: + - long: --color + help: Enable color output + negatable: true + flag.private: help: Specify that this flag should not be displayed in the help text. url: https://bashly.dev/configuration/flag/#private diff --git a/lib/bashly/script/flag.rb b/lib/bashly/script/flag.rb index 7b26c3cb..e3c92ff5 100644 --- a/lib/bashly/script/flag.rb +++ b/lib/bashly/script/flag.rb @@ -11,7 +11,7 @@ class << self def option_keys @option_keys ||= %i[ alias allowed arg completions conflicts default help long needs - private repeatable required short unique validate + negatable private repeatable required short unique validate ] end end @@ -23,9 +23,17 @@ def alt end def aliases + [long, negated_long, short].compact + alt + end + + def positive_aliases primary_aliases + alt end + def negated_long + "--no-#{long_without_prefix}" if negatable && long + end + def default_string if default.is_a?(Array) Shellwords.shelljoin default @@ -41,7 +49,7 @@ def name end def usage_string(extended: false) - result = [aliases.join(', ')] + result = [usage_aliases.join(', ')] result << arg.upcase if arg result << strings[:required] if required && extended result << strings[:repeatable] if repeatable && extended @@ -53,6 +61,18 @@ def usage_string(extended: false) def primary_aliases [long, short].compact end + + def usage_aliases + [usage_long, short].compact + alt + end + + def usage_long + negatable && long ? "--[no-]#{long_without_prefix}" : long + end + + def long_without_prefix + long.delete_prefix '--' + end end end end diff --git a/lib/bashly/views/flag/argfile_case.gtx b/lib/bashly/views/flag/argfile_case.gtx index 89cf0f2a..f446f0f8 100644 --- a/lib/bashly/views/flag/argfile_case.gtx +++ b/lib/bashly/views/flag/argfile_case.gtx @@ -1,6 +1,13 @@ = view_marker -> {{ aliases.join " | " }}) +> {{ positive_aliases.join " | " }}) = render(arg ? :argfile_case_arg : :argfile_case_no_arg).indent 2 > ;; > + +if negatable + > {{ negated_long }}) + > unset "args[{{ name }}]" + > ;; + > +end diff --git a/lib/bashly/views/flag/case.gtx b/lib/bashly/views/flag/case.gtx index bdab88a8..a4166518 100644 --- a/lib/bashly/views/flag/case.gtx +++ b/lib/bashly/views/flag/case.gtx @@ -1,6 +1,14 @@ = view_marker -> {{ aliases.join " | " }}) +> {{ positive_aliases.join " | " }}) = render(:conflicts).indent 2 = render(arg ? :case_arg : :case_no_arg).indent 2 > + +if negatable + > {{ negated_long }}) + > unset "args[{{ name }}]" + > shift + > ;; + > +end diff --git a/schemas/bashly.json b/schemas/bashly.json index fef4250e..340d4557 100644 --- a/schemas/bashly.json +++ b/schemas/bashly.json @@ -290,6 +290,12 @@ ] } }, + "negatable": { + "title": "negatable", + "description": "Whether the current flag also accepts its --no- form\nhttps://bashly.dev/configuration/flag/#negatable", + "type": "boolean", + "default": false + }, "completions": { "title": "completions", "description": "Completions of the current flag\nhttps://bashly.dev/configuration/flag/#completions", diff --git a/spec/approvals/cli/doc/full b/spec/approvals/cli/doc/full index eb4baebc..462a70aa 100644 --- a/spec/approvals/cli/doc/full +++ b/spec/approvals/cli/doc/full @@ -784,6 +784,17 @@ flag.needs See https://bashly.dev/configuration/flag/#needs +flag.negatable + + Allow a boolean long flag to also be disabled with its --no- form. + + flags: + - long: --color + help: Enable color output + negatable: true + + See https://bashly.dev/configuration/flag/#negatable + flag.private Specify that this flag should not be displayed in the help text. diff --git a/spec/approvals/cli/doc/index b/spec/approvals/cli/doc/index index d381f3a9..2e744849 100644 --- a/spec/approvals/cli/doc/index +++ b/spec/approvals/cli/doc/index @@ -48,6 +48,7 @@ flag.default flag.help flag.long flag.needs +flag.negatable flag.private flag.repeatable flag.required diff --git a/spec/approvals/fixtures/negatable-argfile b/spec/approvals/fixtures/negatable-argfile new file mode 100644 index 00000000..6ef969b2 --- /dev/null +++ b/spec/approvals/fixtures/negatable-argfile @@ -0,0 +1,32 @@ +creating user files in src +created src/root_command.sh +created ./colorize +run ./colorize --help to test your bash script ++ ./colorize +# This file is located at 'src/root_command.sh'. +# It contains the implementation for the 'colorize' command. +# 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[--color]} = 1 ++ ./colorize --no-color +# This file is located at 'src/root_command.sh'. +# It contains the implementation for the 'colorize' command. +# 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: none ++ ARGFILE=.colorize-off ++ ./colorize +# This file is located at 'src/root_command.sh'. +# It contains the implementation for the 'colorize' command. +# 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: none ++ ARGFILE=.colorize-off ++ ./colorize --color +# This file is located at 'src/root_command.sh'. +# It contains the implementation for the 'colorize' command. +# 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[--color]} = 1 diff --git a/spec/approvals/validations/flag_negatable_type b/spec/approvals/validations/flag_negatable_type new file mode 100644 index 00000000..efc1114d --- /dev/null +++ b/spec/approvals/validations/flag_negatable_type @@ -0,0 +1 @@ +# \ No newline at end of file diff --git a/spec/approvals/validations/flag_negatable_with_arg b/spec/approvals/validations/flag_negatable_with_arg new file mode 100644 index 00000000..c94de58f --- /dev/null +++ b/spec/approvals/validations/flag_negatable_with_arg @@ -0,0 +1 @@ +# \ No newline at end of file diff --git a/spec/approvals/validations/flag_negatable_with_repeatable b/spec/approvals/validations/flag_negatable_with_repeatable new file mode 100644 index 00000000..9958df53 --- /dev/null +++ b/spec/approvals/validations/flag_negatable_with_repeatable @@ -0,0 +1 @@ +# \ No newline at end of file diff --git a/spec/approvals/validations/flag_negatable_with_required b/spec/approvals/validations/flag_negatable_with_required new file mode 100644 index 00000000..a6bc534c --- /dev/null +++ b/spec/approvals/validations/flag_negatable_with_required @@ -0,0 +1 @@ +# \ No newline at end of file diff --git a/spec/approvals/validations/flag_negatable_without_long b/spec/approvals/validations/flag_negatable_without_long new file mode 100644 index 00000000..64fb9281 --- /dev/null +++ b/spec/approvals/validations/flag_negatable_without_long @@ -0,0 +1 @@ +# \ No newline at end of file diff --git a/spec/approvals/validations/non_uniq_flags_negatable b/spec/approvals/validations/non_uniq_flags_negatable new file mode 100644 index 00000000..662734da --- /dev/null +++ b/spec/approvals/validations/non_uniq_flags_negatable @@ -0,0 +1 @@ +# \ No newline at end of file diff --git a/spec/bashly/completion_builder_spec.rb b/spec/bashly/completion_builder_spec.rb index c66a255a..ff0f7d10 100644 --- a/spec/bashly/completion_builder_spec.rb +++ b/spec/bashly/completion_builder_spec.rb @@ -30,4 +30,21 @@ expect(data['tokens']['get_package']).to eq %w[hello world] end end + + context 'with a negatable flag' do + let(:command) do + Script::Command.new( + 'name' => 'cli', + 'flags' => [ + { 'long' => '--color', 'short' => '-c', 'negatable' => true }, + ] + ) + end + + it 'adds the negated long option to the same option entry' do + data = described_class.new(command).call + + expect(data['options']['root']).to include '--color|--no-color|-c' + end + end end diff --git a/spec/bashly/script/flag_spec.rb b/spec/bashly/script/flag_spec.rb index 100ca4f1..07b319cc 100644 --- a/spec/bashly/script/flag_spec.rb +++ b/spec/bashly/script/flag_spec.rb @@ -54,6 +54,14 @@ expect(subject.aliases).to eq ['--container', '--pod'] end end + + context 'with a negatable flag' do + let(:fixture) { :negatable } + + it 'includes the negated long option' do + expect(subject.aliases).to eq ['--color', '--no-color', '-c'] + end + end end describe '#default_string' do @@ -119,6 +127,14 @@ end end + context 'with a negatable flag' do + let(:fixture) { :negatable_with_alias } + + it 'uses bracket notation for the negated long option' do + expect(subject.usage_string).to eq '--[no-]color, -c, --colour' + end + end + context 'with extended: true' do context 'when the flag is optional' do it 'returns the same string as it does without extended' do @@ -155,5 +171,18 @@ expect(result).to include "args['--container']=\"$2\"" end end + + context 'with a negatable flag' do + let(:fixture) { :negatable } + + it 'assigns and unsets the canonical flag name' do + result = subject.render(:case) + + expect(result).to include '--color | -c)' + expect(result).to include "args['--color']=1" + expect(result).to include '--no-color)' + expect(result).to include 'unset "args[--color]"' + end + end end end diff --git a/spec/fixtures/script/flags.yml b/spec/fixtures/script/flags.yml index 4c8a831f..dbea438b 100644 --- a/spec/fixtures/script/flags.yml +++ b/spec/fixtures/script/flags.yml @@ -13,6 +13,17 @@ long: --container alias: --pod +:negatable: + long: --color + short: -c + negatable: true + +:negatable_with_alias: + long: --color + short: -c + alias: --colour + negatable: true + :long_only: long: --long diff --git a/spec/fixtures/script/validations.yml b/spec/fixtures/script/validations.yml index 6a6bb818..cfbfae2e 100644 --- a/spec/fixtures/script/validations.yml +++ b/spec/fixtures/script/validations.yml @@ -344,6 +344,44 @@ flags: - long: force +:flag_negatable_type: + name: invalid + help: flag.negatable should be boolean + flags: + - long: --color + negatable: maybe + +:flag_negatable_without_long: + name: invalid + help: flag.negatable requires long + flags: + - short: -c + negatable: true + +:flag_negatable_with_arg: + name: invalid + help: flag.negatable cannot be used with arg + flags: + - long: --color + arg: mode + negatable: true + +:flag_negatable_with_repeatable: + name: invalid + help: flag.negatable cannot be used with repeatable + flags: + - long: --color + repeatable: true + negatable: true + +:flag_negatable_with_required: + name: invalid + help: flag.negatable cannot be used with required + flags: + - long: --color + required: true + negatable: true + :flag_required_and_default: name: invalid help: flag cannot have both required and default @@ -449,6 +487,14 @@ alias: [--pod, -p] - long: --podman short: -p + +:non_uniq_flags_negatable: + name: invalid + help: generated negatable flag conflicts with another flag + flags: + - long: --color + negatable: true + - long: --no-color :non_uniq_arg_names: name: invalid diff --git a/spec/fixtures/workspaces/negatable-argfile/.colorize b/spec/fixtures/workspaces/negatable-argfile/.colorize new file mode 100644 index 00000000..4e1e0d2f --- /dev/null +++ b/spec/fixtures/workspaces/negatable-argfile/.colorize @@ -0,0 +1 @@ +--color diff --git a/spec/fixtures/workspaces/negatable-argfile/.colorize-off b/spec/fixtures/workspaces/negatable-argfile/.colorize-off new file mode 100644 index 00000000..04bc1ca1 --- /dev/null +++ b/spec/fixtures/workspaces/negatable-argfile/.colorize-off @@ -0,0 +1,2 @@ +--color +--no-color diff --git a/spec/fixtures/workspaces/negatable-argfile/.gitignore b/spec/fixtures/workspaces/negatable-argfile/.gitignore new file mode 100644 index 00000000..77f1efc1 --- /dev/null +++ b/spec/fixtures/workspaces/negatable-argfile/.gitignore @@ -0,0 +1 @@ +/colorize diff --git a/spec/fixtures/workspaces/negatable-argfile/src/bashly.yml b/spec/fixtures/workspaces/negatable-argfile/src/bashly.yml new file mode 100644 index 00000000..b472aab7 --- /dev/null +++ b/spec/fixtures/workspaces/negatable-argfile/src/bashly.yml @@ -0,0 +1,11 @@ +name: colorize +help: Test negatable argfile behavior +version: 0.1.0 + +argfile: .colorize + +flags: +- long: --color + short: -c + help: Enable color output + negatable: true diff --git a/spec/fixtures/workspaces/negatable-argfile/src/root_command.sh b/spec/fixtures/workspaces/negatable-argfile/src/root_command.sh new file mode 100644 index 00000000..f77da0ae --- /dev/null +++ b/spec/fixtures/workspaces/negatable-argfile/src/root_command.sh @@ -0,0 +1,5 @@ +echo "# This file is located at 'src/root_command.sh'." +echo "# It contains the implementation for the 'colorize' command." +echo "# The code you write here will be wrapped by a function named 'root_command()'." +echo "# Feel free to edit this file; your changes will persist when regenerating." +inspect_args diff --git a/spec/fixtures/workspaces/negatable-argfile/test.sh b/spec/fixtures/workspaces/negatable-argfile/test.sh new file mode 100644 index 00000000..53a9f534 --- /dev/null +++ b/spec/fixtures/workspaces/negatable-argfile/test.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +rm -f ./colorize +rm -f ./src/*.sh + +bundle exec bashly generate + +set -x + +./colorize +./colorize --no-color +ARGFILE=.colorize-off ./colorize +ARGFILE=.colorize-off ./colorize --color diff --git a/support/schema/bashly.yml b/support/schema/bashly.yml index ccc130f8..166471ee 100644 --- a/support/schema/bashly.yml +++ b/support/schema/bashly.yml @@ -247,6 +247,13 @@ definitions: minLength: 1 examples: - --command + negatable: + title: negatable + description: |- + Whether the current flag also accepts its --no- form + https://bashly.dev/configuration/flag/#negatable + type: boolean + default: false completions: title: completions description: |-