Skip to content

Commit 5a92e4f

Browse files
committed
Add exact_match to settings, defaulting to inexact matching
1 parent d920799 commit 5a92e4f

2 files changed

Lines changed: 180 additions & 8 deletions

File tree

‎lib/optimist.rb‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ def self.registry_getopttype(type)
8484
## ignore options that it does not recognize.
8585
attr_accessor :ignore_invalid_options
8686

87-
DEFAULT_SETTINGS = { suggestions: true }
88-
87+
DEFAULT_SETTINGS = { suggestions: true, exact_match: false }
88+
8989
## Initializes the parser, and instance-evaluates any block given.
9090
def initialize(*a, &b)
9191
@version = nil
@@ -246,12 +246,27 @@ def educate_on_error
246246
@educate_on_error = true
247247
end
248248

249+
## Match long variables with inexact match.
250+
## If we hit a complete match, then use that, otherwise see how many long-options partially match.
251+
## If only one partially matches, then we can safely use that.
252+
## Otherwise, we raise an error that the partially given option was ambiguous.
253+
def perform_inexact_match(arg, partial_match) # :nodoc:
254+
return @long[partial_match] if @long.has_key?(partial_match)
255+
partially_matched_keys = @long.keys.grep(/^#{partial_match}/)
256+
case partially_matched_keys.size
257+
when 0 ; nil
258+
when 1 ; @long[partially_matched_keys.first]
259+
else ; raise CommandlineError, "ambiguous option '#{arg}' matched keys (#{partially_matched_keys.join(',')})"
260+
end
261+
end
262+
private :perform_inexact_match
263+
249264
def handle_unknown_argument(arg, candidates, suggestions)
250265
errstring = "unknown argument '#{arg}'"
251266
if (suggestions &&
252-
Module::const_defined?("DidYouMean") &&
253-
Module::const_defined?("DidYouMean::JaroWinkler") &&
254-
Module::const_defined?("DidYouMean::Levenshtein"))
267+
Module::const_defined?("DidYouMean") &&
268+
Module::const_defined?("DidYouMean::JaroWinkler") &&
269+
Module::const_defined?("DidYouMean::Levenshtein"))
255270
input = arg.sub(/^[-]*/,'')
256271

257272
# Code borrowed from did_you_mean gem
@@ -318,8 +333,12 @@ def parse(cmdline = ARGV)
318333

319334
sym = nil if arg =~ /--no-/ # explicitly invalidate --no-no- arguments
320335

336+
## Support inexact matching of long-arguments like perl's Getopt::Long
337+
if !sym && !@settings[:exact_match] && arg.match(/^--(\S*)$/)
338+
sym = perform_inexact_match(arg, $1)
339+
end
340+
321341
next nil if ignore_invalid_options && !sym
322-
323342
handle_unknown_argument(arg, @long.keys, @settings[:suggestions]) unless sym
324343

325344
if given_args.include?(sym) && !@specs[sym].multi?
@@ -996,6 +1015,20 @@ def multi_arg? ; true ; end
9961015
## ## if called with --monkey
9971016
## p opts # => {:monkey=>true, :name=>nil, :num_limbs=>4, :help=>false, :monkey_given=>true}
9981017
##
1018+
## Settings:
1019+
## Optimist::options and Optimist::Parser.new accept +settings+ to control how
1020+
## options are interpreted. These settings are given as hash arguments, e.g:
1021+
##
1022+
## opts = Optimist::options(ARGV, exact_match: false) do
1023+
## opt :foobar, 'messed up'
1024+
## opt :forget, 'forget it'
1025+
## end
1026+
##
1027+
## +settings+ include:
1028+
## * :exact_match : (default=false) Allow minimum unambigous number of characters to match a long option
1029+
## * :suggestions : (default=true) Enables suggestions when unknown arguments are given and DidYouMean is installed. DidYouMean comes standard with Ruby 2.3+
1030+
## Because Optimist::options uses a default argument for +args+, you must pass that argument when using the settings feature.
1031+
##
9991032
## See more examples at https://www.manageiq.org/optimist
10001033
def options(args = ARGV, *a, &b)
10011034
@last_parser = Parser.new(*a, &b)

