# #-- # cmdparse: advanced command line parser supporting commands # Copyright (C) 2004-2020 Thomas Leitner # # This file is part of cmdparse which is licensed under the MIT. #++ # require 'optparse' OptionParser::Officious.delete('version') OptionParser::Officious.delete('help') # Extension for OptionParser objects to allow access to some internals. class OptionParser #:nodoc: # Access the option list stack. attr_reader :stack # Returns +true+ if at least one local option is defined. # # The zeroth stack element is not respected when doing the query because it contains either the # OptionParser::DefaultList or a CmdParse::MultiList with the global options of the # CmdParse::CommandParser. def options_defined? stack[1..-1].each do |list| list.each_option do |switch| return true if switch.kind_of?(OptionParser::Switch) && (switch.short || switch.long) end end false end # Returns +true+ if a banner has been set. def banner? !@banner.nil? end end # Namespace module for cmdparse. # # See CmdParse::CommandParser and CmdParse::Command for the two important classes. module CmdParse # The version of this cmdparse implemention VERSION = '3.0.5'.freeze # Base class for all cmdparse errors. class ParseError < StandardError # Sets the error reason for the subclass. def self.reason(reason) @reason = reason end # Returns the error reason or 'CmdParse error' if it has not been set. def self.get_reason @reason ||= 'CmdParse error' end # Returns the reason plus the original message. def message str = super self.class.get_reason + (str.empty? ? "" : ": #{str}") end end # This error is thrown when an invalid command is encountered. class InvalidCommandError < ParseError reason 'Invalid command' end # This error is thrown when an invalid argument is encountered. class InvalidArgumentError < ParseError reason 'Invalid argument' end # This error is thrown when an invalid option is encountered. class InvalidOptionError < ParseError reason 'Invalid option' end # This error is thrown when no command was given and no default command was specified. class NoCommandGivenError < ParseError reason 'No command given' def initialize #:nodoc: super('') end end # This error is thrown when a command is added to another command which does not support commands. class TakesNoCommandError < ParseError reason 'This command takes no other commands' end # This error is thrown when not enough arguments are provided for the command. class NotEnoughArgumentsError < ParseError reason 'Not enough arguments provided, minimum is' end # This error is thrown when too many arguments are provided for the command. class TooManyArgumentsError < ParseError reason 'Too many arguments provided, maximum is' end # Command Hash - will return partial key matches as well if there is a single non-ambigous # matching key class CommandHash < Hash #:nodoc: def key?(name) #:nodoc: !self[name].nil? end def [](cmd_name) #:nodoc: super || begin possible = keys.select {|key| key[0, cmd_name.length] == cmd_name } fetch(possible[0]) if possible.size == 1 end end end # Container for multiple OptionParser::List objects. # # This is needed for providing what's equivalent to stacked OptionParser instances and the global # options implementation. class MultiList #:nodoc: def initialize(list) #:nodoc: @list = list end def summarize(*args, &block) #:nodoc: # We don't want summary information of the global options to automatically appear. end [:accept, :reject, :prepend, :append].each do |mname| module_eval <<-EOF def #{mname}(*args, &block) @list[-1].#{mname}(*args, &block) end EOF end [:search, :complete, :each_option, :add_banner, :compsys].each do |mname| module_eval <<-EOF def #{mname}(*args, &block) #:nodoc: @list.reverse_each {|list| list.#{mname}(*args, &block)} end EOF end end # === Base class for commands # # This class implements all needed methods so that it can be used by the CommandParser class. # # Commands can either be created by sub-classing or on the fly when using the #add_command method. # The latter allows for a more terse specification of a command while the sub-class approach # allows to customize all aspects of a command by overriding methods. # # Basic example for sub-classing: # # class TestCommand < CmdParse::Command # def initialize # super('test', takes_commands: false) # options.on('-m', '--my-opt', 'My option') { 'Do something' } # end # end # # parser = CmdParse::CommandParser.new # parser.add_command(TestCommand.new) # parser.parse # # Basic example for on the fly creation: # # parser = CmdParse::CommandParser.new # parser.add_command('test') do |cmd| # takes_commands(false) # options.on('-m', '--my-opt', 'My option') { 'Do something' } # end # parser.parse # # === Basic Properties # # The only thing that is mandatory to set for a Command is its #name. If the command does not take # any sub-commands, then additionally an #action block needs to be specified or the #execute # method overridden. # # However, there are several other methods that can be used to configure the behavior of a # command: # # #takes_commands:: For specifying whether sub-commands are allowed. # #options:: For specifying command specific options. # #add_command:: For specifying sub-commands if the command takes them. # # === Help Related Methods # # Many of this class' methods are related to providing useful help output. While the most common # methods can directly be invoked to set or retrieve information, many other methods compute the # needed information dynamically and therefore need to be overridden to customize their return # value. # # #short_desc:: # For a short description of the command (getter/setter). # #long_desc:: # For a detailed description of the command (getter/setter). # #argument_desc:: # For describing command arguments (setter). # #help, #help_banner, #help_short_desc, #help_long_desc, #help_commands, #help_arguments, #help_options:: # For outputting the general command help or individual sections of the command help (getter). # #usage, #usage_options, #usage_arguments, #usage_commands:: # For outputting the usage line or individual parts of it (getter). # # === Built-in Commands # # cmdparse ships with two built-in commands: # * HelpCommand (for showing help messages) and # * VersionCommand (for showing version information). class Command # The name of the command. attr_reader :name # Returns the name of the default sub-command or +nil+ if there isn't any. attr_reader :default_command # Sets or returns the super-command of this command. The super-command is either a Command # instance for normal commands or a CommandParser instance for the main command (ie. # CommandParser#main_command). attr_accessor :super_command # Returns the mapping of command name to command for all sub-commands of this command. attr_reader :commands # A data store (initially an empty Hash) that can be used for storing anything. For example, it # can be used to store option values. cmdparse itself doesn't do anything with it. attr_accessor :data # Initializes the command called +name+. # # Options: # # takes_commands:: Specifies whether this command can take sub-commands. def initialize(name, takes_commands: true) @name = name.freeze @options = OptionParser.new @commands = CommandHash.new @default_command = nil @action = nil @argument_desc ||= {} @data = {} takes_commands(takes_commands) end # Sets whether this command can take sub-command. # # The argument +val+ needs to be +true+ or +false+. def takes_commands(val) if !val && !commands.empty? raise Error, "Can't change takes_commands to false because there are already sub-commands" else @takes_commands = val end end alias takes_commands= takes_commands # Return +true+ if this command can take sub-commands. def takes_commands? @takes_commands end # :call-seq: # command.options {|opts| ...} -> opts # command.options -> opts # # Yields the OptionParser instance that is used for parsing the options of this command (if a # block is given) and returns it. def options #:yields: options yield(@options) if block_given? @options end # :call-seq: # command.add_command(other_command, default: false) {|cmd| ... } -> command # command.add_command('other', default: false) {|cmd| ...} -> command # # Adds a command to the command list. # # The argument +command+ can either be a Command object or a String in which case a new Command # object is created. In both cases the Command object is yielded. # # If the optional argument +default+ is +true+, then the command is used when no other # sub-command is specified on the command line. # # If this command takes no other commands, an error is raised. def add_command(command, default: false) # :yields: command_object raise TakesNoCommandError.new(name) unless takes_commands? command = Command.new(command) if command.kind_of?(String) command.super_command = self @commands[command.name] = command @default_command = command.name if default command.fire_hook_after_add yield(command) if block_given? self end # :call-seq: # command.command_chain -> [top_level_command, super_command, ..., command] # # Returns the command chain, i.e. a list containing this command and all of its super-commands, # starting at the top level command. def command_chain cmds = [] cmd = self while !cmd.nil? && !cmd.super_command.kind_of?(CommandParser) cmds.unshift(cmd) cmd = cmd.super_command end cmds end # Returns the associated CommandParser instance for this command or +nil+ if no command parser # is associated. def command_parser cmd = super_command cmd = cmd.super_command while !cmd.nil? && !cmd.kind_of?(CommandParser) cmd end # Sets the given +block+ as the action block that is used on when executing this command. # # If a sub-class is created for specifying a command, then the #execute method should be # overridden instead of setting an action block. # # See also: #execute def action(&block) @action = block end # Invokes the action block with the parsed arguments. # # This method is called by the CommandParser instance if this command was specified on the # command line to be executed. # # Sub-classes can either specify an action block or directly override this method (the latter is # preferred). def execute(*args) @action.call(*args) end # Sets the short description of the command if an argument is given. Always returns the short # description. # # The short description is ideally shorter than 60 characters. def short_desc(*val) @short_desc = val[0] unless val.empty? @short_desc end alias short_desc= short_desc # Sets the detailed description of the command if an argument is given. Always returns the # detailed description. # # This may be a single string or an array of strings for multiline description. Each string # is ideally shorter than 76 characters. def long_desc(*val) @long_desc = val.flatten unless val.empty? @long_desc end alias long_desc= long_desc # :call-seq: # cmd.argument_desc(name => desc, ...) # # Sets the descriptions for one or more arguments using name-description pairs. # # The used names should correspond to the names used in #usage_arguments. def argument_desc(hash) @argument_desc.update(hash) end # Returns the number of arguments required for the execution of the command, i.e. the number of # arguments the #action block or the #execute method takes. # # If the returned number is negative, it means that the minimum number of arguments is -n-1. # # See: Method#arity, Proc#arity def arity (@action || method(:execute)).arity end # Returns +true+ if the command can take one or more arguments. def takes_arguments? arity.abs > 0 end # Returns a string containing the help message for the command. def help output = '' output << help_banner output << help_short_desc output << help_long_desc output << help_commands output << help_arguments output << help_options('Options (take precedence over global options)', options) output << help_options('Global Options', command_parser.global_options) end # Returns the banner (including the usage line) of the command. # # The usage line is command specific but the rest is the same for all commands and can be set # via +command_parser.main_options.banner+. def help_banner output = '' if command_parser.main_options.banner? output << format(command_parser.main_options.banner, indent: 0) << "\n\n" end output << format(usage, indent: 7) << "\n\n" end # Returns the usage line for the command. # # The usage line is automatically generated from the available information. If this is not # suitable, override this method to provide a command specific usage line. # # Typical usage lines looks like the following: # # Usage: program [options] command [options] {sub_command1 | sub_command2} # Usage: program [options] command [options] ARG1 [ARG2] [REST...] # # See: #usage_options, #usage_arguments, #usage_commands def usage tmp = "Usage: #{command_parser.main_options.program_name}" tmp << command_parser.main_command.usage_options tmp << command_chain.map {|cmd| " #{cmd.name}#{cmd.usage_options}"}.join('') if takes_commands? tmp << " #{usage_commands}" elsif takes_arguments? tmp << " #{usage_arguments}" end tmp end # Returns a string describing the options of the command for use in the usage line. # # If there are any options, the resulting string also includes a leading space! # # A typical return value would look like the following: # # [options] # # See: #usage def usage_options (options.options_defined? ? ' [options]' : '') end # Returns a string describing the arguments for the command for use in the usage line. # # By default the names of the action block or #execute method arguments are used (done via # Ruby's reflection API). If this is not wanted, override this method. # # A typical return value would look like the following: # # ARG1 [ARG2] [REST...] # # See: #usage, #argument_desc def usage_arguments (@action || method(:execute)).parameters.map do |type, name| case type when :req then name.to_s when :opt then "[#{name}]" when :rest then "[#{name}...]" end end.join(" ").upcase end # Returns a string describing the sub-commands of the commands for use in the usage line. # # Override this method for providing a command specific specialization. # # A typical return value would look like the following: # # {command | other_command | another_command } def usage_commands (commands.empty? ? '' : "{#{commands.keys.sort.join(" | ")}}") end # Returns the formatted short description. # # For the output format see #cond_format_help_section def help_short_desc cond_format_help_section("Summary", "#{name} - #{short_desc}", condition: short_desc && !short_desc.empty?) end # Returns the formatted detailed description. # # For the output format see #cond_format_help_section def help_long_desc cond_format_help_section("Description", [long_desc].flatten, condition: long_desc && !long_desc.empty?) end # Returns the formatted sub-commands of this command. # # For the output format see #cond_format_help_section def help_commands describe_commands = lambda do |command, level = 0| command.commands.sort.collect do |name, cmd| str = " " * level << name << (name == command.default_command ? " (*)" : '') str = str.ljust(command_parser.help_desc_indent) << cmd.short_desc.to_s str = format(str, width: command_parser.help_line_width - command_parser.help_indent, indent: command_parser.help_desc_indent) str << "\n" << (cmd.takes_commands? ? describe_commands.call(cmd, level + 1) : "") end.join('') end cond_format_help_section("Available commands", describe_commands.call(self), condition: takes_commands?, preformatted: true) end # Returns the formatted arguments of this command. # # For the output format see #cond_format_help_section def help_arguments desc = @argument_desc.map {|k, v| k.to_s.ljust(command_parser.help_desc_indent) << v.to_s} cond_format_help_section('Arguments', desc, condition: !@argument_desc.empty?) end # Returns the formatted option descriptions for the given OptionParser instance. # # The section title needs to be specified with the +title+ argument. # # For the output format see #cond_format_help_section def help_options(title, options) summary = '' summary_width = command_parser.main_options.summary_width options.summarize([], summary_width, summary_width - 1, '') do |line| summary << format(line, width: command_parser.help_line_width - command_parser.help_indent, indent: summary_width + 1, indent_first_line: false) << "\n" end cond_format_help_section(title, summary, condition: !summary.empty?, preformatted: true) end # This hook method is called when the command (or one of its super-commands) is added to another # Command instance that has an associated command parser (#see command_parser). # # It can be used, for example, to add global options. def on_after_add end # For sorting commands by name. def <=>(other) name <=> other.name end protected # Conditionally formats a help section. # # Returns either the formatted help section if the condition is +true+ or an empty string # otherwise. # # The help section starts with a title and the given lines are indented to easily distinguish # different sections. # # A typical help section would look like the following: # # Summary: # help - Provide help for individual commands # # Options: # # condition:: The formatted help section is only returned if the condition is +true+. # # indent:: Whether the lines should be indented with CommandParser#help_indent spaces. # # preformatted:: Assume that the given lines are already correctly formatted and don't try to # reformat them. def cond_format_help_section(title, *lines, condition: true, indent: true, preformatted: false) if condition out = "#{title}:\n" lines = lines.flatten.join("\n").split(/\n/) if preformatted lines.map! {|l| ' ' * command_parser.help_indent << l} if indent out << lines.join("\n") else out << format(lines.join("\n"), indent: (indent ? command_parser.help_indent : 0), indent_first_line: true) end out << "\n\n" else '' end end # Returns the text in +content+ formatted so that no line is longer than +width+ characters. # # Options: # # width:: The maximum width of a line. If not specified, the CommandParser#help_line_width value # is used. # # indent:: This option specifies the amount of spaces prepended to each line. If not specified, # the CommandParser#help_indent value is used. # # indent_first_line:: If this option is +true+, then the first line is also indented. def format(content, width: command_parser.help_line_width, indent: command_parser.help_indent, indent_first_line: false) content = (content || '').dup line_length = width - indent first_line_pattern = other_lines_pattern = /\A.{1,#{line_length}}\z|\A.{1,#{line_length}}[ \n]/m (first_line_pattern = /\A.{1,#{width}}\z|\A.{1,#{width}}[ \n]/m) unless indent_first_line pattern = first_line_pattern content.split(/\n\n/).map do |paragraph| lines = [] until paragraph.empty? unless (str = paragraph.slice!(pattern)) and (str = str.sub(/[ \n]\z/, '')) str = paragraph.slice!(0, line_length) end lines << (lines.empty? && !indent_first_line ? '' : ' ' * indent) + str.tr("\n", ' ') pattern = other_lines_pattern end lines.join("\n") end.join("\n\n") end def fire_hook_after_add #:nodoc: return unless command_parser @options.stack[0] = MultiList.new(command_parser.global_options.stack) on_after_add @commands.each_value {|cmd| cmd.fire_hook_after_add} end end # The default help Command. # # It adds the options "-h" and "--help" to the CommandParser#global_options. # # When the command is specified on the command line (or one of the above mentioned options), it # shows the main help or individual command help. class HelpCommand < Command def initialize #:nodoc: super('help', takes_commands: false) short_desc('Provide help for individual commands') long_desc('This command prints the program help if no arguments are given. If one or ' \ 'more command names are given as arguments, these arguments are interpreted ' \ 'as a hierachy of commands and the help for the right most command is show.') argument_desc(COMMAND: 'The name of a command or sub-command') end def on_after_add #:nodoc: command_parser.global_options.on_tail("-h", "--help", "Show help") do execute(*command_parser.current_command.command_chain.map(&:name)) exit end end def usage_arguments #:nodoc: "[COMMAND COMMAND...]" end def execute(*args) #:nodoc: if !args.empty? cmd = command_parser.main_command arg = args.shift while !arg.nil? && cmd.commands.key?(arg) cmd = cmd.commands[arg] arg = args.shift end if arg.nil? puts cmd.help else raise InvalidArgumentError, args.unshift(arg).join(' ') end else puts command_parser.main_command.help end end end # The default version command. # # It adds the options "-v" and "--version" to the CommandParser#main_options but this can be # changed in ::new. # # When the command is specified on the command line (or one of the above mentioned options), it # shows the version of the program configured by the settings # # * command_parser.main_options.program_name # * command_parser.main_options.version class VersionCommand < Command # Create a new version command. # # Options: # # add_switches:: Specifies whether the '-v' and '--version' switches should be added to the # CommandParser#main_options def initialize(add_switches: true) super('version', takes_commands: false) short_desc("Show the version of the program") @add_switches = add_switches end def on_after_add #:nodoc: command_parser.main_options.on_tail("--version", "-v", "Show the version of the program") do execute end if @add_switches end def execute #:nodoc: version = command_parser.main_options.version version = version.join('.') if version.kind_of?(Array) puts command_parser.main_options.banner + "\n" if command_parser.main_options.banner? puts "#{command_parser.main_options.program_name} #{version}" exit end end # === Main Class for Creating a Command Based CLI Program # # This class can directly be used (or sub-classed, if need be) to create a command based CLI # program. # # The CLI program itself is represented by the #main_command, a Command instance (as are all # commands and sub-commands). This main command can either hold sub-commands (the normal use case) # which represent the programs top level commands or take no commands in which case it acts # similar to a simple OptionParser based program (albeit with better help functionality). # # Parsing the command line for commands is done by this class, option parsing is delegated to the # battle tested OptionParser of the Ruby standard library. # # === Usage # # After initialization some optional information is expected to be set on the Command#options of # the #main_command: # # banner:: A banner that appears in the help output before anything else. # program_name:: The name of the program. If not set, this value is computed from $0. # version:: The version string of the program. # # In addition to the main command's options instance (which represents the top level options that # need to be specified before any command name), there is also a #global_options instance which # represents options that can be specified anywhere on the command line. # # Top level commands can be added to the main command by using the #add_command method. # # Once everything is set up, the #parse method is used for parsing the command line. class CommandParser # The top level command representing the program itself. attr_reader :main_command # The command that is being executed. Only available during parsing of the command line # arguments. attr_reader :current_command # A data store (initially an empty Hash) that can be used for storing anything. For example, it # can be used to store global option values. cmdparse itself doesn't do anything with it. attr_accessor :data # Should exceptions be handled gracefully? I.e. by printing error message and the help screen? # # See ::new for possible values. attr_reader :handle_exceptions # The maximum width of the help lines. attr_accessor :help_line_width # The amount of spaces to indent the content of help sections. attr_accessor :help_indent # The indentation used for, among other things, command descriptions. attr_accessor :help_desc_indent # Creates a new CommandParser object. # # Options: # # handle_exceptions:: Set to +true+ if exceptions should be handled gracefully by showing the # error and a help message, or to +false+ if exception should not be handled # at all. If this options is set to :no_help, the exception is handled but # no help message is shown. # # takes_commands:: Specifies whether the main program takes any commands. def initialize(handle_exceptions: false, takes_commands: true) @global_options = OptionParser.new @main_command = Command.new('main', takes_commands: takes_commands) @main_command.super_command = self @main_command.options.stack[0] = MultiList.new(@global_options.stack) @handle_exceptions = handle_exceptions @help_line_width = 80 @help_indent = 4 @help_desc_indent = 18 @data = {} end # :call-seq: # cmdparse.main_options -> OptionParser instance # cmdparse.main_options {|opts| ...} -> opts (OptionParser instance) # # Yields the main options (that are only available directly after the program name) if a block # is given and returns them. # # The main options are also used for setting the program name, version and banner. def main_options yield(@main_command.options) if block_given? @main_command.options end # :call-seq: # cmdparse.global_options -> OptionParser instance # cmdparse.gloabl_options {|opts| ...} -> opts (OptionParser instance) # # Yields the global options if a block is given and returns them. # # The global options are those options that can be used on the top level and with any # command. def global_options yield(@global_options) if block_given? @global_options end # Adds a top level command. # # See Command#add_command for detailed invocation information. def add_command(*args, **kws, &block) @main_command.add_command(*args, **kws, &block) end # Parses the command line arguments. # # If a block is given, the current hierarchy level and the name of the current command is # yielded after the option parsing is done but before a command is executed. def parse(argv = ARGV) # :yields: level, command_name level = 0 @current_command = @main_command while true argv = if @current_command.takes_commands? || ENV.include?('POSIXLY_CORRECT') @current_command.options.order(argv) else @current_command.options.permute(argv) end yield(level, @current_command.name) if block_given? if @current_command.takes_commands? cmd_name = argv.shift || @current_command.default_command if cmd_name.nil? raise NoCommandGivenError.new elsif !@current_command.commands.key?(cmd_name) raise InvalidCommandError.new(cmd_name) end @current_command = @current_command.commands[cmd_name] level += 1 else original_n = @current_command.arity n = (original_n < 0 ? -original_n - 1 : original_n) if argv.size < n raise NotEnoughArgumentsError.new("#{n} - #{@current_command.usage_arguments}") elsif argv.size > n && original_n > 0 raise TooManyArgumentsError.new("#{n} - #{@current_command.usage_arguments}") end argv.slice!(n..-1) unless original_n < 0 @current_command.execute(*argv) break end end rescue ParseError, OptionParser::ParseError => e raise unless @handle_exceptions puts "Error while parsing command line:\n " + e.message if @handle_exceptions != :no_help && @main_command.commands.key?('help') puts @main_command.commands['help'].execute(*@current_command.command_chain.map(&:name)) end exit(64) # FreeBSD standard exit error for "command was used incorrectly" ensure @current_command = nil end end end