lib/claide.rb in claide-0.2.0 vs lib/claide.rb in claide-0.3.0

- old
+ new

@@ -2,716 +2,18 @@ # The mods of interest are {CLAide::ARGV}, {CLAide::Command}, and # {CLAide::InformativeError} # module CLAide + # @return [String] # # CLAide’s version, following [semver](http://semver.org). # - VERSION = '0.2.0' + VERSION = '0.3.0' - # This class is responsible for parsing the parameters specified by the user, - # accessing individual parameters, and keep state by removing handled - # parameters. - # - class ARGV - - # @param [Array<String>] argv - # - # A list of parameters. Each entry is ensured to be a string by calling - # `#to_s` on it. - # - def initialize(argv) - @entries = self.class.parse(argv) - end - - # @return [Boolean] - # - # Returns wether or not there are any remaining unhandled parameters. - # - def empty? - @entries.empty? - end - - # @return [Array<String>] - # - # A list of the remaining unhandled parameters, in the same format a user - # specifies it in. - # - # @example - # - # argv = CLAide::ARGV.new(['tea', '--no-milk', '--sweetner=honey']) - # argv.shift_argument # => 'tea' - # argv.remainder # => ['--no-milk', '--sweetner=honey'] - # - def remainder - @entries.map do |type, (key, value)| - case type - when :arg - key - when :flag - "--#{'no-' if value == false}#{key}" - when :option - "--#{key}=#{value}" - end - end - end - - # @return [Hash] - # - # A hash that consists of the remaining flags and options and their - # values. - # - # @example - # - # argv = CLAide::ARGV.new(['tea', '--no-milk', '--sweetner=honey']) - # argv.options # => { 'milk' => false, 'sweetner' => 'honey' } - # - def options - options = {} - @entries.each do |type, (key, value)| - options[key] = value unless type == :arg - end - options - end - - # @return [Array<String>] - # - # A list of the remaining arguments. - # - # @example - # - # argv = CLAide::ARGV.new(['tea', 'white', '--no-milk', 'biscuit']) - # argv.shift_argument # => 'tea' - # argv.arguments # => ['white', 'biscuit'] - # - def arguments - @entries.map { |type, value| value if type == :arg }.compact - end - - # @return [Array<String>] - # - # A list of the remaining arguments. - # - # @note - # - # This version also removes the arguments from the remaining parameters. - # - # @example - # - # argv = CLAide::ARGV.new(['tea', 'white', '--no-milk', 'biscuit']) - # argv.arguments # => ['tea', 'white', 'biscuit'] - # argv.arguments! # => ['tea', 'white', 'biscuit'] - # argv.arguments # => [] - # - def arguments! - arguments = [] - while arg = shift_argument - arguments << arg - end - arguments - end - - # @return [String] - # - # The first argument in the remaining parameters. - # - # @note - # - # This will remove the argument from the remaining parameters. - # - # @example - # - # argv = CLAide::ARGV.new(['tea', 'white']) - # argv.shift_argument # => 'tea' - # argv.arguments # => ['white'] - # - def shift_argument - if index = @entries.find_index { |type, _| type == :arg } - entry = @entries[index] - @entries.delete_at(index) - entry.last - end - end - - # @return [Boolean, nil] - # - # Returns `true` if the flag by the specified `name` is among the - # remaining parameters and is not negated. - # - # @param [String] name - # - # The name of the flag to look for among the remaining parameters. - # - # @param [Boolean] default - # - # The value that is returned in case the flag is not among the remaining - # parameters. - # - # @note - # - # This will remove the flag from the remaining parameters. - # - # @example - # - # argv = CLAide::ARGV.new(['tea', '--no-milk', '--sweetner=honey']) - # argv.flag?('milk') # => false - # argv.flag?('milk') # => nil - # argv.flag?('milk', true) # => true - # argv.remainder # => ['tea', '--sweetner=honey'] - # - def flag?(name, default = nil) - delete_entry(:flag, name, default) - end - - # @return [String, nil] - # - # Returns the value of the option by the specified `name` is among the - # remaining parameters. - # - # @param [String] name - # - # The name of the option to look for among the remaining parameters. - # - # @param [String] default - # - # The value that is returned in case the option is not among the - # remaining parameters. - # - # @note - # - # This will remove the option from the remaining parameters. - # - # @example - # - # argv = CLAide::ARGV.new(['tea', '--no-milk', '--sweetner=honey']) - # argv.option('sweetner') # => 'honey' - # argv.option('sweetner') # => nil - # argv.option('sweetner', 'sugar') # => 'sugar' - # argv.remainder # => ['tea', '--no-milk'] - # - def option(name, default = nil) - delete_entry(:option, name, default) - end - - private - - def delete_entry(requested_type, requested_key, default) - result = nil - @entries.delete_if do |type, (key, value)| - if requested_key == key && requested_type == type - result = value - true - end - end - result.nil? ? default : result - end - - # @return [Array<Array>] - # - # A list of tuples for each parameter, where the first entry is the - # `type` and the second entry the actual parsed parameter. - # - # @example - # - # list = parse(['tea', '--no-milk', '--sweetner=honey']) - # list # => [[:arg, "tea"], - # [:flag, ["milk", false]], - # [:option, ["sweetner", "honey"]]] - # - def self.parse(argv) - entries = [] - copy = argv.map(&:to_s) - while x = copy.shift - type = key = value = nil - if is_arg?(x) - # A regular argument (e.g. a command) - type, value = :arg, x - else - key = x[2..-1] - if key.include?('=') - # An option with a value - type = :option - key, value = key.split('=', 2) - else - # A boolean flag - type = :flag - value = true - if key[0,3] == 'no-' - # A negated boolean flag - key = key[3..-1] - value = false - end - end - value = [key, value] - end - entries << [type, value] - end - entries - end - - def self.is_arg?(x) - x[0,2] != '--' - end - end - - # Including this module into an exception class will ensure that when raised, - # while running {Command.run}, only the message of the exception will be - # shown to the user. Unless disabled with the `--verbose` flag. - # - # In addition, the message will be colored red, if {Command.colorize_output} - # is set to `true`. - # - module InformativeError - attr_writer :exit_status - - # @return [Numeric] - # - # The exist status code that should be used to terminate the program with. - # - # Defaults to `1`. - # - def exit_status - @exit_status ||= 1 - end - end - - # The exception class that is raised to indicate a help banner should be - # shown while running {Command.run}. - # - class Help < StandardError - include InformativeError - - # @return [Command] - # - # The command instance for which a help banner should be shown. - # - attr_reader :command - - # @return [String] - # - # The optional error message that will be shown before the help banner. - # - attr_reader :error_message - - # @param [Command] command - # - # An instance of a command class for which a help banner should be shown. - # - # @param [String] error_message - # - # An optional error message that will be shown before the help banner. - # If specified, the exit status, used to terminate the program with, will - # be set to `1`, otherwise a {Help} exception is treated as not being a - # real error and exits with `0`. - # - def initialize(command, error_message = nil) - @command, @error_message = command, error_message - @exit_status = @error_message.nil? ? 0 : 1 - end - - # @return [String] - # - # The optional error message, colored in red if {Command.colorize_output} - # is set to `true`. - # - def formatted_error_message - if @error_message - message = "[!] #{@error_message}" - @command.colorize_output? ? message.red : message - end - end - - # @return [String] - # - # The optional error message, combined with the help banner of the - # command. - # - def message - [formatted_error_message, @command.formatted_banner].compact.join("\n\n") - end - end - - # 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 wether 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 [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 [String] - # - # A list of arguments the command handles. This is shown in the usage - # section of the command’s help banner. - # - attr_accessor :arguments - - # @return [Boolean] - # - # The default value for {Command#colorize_output}. This defaults to - # `true` if `String` has the instance methods `#green` and `#red`. - # Which are defined by, for instance, the - # [colored](https://github.com/defunkt/colored) gem. - # - def colorize_output - if @colorize_output.nil? - @colorize_output = String.method_defined?(:red) && - String.method_defined?(:green) - end - @colorize_output - end - attr_writer :colorize_output - alias_method :colorize_output?, :colorize_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 full command up-to this command. - # - # @example - # - # BevarageMaker::Tea.full_command # => "beverage-maker tea" - # - def full_command - if superclass == Command - "#{command}" - else - "#{superclass.full_command} #{command}" - end - end - - # @return [Array<Command>] - # - # A list of command classes that are nested under this command. - # - def subcommands - @subcommands ||= [] - end - - # @visibility private - # - # Automatically registers a subclass as a subcommand. - # - def inherited(subcommand) - subcommands << subcommand - end - - # Should be overriden 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 - # concatening 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 options - options = [ - ['--verbose', 'Show more debugging information'], - ['--help', 'Show help banner of specified command'], - ] - if Command.colorize_output? - options.unshift(['--no-color', 'Show output without color']) - end - options - 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 parse(argv) - argv = ARGV.new(argv) unless argv.is_a?(ARGV) - cmd = argv.arguments.first - if cmd && subcommand = subcommands.find { |sc| sc.command == cmd } - argv.shift_argument - subcommand.parse(argv) - else - new(argv) - end - end - - # Instantiates the command class matching the parameters through - # {Command.parse}, validates it through {Command#validate!}, and runs it - # through {Command#run}. - # - # @note - # - # You should normally call this on - # - # @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 run(argv) - command = parse(argv) - command.validate! - command.run - rescue Exception => exception - if exception.is_a?(InformativeError) - puts exception.message - if 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 report_error(exception) - raise exception - end - 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.colorize_output} returns `true` and the user - # did **not** specify the `--no-color` option. - # - # @note (see #verbose) - # - # @return [Boolean] - # - # Wether or not to color {InformativeError} exception messages red and - # subcommands in help banners green. - # - attr_accessor :colorize_output - alias_method :colorize_output?, :colorize_output - - # 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 wether - # or not the `--verbose` option is specified; and the {#colorize_output} - # attribute to `false` if {Command.colorize_output} returns `true`, but the - # user specified the `--no-color` option. - # - # @param [ARGV] argv - # - # A list of (user-supplied) params that should be handled. - # - def initialize(argv) - @verbose = argv.flag?('verbose') - @colorize_output = argv.flag?('color', Command.colorize_output?) - @argv = argv - end - - # 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! - help! if @argv.flag?('help') - help! "Unknown arguments: #{@argv.remainder.join(' ')}" if !@argv.empty? - help! if self.class.abstract_command? - end - - # This method should be overriden by the command class to perform its work. - # - # @return [void - # - def run - raise "A subclass should override the Command#run method to actually " \ - "perform some work." - end - - # @visibility private - def formatted_options_description - opts = self.class.options - size = opts.map { |opt| opt.first.size }.max - opts.map { |key, desc| " #{key.ljust(size)} #{desc}" }.join("\n") - end - - # @visibility private - def formatted_usage_description - if message = self.class.description || self.class.summary - message = strip_heredoc(message) - message = message.split("\n").map { |line| " #{line}" }.join("\n") - args = " #{self.class.arguments}" if self.class.arguments - " $ #{self.class.full_command}#{args}\n\n#{message}" - end - end - - # @visibility private - def formatted_subcommand_summaries - subcommands = self.class.subcommands.reject do |subcommand| - subcommand.summary.nil? - end.sort_by(&:command) - unless subcommands.empty? - command_size = subcommands.map { |cmd| cmd.command.size }.max - subcommands.map do |subcommand| - command = subcommand.command.ljust(command_size) - command = command.green if colorize_output? - " * #{command} #{subcommand.summary}" - end.join("\n") - end - end - - # @visibility private - def formatted_banner - banner = [] - if self.class.abstract_command? - banner << self.class.description if self.class.description - elsif usage = formatted_usage_description - banner << 'Usage:' - banner << usage - end - if commands = formatted_subcommand_summaries - banner << 'Commands:' - banner << commands - end - banner << 'Options:' - banner << formatted_options_description - banner.join("\n\n") - end - - protected - - # @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) - raise Help.new(self, error_message) - end - - private - - # Lifted straight from ActiveSupport. Thanks guys! - def strip_heredoc(string) - if min = string.scan(/^[ \t]*(?=\S)/).min - string.gsub(/^[ \t]{#{min.size}}/, '') - else - string - end - end - end + require 'claide/argv.rb' + require 'claide/command.rb' + require 'claide/help.rb' + require 'claide/informative_error.rb' end