‎test/optimist/parser_test.rb‎

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,57 @@ def test_synopsis
4545

4646

4747
def test_unknown_arguments
48-
assert_raises(CommandlineError) { @p.parse(%w(--arg)) }
48+
err = assert_raises(CommandlineError) { @p.parse(%w(--arg)) }
49+
assert_match(/unknown argument '--arg'$/, err.message)
4950
@p.opt "arg"
5051
@p.parse(%w(--arg))
51-
assert_raises(CommandlineError) { @p.parse(%w(--arg2)) }
52+
err = assert_raises(CommandlineError) { @p.parse(%w(--arg2)) }
53+
assert_match(/unknown argument '--arg2'$/, err.message)
54+
end
55+
56+
def test_unknown_arguments_with_suggestions
57+
sugp = Parser.new(:suggestions => true)
58+
err = assert_raises(CommandlineError) { sugp.parse(%w(--bone)) }
59+
assert_match(/unknown argument '--bone'$/, err.message)
60+
61+
if (Module::const_defined?("DidYouMean") &&
62+
Module::const_defined?("DidYouMean::JaroWinkler") &&
63+
Module::const_defined?("DidYouMean::Levenshtein"))
64+
sugp.opt "cone"
65+
sugp.parse(%w(--cone))
66+
67+
# single letter mismatch
68+
err = assert_raises(CommandlineError) { sugp.parse(%w(--bone)) }
69+
assert_match(/unknown argument '--bone'. Did you mean: \[--cone\] \?$/, err.message)
70+
71+
# transposition
72+
err = assert_raises(CommandlineError) { sugp.parse(%w(--ocne)) }
73+
assert_match(/unknown argument '--ocne'. Did you mean: \[--cone\] \?$/, err.message)
74+
75+
# extra letter at end
76+
err = assert_raises(CommandlineError) { sugp.parse(%w(--cones)) }
77+
assert_match(/unknown argument '--cones'. Did you mean: \[--cone\] \?$/, err.message)
78+
79+
# too big of a mismatch to suggest (extra letters in front)
80+
err = assert_raises(CommandlineError) { sugp.parse(%w(--snowcone)) }
81+
assert_match(/unknown argument '--snowcone'$/, err.message)
82+
83+
# too big of a mismatch to suggest (nothing close)
84+
err = assert_raises(CommandlineError) { sugp.parse(%w(--clown-nose)) }
85+
assert_match(/unknown argument '--clown-nose'$/, err.message)
86+
87+
sugp.opt "zippy"
88+
sugp.opt "zapzy"
89+
# single letter mismatch, matches two
90+
err = assert_raises(CommandlineError) { sugp.parse(%w(--zipzy)) }
91+
assert_match(/unknown argument '--zipzy'. Did you mean: \[--zippy, --zapzy\] \?$/, err.message)
92+
93+
sugp.opt "big_bug"
94+
# suggest common case of dash versus underscore in argnames
95+
err = assert_raises(CommandlineError) { sugp.parse(%w(--big_bug)) }
96+
assert_match(/unknown argument '--big_bug'. Did you mean: \[--big-bug\] \?$/, err.message)
97+
end
98+
5299
end
53100

54101
def test_unknown_arguments_with_suggestions
@@ -778,6 +825,20 @@ def test_arguments_passed_through_block
778825
end
779826
assert_equal @goat, boat
780827
end
828+
829+
## test-only access reader method so that we dont have to
830+
## expose settings in the public API.
831+
class Optimist::Parser
832+
def get_settings_for_testing ; return @settings ;end
833+
end
834+
835+
def test_two_arguments_passed_through_block
836+
newp = Parser.new(:abcd => 123, :efgh => "other" ) do |i|
837+
end
838+
assert_equal newp.get_settings_for_testing[:abcd], 123
839+
assert_equal newp.get_settings_for_testing[:efgh], "other"
840+
end
841+
781842

782843
def test_version_and_help_override_errors
783844
@p.opt :asdf, "desc", :type => String
@@ -1161,6 +1222,54 @@ def test_default_shorts_assigned_only_after_user_shorts
11611222
assert opts[:ccd]
11621223
end
11631224

