Skip to content

Commit 0879c17

Browse files
committed
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.
1 parent 57fe753 commit 0879c17

File tree

6 files changed

+87
-11
lines changed

6 files changed

+87
-11
lines changed

lib/thor/base.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,8 +493,7 @@ def public_command(*names)
493493
alias_method :public_task, :public_command
494494

495495
def handle_no_command_error(command, has_namespace = $thor_runner) #:nodoc:
496-
raise UndefinedCommandError, "Could not find command #{command.inspect} in #{namespace.inspect} namespace." if has_namespace
497-
raise UndefinedCommandError, "Could not find command #{command.inspect}."
496+
raise UndefinedCommandError.new(command, all_commands.keys, (namespace if has_namespace))
498497
end
499498
alias_method :handle_no_task_error, :handle_no_command_error
500499

lib/thor/error.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,35 @@ class Error < StandardError
1010

1111
# Raised when a command was not found.
1212
class UndefinedCommandError < Error
13+
class SpellChecker
14+
attr_reader :error
15+
16+
def initialize(error)
17+
@error = error
18+
end
19+
20+
def corrections
21+
@corrections ||= spell_checker.correct(error.command).map(&:inspect)
22+
end
23+
24+
def spell_checker
25+
DidYouMean::SpellChecker.new(dictionary: error.all_commands)
26+
end
27+
end
28+
29+
attr_reader :command, :all_commands
30+
31+
def initialize(command, all_commands, namespace)
32+
@command = command
33+
@all_commands = all_commands
34+
35+
message = "Could not find command #{command.inspect}"
36+
message = namespace ? "#{message} in #{namespace.inspect} namespace." : "#{message}."
37+
38+
super(message)
39+
end
40+
41+
prepend DidYouMean::Correctable
1342
end
1443
UndefinedTaskError = UndefinedCommandError
1544

@@ -22,11 +51,44 @@ class InvocationError < Error
2251
end
2352

2453
class UnknownArgumentError < Error
54+
class SpellChecker
55+
attr_reader :error
56+
57+
def initialize(error)
58+
@error = error
59+
end
60+
61+
def corrections
62+
@corrections ||=
63+
error.unknown.flat_map { |unknown| spell_checker.correct(unknown) }.uniq.map(&:inspect)
64+
end
65+
66+
def spell_checker
67+
@spell_checker ||=
68+
DidYouMean::SpellChecker.new(dictionary: error.switches)
69+
end
70+
end
71+
72+
attr_reader :switches, :unknown
73+
74+
def initialize(switches, unknown)
75+
@switches = switches
76+
@unknown = unknown
77+
78+
super("Unknown switches #{unknown.map(&:inspect).join(', ')}")
79+
end
80+
81+
prepend DidYouMean::Correctable
2582
end
2683

2784
class RequiredArgumentMissingError < InvocationError
2885
end
2986

3087
class MalformattedArgumentError < InvocationError
3188
end
89+
90+
DidYouMean::SPELL_CHECKERS.merge!(
91+
'Thor::UndefinedCommandError' => UndefinedCommandError::SpellChecker,
92+
'Thor::UnknownArgumentError' => UnknownArgumentError::SpellChecker
93+
)
3294
end

lib/thor/parser/options.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def check_unknown!
127127

128128
# an unknown option starts with - or -- and has no more --'s afterward.
129129
unknown = to_check.select { |str| str =~ /^--?(?:(?!--).)*$/ }
130-
raise UnknownArgumentError, "Unknown switches '#{unknown.join(', ')}'" unless unknown.empty?
130+
raise UnknownArgumentError.new(@switches.keys, unknown) unless unknown.empty?
131131
end
132132

133133
protected

spec/base_spec.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,15 @@ def hello
262262
end.to raise_error(Thor::UndefinedCommandError, 'Could not find command "what" in "my_script" namespace.')
263263
end
264264

265+
it "suggests commands that are similar if there is a typo" do
266+
expected = <<~MSG
267+
Could not find command "paintz" in "barn" namespace.
268+
Did you mean? "paint"
269+
MSG
270+
271+
expect(capture(:stderr) { Barn.start(%w(paintz)) }).to eq(expected)
272+
end
273+
265274
it "does not steal args" do
266275
args = %w(foo bar --force true)
267276
MyScript.start(args)
@@ -271,7 +280,7 @@ def hello
271280
it "checks unknown options" do
272281
expect(capture(:stderr) do
273282
MyScript.start(%w(foo bar --force true --unknown baz))
274-
end.strip).to eq("Unknown switches '--unknown'")
283+
end.strip).to eq("Unknown switches \"--unknown\"")
275284
end
276285

277286
it "checks unknown options except specified" do

spec/parser/options_spec.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,13 @@ def remaining
113113
it "raises an error for unknown switches" do
114114
create :foo => "baz", :bar => :required
115115
parse("--bar", "baz", "--baz", "unknown")
116-
expect { check_unknown! }.to raise_error(Thor::UnknownArgumentError, "Unknown switches '--baz'")
116+
117+
expected = <<~MSG.chomp
118+
Unknown switches "--baz"
119+
Did you mean? "--bar"
120+
MSG
121+
122+
expect { check_unknown! }.to raise_error(Thor::UnknownArgumentError, expected)
117123
end
118124

119125
it "skips leading non-switches" do

spec/thor_spec.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def exec(*args)
182182
it "does not accept if first non-option looks like an option, but only refuses that invalid option" do
183183
expect(capture(:stderr) do
184184
my_script2.start(%w[exec --foo command --bar])
185-
end.strip).to eq("Unknown switches '--foo'")
185+
end.strip).to eq("Unknown switches \"--foo\"")
186186
end
187187

188188
it "still accepts options that are given before non-options" do
@@ -196,7 +196,7 @@ def exec(*args)
196196
it "does not accept when non-option looks like an option and is after real options" do
197197
expect(capture(:stderr) do
198198
my_script2.start(%w[exec --verbose --foo])
199-
end.strip).to eq("Unknown switches '--foo'")
199+
end.strip).to eq("Unknown switches \"--foo\"")
200200
end
201201

202202
it "still accepts options that require a value" do
@@ -236,25 +236,25 @@ def checked(*args)
236236
it "does not accept if non-option that looks like an option is before the arguments" do
237237
expect(capture(:stderr) do
238238
my_script.start(%w[checked --foo command --bar])
239-
end.strip).to eq("Unknown switches '--foo, --bar'")
239+
end.strip).to eq("Unknown switches \"--foo\", \"--bar\"")
240240
end
241241

242242
it "does not accept if non-option that looks like an option is after an argument" do
243243
expect(capture(:stderr) do
244244
my_script.start(%w[checked command --foo --bar])
245-
end.strip).to eq("Unknown switches '--foo, --bar'")
245+
end.strip).to eq("Unknown switches \"--foo\", \"--bar\"")
246246
end
247247

248248
it "does not accept when non-option that looks like an option is after real options" do
249249
expect(capture(:stderr) do
250250
my_script.start(%w[checked --verbose --foo])
251-
end.strip).to eq("Unknown switches '--foo'")
251+
end.strip).to eq("Unknown switches \"--foo\"")
252252
end
253253

254254
it "does not accept when non-option that looks like an option is before real options" do
255255
expect(capture(:stderr) do
256256
my_script.start(%w[checked --foo --verbose])
257-
end.strip).to eq("Unknown switches '--foo'")
257+
end.strip).to eq("Unknown switches \"--foo\"")
258258
end
259259

260260
it "still accepts options that require a value" do

0 commit comments

Comments
 (0)