From 0879c1773d188902d54f95174f33961ac33111f8 Mon Sep 17 00:00:00 2001 From: Kevin Deisz Date: Tue, 23 Oct 2018 13:37:17 -0400 Subject: [PATCH 1/3] Support did-you-mean functionality in thor When an invocation errors out because it cannot find a corresponding command, this will attempt to suggest alternatives in the case of typos. Also, when invalid switches are passed and checking for invalid switches is enabled, it will attempt to suggest alternatives as well. --- lib/thor/base.rb | 3 +- lib/thor/error.rb | 62 +++++++++++++++++++++++++++++++++++++ lib/thor/parser/options.rb | 2 +- spec/base_spec.rb | 11 ++++++- spec/parser/options_spec.rb | 8 ++++- spec/thor_spec.rb | 12 +++---- 6 files changed, 87 insertions(+), 11 deletions(-) diff --git a/lib/thor/base.rb b/lib/thor/base.rb index c5f004e3e..40f590410 100644 --- a/lib/thor/base.rb +++ b/lib/thor/base.rb @@ -493,8 +493,7 @@ def public_command(*names) alias_method :public_task, :public_command def handle_no_command_error(command, has_namespace = $thor_runner) #:nodoc: - raise UndefinedCommandError, "Could not find command #{command.inspect} in #{namespace.inspect} namespace." if has_namespace - raise UndefinedCommandError, "Could not find command #{command.inspect}." + raise UndefinedCommandError.new(command, all_commands.keys, (namespace if has_namespace)) end alias_method :handle_no_task_error, :handle_no_command_error diff --git a/lib/thor/error.rb b/lib/thor/error.rb index 9910bfb2e..5d9730972 100644 --- a/lib/thor/error.rb +++ b/lib/thor/error.rb @@ -10,6 +10,35 @@ class Error < StandardError # Raised when a command was not found. class UndefinedCommandError < Error + class SpellChecker + attr_reader :error + + def initialize(error) + @error = error + end + + def corrections + @corrections ||= spell_checker.correct(error.command).map(&:inspect) + end + + def spell_checker + DidYouMean::SpellChecker.new(dictionary: error.all_commands) + end + end + + attr_reader :command, :all_commands + + def initialize(command, all_commands, namespace) + @command = command + @all_commands = all_commands + + message = "Could not find command #{command.inspect}" + message = namespace ? "#{message} in #{namespace.inspect} namespace." : "#{message}." + + super(message) + end + + prepend DidYouMean::Correctable end UndefinedTaskError = UndefinedCommandError @@ -22,6 +51,34 @@ class InvocationError < Error end class UnknownArgumentError < Error + class SpellChecker + attr_reader :error + + def initialize(error) + @error = error + end + + def corrections + @corrections ||= + error.unknown.flat_map { |unknown| spell_checker.correct(unknown) }.uniq.map(&:inspect) + end + + def spell_checker + @spell_checker ||= + DidYouMean::SpellChecker.new(dictionary: error.switches) + end + end + + attr_reader :switches, :unknown + + def initialize(switches, unknown) + @switches = switches + @unknown = unknown + + super("Unknown switches #{unknown.map(&:inspect).join(', ')}") + end + + prepend DidYouMean::Correctable end class RequiredArgumentMissingError < InvocationError @@ -29,4 +86,9 @@ class RequiredArgumentMissingError < InvocationError class MalformattedArgumentError < InvocationError end + + DidYouMean::SPELL_CHECKERS.merge!( + 'Thor::UndefinedCommandError' => UndefinedCommandError::SpellChecker, + 'Thor::UnknownArgumentError' => UnknownArgumentError::SpellChecker + ) end diff --git a/lib/thor/parser/options.rb b/lib/thor/parser/options.rb index 7459978eb..df1790226 100644 --- a/lib/thor/parser/options.rb +++ b/lib/thor/parser/options.rb @@ -127,7 +127,7 @@ def check_unknown! # an unknown option starts with - or -- and has no more --'s afterward. unknown = to_check.select { |str| str =~ /^--?(?:(?!--).)*$/ } - raise UnknownArgumentError, "Unknown switches '#{unknown.join(', ')}'" unless unknown.empty? + raise UnknownArgumentError.new(@switches.keys, unknown) unless unknown.empty? end protected diff --git a/spec/base_spec.rb b/spec/base_spec.rb index 4ba1f689f..2c53994c3 100644 --- a/spec/base_spec.rb +++ b/spec/base_spec.rb @@ -262,6 +262,15 @@ def hello end.to raise_error(Thor::UndefinedCommandError, 'Could not find command "what" in "my_script" namespace.') end + it "suggests commands that are similar if there is a typo" do + expected = <<~MSG + Could not find command "paintz" in "barn" namespace. + Did you mean? "paint" + MSG + + expect(capture(:stderr) { Barn.start(%w(paintz)) }).to eq(expected) + end + it "does not steal args" do args = %w(foo bar --force true) MyScript.start(args) @@ -271,7 +280,7 @@ def hello it "checks unknown options" do expect(capture(:stderr) do MyScript.start(%w(foo bar --force true --unknown baz)) - end.strip).to eq("Unknown switches '--unknown'") + end.strip).to eq("Unknown switches \"--unknown\"") end it "checks unknown options except specified" do diff --git a/spec/parser/options_spec.rb b/spec/parser/options_spec.rb index 6f097c391..85862b8f5 100644 --- a/spec/parser/options_spec.rb +++ b/spec/parser/options_spec.rb @@ -113,7 +113,13 @@ def remaining it "raises an error for unknown switches" do create :foo => "baz", :bar => :required parse("--bar", "baz", "--baz", "unknown") - expect { check_unknown! }.to raise_error(Thor::UnknownArgumentError, "Unknown switches '--baz'") + + expected = <<~MSG.chomp + Unknown switches "--baz" + Did you mean? "--bar" + MSG + + expect { check_unknown! }.to raise_error(Thor::UnknownArgumentError, expected) end it "skips leading non-switches" do diff --git a/spec/thor_spec.rb b/spec/thor_spec.rb index fd1e2a609..e9e25c0a5 100644 --- a/spec/thor_spec.rb +++ b/spec/thor_spec.rb @@ -182,7 +182,7 @@ def exec(*args) it "does not accept if first non-option looks like an option, but only refuses that invalid option" do expect(capture(:stderr) do my_script2.start(%w[exec --foo command --bar]) - end.strip).to eq("Unknown switches '--foo'") + end.strip).to eq("Unknown switches \"--foo\"") end it "still accepts options that are given before non-options" do @@ -196,7 +196,7 @@ def exec(*args) it "does not accept when non-option looks like an option and is after real options" do expect(capture(:stderr) do my_script2.start(%w[exec --verbose --foo]) - end.strip).to eq("Unknown switches '--foo'") + end.strip).to eq("Unknown switches \"--foo\"") end it "still accepts options that require a value" do @@ -236,25 +236,25 @@ def checked(*args) it "does not accept if non-option that looks like an option is before the arguments" do expect(capture(:stderr) do my_script.start(%w[checked --foo command --bar]) - end.strip).to eq("Unknown switches '--foo, --bar'") + end.strip).to eq("Unknown switches \"--foo\", \"--bar\"") end it "does not accept if non-option that looks like an option is after an argument" do expect(capture(:stderr) do my_script.start(%w[checked command --foo --bar]) - end.strip).to eq("Unknown switches '--foo, --bar'") + end.strip).to eq("Unknown switches \"--foo\", \"--bar\"") end it "does not accept when non-option that looks like an option is after real options" do expect(capture(:stderr) do my_script.start(%w[checked --verbose --foo]) - end.strip).to eq("Unknown switches '--foo'") + end.strip).to eq("Unknown switches \"--foo\"") end it "does not accept when non-option that looks like an option is before real options" do expect(capture(:stderr) do my_script.start(%w[checked --foo --verbose]) - end.strip).to eq("Unknown switches '--foo'") + end.strip).to eq("Unknown switches \"--foo\"") end it "still accepts options that require a value" do From 44dfb35e757bd52b4e09d894c676f2fdee2286cf Mon Sep 17 00:00:00 2001 From: Kevin Deisz Date: Tue, 23 Oct 2018 13:54:38 -0400 Subject: [PATCH 2/3] Fix up did_you_mean on older ruby versions --- lib/thor/error.rb | 21 +++++++++++++++------ spec/base_spec.rb | 6 ++---- spec/parser/options_spec.rb | 6 ++---- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/lib/thor/error.rb b/lib/thor/error.rb index 5d9730972..3704b4c27 100644 --- a/lib/thor/error.rb +++ b/lib/thor/error.rb @@ -1,4 +1,11 @@ class Thor + Correctable = + begin + require 'did_you_mean' + DidYouMean::Correctable + rescue LoadError + end + # Thor::Error is raised when it's caused by wrong usage of thor classes. Those # errors have their backtrace suppressed and are nicely shown to the user. # @@ -38,7 +45,7 @@ def initialize(command, all_commands, namespace) super(message) end - prepend DidYouMean::Correctable + prepend Correctable if Correctable end UndefinedTaskError = UndefinedCommandError @@ -78,7 +85,7 @@ def initialize(switches, unknown) super("Unknown switches #{unknown.map(&:inspect).join(', ')}") end - prepend DidYouMean::Correctable + prepend Correctable if Correctable end class RequiredArgumentMissingError < InvocationError @@ -87,8 +94,10 @@ class RequiredArgumentMissingError < InvocationError class MalformattedArgumentError < InvocationError end - DidYouMean::SPELL_CHECKERS.merge!( - 'Thor::UndefinedCommandError' => UndefinedCommandError::SpellChecker, - 'Thor::UnknownArgumentError' => UnknownArgumentError::SpellChecker - ) + if Correctable + DidYouMean::SPELL_CHECKERS.merge!( + 'Thor::UndefinedCommandError' => UndefinedCommandError::SpellChecker, + 'Thor::UnknownArgumentError' => UnknownArgumentError::SpellChecker + ) + end end diff --git a/spec/base_spec.rb b/spec/base_spec.rb index 2c53994c3..016dc4fa8 100644 --- a/spec/base_spec.rb +++ b/spec/base_spec.rb @@ -263,10 +263,8 @@ def hello end it "suggests commands that are similar if there is a typo" do - expected = <<~MSG - Could not find command "paintz" in "barn" namespace. - Did you mean? "paint" - MSG + expected = "Could not find command \"paintz\" in \"barn\" namespace.\n" + expected << "Did you mean? \"paint\"" if Thor::Correctable expect(capture(:stderr) { Barn.start(%w(paintz)) }).to eq(expected) end diff --git a/spec/parser/options_spec.rb b/spec/parser/options_spec.rb index 85862b8f5..e1239e463 100644 --- a/spec/parser/options_spec.rb +++ b/spec/parser/options_spec.rb @@ -114,10 +114,8 @@ def remaining create :foo => "baz", :bar => :required parse("--bar", "baz", "--baz", "unknown") - expected = <<~MSG.chomp - Unknown switches "--baz" - Did you mean? "--bar" - MSG + expected = "Unknown switches \"--baz\"" + expected << "\nDid you mean? \"--bar\"" if Thor::Correctable expect { check_unknown! }.to raise_error(Thor::UnknownArgumentError, expected) end From 3019cb518536118ef3a62e6c83250076193b4960 Mon Sep 17 00:00:00 2001 From: Kevin Deisz Date: Tue, 23 Oct 2018 14:01:13 -0400 Subject: [PATCH 3/3] Fix up keyword argument usage in did_you_mean for ruby 1.8 --- lib/thor/error.rb | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/thor/error.rb b/lib/thor/error.rb index 3704b4c27..c1f54fec3 100644 --- a/lib/thor/error.rb +++ b/lib/thor/error.rb @@ -2,6 +2,20 @@ class Thor Correctable = begin require 'did_you_mean' + + module DidYouMean + # In order to support versions of Ruby that don't have keyword + # arguments, we need our own spell checker class that doesn't take key + # words. Even though this code wouldn't be hit because of the check + # above, it's still necessary because the interpreter would otherwise be + # unable to parse the file. + class NoKwargSpellChecker < SpellChecker + def initialize(dictionary) + @dictionary = dictionary + end + end + end + DidYouMean::Correctable rescue LoadError end @@ -29,7 +43,7 @@ def corrections end def spell_checker - DidYouMean::SpellChecker.new(dictionary: error.all_commands) + DidYouMean::NoKwargSpellChecker.new(error.all_commands) end end @@ -72,7 +86,7 @@ def corrections def spell_checker @spell_checker ||= - DidYouMean::SpellChecker.new(dictionary: error.switches) + DidYouMean::NoKwargSpellChecker.new(error.switches) end end