lib/claide/command.rb
# encoding: utf-8
require 'claide/command/banner'
require 'claide/command/plugin_manager'
require 'claide/command/argument_suggester'
module CLAide
# This class is used to build a command-line interface
#
# Each command is represented by a subclass of this class, which may be
# nested to create more granular commands.
#
# Following is an overview of the types of commands and what they should do.
#
# ### Any command type
#
# * Inherit from the command class under which the command should be nested.
# * Set {Command.summary} to a brief description of the command.
# * Override {Command.options} to return the options it handles and their
# descriptions and prepending them to the results of calling `super`.
# * Override {Command#initialize} if it handles any parameters.
# * Override {Command#validate!} to check if the required parameters the
# command handles are valid, or call {Command#help!} in case they’re not.
#
# ### Abstract command
#
# The following is needed for an abstract command:
#
# * Set {Command.abstract_command} to `true`.
# * Subclass the command.
#
# When the optional {Command.description} is specified, it will be shown at
# the top of the command’s help banner.
#
# ### Normal command
#
# The following is needed for a normal command:
#
# * Set {Command.arguments} to the description of the arguments this command
# handles.
# * Override {Command#run} to perform the actual work.
#
# When the optional {Command.description} is specified, it will be shown
# underneath the usage section of the command’s help banner. Otherwise this
# defaults to {Command.summary}.
#
class Command
class << self
# @return [Boolean] Indicates whether or not this command can actually
# perform work of itself, or that it only contains subcommands.
#
attr_accessor :abstract_command
alias_method :abstract_command?, :abstract_command
# @return [Boolean] Indicates whether or not this command is used during
# command parsing and whether or not it should be shown in the
# help banner or to show its subcommands instead.
#
# Setting this to `true` implies it’s an abstract command.
#
attr_reader :ignore_in_command_lookup
alias_method :ignore_in_command_lookup?, :ignore_in_command_lookup
def ignore_in_command_lookup=(flag)
@ignore_in_command_lookup = self.abstract_command = flag
end
# @return [String] The subcommand which an abstract command should invoke
# by default.
#
attr_accessor :default_subcommand
# @return [String] A brief description of the command, which is shown
# next to the command in the help banner of a parent command.
#
attr_accessor :summary
# @return [String] A longer description of the command, which is shown
# underneath the usage section of the command’s help banner. Any
# indentation in this value will be ignored.
#
attr_accessor :description
# @return [Array<String>] The prefixes used to search for CLAide plugins.
# Plugins are loaded via their `<plugin_prefix>_plugin.rb` file.
# Defaults to search for `claide` plugins.
#
def plugin_prefixes
@plugin_prefixes ||= ['claide']
end
attr_writer :plugin_prefixes
# @return [Array<Argument>]
# A list of arguments the command handles. This is shown
# in the usage section of the command’s help banner.
# Each Argument in the array represents an argument by its name
# (or list of alternatives) and whether it's required or optional
#
def arguments
@arguments ||= []
end
# @param [Array<Argument>] arguments
# An array listing the command arguments.
# Each Argument object describe the argument by its name
# (or list of alternatives) and whether it's required or optional
#
# @todo Remove deprecation
#
def arguments=(arguments)
if arguments.is_a?(Array)
if arguments.empty? || arguments[0].is_a?(Argument)
@arguments = arguments
else
self.arguments_array = arguments
end
else
self.arguments_string = arguments
end
end
# @return [Boolean] The default value for {Command#ansi_output}. This
# defaults to `true` if `STDOUT` is connected to a TTY and
# `String` has the instance methods `#red`, `#green`, and
# `#yellow` (which are defined by, for instance, the
# [colored](https://github.com/defunkt/colored) gem).
#
def ansi_output
if @ansi_output.nil?
@ansi_output = STDOUT.tty?
end
@ansi_output
end
attr_writer :ansi_output
alias_method :ansi_output?, :ansi_output
# @return [String] The name of the command. Defaults to a snake-cased
# version of the class’ name.
#
def command
@command ||= name.split('::').last.gsub(/[A-Z]+[a-z]*/) do |part|
part.downcase << '-'
end[0..-2]
end
attr_writer :command
# @return [String] The version of the command. This value will be printed
# by the `--version` flag if used for the root command.
#
attr_accessor :version
end
#-------------------------------------------------------------------------#
# @return [String] The full command up-to this command, as it would be
# looked up during parsing.
#
# @note (see #ignore_in_command_lookup)
#
# @example
#
# BevarageMaker::Tea.full_command # => "beverage-maker tea"
#
def self.full_command
if superclass == Command
ignore_in_command_lookup? ? '' : command
else
if ignore_in_command_lookup?
superclass.full_command
else
"#{superclass.full_command} #{command}"
end
end
end
# @return [Bool] Whether this is the root command class
#
def self.root_command?
superclass == CLAide::Command
end
# @return [Array<Class>] A list of all command classes that are nested
# under this command.
#
def self.subcommands
@subcommands ||= []
end
# @return [Array<Class>] A list of command classes that are nested under
# this command _or_ the subcommands of those command classes in
# case the command class should be ignored in command lookup.
#
def self.subcommands_for_command_lookup
subcommands.map do |subcommand|
if subcommand.ignore_in_command_lookup?
subcommand.subcommands_for_command_lookup
else
subcommand
end
end.flatten
end
# Searches the list of subcommands that should not be ignored for command
# lookup for a subcommand with the given `name`.
#
# @param [String] name
# The name of the subcommand to be found.
#
# @return [CLAide::Command, nil] The subcommand, if found.
#
def self.find_subcommand(name)
subcommands_for_command_lookup.find { |sc| sc.command == name }
end
# @visibility private
#
# Automatically registers a subclass as a subcommand.
#
def self.inherited(subcommand)
subcommands << subcommand
end
DEFAULT_ROOT_OPTIONS = [
['--version', 'Show the version of the tool'],
]
DEFAULT_OPTIONS = [
['--verbose', 'Show more debugging information'],
['--no-ansi', 'Show output without ANSI codes'],
['--help', 'Show help banner of specified command'],
]
# Should be overridden by a subclass if it handles any options.
#
# The subclass has to combine the result of calling `super` and its own
# list of options. The recommended way of doing this is by concatenating
# to this classes’ own options.
#
# @return [Array<Array>]
#
# A list of option name and description tuples.
#
# @example
#
# def self.options
# [
# ['--verbose', 'Print more info'],
# ['--help', 'Print help banner'],
# ].concat(super)
# end
#
def self.options
if root_command?
DEFAULT_ROOT_OPTIONS + DEFAULT_OPTIONS
else
DEFAULT_OPTIONS
end
end
# Adds a new option for the current command.
#
# This method can be used in conjunction with overriding `options`.
#
# @return [void]
#
# @example
#
# option '--help', 'Print help banner '
#
def self.option(name, description)
mod = Module.new do
define_method(:options) do
[
[name, description],
].concat(super())
end
end
extend(mod)
end
private_class_method :option
# Handles root commands options if appropriate.
#
# @param [ARGV] argv
# The parameters of the command.
#
# @return [Bool] Whether any root command option was handled.
#
def handle_root_options(argv)
return false unless self.class.root_command?
if argv.flag?('version')
print_version
return true
end
false
end
# Prints the version of the command optionally including plugins.
#
def print_version
puts self.class.version
if verbose?
PluginManager.specifications.each do |spec|
puts "#{spec.name}: #{spec.version}"
end
end
end
# Instantiates the command class matching the parameters through
# {Command.parse}, validates it through {Command#validate!}, and runs it
# through {Command#run}.
#
# @note The ANSI support is configured before running a command to allow
# the same process to run multiple commands with different
# settings. For example a process with ANSI output enabled might
# want to programmatically invoke another command with the output
# enabled.
#
# @param [Array, ARGV] argv
# A list of parameters. For instance, the standard `ARGV` constant,
# which contains the parameters passed to the program.
#
# @return [void]
#
def self.run(argv = [])
plugin_prefixes.each do |plugin_prefix|
PluginManager.load_plugins(plugin_prefix)
end
argv = ARGV.coerce(argv)
command = parse(argv)
ANSI.disabled = !command.ansi_output?
unless command.handle_root_options(argv)
command.validate!
command.run
end
rescue Object => exception
handle_exception(command, exception)
end
# @param [Array, ARGV] argv
# A list of (remaining) parameters.
#
# @return [Command] An instance of the command class that was matched by
# going through the arguments in the parameters and drilling down
# command classes.
#
def self.parse(argv)
argv = ARGV.coerce(argv)
cmd = argv.arguments.first
if cmd && subcommand = find_subcommand(cmd)
argv.shift_argument
subcommand.parse(argv)
elsif abstract_command? && default_subcommand
load_default_subcommand(argv)
else
new(argv)
end
end
# @param [Array, ARGV] argv
# A list of (remaining) parameters.
#
# @return [Command] Returns the default subcommand initialized with the
# given arguments.
#
def self.load_default_subcommand(argv)
unless subcommand = find_subcommand(default_subcommand)
raise 'Unable to find the default subcommand ' \
"`#{default_subcommand}` for command `#{self}`."
end
result = subcommand.parse(argv)
result.invoked_as_default = true
result
end
# Presents an exception to the user in a short manner in case of an
# `InformativeError` or in long form in other cases,
#
# @param [Command, nil] command
# The command from where the exception originated.
#
# @param [Object] exception
# The exception to present.
#
# @return [void]
#
def self.handle_exception(command, exception)
if exception.is_a?(InformativeError)
puts exception.message
if command.nil? || command.verbose?
puts
puts(*exception.backtrace)
end
exit exception.exit_status
else
report_error(exception)
end
end
# Allows the application to perform custom error reporting, by overriding
# this method.
#
# @param [Exception] exception
#
# An exception that occurred while running a command through
# {Command.run}.
#
# @raise
#
# By default re-raises the specified exception.
#
# @return [void]
#
def self.report_error(exception)
plugins = PluginManager.plugins_involved_in_exception(exception)
unless plugins.empty?
puts '[!] The exception involves the following plugins:' \
"\n - #{plugins.join("\n - ")}\n".ansi.yellow
end
raise exception
end
# @visibility private
#
# @param [String] error_message
# The error message to show to the user.
#
# @param [Class] help_class
# The class to use to raise a ‘help’ error.
#
# @raise [Help]
#
# Signals CLAide that a help banner for this command should be shown,
# with an optional error message.
#
# @return [void]
#
def self.help!(error_message = nil, help_class = Help)
raise help_class.new(banner, error_message)
end
# @visibility private
#
# Returns the banner for the command.
#
# @param [Class] banner_class
# The class to use to format help banners.
#
# @return [String] The banner for the command.
#
def self.banner(banner_class = Banner)
banner_class.new(self).formatted_banner
end
# @visibility private
#
# Print banner and exit
#
# @note Calling this method exits the current process.
#
# @return [void]
#
def self.banner!
puts banner
exit 0
end
#-------------------------------------------------------------------------#
# Set to `true` if the user specifies the `--verbose` option.
#
# @note
#
# If you want to make use of this value for your own configuration, you
# should check the value _after_ calling the `super` {Command#initialize}
# implementation.
#
# @return [Boolean]
#
# Wether or not backtraces should be included when presenting the user an
# exception that includes the {InformativeError} module.
#
attr_accessor :verbose
alias_method :verbose?, :verbose
# Set to `true` if {Command.ansi_output} returns `true` and the user
# did **not** specify the `--no-ansi` option.
#
# @note (see #verbose)
#
# @return [Boolean]
#
# Whether or not to use ANSI codes to prettify output. For instance, by
# default {InformativeError} exception messages will be colored red and
# subcommands in help banners green.
#
attr_accessor :ansi_output
alias_method :ansi_output?, :ansi_output
# Set to `true` if initialized with a `--help` flag
#
# @return [Boolean]
#
# Whether the command was initialized with argv containing --help
#
attr_accessor :help_arg
alias_method :help?, :help_arg
# Subclasses should override this method to remove the arguments/options
# they support from `argv` _before_ calling `super`.
#
# The `super` implementation sets the {#verbose} attribute based on whether
# or not the `--verbose` option is specified; and the {#ansi_output}
# attribute to `false` if {Command.ansi_output} returns `true`, but the
# user specified the `--no-ansi` option.
#
# @param [ARGV, Array] argv
#
# A list of (user-supplied) params that should be handled.
#
def initialize(argv)
argv = ARGV.coerce(argv)
@verbose = argv.flag?('verbose')
@ansi_output = argv.flag?('ansi', Command.ansi_output?)
@argv = argv
@help_arg = argv.flag?('help')
end
# Convenience method.
# Instantiate the command and run it with the provided arguments at once.
#
# @note This method validate! the command before running it, but contrary to
# CLAide::Command::run, it does not load plugins nor exit on failure.
# It is up to the caller to rescue any possible exception raised.
#
# @param [String..., Array<String>] args
# The arguments to initialize the command with
#
# @raise [Help] If validate! fails
#
def self.invoke(*args)
command = new(ARGV.new(args.flatten))
command.validate!
command.run
end
# @return [Bool] Whether the command was invoked by an abstract command by
# default.
#
attr_accessor :invoked_as_default
alias_method :invoked_as_default?, :invoked_as_default
# Raises a Help exception if the `--help` option is specified, if `argv`
# still contains remaining arguments/options by the time it reaches this
# implementation, or when called on an ‘abstract command’.
#
# Subclasses should call `super` _before_ doing their own validation. This
# way when the user specifies the `--help` flag a help banner is shown,
# instead of possible actual validation errors.
#
# @raise [Help]
#
# @return [void]
#
def validate!
banner! if help?
unless @argv.empty?
argument = @argv.remainder.first
help! ArgumentSuggester.new(argument, self.class).suggestion
end
help! if self.class.abstract_command?
end
# This method should be overridden by the command class to perform its
# work.
#
# @return [void]
#
def run
raise 'A subclass should override the `CLAide::Command#run` method to ' \
'actually perform some work.'
end
protected
# Returns the class of the invoked command
#
# @return [Command]
#
def invoked_command_class
if invoked_as_default?
self.class.superclass
else
self.class
end
end
# @param [String] error_message
# A custom optional error message
#
# @raise [Help]
#
# Signals CLAide that a help banner for this command should be shown,
# with an optional error message.
#
# @return [void]
#
def help!(error_message = nil)
invoked_command_class.help!(error_message)
end
# Print banner and exit
#
# @note Calling this method exits the current process.
#
# @return [void]
#
def banner!
invoked_command_class.banner!
end
#-------------------------------------------------------------------------#
# Handle deprecated form of self.arguments as an
# Array<Array<(String, Symbol)>> like in:
#
# self.arguments = [ ['NAME', :required], ['QUERY', :optional] ]
#
# @todo Remove deprecated format support
#
def self.arguments_array=(arguments)
warn '[!] The signature of CLAide#arguments has changed. ' \
"Use CLAide::Argument (#{self}: `#{arguments}`)".ansi.yellow
@arguments = arguments.map do |(name_str, type)|
names = name_str.split('|')
required = (type == :required)
Argument.new(names, required)
end
end
# Handle deprecated form of self.arguments as a String, like in:
#
# self.arguments = 'NAME [QUERY]'
#
# @todo Remove deprecated format support
#
def self.arguments_string=(arguments)
warn '[!] The specification of arguments as a string has been' \
" deprecated #{self}: `#{arguments}`".ansi.yellow
@arguments = arguments.split(' ').map do |argument|
if argument.start_with?('[')
Argument.new(argument.sub(/\[(.*)\]/, '\1').split('|'), false)
else
Argument.new(argument.split('|'), true)
end
end
end
# Handle depracted form of assigning a plugin prefix.
#
# @todo Remove deprecated form.
#
def self.plugin_prefix=(prefix)
warn '[!] The specification of a singular plugin prefix has been ' \
"deprecated. Use `#{self}::plugin_prefixes` instead."
plugin_prefixes << prefix
end
end
end