1225+
def test_inexact_match
1226+
newp = Parser.new()
1227+
newp.opt :liberation, "liberate something", :type => :int
1228+
newp.opt :evaluate, "evaluate something", :type => :string
1229+
opts = newp.parse %w(--lib 5 --ev bar)
1230+
assert_equal 5, opts[:liberation]
1231+
assert_equal 'bar', opts[:evaluate]
1232+
assert_equal nil, opts[:eval]
1233+
end
1234+
1235+
def test_exact_match
1236+
newp = Parser.new(exact_match: true)
1237+
newp.opt :liberation, "liberate something", :type => :int
1238+
newp.opt :evaluate, "evaluate something", :type => :string
1239+
assert_raises(CommandlineError, /unknown argument '--lib'/) do
1240+
newp.parse %w(--lib 5)
1241+
end
1242+
assert_raises_errmatch(CommandlineError, /unknown argument '--ev'/) do
1243+
newp.parse %w(--ev bar)
1244+
end
1245+
end
1246+
1247+
def test_inexact_collision
1248+
newp = Parser.new()
1249+
newp.opt :bookname, "name of a book", :type => :string
1250+
newp.opt :bookcost, "cost of the book", :type => :string
1251+
opts = newp.parse %w(--bookn hairy_potsworth --bookc 10)
1252+
assert_equal 'hairy_potsworth', opts[:bookname]
1253+
assert_equal '10', opts[:bookcost]
1254+
assert_raises(CommandlineError) do
1255+
newp.parse %w(--book 5) # ambiguous
1256+
end
1257+
## partial match causes 'specified multiple times' error
1258+
assert_raises(CommandlineError, /specified multiple times/) do
1259+
newp.parse %w(--bookc 17 --bookcost 22)
1260+
end
1261+
end
1262+
1263+
def test_inexact_collision_with_exact
1264+
newp = Parser.new()
1265+
newp.opt :book, "name of a book", :type => :string, :default => "ABC"
1266+
newp.opt :bookcost, "cost of the book", :type => :int, :default => 5
1267+
opts = newp.parse %w(--book warthog --bookc 3)
1268+
assert_equal 'warthog', opts[:book]
1269+
assert_equal 3, opts[:bookcost]
1270+
1271+
end
1272+
11641273
def test_accepts_arguments_with_spaces
11651274
@p.opt :arg1, "arg", :type => String
11661275
@p.opt :arg2, "arg2", :type => String
@@ -1316,6 +1425,36 @@ def test_ignore_invalid_options_stop_on_unknown_partial_mid_short
13161425
assert opts[:arg1]
13171426
assert_equal %w{-bu potato}, @p.leftovers
13181427
end
1428+
1429+
# Due to strangeness in how the cloaker works, there were
1430+
# cases where Optimist.parse would work, but Optimist.options
1431+
# did not, depending on arguments given to the function.
1432+
# These serve to validate different args given to Optimist.options
1433+
def test_options_takes_hashy_settings
1434+
passargs_copy = []
1435+
settings_copy = []
1436+
::Optimist.options(%w(--wig --pig), :fizz=>:buzz, :bear=>:cat) do |*passargs|
1437+
opt :wig
1438+
opt :pig
1439+
passargs_copy = passargs.dup
1440+
settings_copy = @settings
1441+
end
1442+
assert_equal [], passargs_copy
1443+
assert_equal({:fizz=>:buzz, :bear=>:cat}, settings_copy)
1444+
end
1445+
1446+
def test_options_takes_some_other_data
1447+
passargs_copy = []
1448+
settings_copy = []
1449+
::Optimist.options(%w(--nose --close), 1, 2, 3) do |*passargs|
1450+
opt :nose
1451+
opt :close
1452+
passargs_copy = passargs.dup
1453+
settings_copy = @settings
1454+
end
1455+
assert_equal [1,2,3], passargs_copy
1456+
assert_equal({}, settings_copy)
1457+
end
13191458
end
13201459

13211460
end

0 commit comments

Comments
 (0)