require 'optparse' module Commander class Runner #-- # Exceptions #++ class CommandError < StandardError; end class InvalidCommandError < CommandError; end ## # Array of commands. attr_reader :commands ## # Global options. attr_reader :options ## # Initialize a new command runner. Optionally # supplying +args+ for mocking, or arbitrary usage. def initialize args = ARGV @args, @commands, @aliases, @options = args, {}, {}, [] @program = program_defaults create_default_commands end ## # Run command parsing and execution process. def run! trace = false require_program :name, :version, :description global_option('--help', 'Display help documentation') { command(:help).run *@args[1..-1]; return } global_option('--version', 'Display version information') { say version; return } global_option('--trace', 'Display backtrace when an error occurs') { trace = true } parse_global_options remove_global_options unless trace begin call_active_command rescue InvalidCommandError say 'invalid command. Use --help for more information' rescue \ OptionParser::InvalidOption, OptionParser::InvalidArgument, OptionParser::MissingArgument => e say e rescue Exception => e say "error: #{e}. Use --trace to view backtrace" end else call_active_command end end ## # Return program version. def version '%s %s' % [program(:name), program(:version)] end ## # Invoke the active command. def call_active_command if alias? command_name_from_args active_command.run *(@aliases[command_name_from_args.to_s] + args_without_command_name) else active_command.run *args_without_command_name end end ## # Assign program information. # # === Examples: # # # Set data # program :name, 'Commander' # program :version, Commander::VERSION # program :description, 'Commander utility program.' # program :help, 'Copyright', '2008 TJ Holowaychuk' # program :help, 'Anything', 'You want' # program :int_message 'Bye bye!' # # # Get data # program :name # => 'Commander' # # === Keys: # # :name (required) Program name # :version (required) Program version triple, ex: '0.0.1' # :description (required) Program description # :help_formatter Defaults to Commander::HelpFormatter::Terminal # :help Allows addition of arbitrary global help blocks # :int_message Message to display when interrupted (CTRL + C) # def program key, *args if key == :help and !args.empty? @program[:help] ||= {} @program[:help][args.first] = args[1] else @program[key] = *args unless args.empty? @program[key] end end ## # Creates and yields a command instance when a block is passed. # Otherise attempts to return the command, raising InvalidCommandError when # it does not exist. # # === Examples: # # command :my_command do |c| # c.when_called do |args| # # Code # end # end # def command name, &block yield add_command(Commander::Command.new(name)) if block @commands[name.to_s] or raise InvalidCommandError, "invalid command '#{ name || 'nil' }'", caller end ## # Add a global option; follows the same syntax as # Command#option. This would be used for switches such # as --version, --trace, etc. def global_option *args, &block @options << [args, block] end ## # Alias command +name+ with +alias_name+. Optionallry +args+ may be passed # as if they were being passed straight to the original command via the command-line. def alias_command alias_name, name, *args @commands[alias_name.to_s] = command name @aliases[alias_name.to_s] = args end ## # Default command +name+ to be used when no other # command is found in the arguments. def default_command name @default_command = name end ## # Add a command object to this runner. def add_command command @commands[command.name] = command end ## # Check if command +name+ is an alias. def alias? name @aliases.include? name.to_s end ## # Check if a command +name+ exists. def command_exists? name @commands[name.to_s] end ## # Get active command within arguments passed to this runner. def active_command @__active_command ||= command(command_name_from_args) end ## # Attemps to locate a command name from within the arguments. # Supports multi-word commands, using the largest possible match. def command_name_from_args @__command_name_from_args ||= (valid_command_names_from(*@args.dup).sort.last || @default_command) end ## # Returns array of valid command names found within +args+. def valid_command_names_from *args arg_string = args.delete_switches.join ' ' commands.keys.find_all { |name| name if /^#{name}/.match arg_string } end ## # Help formatter instance. def help_formatter @__help_formatter ||= program(:help_formatter).new self end ## # Return arguments without the command name. def args_without_command_name removed = [] parts = command_name_from_args.split rescue [] @args.dup.delete_if do |arg| removed << arg if parts.include?(arg) and not removed.include?(arg) end end private ## # Returns hash of program defaults. def program_defaults return :help_formatter => HelpFormatter::Terminal, :int_message => "\nProcess interrupted" end ## # Creates default commands such as 'help' which is # essentially the same as using the --help switch. def create_default_commands command :help do |c| c.syntax = 'command help ' c.summary = 'Display help documentation for ' c.description = 'Display help documentation for the global or sub commands' c.example 'Display global help', 'command help' c.example "Display help for 'foo'", 'command help foo' c.when_called do |args, options| if args.empty? say help_formatter.render else say help_formatter.render_command(command(args.join(' '))) end end end end ## # Removes global options from args. This prevents an invalid # option error from ocurring when options are parsed # again for the sub-command. def remove_global_options # TODO: refactor with flipflop options.each do |(args, proc)| switch, has_arg = args.first.split past_switch, arg_removed = false, false @args.delete_if do |arg| if arg == switch past_switch, arg_removed = true, false true elsif past_switch && !arg_removed && arg !~ /^-/ arg_removed = true else arg_removed = true false end end end end ## # Parse global command options. def parse_global_options options.inject OptionParser.new do |options, (args, proc)| options.on *args, &proc end.parse! @args.dup rescue OptionParser::InvalidOption # Ignore invalid options since options will be further # parsed by our sub commands. end ## # Raises a CommandError when the program any of the +keys+ are not present, or empty. def require_program *keys keys.each do |key| raise CommandError, "program #{key} required" if program(key).nil? or program(key).empty? end end private def say *args #:nodoc: $termina.say *args end end end