Skip to content

Commit

Permalink
Fixes #29413 - Add fuzzy subcommand matching
Browse files Browse the repository at this point in the history
  • Loading branch information
ofedoren committed Apr 27, 2020
1 parent 869eba9 commit aa8a2d7
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 58 deletions.
25 changes: 25 additions & 0 deletions doc/creating_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,31 @@ Options:
-h, --help print help
```

#### Aliasing subcommands

Commands can have two or more names, e.g. aliases. To support such functionality
simple name addition could be used via `command_name` or `command_names` method:
```ruby
module HammerCLIHello

class SayCommand < HammerCLI::AbstractCommand

class GreetingsCommand < HammerCLI::AbstractCommand
command_name 'hello'
command_name 'hi'
# or use can use other method:
command_names 'hello', 'hi'

desc 'Say Hello World!'
# ...
end

autoload_subcommands
end

HammerCLI::MainCommand.subcommand 'say', "Say something", HammerCLIHello::SayCommand
end
```

### Conflicting subcommands
It can happen that two different plugins define subcommands with the same name by accident.
Expand Down
13 changes: 11 additions & 2 deletions lib/hammer_cli/abstract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,17 @@ def self.desc(desc=nil)
end

def self.command_name(name=nil)
@name = name if name
@name || (superclass.respond_to?(:command_name) ? superclass.command_name : nil)
if @names && name
@names << name if !@names.include?(name)
else
@names = [name] if name
end
@names || (superclass.respond_to?(:command_names) ? superclass.command_names : nil)
end

def self.command_names(*names)
@names = names unless names.empty?
@names || (superclass.respond_to?(:command_names) ? superclass.command_names : nil)
end

def self.warning(message = nil)
Expand Down
2 changes: 1 addition & 1 deletion lib/hammer_cli/exception_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def handle_general_exception(e)

def handle_usage_exception(e)
print_error (_("Error: %{message}") + "\n\n" +
_("See: '%{path} --help'.")) % {:message => e.message, :path => e.command.invocation_path}
_("See: '%{path} --help'.")) % { message: e.message, path: HammerCLI.expand_invocation_path(e.command.invocation_path) }
log_full_error e
HammerCLI::EX_USAGE
end
Expand Down
21 changes: 20 additions & 1 deletion lib/hammer_cli/help/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def initialize(richtext = false)
def add_usage(invocation_path, usage_descriptions)
heading(Clamp.message(:usage_heading))
usage_descriptions.each do |usage|
puts " #{invocation_path} #{usage}".rstrip
puts " #{HammerCLI.expand_invocation_path(invocation_path)} #{usage}".rstrip
end
end

Expand Down Expand Up @@ -61,6 +61,25 @@ def heading(label)
label = HighLine.color(label, :bold) if @richtext
puts label
end

private

def expand_invocation_path(path)
bits = path.split(' ')
parent_command = HammerCLI::MainCommand
new_path = (bits[1..-1] || []).each_with_object([]) do |bit, names|
subcommand = parent_command.find_subcommand(bit)
next if subcommand.nil?

names << if subcommand.names.size > 1
"<#{subcommand.names.join('|')}>"
else
subcommand.names.first
end
parent_command = subcommand.subcommand_class
end
new_path.unshift(bits.first).join(' ')
end
end
end
end
26 changes: 25 additions & 1 deletion lib/hammer_cli/subcommand.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ def subcommand_class
@subcommand_class
end

def help
names = HammerCLI.context[:full_help] ? @names.join(", ") : @names.first
[names, description]
end

attr_reader :warning
end

Expand Down Expand Up @@ -90,8 +95,27 @@ def lazy_subcommand!(name, description, subcommand_class_name, path, options = {
logger.info "subcommand #{name} (#{subcommand_class_name}) was created."
end

def find_subcommand(name, fuzzy: true)
subcommand = super(name)
if subcommand.nil? && fuzzy
find_subcommand_starting_with(name)
else
subcommand
end
end

def find_subcommand_starting_with(name)
subcommands = recognised_subcommands.select { |sc| sc.names.any? { |n| n.start_with?(name) } }
if subcommands.size > 1
raise HammerCLI::CommandConflict, _('Found more than one command.') + "\n\n" +
_('Did you mean one of these?') + "\n\t" +
subcommands.collect(&:names).flatten.select { |n| n.start_with?(name) }.join("\n\t")
end
subcommands.first
end

def define_subcommand(name, subcommand_class, definition, &block)
existing = find_subcommand(name)
existing = find_subcommand(name, fuzzy: false)
if existing
raise HammerCLI::CommandConflict, _("Can't replace subcommand %<name>s (%<existing_class>s) with %<name>s (%<new_class>s).") % {
:name => name,
Expand Down
4 changes: 2 additions & 2 deletions lib/hammer_cli/testing/command_assertions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ def usage_error(command, message, heading=nil)
if heading.nil?
["Error: #{message}",
"",
"See: '#{command} --help'.",
"See: '#{HammerCLI.expand_invocation_path(command)} --help'.",
""].join("\n")
else
["#{heading}:",
" Error: #{message}",
" ",
" See: '#{command} --help'.",
" See: '#{HammerCLI.expand_invocation_path(command)} --help'.",
""].join("\n")
end
end
Expand Down
17 changes: 17 additions & 0 deletions lib/hammer_cli/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,21 @@ def self.insert_relative(array, mode, idx, *new_items)

array.insert(idx, *new_items)
end

def self.expand_invocation_path(path)
bits = path.split(' ')
parent_command = HammerCLI::MainCommand
new_path = (bits[1..-1] || []).each_with_object([]) do |bit, names|
subcommand = parent_command.find_subcommand(bit)
next if subcommand.nil?

names << if subcommand.names.size > 1
"<#{subcommand.names.join('|')}>"
else
subcommand.names.first
end
parent_command = subcommand.subcommand_class
end
new_path.unshift(bits.first).join(' ')
end
end
25 changes: 23 additions & 2 deletions test/unit/abstract_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,27 @@ class Subcommand2 < HammerCLI::AbstractCommand; end
end

end

describe 'find_subcommand' do
it 'should find by full name' do
main_cmd.find_subcommand('some_command').wont_be_nil
end

it 'should find by partial name' do
main_cmd.find_subcommand('some_').wont_be_nil
end

it 'should not find by wrong name' do
main_cmd.find_subcommand('not_existing').must_be_nil
end

it 'should raise if more than one were found' do
main_cmd.subcommand('pong', 'description', Subcommand2)
proc do
main_cmd.find_subcommand('p')
end.must_raise HammerCLI::CommandConflict
end
end
end

describe "options" do
Expand Down Expand Up @@ -413,7 +434,7 @@ class CmdName1 < HammerCLI::AbstractCommand
class CmdName2 < CmdName1
end

CmdName2.command_name.must_equal 'cmd'
CmdName2.command_name.must_equal ['cmd']
end

it "should inherit output definition" do
Expand Down Expand Up @@ -525,7 +546,7 @@ def execute
opt = cmd.find_option('--flag')
opt.is_a?(HammerCLI::Options::OptionDefinition).must_equal true
cmd.output_definition.empty?.must_equal false
cmd.new({}).help.must_match(/.*text.*/)
cmd.new('', {}).help.must_match(/.*text.*/)
end

it 'should store more than one extension' do
Expand Down
98 changes: 49 additions & 49 deletions test/unit/command_extensions_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,49 +70,49 @@ class CmdExtensions < HammerCLI::CommandExtensions

it 'should extend help only' do
cmd.extend_with(CmdExtensions.new(only: :help))
cmd.new({}).help.must_match(/.*Section.*/)
cmd.new({}).help.must_match(/.*text.*/)
cmd.new('', {}).help.must_match(/.*Section.*/)
cmd.new('', {}).help.must_match(/.*text.*/)
end

it 'should extend params only' do
cmd.extend_with(CmdExtensions.new(only: :request_params))
cmd.new({}).extended_request[0].must_equal(thin: true)
cmd.new({}).extended_request[1].must_equal({})
cmd.new({}).extended_request[2].must_equal({})
cmd.new('', {}).extended_request[0].must_equal(thin: true)
cmd.new('', {}).extended_request[1].must_equal({})
cmd.new('', {}).extended_request[2].must_equal({})
end

it 'should extend headers only' do
cmd.extend_with(CmdExtensions.new(only: :request_headers))
cmd.new({}).extended_request[0].must_equal({})
cmd.new({}).extended_request[1].must_equal(ssl: true)
cmd.new({}).extended_request[2].must_equal({})
cmd.new('', {}).extended_request[0].must_equal({})
cmd.new('', {}).extended_request[1].must_equal(ssl: true)
cmd.new('', {}).extended_request[2].must_equal({})
end

it 'should extend options only' do
cmd.extend_with(CmdExtensions.new(only: :request_options))
cmd.new({}).extended_request[0].must_equal({})
cmd.new({}).extended_request[1].must_equal({})
cmd.new({}).extended_request[2].must_equal(with_authentication: true)
cmd.new('', {}).extended_request[0].must_equal({})
cmd.new('', {}).extended_request[1].must_equal({})
cmd.new('', {}).extended_request[2].must_equal(with_authentication: true)
end

it 'should extend params and options and headers' do
cmd.extend_with(CmdExtensions.new(only: :request))
cmd.new({}).extended_request[0].must_equal(thin: true)
cmd.new({}).extended_request[1].must_equal(ssl: true)
cmd.new({}).extended_request[2].must_equal(with_authentication: true)
cmd.new('', {}).extended_request[0].must_equal(thin: true)
cmd.new('', {}).extended_request[1].must_equal(ssl: true)
cmd.new('', {}).extended_request[2].must_equal(with_authentication: true)
end

it 'should extend data only' do
cmd.extend_with(CmdExtensions.new(only: :data))
cmd.new({}).help.wont_match(/.*Section.*/)
cmd.new({}).help.wont_match(/.*text.*/)
cmd.new('', {}).help.wont_match(/.*Section.*/)
cmd.new('', {}).help.wont_match(/.*text.*/)
cmd.output_definition.empty?.must_equal true
opt = cmd.find_option('--ext')
opt.is_a?(HammerCLI::Options::OptionDefinition).must_equal false
cmd.new({}).extended_request[0].must_equal({})
cmd.new({}).extended_request[1].must_equal({})
cmd.new({}).extended_request[2].must_equal({})
cmd.new({}).extended_data({}).must_equal('key' => 'value')
cmd.new('', {}).extended_request[0].must_equal({})
cmd.new('', {}).extended_request[1].must_equal({})
cmd.new('', {}).extended_request[2].must_equal({})
cmd.new('', {}).extended_data({}).must_equal('key' => 'value')
end

it 'should extend option family only' do
Expand All @@ -128,72 +128,72 @@ class CmdExtensions < HammerCLI::CommandExtensions
opt = cmd.find_option('--ext')
opt.is_a?(HammerCLI::Options::OptionDefinition).must_equal false
cmd.output_definition.empty?.must_equal false
cmd.new({}).extended_request[0].must_equal(thin: true)
cmd.new({}).extended_request[1].must_equal(ssl: true)
cmd.new({}).extended_request[2].must_equal(with_authentication: true)
cmd.new('', {}).extended_request[0].must_equal(thin: true)
cmd.new('', {}).extended_request[1].must_equal(ssl: true)
cmd.new('', {}).extended_request[2].must_equal(with_authentication: true)
end

it 'should extend all except output' do
cmd.extend_with(CmdExtensions.new(except: :output))
cmd.output_definition.empty?.must_equal true
opt = cmd.find_option('--ext')
opt.is_a?(HammerCLI::Options::OptionDefinition).must_equal true
cmd.new({}).extended_request[0].must_equal(thin: true)
cmd.new({}).extended_request[1].must_equal(ssl: true)
cmd.new({}).extended_request[2].must_equal(with_authentication: true)
cmd.new('', {}).extended_request[0].must_equal(thin: true)
cmd.new('', {}).extended_request[1].must_equal(ssl: true)
cmd.new('', {}).extended_request[2].must_equal(with_authentication: true)
end

it 'should extend all except help' do
cmd.extend_with(CmdExtensions.new(except: :help))
cmd.new({}).help.wont_match(/.*Section.*/)
cmd.new({}).help.wont_match(/.*text.*/)
cmd.new('', {}).help.wont_match(/.*Section.*/)
cmd.new('', {}).help.wont_match(/.*text.*/)
cmd.output_definition.empty?.must_equal false
opt = cmd.find_option('--ext')
opt.is_a?(HammerCLI::Options::OptionDefinition).must_equal true
cmd.new({}).extended_request[0].must_equal(thin: true)
cmd.new({}).extended_request[1].must_equal(ssl: true)
cmd.new({}).extended_request[2].must_equal(with_authentication: true)
cmd.new('', {}).extended_request[0].must_equal(thin: true)
cmd.new('', {}).extended_request[1].must_equal(ssl: true)
cmd.new('', {}).extended_request[2].must_equal(with_authentication: true)
end

it 'should extend all except params' do
cmd.extend_with(CmdExtensions.new(except: :request_params))
cmd.new({}).extended_request[0].must_equal({})
cmd.new({}).extended_request[1].must_equal(ssl: true)
cmd.new({}).extended_request[2].must_equal(with_authentication: true)
cmd.new('', {}).extended_request[0].must_equal({})
cmd.new('', {}).extended_request[1].must_equal(ssl: true)
cmd.new('', {}).extended_request[2].must_equal(with_authentication: true)
end

it 'should extend all except headers' do
cmd.extend_with(CmdExtensions.new(except: :request_headers))
cmd.new({}).extended_request[0].must_equal(thin: true)
cmd.new({}).extended_request[1].must_equal({})
cmd.new({}).extended_request[2].must_equal(with_authentication: true)
cmd.new('', {}).extended_request[0].must_equal(thin: true)
cmd.new('', {}).extended_request[1].must_equal({})
cmd.new('', {}).extended_request[2].must_equal(with_authentication: true)
end

it 'should extend all except options' do
cmd.extend_with(CmdExtensions.new(except: :request_options))
cmd.new({}).extended_request[0].must_equal(thin: true)
cmd.new({}).extended_request[1].must_equal(ssl: true)
cmd.new({}).extended_request[2].must_equal({})
cmd.new('', {}).extended_request[0].must_equal(thin: true)
cmd.new('', {}).extended_request[1].must_equal(ssl: true)
cmd.new('', {}).extended_request[2].must_equal({})
end

it 'should extend all except params and options and headers' do
cmd.extend_with(CmdExtensions.new(except: :request))
cmd.new({}).extended_request[0].must_equal({})
cmd.new({}).extended_request[1].must_equal({})
cmd.new({}).extended_request[2].must_equal({})
cmd.new('', {}).extended_request[0].must_equal({})
cmd.new('', {}).extended_request[1].must_equal({})
cmd.new('', {}).extended_request[2].must_equal({})
end

it 'should extend all except data' do
cmd.extend_with(CmdExtensions.new(except: :data))
cmd.new({}).help.must_match(/.*Section.*/)
cmd.new({}).help.must_match(/.*text.*/)
cmd.new('', {}).help.must_match(/.*Section.*/)
cmd.new('', {}).help.must_match(/.*text.*/)
cmd.output_definition.empty?.must_equal false
opt = cmd.find_option('--ext')
opt.is_a?(HammerCLI::Options::OptionDefinition).must_equal true
cmd.new({}).extended_request[0].must_equal(thin: true)
cmd.new({}).extended_request[1].must_equal(ssl: true)
cmd.new({}).extended_request[2].must_equal(with_authentication: true)
cmd.new({}).extended_data({}).must_equal({})
cmd.new('', {}).extended_request[0].must_equal(thin: true)
cmd.new('', {}).extended_request[1].must_equal(ssl: true)
cmd.new('', {}).extended_request[2].must_equal(with_authentication: true)
cmd.new('', {}).extended_data({}).must_equal({})
end

it 'should extend all except option family' do
Expand Down

0 comments on commit aa8a2d7

Please sign in to comment.