diff --git a/lib/optimist.rb b/lib/optimist.rb index d3f21da..ae3cfb8 100644 --- a/lib/optimist.rb +++ b/lib/optimist.rb @@ -175,8 +175,8 @@ def opt(name, desc = "", opts = {}, &b) @short[short] = o.name end - raise ArgumentError, "permitted values for option #{o.long.inspect} must be either nil or an array;" unless o.permitted.nil? or o.permitted.is_a? Array - + raise ArgumentError, "permitted values for option #{o.long.long.inspect} must be either nil, Range, Regexp or an Array;" unless o.permitted_type_valid? + @specs[o.name] = o @order << [:opt, o.name] end @@ -410,9 +410,11 @@ def parse(cmdline = ARGV) params << (opts.array_default? ? opts.default.clone : [opts.default]) end - params[0].each do |p| - raise CommandlineError, "option '#{arg}' only accepts one of: #{opts.permitted.join(', ')}" unless opts.permitted.include? p - end unless opts.permitted.nil? + if params.first && opts.permitted + params.first.each do |val| + opts.validate_permitted(arg, val) + end + end vals["#{sym}_given".intern] = true # mark argument as specified on the commandline @@ -730,7 +732,7 @@ def add(values) class Option - attr_accessor :name, :short, :long, :default, :permitted + attr_accessor :name, :short, :long, :default, :permitted, :permitted_response attr_writer :multi_given def initialize @@ -741,6 +743,7 @@ def initialize @hidden = false @default = nil @permitted = nil + @permitted_response = "option '%{arg}' only accepts %{valid_string}" @optshash = Hash.new() end @@ -796,32 +799,80 @@ def full_description desc_str end + ## Format stdio like objects to a string + def format_stdio(obj) + case obj + when $stdout then '' + when $stdin then '' + when $stderr then '' + else obj # pass-through-case + end + end + ## Generate the default value string for the educate line private def default_description_str str default_s = case default - when $stdout then '' - when $stdin then '' - when $stderr then '' when Array default.join(', ') else - default.to_s + format_stdio(default).to_s end defword = str.end_with?('.') ? 'Default' : 'default' " (#{defword}: #{default_s})" end + def permitted_valid_string + case permitted + when Array + return "one of: " + permitted.to_a.map(&:to_s).join(', ') + when Range + return "value in range of: #{permitted}" + when Regexp + return "value matching: #{permitted.inspect}" + end + raise NotImplementedError, "invalid branch" + end + + def permitted_type_valid? + case permitted + when NilClass, Array, Range, Regexp then true + else false + end + end + + def validate_permitted(arg, value) + return true if permitted.nil? + unless permitted_value?(value) + format_hash = {arg: arg, given: value, value: value, valid_string: permitted_valid_string(), permitted: permitted } + raise CommandlineError, permitted_response % format_hash + end + true + end + + # incoming values from the command-line should be strings, so we should + # stringify any permitted types as the basis of comparison. + def permitted_value?(val) + case permitted + when nil then true + when Regexp then val.match permitted + when Range then permitted.to_a.map(&:to_s).include? val + when Array then permitted.map(&:to_s).include? val + else false + end + end + ## Generate the permitted values string for the educate line private def permitted_description_str str - permitted_s = permitted.map do |p| - case p - when $stdout then '' - when $stdin then '' - when $stderr then '' - else - p.to_s - end - end.join(', ') + permitted_s = case permitted + when Array + permitted.map do |p| + format_stdio(p).to_s + end.join(', ') + when Range, Regexp + permitted.inspect + else + raise NotImplementedError + end permword = str.end_with?('.') ? 'Permitted' : 'permitted' " (#{permword}: #{permitted_s})" end @@ -868,6 +919,7 @@ def self.create(name, desc="", opts={}, settings={}) ## autobox :default for :multi (multi-occurrence) arguments defvalue = [defvalue] if defvalue && multi_given && !defvalue.kind_of?(Array) opt_inst.permitted = permitted + opt_inst.permitted_response = opts[:permitted_response] if opts[:permitted_response] opt_inst.default = defvalue opt_inst.name = name opt_inst.opts = opts diff --git a/test/optimist/parser_educate_test.rb b/test/optimist/parser_educate_test.rb index 18185b1..b195268 100644 --- a/test/optimist/parser_educate_test.rb +++ b/test/optimist/parser_educate_test.rb @@ -169,6 +169,16 @@ def test_help_has_grammatical_permitted_text assert help[1] =~ /Permitted/ assert help[2] =~ /permitted/ end + + def test_help_with_permitted_range + parser.opt :rating, 'rating', permitted: 1..5 + parser.opt :hex, 'hexadecimal', permitted: /^[0-9a-f]/i + sio = StringIO.new 'w' + parser.educate sio + help = sio.string.split "\n" + assert_match %r{rating \(permitted: 1\.\.5\)}, help[1] + assert_match %r{hexadecimal \(permitted: \/\^\[0-9a-f\]\/i\)}, help[2] + end ############ private diff --git a/test/optimist/parser_permitted_test.rb b/test/optimist/parser_permitted_test.rb new file mode 100644 index 0000000..42de4c8 --- /dev/null +++ b/test/optimist/parser_permitted_test.rb @@ -0,0 +1,93 @@ +require 'stringio' +require 'test_helper' + +module Optimist + +class ParserPermittedTest < ::Minitest::Test + def setup + @p = Parser.new + end + + def test_permitted_flags_filter_inputs + @p.opt "arg", "desc", :type => :strings, :permitted => %w(foo bar) + + result = @p.parse(%w(--arg foo)) + assert_equal ["foo"], result["arg"] + assert_raises(CommandlineError) { @p.parse(%w(--arg baz)) } + end + + def test_permitted_invalid_scalar_value + err_regexp = /permitted values for option "(bad|mad|sad)" must be either nil, Range, Regexp or an Array/ + assert_raises(ArgumentError, err_regexp) { + @p.opt 'bad', 'desc', :permitted => 1 + } + assert_raises(ArgumentError, err_regexp) { + @p.opt 'mad', 'desc', :permitted => "A" + } + assert_raises_errmatch(ArgumentError, err_regexp) { + @p.opt 'sad', 'desc', :permitted => :abcd + } + end + + def test_permitted_with_string_array + @p.opt 'fiz', 'desc', :type => 'string', :permitted => ['foo', 'bar'] + @p.parse(%w(--fiz foo)) + assert_raises_errmatch(CommandlineError, /option '--fiz' only accepts one of: foo, bar/) { + @p.parse(%w(--fiz buz)) + } + end + def test_permitted_with_symbol_array + @p.opt 'fiz', 'desc', :type => 'string', :permitted => %i[dog cat] + @p.parse(%w(--fiz dog)) + @p.parse(%w(--fiz cat)) + assert_raises_errmatch(CommandlineError, /option '--fiz' only accepts one of: dog, cat/) { + @p.parse(%w(--fiz rat)) + } + end + + def test_permitted_with_numeric_array + @p.opt 'mynum', 'desc', :type => Integer, :permitted => [1,2,4] + @p.parse(%w(--mynum 1)) + @p.parse(%w(--mynum 4)) + assert_raises_errmatch(CommandlineError, /option '--mynum' only accepts one of: 1, 2, 4/) { + @p.parse(%w(--mynum 3)) + } + end + + def test_permitted_with_numeric_range + @p.opt 'fiz', 'desc', :type => Integer, :permitted => 1..3 + opts = @p.parse(%w(--fiz 1)) + assert_equal opts['fiz'], 1 + opts = @p.parse(%w(--fiz 3)) + assert_equal opts['fiz'], 3 + assert_raises_errmatch(CommandlineError, /option '--fiz' only accepts value in range of: 1\.\.3/) { + @p.parse(%w(--fiz 4)) + } + end + + def test_permitted_with_regexp + @p.opt 'zipcode', 'desc', :type => String, :permitted => /^[0-9]{5}$/ + @p.parse(%w(--zipcode 39762)) + err_regexp = %r|option '--zipcode' only accepts value matching: /\^\[0-9\]\{5\}\$/| + assert_raises_errmatch(CommandlineError, err_regexp) { + @p.parse(%w(--zipcode A9A9AA)) + } + end + def test_permitted_with_reason + # test all keys passed into the formatter for the permitted_response + @p.opt 'zipcode', 'desc', type: String, permitted: /^[0-9]{5}$/, + permitted_response: "opt %{arg} should be a zipcode but you have %{value}" + @p.opt :wig, 'wig', type: Integer, permitted: 1..4, + permitted_response: "opt %{arg} exceeded four wigs (%{valid_string}), %{permitted}, but you gave '%{given}'" + err_regexp = %r|opt --zipcode should be a zipcode but you have A9A9AA| + assert_raises_errmatch(CommandlineError, err_regexp) { + @p.parse(%w(--zipcode A9A9AA)) + } + err_regexp = %r|opt --wig exceeded four wigs \(value in range of: 1\.\.4\), 1\.\.4, but you gave '5'| + assert_raises_errmatch(CommandlineError, err_regexp) { + @p.parse(%w(--wig 5)) + } + end + +end +end