Skip to content

Commit

Permalink
Merge pull request #154 from nanobowers/inexact_match_2024
Browse files Browse the repository at this point in the history
Add exact_match to settings, defaulting to inexact matching
  • Loading branch information
Fryguy committed May 3, 2024
2 parents d920799 + 1bc055f commit 6944cdb
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 7 deletions.
43 changes: 38 additions & 5 deletions lib/optimist.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def self.registry_getopttype(type)
## ignore options that it does not recognize.
attr_accessor :ignore_invalid_options

DEFAULT_SETTINGS = { suggestions: true }
DEFAULT_SETTINGS = { suggestions: true, exact_match: true }

## Initializes the parser, and instance-evaluates any block given.
def initialize(*a, &b)
Expand Down Expand Up @@ -246,6 +246,21 @@ def educate_on_error
@educate_on_error = true
end

## Match long variables with inexact match.
## If we hit a complete match, then use that, otherwise see how many long-options partially match.
## If only one partially matches, then we can safely use that.
## Otherwise, we raise an error that the partially given option was ambiguous.
def perform_inexact_match(arg, partial_match) # :nodoc:
return @long[partial_match] if @long.has_key?(partial_match)
partially_matched_keys = @long.keys.select { |opt| opt.start_with?(partial_match) }
case partially_matched_keys.size
when 0 ; nil
when 1 ; @long[partially_matched_keys.first]
else ; raise CommandlineError, "ambiguous option '#{arg}' matched keys (#{partially_matched_keys.join(',')})"
end
end
private :perform_inexact_match

