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

adding support for DidYouMean when long options are spelled incorrectly #150

Merged
merged 4 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
26 changes: 26 additions & 0 deletions examples/didyoumean.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env ruby
require_relative '../lib/optimist'

opts = Optimist::options do
opt :cone, "Ice cream cone"
opt :zippy, "It zips"
opt :zapzy, "It zapz"
opt :big_bug, "Madagascar cockroach"
end
p opts

# $ ./didyoumean.rb --one
# Error: unknown argument '--one'. Did you mean: [--cone] ?.
# Try --help for help.

# $ ./didyoumean.rb --zappy
# Error: unknown argument '--zappy'. Did you mean: [--zapzy, --zippy] ?.
# Try --help for help.

# $ ./didyoumean.rb --big_bug
# Error: unknown argument '--big_bug'. Did you mean: [--big-bug] ?.
# Try --help for help.

# $ ./didyoumean.rb --bigbug
# Error: unknown argument '--bigbug'. Did you mean: [--big-bug] ?.
# Try --help for help.
64 changes: 51 additions & 13 deletions lib/optimist.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ def self.registry_getopttype(type)
## ignore options that it does not recognize.
attr_accessor :ignore_invalid_options

DEFAULT_SETTINGS = { suggestions: true }

## Initializes the parser, and instance-evaluates any block given.
def initialize(*a, &b)
@version = nil
Expand All @@ -97,8 +99,17 @@ def initialize(*a, &b)
@synopsis = nil
@usage = nil

# instance_eval(&b) if b # can't take arguments
cloaker(&b).bind(self).call(*a) if b
## allow passing settings through Parser.new as an optional hash.
## but keep compatibility with non-hashy args, though.
begin
settings_hash = Hash[*a]
@settings = DEFAULT_SETTINGS.merge(settings_hash)
a=[] ## clear out args if using as settings-hash
rescue ArgumentError
@settings = DEFAULT_SETTINGS
end

self.instance_exec(*a, &b) if block_given?
end

## Define an option. +name+ is the option name, a unique identifier
Expand Down Expand Up @@ -231,6 +242,42 @@ def educate_on_error
@educate_on_error = true
end

def handle_unknown_argument(arg, candidates, suggestions)
errstring = "unknown argument '#{arg}'"
errstring += " for command '#{subcommand_name}'" if self.respond_to?(:subcommand_name)
if (suggestions &&
Module::const_defined?("DidYouMean") &&
Module::const_defined?("DidYouMean::JaroWinkler") &&
Module::const_defined?("DidYouMean::Levenshtein"))
input = arg.sub(/^[-]*/,'')

# Code borrowed from did_you_mean gem
jw_threshold = 0.75
seed = candidates.select {|candidate| DidYouMean::JaroWinkler.distance(candidate, input) >= jw_threshold } \
.sort_by! {|candidate| DidYouMean::JaroWinkler.distance(candidate.to_s, input) } \
.reverse!
# Correct mistypes
threshold = (input.length * 0.25).ceil
has_mistype = seed.rindex {|c| DidYouMean::Levenshtein.distance(c, input) <= threshold }
corrections = if has_mistype
seed.take(has_mistype + 1)
else
# Correct misspells
seed.select do |candidate|
length = input.length < candidate.length ? input.length : candidate.length

DidYouMean::Levenshtein.distance(candidate, input) < length
end.first(1)
end
unless corrections.empty?
dashdash_corrections = corrections.map{|s| "--#{s}" }
errstring << ". Did you mean: [#{dashdash_corrections.join(', ')}] ?"
end
end
raise CommandlineError, errstring
end
private :handle_unknown_argument

## Parses the commandline. Typically called by Optimist::options,
## but you can call it directly if you need more control.
##
Expand Down Expand Up @@ -269,7 +316,8 @@ def parse(cmdline = ARGV)
sym = nil if arg =~ /--no-/ # explicitly invalidate --no-no- arguments

next nil if ignore_invalid_options && !sym
raise CommandlineError, "unknown argument '#{arg}'" unless sym

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

if given_args.include?(sym) && !@specs[sym].multi?
raise CommandlineError, "option '#{arg}' specified multiple times"
Expand Down Expand Up @@ -571,16 +619,6 @@ def wrap_line(str, opts = {})
ret
end

## instance_eval but with ability to handle block arguments
## thanks to _why: http://redhanded.hobix.com/inspect/aBlockCostume.html
def cloaker(&b)
(class << self; self; end).class_eval do
define_method :cloaker_, &b
meth = instance_method :cloaker_
remove_method :cloaker_
meth
end
end
end

class Option
Expand Down
47 changes: 47 additions & 0 deletions test/optimist/parser_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,53 @@ def test_unknown_arguments
assert_raises(CommandlineError) { @p.parse(%w(--arg2)) }
end

def test_unknown_arguments_with_suggestions
unless (Module::const_defined?("DidYouMean") &&
Module::const_defined?("DidYouMean::JaroWinkler") &&
Module::const_defined?("DidYouMean::Levenshtein"))
# if we cannot
skip("Skipping because DidYouMean was not found")
return false
end
sugp = Parser.new(:suggestions => true)
err = assert_raises(CommandlineError) { sugp.parse(%w(--bone)) }
assert_match(/unknown argument '--bone'$/, err.message)

sugp.opt "cone"
sugp.parse(%w(--cone))

# single letter mismatch
err = assert_raises(CommandlineError) { sugp.parse(%w(--bone)) }
assert_match(/unknown argument '--bone'. Did you mean: \[--cone\] \?$/, err.message)

# transposition
err = assert_raises(CommandlineError) { sugp.parse(%w(--ocne)) }
assert_match(/unknown argument '--ocne'. Did you mean: \[--cone\] \?$/, err.message)

# extra letter at end
err = assert_raises(CommandlineError) { sugp.parse(%w(--cones)) }
assert_match(/unknown argument '--cones'. Did you mean: \[--cone\] \?$/, err.message)

# too big of a mismatch to suggest (extra letters in front)
err = assert_raises(CommandlineError) { sugp.parse(%w(--snowcone)) }
assert_match(/unknown argument '--snowcone'$/, err.message)

# too big of a mismatch to suggest (nothing close)
err = assert_raises(CommandlineError) { sugp.parse(%w(--clown-nose)) }
assert_match(/unknown argument '--clown-nose'$/, err.message)

sugp.opt "zippy"
sugp.opt "zapzy"
# single letter mismatch, matches two
err = assert_raises(CommandlineError) { sugp.parse(%w(--zipzy)) }
assert_match(/unknown argument '--zipzy'. Did you mean: \[--zippy, --zapzy\] \?$/, err.message)

sugp.opt "big_bug"
# suggest common case of dash versus underscore in argnames
err = assert_raises(CommandlineError) { sugp.parse(%w(--big_bug)) }
assert_match(/unknown argument '--big_bug'. Did you mean: \[--big-bug\] \?$/, err.message)
end

def test_syntax_check
@p.opt "arg"

Expand Down