Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add exact_match to settings, defaulting to inexact matching #154

Merged
merged 8 commits into from
May 3, 2024
Merged
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
Comment on lines +784 to +788
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it a problem to expose settings in the public API? I'm wondering if we should just allow it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Note: it's fine for this PR to keep them private, and I won't hold up merge for that)


def test_two_arguments_passed_through_block
newp = Parser.new(:abcd => 123, :efgh => "other" ) do |i|
end
Comment on lines +790 to +792
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting - I actually thought this should raise an exception. (We can discuss separately, I won't hold up the PR for this)

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

Loading