diff --git a/lib/thor.rb b/lib/thor.rb index 7e6a11be..d256ea80 100644 --- a/lib/thor.rb +++ b/lib/thor.rb @@ -163,6 +163,97 @@ def method_option(name, options = {}) end alias_method :option, :method_option + # Returns this class exclusive options array set. + # + # ==== Rturns + # Array[Array[Thor::Option.name]] + # + def method_exclusive_option_names + @method_exclusive_option_names ||=[] + end + + # Adds and declareds option group for exclusive options in the + # block and arguments. You can declare options as the outside of the block. + # If :for is given as option, + # it allows you to change the options from a prvious defined command. + # + # ==== Parameters + # Array[Thor::Option.name] + # options:: :for is applied for previous defined command. + # + # ==== Examples + # + # exclusive do + # option :one + # option :two + # end + # + # Or + # + # option :one + # option :two + # exclusive :one, :two + # + # If you give "--one" and "--two" at the same time. + # ExclusiveArgumentsError will be raised. + # + def method_exclusive(*args, &block) + register_options_relation_for(:method_options, + :method_exclusive_option_names, *args, &block) + end + alias_method :exclusive, :method_exclusive + + # Returns this class at least one of required options array set. + # + # ==== Rturns + # Array[Array[Thor::Option.name]] + # + def method_at_least_one_option_names + @method_at_least_one_option_names ||=[] + end + + # Adds and declareds option group for required at least one of options in the + # block of arguments. You can declare options as the outside of the block. + # If :for is given as option, + # it allows you to change the options from a prvious defined command. + # + # ==== Parameters + # Array[Thor::Option.name] + # options:: :for is applied for previous defined command. + # + # ==== Examples + # + # at_least_one do + # option :one + # option :two + # end + # + # Or + # + # option :one + # option :two + # at_least_one :one, :two + # + # If you do not give "--one" and "--two". + # AtLeastOneRequiredArgumentError will be raised. + # + # You can use at_least_one and exclusive at the same time. + # + # exclusive do + # at_least_one do + # option :one + # option :two + # end + # end + # + # Then it is required either only one of "--one" or "--two". + # + def method_at_least_one(*args, &block) + register_options_relation_for(:method_options, + :method_at_least_one_option_names, *args, &block) + end + alias_method :at_least_one, :method_at_least_one + # Prints help information for the given command. # # ==== Parameters @@ -178,6 +269,9 @@ def command_help(shell, command_name) shell.say " #{banner(command).split("\n").join("\n ")}" shell.say class_options_help(shell, nil => command.options.values) + print_exclusive_options(shell, command) + print_at_least_one_required_options(shell, command) + if command.long_description shell.say "Description:" shell.print_wrapped(command.long_description, :indent => 2) @@ -208,6 +302,8 @@ def help(shell, subcommand = false) shell.print_table(list, :indent => 2, :truncate => true) shell.say class_options_help(shell) + print_exclusive_options(shell) + print_at_least_one_required_options(shell) end # Returns commands ready to be printed. @@ -222,6 +318,26 @@ def printable_commands(all = true, subcommand = false) end alias_method :printable_tasks, :printable_commands + def print_exclusive_options(shell, command = nil) + opts = [] + opts = command.method_exclusive_option_names unless command.nil? + opts += class_exclusive_option_names + unless opts.empty? + shell.say "Exclusive Options:" + shell.print_table(opts.map{ |ex| ex.map{ |e| "--#{e}"}}, :indent => 2 ) + shell.say + end + end + def print_at_least_one_required_options(shell, command = nil) + opts = [] + opts = command.method_at_least_one_option_names unless command.nil? + opts += class_at_least_one_option_names + unless opts.empty? + shell.say "Required At Least One:" + shell.print_table(opts.map{ |ex| ex.map{ |e| "--#{e}"}}, :indent => 2 ) + shell.say + end + end def subcommands @subcommands ||= from_superclass(:subcommands, []) end @@ -419,8 +535,11 @@ def create_command(meth) #:nodoc: if @usage && @desc base_class = @hide ? Thor::HiddenCommand : Thor::Command - commands[meth] = base_class.new(meth, @desc, @long_desc, @usage, method_options) + relations = {:exclusive_option_names => method_exclusive_option_names, + :at_least_one_option_names => method_at_least_one_option_names} + commands[meth] = base_class.new(meth, @desc, @long_desc, @usage, method_options, relations) @usage, @desc, @long_desc, @method_options, @hide = nil + @method_exclusive_option_names, @method_at_least_one_option_names = nil true elsif all_commands[meth] || meth == "method_missing" true diff --git a/lib/thor/base.rb b/lib/thor/base.rb index bd58e91c..8b74c048 100644 --- a/lib/thor/base.rb +++ b/lib/thor/base.rb @@ -73,9 +73,21 @@ def initialize(args = [], local_options = {}, config = {}) # Let Thor::Options parse the options first, so it can remove # declared options from the array. This will leave us with # a list of arguments that weren't declared. - stop_on_unknown = self.class.stop_on_unknown_option? config[:current_command] + current_command = config[:current_command] + stop_on_unknown = self.class.stop_on_unknown_option? current_command + + # Give a relation of options. + # After parsing, Thor::Options check whether right relations are kept + relations = {:exclusive_option_names => [], :at_least_one_option_names => []} + unless current_command.nil? + relations = current_command.options_relation + end + self.class.class_exclusive_option_names.map{ |n| relations[:exclusive_option_names] << n} disable_required_check = self.class.disable_required_check? config[:current_command] - opts = Thor::Options.new(parse_options, hash_options, stop_on_unknown, disable_required_check) + self.class.class_at_least_one_option_names.map{ |n| relations[:at_least_one_option_names] << n} + + opts = Thor::Options.new(parse_options, hash_options, stop_on_unknown, disable_required_check, relations) + self.options = opts.parse(array_options) self.options = config[:class_options].merge(options) if config[:class_options] @@ -278,6 +290,24 @@ def arguments @arguments ||= from_superclass(:arguments, []) end + # Returns this class exclusive options array set, looking up in the ancestors chain. + # + # ==== Rturns + # Array[Array[Thor::Option.name]] + # + def class_exclusive_option_names + @class_exclusive_option_names ||= from_superclass(:class_exclusive_option_names, []) + end + + # Returns this class at least one of required options array set, looking up in the ancestors chain. + # + # ==== Rturns + # Array[Array[Thor::Option.name]] + # + def class_at_least_one_option_names + @class_at_least_one_option_names ||= from_superclass(:class_at_least_one_option_names, []) + end + # Adds a bunch of options to the set of class options. # # class_options :foo => false, :bar => :required, :baz => :string @@ -313,6 +343,67 @@ def class_option(name, options = {}) build_option(name, options, class_options) end + # Adds and declareds option group for exclusive options in the + # block and arguments. You can declare options as the outside of the block. + # + # ==== Parameters + # Array[Thor::Option.name] + # + # ==== Examples + # + # class_exclusive do + # class_option :one + # class_option :two + # end + # + # Or + # + # class_option :one + # class_option :two + # class_exclusive :one, :two + # + # If you give "--one" and "--two" at the same time. + # ExclusiveArgumentsError will be raised. + # + def class_exclusive(*args, &block) + register_options_relation_for(:class_options, + :class_exclusive_option_names, *args, &block) + end + + # Adds and declareds option group for required at least one of options in the + # block and arguments. You can declare options as the outside of the block. + # + # ==== Examples + # + # class_at_least_one do + # class_option :one + # class_option :two + # end + # + # Or + # + # class_option :one + # class_option :two + # class_at_least_one :one, :two + # + # If you do not give "--one" and "--two". + # AtLeastOneRequiredArgumentError will be raised. + # You can use class_at_least_one and class_exclusive at the same time. + # + # class_exclusive do + # class_at_least_one do + # class_option :one + # class_option :two + # end + # end + # + # Then it is required either only one of "--one" or "--two". + # + def class_at_least_one(*args, &block) + register_options_relation_for(:class_options, + :class_at_least_one_option_names, *args, &block) + end + # Removes a previous defined argument. If :undefine is given, undefine # accessors as well. # @@ -693,6 +784,34 @@ def initialize_added #:nodoc: def dispatch(command, given_args, given_opts, config) #:nodoc: raise NotImplementedError end + + # Register a relation of options for target(method_option/class_option) + # by args and block. + def register_options_relation_for( target, relation, *args, &block) + opt = args.pop if args.last.is_a? Hash + opt ||= {} + names = args.map{ |arg| arg.to_s } + names += built_option_names(target, opt, &block) if block_given? + command_scope_member(relation, opt) << names + end + + # Get target(method_options or class_options) options + # of before and after by block evaluation. + def built_option_names(target, opt = {}, &block) + before = command_scope_member(target, opt).map{ |k,v| v.name } + instance_eval(&block) + after = command_scope_member(target, opt).map{ |k,v| v.name } + after - before + end + + # Get command scope member by name. + def command_scope_member( name, options = {} ) #:nodoc: + if options[:for] + find_and_refresh_command(options[:for]).send(name) + else + send( name ) + end + end end end end diff --git a/lib/thor/command.rb b/lib/thor/command.rb index f2dcca49..b8a9fc02 100644 --- a/lib/thor/command.rb +++ b/lib/thor/command.rb @@ -1,14 +1,15 @@ class Thor - class Command < Struct.new(:name, :description, :long_description, :usage, :options, :ancestor_name) + class Command < Struct.new(:name, :description, :long_description, :usage, :options, :options_relation, :ancestor_name) FILE_REGEXP = /^#{Regexp.escape(File.dirname(__FILE__))}/ - def initialize(name, description, long_description, usage, options = nil) - super(name.to_s, description, long_description, usage, options || {}) + def initialize(name, description, long_description, usage, options = nil, options_relation = nil) + super(name.to_s, description, long_description, usage, options || {}, options_relation || {}) end def initialize_copy(other) #:nodoc: super(other) self.options = other.options.dup if other.options + self.options_relation = other.options_relation.dup if other.options_relation end def hidden? @@ -62,6 +63,14 @@ def formatted_usage(klass, namespace = true, subcommand = false) end.join("\n") end + def method_exclusive_option_names #:nodoc: + self.options_relation[:exclusive_option_names] || [] + end + + def method_at_least_one_option_names #:nodoc: + self.options_relation[:at_least_one_option_names] || [] + end + protected # Add usage with required arguments @@ -82,7 +91,6 @@ def not_debugging?(instance) def required_options @required_options ||= options.map { |_, o| o.usage if o.required? }.compact.sort.join(" ") end - # Given a target, checks if this class name is a public method. def public_method?(instance) #:nodoc: !(instance.public_methods & [name.to_s, name.to_sym]).empty? diff --git a/lib/thor/error.rb b/lib/thor/error.rb index cc3dfe41..973f7b8e 100644 --- a/lib/thor/error.rb +++ b/lib/thor/error.rb @@ -108,4 +108,10 @@ class RequiredArgumentMissingError < InvocationError class MalformattedArgumentError < InvocationError end + + class ExclusiveArgumentError < InvocationError + end + + class AtLeastOneRequiredArgumentError < InvocationError + end end diff --git a/lib/thor/parser/options.rb b/lib/thor/parser/options.rb index 2d6145bb..de68e62b 100644 --- a/lib/thor/parser/options.rb +++ b/lib/thor/parser/options.rb @@ -29,8 +29,10 @@ def self.to_switches(options) # # If +stop_on_unknown+ is true, #parse will stop as soon as it encounters # an unknown option or a regular argument. - def initialize(hash_options = {}, defaults = {}, stop_on_unknown = false, disable_required_check = false) + def initialize(hash_options = {}, defaults = {}, stop_on_unknown = false, disable_required_check = false, relations = {}) @stop_on_unknown = stop_on_unknown + @exclusives = (relations[:exclusive_option_names] || []).select{|array| !array.empty?} + @at_least_ones = (relations[:at_least_one_option_names] || []).select{|array| !array.empty?} @disable_required_check = disable_required_check options = hash_options.values super(options) @@ -132,12 +134,37 @@ def parse(args) # rubocop:disable Metrics/MethodLength end check_requirement! unless @disable_required_check + check_exclusive! + check_at_least_one! assigns = Thor::CoreExt::HashWithIndifferentAccess.new(@assigns) assigns.freeze assigns end + def check_exclusive! + opts = @assigns.keys + # When option A and B are exclusive, if A and B are given at the same time, + # the diffrence of argument array size will decrease. + found = @exclusives.find{ |ex| (ex - opts).size < ex.size - 1 } + if found + names = names_to_switch_names(found & opts).map{|n| "'#{n}'"} + class_name = self.class.name.split("::").last.downcase + fail ExclusiveArgumentError, "Found exclusive #{class_name} #{names.join(", ")}" + end + end + def check_at_least_one! + opts = @assigns.keys + # When at least one is required of the options A and B, + # if the both options were not given, none? would be true. + found = @at_least_ones.find{ |one_reqs| one_reqs.none?{ |o| opts.include? o} } + if found + names = names_to_switch_names(found).map{|n| "'#{n}'"} + class_name = self.class.name.split("::").last.downcase + fail AtLeastOneRequiredArgumentError, "Not found at least one of required #{class_name} #{names.join(", ")}" + end + end + def check_unknown! to_check = @stopped_parsing_after_extra_index ? @extra[0...@stopped_parsing_after_extra_index] : @extra @@ -147,6 +174,16 @@ def check_unknown! end protected + # Option names changes to swith name or human name + def names_to_switch_names(names = []) + @switches.map do |_, o| + if names.include? o.name + o.respond_to?(:switch_name) ? o.switch_name : o.human_name + else + nil + end + end.compact + end def assign_result!(option, result) if option.repeatable && option.type == :hash diff --git a/spec/base_spec.rb b/spec/base_spec.rb index 97489fc0..406014e9 100644 --- a/spec/base_spec.rb +++ b/spec/base_spec.rb @@ -70,6 +70,51 @@ def hello end end + describe "#class_exclusive_option_names" do + it "returns the exclusive option names for the class" do + expect(MyClassOptionScript.class_exclusive_option_names.size).to be(1) + expect(MyClassOptionScript.class_exclusive_option_names.first.size).to be(2) + end + end + describe "#class_at_least_one_option_names" do + it "returns the at least one of option names for the class" do + expect(MyClassOptionScript.class_at_least_one_option_names.size).to be(1) + expect(MyClassOptionScript.class_at_least_one_option_names.first.size).to be(2) + end + end + describe "#class_exclusive" do + it "raise error when exclusive options are given" do + begin + ENV["THOR_DEBUG"] = "1" + expect do + MyClassOptionScript.start %w[mix --one --two --three --five] + end.to raise_error(Thor::ExclusiveArgumentError, "Found exclusive options '--one', '--two'") + + expect do + MyClassOptionScript.start %w[mix --one --three --five --six] + end.to raise_error(Thor::ExclusiveArgumentError, "Found exclusive options '--five', '--six'") + ensure + ENV["THOR_DEBUG"] = nil + end + end + end + describe "#class_at_least_one" do + it "raise error when at least one of required options are not given" do + begin + ENV["THOR_DEBUG"] = "1" + + expect do + MyClassOptionScript.start %w[mix --five] + end.to raise_error(Thor::AtLeastOneRequiredArgumentError, "Not found at least one of required options '--three', '--four'") + + expect do + MyClassOptionScript.start %w[mix --one --three] + end.to raise_error(Thor::AtLeastOneRequiredArgumentError, "Not found at least one of required options '--five', '--six', '--seven'") + ensure + ENV["THOR_DEBUG"] = nil + end + end + end describe ":aliases" do it "supports string aliases without a dash prefix" do expect(MyCounter.start(%w(1 2 -z 3))[4]).to eq(3) @@ -98,6 +143,7 @@ def hello end end + describe "#remove_argument" do it "removes previously defined arguments from class" do expect(ClearCounter.arguments).to be_empty @@ -200,7 +246,7 @@ def hello expect(Thor::Base.subclass_files[File.expand_path(thorfile)]).to eq([ MyScript, MyScript::AnotherScript, MyChildScript, Barn, PackageNameScript, Scripts::MyScript, Scripts::MyDefaults, - Scripts::ChildDefault, Scripts::Arities, Apple, Pear + Scripts::ChildDefault, Scripts::Arities, Apple, Pear, MyClassOptionScript, MyOptionScript ]) end diff --git a/spec/fixtures/script.thor b/spec/fixtures/script.thor index eac5d85e..d7972766 100644 --- a/spec/fixtures/script.thor +++ b/spec/fixtures/script.thor @@ -258,3 +258,69 @@ class Pear < Thor namespace :fruits desc 'pear', 'pear'; def pear; end end +class MyClassOptionScript < Thor + class_option :free + + class_exclusive do + class_option :one + class_option :two + end + + class_at_least_one do + class_option :three + class_option :four + end + desc "mix", "" + exclusive do + at_least_one do + option :five + option :six + option :seven + end + end + def mix + end +end +class MyOptionScript < Thor + desc "exclusive", "" + exclusive do + method_option :one + method_option :two + method_option :three + end + method_option :after1 + method_option :after2 + def exclusive + end + exclusive :after1, :after2, {:for => :exclusive} + + desc "at_least_one", "" + at_least_one do + method_option :one + method_option :two + method_option :three + end + method_option :after1 + method_option :after2 + def at_least_one + end + at_least_one :after1, :after2, :for => :at_least_one + + desc "only_one", "" + exclusive do + at_least_one do + option :one + option :two + option :three + end + end + def only_one + end + + desc "no_relastions", "" + option :no_rel1 + option :no_rel2 + def no_relations + + end +end diff --git a/spec/parser/options_spec.rb b/spec/parser/options_spec.rb index 5caf9f67..cba47c93 100644 --- a/spec/parser/options_spec.rb +++ b/spec/parser/options_spec.rb @@ -2,11 +2,15 @@ require "thor/parser" describe Thor::Options do - def create(opts, defaults = {}, stop_on_unknown = false) + def create(opts, defaults = {}, stop_on_unknown = false, exclusives = [], at_least_ones = []) + relation = { + :exclusive_option_names => exclusives, + :at_least_one_option_names => at_least_ones + } opts.each do |key, value| opts[key] = Thor::Option.parse(key, value) unless value.is_a?(Thor::Option) end - @opt = Thor::Options.new(opts, defaults, stop_on_unknown) + @opt = Thor::Options.new(opts, defaults, stop_on_unknown, false, relation) end def parse(*args) @@ -252,6 +256,66 @@ def remaining end end + context "when exclusives is given" do + before do + create({:foo => :boolean, :bar => :boolean, :baz =>:boolean, :qux => :boolean}, {}, false, + [["foo", "bar"], ["baz","qux"]]) + end + + it "raises an error if exclusive argumets are given" do + expect{parse(%w[--foo --bar])}.to raise_error(Thor::ExclusiveArgumentError, "Found exclusive options '--foo', '--bar'") + end + + it "does not raise an error if exclusive argumets are not given" do + expect{parse(%w[--foo --baz])}.not_to raise_error + end + end + + context "when at_least_ones is given" do + before do + create({:foo => :string, :bar => :boolean, :baz =>:boolean, :qux => :boolean}, {}, false, + [], [["foo", "bar"], ["baz","qux"]]) + end + + it "raises an error if at least one of required argumet is not given" do + expect{parse(%w[--baz])}.to raise_error(Thor::AtLeastOneRequiredArgumentError, "Not found at least one of required options '--foo', '--bar'") + end + + it "does not raise an error if at least one of required argument is given" do + expect{parse(%w[--foo --baz])}.not_to raise_error + end + end + + context "when exclusives is given" do + before do + create({:foo => :boolean, :bar => :boolean, :baz =>:boolean, :qux => :boolean}, {}, false, + [["foo", "bar"], ["baz","qux"]]) + end + + it "raises an error if exclusive argumets are given" do + expect{parse(%w[--foo --bar])}.to raise_error(Thor::ExclusiveArgumentError, "Found exclusive options '--foo', '--bar'") + end + + it "does not raise an error if exclusive argumets are not given" do + expect{parse(%w[--foo --baz])}.not_to raise_error + end + end + + context "when at_least_ones is given" do + before do + create({:foo => :string, :bar => :boolean, :baz =>:boolean, :qux => :boolean}, {}, false, + [], [["foo", "bar"], ["baz","qux"]]) + end + + it "raises an error if at least one of required argumet is not given" do + expect{parse(%w[--baz])}.to raise_error(Thor::AtLeastOneRequiredArgumentError, "Not found at least one of required options '--foo', '--bar'") + end + + it "does not raise an error if at least one of required argument is given" do + expect{parse(%w[--foo --baz])}.not_to raise_error + end + end + describe "with :string type" do before do create %w(--foo -f) => :required diff --git a/spec/thor_spec.rb b/spec/thor_spec.rb index fbb087e5..24393ce8 100644 --- a/spec/thor_spec.rb +++ b/spec/thor_spec.rb @@ -422,6 +422,25 @@ def self.exit_on_failure? end end + describe "#method_exclusive" do + it "returns the exclusive option names for the class" do + cmd = MyOptionScript.commands["exclusive"] + exclusives = cmd.options_relation[:exclusive_option_names] + expect(exclusives.size).to be(2) + expect(exclusives.first).to eq(%w[one two three]) + expect(exclusives.last).to eq(%w[after1 after2]) + end + end + describe "#method_at_least_one" do + it "returns the at least one of option names for the class" do + cmd = MyOptionScript.commands["at_least_one"] + at_least_ones = cmd.options_relation[:at_least_one_option_names] + expect(at_least_ones.size).to be(2) + expect(at_least_ones.first).to eq(%w[one two three]) + expect(at_least_ones.last).to eq(%w[after1 after2]) + end + end + describe "#start" do it "calls a no-param method when no params are passed" do expect(MyScript.start(%w(zoo))).to eq(true) @@ -550,6 +569,26 @@ def shell content = capture(:stdout) { Scripts::MyScript.help(shell) } expect(content).to match(/zoo ACCESSOR \-\-param\=PARAM/) end + + it "prints class exclusive options" do + content = capture(:stdout) { MyClassOptionScript.help(shell) } + expect(content).to match(/Exclusive Options:\n\s+--one\s+--two\n/) + end + + it "does not print class exclusive options" do + content = capture(:stdout) { Scripts::MyScript.help(shell) } + expect(content).not_to match(/Exclusive Options:/) + end + + it "prints class at least one of requred options" do + content = capture(:stdout) { MyClassOptionScript.help(shell) } + expect(content).to match(/Required At Least One:\n\s+--three\s+--four\n/) + end + + it "does not print class at least one of required options" do + content = capture(:stdout) { Scripts::MyScript.help(shell) } + expect(content).not_to match(/Required At Least One:/) + end end describe "for a specific command" do @@ -607,6 +646,21 @@ def shell MyScript.command_help(shell, "name_with_dashes") end).not_to match(/so very long/i) end + + it "prints exclusive and at least one options" do + message = expect(capture(:stdout) do + MyClassOptionScript.command_help(shell, "mix") + end) + message.to match(/Exclusive Options:\n\s+--five\s+--six\s+--seven\n\s+--one\s+--two/) + message.to match(/Required At Least One:\n\s+--five\s+--six\s+--seven\n\s+--three\s+--four/) + end + it "does not print exclusive and at least one options" do + message = expect(capture(:stdout) do + MyOptionScript.command_help(shell, "no_relations") + end) + message.not_to match(/Exclusive Options:/) + message.not_to match(/Rquired At Least One:/) + end end describe "instance method" do