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 :version, :description trap('INT') { abort program(:int_message) } global_option('-h', '--help', 'Display help documentation') { command(:help).run *@args[1..-1]; return } global_option('-v', '--version', 'Display version information') { say version; return } global_option('-t', '--trace', 'Display backtrace when an error occurs') { trace = true } parse_global_options remove_global_options unless trace begin run_active_command rescue InvalidCommandError => e abort "#{e}. Use --help for more information" rescue \ OptionParser::InvalidOption, OptionParser::InvalidArgument, OptionParser::MissingArgument => e abort e rescue => e abort "error: #{e}. Use --trace to view backtrace" end else run_active_command end end ## # Return program version. def version '%s %s' % [program(:name), program(:version)] end ## # Run the active command. def run_active_command require_valid_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: # # :version (required) Program version triple, ex: '0.0.1' # :description (required) Program description # :name Program name, defaults to basename of executable # :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] 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 switches, description = Runner.seperate_switches_from_description *args @options << { :args => args, :proc => block, :switches => switches, :description => description, } 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", :name => File.basename($0) 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 <sub_command>' c.summary = 'Display help documentation for <sub_command>' 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 command = command args.join(' ') require_valid_command command say help_formatter.render_command(command) end end end end ## # Raises InvalidCommandError when a +command+ is not found. def require_valid_command command = active_command raise InvalidCommandError, 'invalid command', caller if command.nil? 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 |option| switch, has_arg = option[: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, option| options.on *option[:args], &global_option_proc(option[:switches], &option[:proc]) end.parse! @args.dup rescue OptionParser::InvalidOption # Ignore invalid options since options will be further # parsed by our sub commands. end ## # Returns a proc allowing for sub-commands to inherit global options. # This functionality works weither a block is present for the global # option or not, so simple switches such as --verbose can be used # without a block, and used throughout all sub-commands. def global_option_proc switches, &block lambda do |value| unless active_command.nil? active_command.proxy_options << [Runner.switch_to_sym(switches.last), value] end yield value if block and !value.nil? end 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 ## # Return switches and description seperated from the +args+ passed. def self.seperate_switches_from_description *args switches = args.find_all { |arg| arg.to_s =~ /^-/ } description = args.last unless !args.last.is_a? String or args.last.match(/^-/) return switches, description end ## # Attempts to generate a method name symbol from +switch+. # For example: # # -h # => :h # --trace # => :trace # --some-switch # => :some_switch # --[no-]feature # => :feature # --file FILE # => :file # --list of,things # => :list def self.switch_to_sym switch switch.scan(/[\-\]](\w+)/).join('_').to_sym rescue nil end private def say *args #:nodoc: $terminal.say *args end end end