def handle_unknown_argument(arg, candidates, suggestions)
errstring = "unknown argument '#{arg}'"
if (suggestions &&
Expand Down Expand Up @@ -316,10 +331,14 @@ def parse(cmdline = ARGV)
else raise CommandlineError, "invalid argument syntax: '#{arg}'"
end

sym = nil if arg =~ /--no-/ # explicitly invalidate --no-no- arguments
if arg.start_with?("--no-") # explicitly invalidate --no-no- arguments
sym = nil
## Support inexact matching of long-arguments like perl's Getopt::Long
elsif !sym && !@settings[:exact_match] && arg.match(/^--(\S+)$/)
sym = perform_inexact_match(arg, $1)
end

next nil if ignore_invalid_options && !sym

handle_unknown_argument(arg, @long.keys, @settings[:suggestions]) unless sym

if given_args.include?(sym) && !@specs[sym].multi?
Expand Down Expand Up @@ -513,7 +532,7 @@ def each_arg(args)
until i >= args.length
return remains += args[i..-1] if @stop_words.member? args[i]
case args[i]
when /^--$/ # arg terminator
when "--" # arg terminator
return remains += args[(i + 1)..-1]
when /^--(\S+?)=(.*)$/ # long argument with equals
num_params_taken = yield "--#{$1}", [$2]
Expand Down Expand Up @@ -598,7 +617,7 @@ def resolve_default_short_options!
opts = @specs[name]
next if type != :opt || opts.short

c = opts.long.split(//).find { |d| d !~ INVALID_SHORT_ARG_REGEX && !@short.member?(d) }
c = opts.long.chars.find { |d| d !~ INVALID_SHORT_ARG_REGEX && !@short.member?(d) }
if c # found a character to use
opts.short = c
@short[c] = name
Expand Down Expand Up @@ -996,6 +1015,20 @@ def multi_arg? ; true ; end
## ## if called with --monkey
## p opts # => {:monkey=>true, :name=>nil, :num_limbs=>4, :help=>false, :monkey_given=>true}
##
## Settings:
## Optimist::options and Optimist::Parser.new accept +settings+ to control how
## options are interpreted. These settings are given as hash arguments, e.g:
##
## opts = Optimist::options(ARGV, exact_match: false) do
## opt :foobar, 'messed up'
## opt :forget, 'forget it'
## end
##
## +settings+ include:
## * :exact_match : (default=true) Allow minimum unambigous number of characters to match a long option
## * :suggestions : (default=true) Enables suggestions when unknown arguments are given and DidYouMean is installed. DidYouMean comes standard with Ruby 2.3+
## Because Optimist::options uses a default argument for +args+, you must pass that argument when using the settings feature.
##
## See more examples at https://www.manageiq.org/optimist
def options(args = ARGV, *a, &b)
@last_parser = Parser.new(*a, &b)
Expand Down
98 changes: 96 additions & 2 deletions test/optimist/parser_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ def test_synopsis


def test_unknown_arguments
assert_raises(CommandlineError) { @p.parse(%w(--arg)) }
err = assert_raises(CommandlineError) { @p.parse(%w(--arg)) }
assert_match(/unknown argument '--arg'/, err.message)
@p.opt "arg"
@p.parse(%w(--arg))
assert_raises(CommandlineError) { @p.parse(%w(--arg2)) }
err = assert_raises(CommandlineError) { @p.parse(%w(--arg2)) }
assert_match(/unknown argument '--arg2'/, err.message)
end

def test_unknown_arguments_with_suggestions
Expand Down Expand Up @@ -779,6 +781,20 @@ def test_arguments_passed_through_block
assert_equal @goat, boat
end

## test-only access reader method so that we dont have to
## expose settings in the public API.
class Optimist::Parser
def get_settings_for_testing ; return @settings ;end
end

def test_two_arguments_passed_through_block
newp = Parser.new(:abcd => 123, :efgh => "other" ) do |i|
end
assert_equal newp.get_settings_for_testing[:abcd], 123
assert_equal newp.get_settings_for_testing[:efgh], "other"
end


def test_version_and_help_override_errors
@p.opt :asdf, "desc", :type => String
@p.version "version"
Expand Down Expand Up @@ -1161,6 +1177,53 @@ def test_default_shorts_assigned_only_after_user_shorts
assert opts[:ccd]
end

def test_inexact_match
newp = Parser.new(exact_match: false)
newp.opt :liberation, "liberate something", :type => :int
newp.opt :evaluate, "evaluate something", :type => :string
opts = newp.parse %w(--lib 5 --ev bar)
assert_equal 5, opts[:liberation]
assert_equal 'bar', opts[:evaluate]
assert_nil opts[:eval]
end

def test_exact_match
newp = Parser.new()
newp.opt :liberation, "liberate something", :type => :int
newp.opt :evaluate, "evaluate something", :type => :string
assert_raises(CommandlineError, /unknown argument '--lib'/) do
newp.parse %w(--lib 5)
end
assert_raises_errmatch(CommandlineError, /unknown argument '--ev'/) do
newp.parse %w(--ev bar)
end
end

def test_inexact_collision
newp = Parser.new(exact_match: false)
newp.opt :bookname, "name of a book", :type => :string
newp.opt :bookcost, "cost of the book", :type => :string
opts = newp.parse %w(--bookn hairy_potsworth --bookc 10)
assert_equal 'hairy_potsworth', opts[:bookname]
assert_equal '10', opts[:bookcost]
assert_raises(CommandlineError) do
newp.parse %w(--book 5) # ambiguous
end
## partial match causes 'specified multiple times' error
assert_raises(CommandlineError, /specified multiple times/) do
newp.parse %w(--bookc 17 --bookcost 22)
end
end

def test_inexact_collision_with_exact
newp = Parser.new(exact_match: false)
newp.opt :book, "name of a book", :type => :string, :default => "ABC"
newp.opt :bookcost, "cost of the book", :type => :int, :default => 5
opts = newp.parse %w(--book warthog --bookc 3)
assert_equal 'warthog', opts[:book]
assert_equal 3, opts[:bookcost]
end

def test_accepts_arguments_with_spaces
@p.opt :arg1, "arg", :type => String
@p.opt :arg2, "arg2", :type => String
Expand Down Expand Up @@ -1316,6 +1379,37 @@ def test_ignore_invalid_options_stop_on_unknown_partial_mid_short
assert opts[:arg1]
assert_equal %w{-bu potato}, @p.leftovers
end

# Due to strangeness in how the cloaker works, there were
# cases where Optimist.parse would work, but Optimist.options
# did not, depending on arguments given to the function.
# These serve to validate different args given to Optimist.options
def test_options_takes_hashy_settings
passargs_copy = []
settings_copy = []
::Optimist.options(%w(--wig --pig), :fizz=>:buzz, :bear=>:cat) do |*passargs|
opt :wig
opt :pig
passargs_copy = passargs.dup
settings_copy = @settings
end
assert_equal [], passargs_copy
assert_equal settings_copy[:fizz], :buzz
assert_equal settings_copy[:bear], :cat
end

def test_options_takes_some_other_data
passargs_copy = []
settings_copy = []
::Optimist.options(%w(--nose --close), 1, 2, 3) do |*passargs|
opt :nose
opt :close
passargs_copy = passargs.dup
settings_copy = @settings
end
assert_equal [1,2,3], passargs_copy
assert_equal(Optimist::Parser::DEFAULT_SETTINGS, settings_copy)
end
end

end
6 changes: 6 additions & 0 deletions test/support/assert_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,11 @@ def assert_system_exit *exp
end
flunk "#{msg}#{mu_pp(exp)} SystemExit expected but nothing was raised."
end

# wrapper around common assertion checking pattern
def assert_raises_errmatch(err_klass, err_regexp, &b)
err = assert_raises(err_klass, &b)
assert_match(err_regexp, err.message)
end
end

0 comments on commit 6944cdb

Please sign in to comment.