class Commander
An implementation of git like command syntax for ruby applications: see github.com/phR0ze/ruby-nub
Constants
- OptionMatch
Attributes
Public Class Methods
Initialize the commands for your application @param app [String] application name e.g. reduce @param version [String] version of the application e.g. 1.0.0 @param examples [String] optional examples to list after the title before usage
# File lib/nub/commander.rb, line 149 def initialize(app:nil, version:nil, examples:nil) @k = OpenStruct.new({ global: 'global' }) @app = app @app_default = Sys.caller_filename @version = version @examples = examples @just = 40 # Regexps @short_regex = /^(-\w).*$/ @long_regex = /(--[\w\-]+)(=.+)*$/ @value_regex = /.*=(.*)$/ # Command line expression results # {command_name => {}} @cmds = {} # Configuration - ordered list of commands @config = [] # Configure default global options add_global('-h|--help', 'Print command/options help') end
Public Instance Methods
Hash
like accessor for checking if a command or option is set
# File lib/nub/commander.rb, line 177 def [](key) return @cmds[key] if @cmds[key] end
Add a command to the command list @param cmd [String] name of the command @param desc [String] description of the command @param nodes [List] list of command nodes (i.e. options or commands) @param examples [String] the command's examples
# File lib/nub/commander.rb, line 191 def add(cmd, desc, nodes:[], examples:nil) Log.die("'#{@k.global}' is a reserved command name") if cmd == @k.global Log.die("'#{cmd}' already exists") if @config.any?{|x| x.name == cmd} Log.die("'help' is a reserved option name") if nodes.any?{|x| x.class == Option && !x.key.nil? && x.key.include?('help')} Log.die("command names must be pure lowercase letters or hypen") if cmd =~ /[^a-z-]/ # Validate sub command key words validate_subcmd = ->(subcmd){ subcmd.nodes = [] if !subcmd.nodes Log.die("'#{@k.global}' is a reserved command name") if subcmd.name == @k.global Log.die("'help' is a reserved option name") if subcmd.nodes.any?{|x| x.class == Option && !x.key.nil? && x.key.include?('help')} Log.die("command names must be pure lowercase letters or hypen") if subcmd.name =~ /[^a-z-]/ subcmd.nodes.select{|x| x.class != Option}.each{|x| validate_subcmd.(x)} } nodes.select{|x| x.class != Option}.each{|x| validate_subcmd.(x)} @config << add_cmd(cmd, desc, nodes, examples:examples) end
Add global options (any option coming before all commands) @param key [String] option short hand, long hand and hint e.g. -s|–skip=COMPONENTS @param desc [String] the option's description @param type [Type] the option's type @param required [Bool] require the option if true else optional @param allowed [Hash] hash of allowed values to description map
# File lib/nub/commander.rb, line 216 def add_global(key, desc, type:nil, required:false, allowed:{}) options = [Option.new(key, desc, type:type, required:required, allowed:allowed)] # Aggregate global options if (global = @config.find{|x| x.name == @k.global}) global.nodes.each{|x| options << x} @config.reject!{|x| x.name == @k.global} end @config << add_cmd(@k.global, 'Global options:', options) end
Return the app's help string @return [String] the app's help string
# File lib/nub/commander.rb, line 237 def help # Global help help = @app.nil? ? "" : "#{banner}\n" if !@examples.nil? && !@examples.empty? newline = @examples.strip_color[-1] != "\n" ? "\n" : "" help += "Examples:\n#{@examples}\n#{newline}" end app = @app || @app_default help += "Usage: ./#{app} [commands] [options]\n" help += @config.find{|x| x.name == @k.global}.help help += "COMMANDS:\n" @config.select{|x| x.name != @k.global}.each{|x| help += " #{x.name.ljust(@just)}#{x.desc}\n" } help += "\nsee './#{app} COMMAND --help' for specific command help\n" return help end
Test if the key exists
# File lib/nub/commander.rb, line 182 def key?(key) return @cmds.key?(key) end
Construct the command line parser and parse
# File lib/nub/commander.rb, line 256 def parse! # Clear out the previous run every time, in case run more than once @cmds = {} # Set help if nothing was given ARGV.clear and ARGV << '-h' if ARGV.empty? # Parse commands recursively move_globals_to_front! expand_chained_options! while (cmd = @config.find{|x| x.name == ARGV.first}) ARGV.shift && parse_commands(cmd, nil, @config.select{|x| x.name != cmd.name}, ARGV, @cmds) end # Ensure specials (global) are always set @cmds[:global] = {} if !@cmds[:global] # Ensure all options were consumed Log.die("invalid options #{ARGV}") if ARGV.any? # Print banner on success puts(banner) if @app end
Private Instance Methods
Add a command to the command list @param name [String] name of the command @param desc [String] description of the command @param nodes [Array] list of command nodes (i.e. options or commands) @param hierarchy [Array] list of commands @return [Command] new command
# File lib/nub/commander.rb, line 607 def add_cmd(name, desc, nodes, examples:nil, hierarchy:[]) hierarchy << name cmd = Command.new(name, desc, examples:examples) subcmds = nodes.select{|x| x.class == Command}.sort{|x,y| x.name <=> y.name} # Build help for command #--------------------------------------------------------------------------- cmd.help = "#{desc}\n" if !cmd.examples.nil? && !cmd.examples.empty? newline = cmd.examples.strip_color[-1] != "\n" ? "\n" : "" cmd.help += "Examples:\n#{cmd.examples.colorize(:green)}#{newline}" end app = @app || @app_default cmd_prompt = subcmds.any? ? "[commands] " : "" cmd.help += "\nUsage: ./#{app} #{hierarchy * ' '} #{cmd_prompt}[options]\n" if name != @k.global cmd.help = "#{banner}\n#{cmd.help}" if @app && name != @k.global # Add help for each sub-command before options cmd.help += "COMMANDS:\n" if subcmds.any? subcmds.each{|x| cmd.help += " #{x.name.ljust(@just)}#{x.desc}\n" } # Insert standard help option for command (re-using one from global, all identical) nodes << @config.find{|x| x.name == @k.global}.nodes.find{|x| x.long == '--help'} if name != @k.global # Add positional options first sorted_options = nodes.select{|x| x.class == Option && x.key.nil?} sorted_options += nodes.select{|x| x.class == Option && !x.key.nil?}.sort{|x,y| x.key <=> y.key} cmd.help += "OPTIONS:\n" if subcmds.any? && sorted_options.any? positional_index = -1 sorted_options.each{|x| required = x.required ? ", Required" : "" positional_index += 1 if x.key.nil? key = x.key.nil? ? "#{name}#{positional_index}" : x.key if x.type == FalseClass || x.type == TrueClass type = "Flag(#{x.type.to_s[/(.*)Class/, 1].downcase})" elsif x.type == Array type = "#{x.type}(String)" else type = x.type end cmd.help += " #{key.ljust(@just)}#{x.desc}: #{type}#{required}\n" x.allowed.sort{|x,y| x <=> y}.each{|x| cmd.help += " #{''.ljust(@just)} #{x.first}: #{x.last}\n" } } # Add hint as to how to get specific sub command help cmd.help += "\nsee './#{app} #{name} COMMAND --help' for specific command help\n" if subcmds.any? # Configure help for each sub command subcmds.each{|x| cmd.nodes << add_cmd(x.name, x.desc, x.nodes, examples:x.examples, hierarchy:hierarchy)} # Add options after any sub-commands cmd.nodes += sorted_options return cmd end
Convert the given option value to appropriate type and validate against allowed @param value [String] type to convert and and check @param cmd [Command] configured command to reference @param opt [Option] matching option to validate against @return [String|Integer|Array] depending on option type
# File lib/nub/commander.rb, line 574 def convert_value(value, cmd, opt) if value if opt.type == String if opt.allowed.any? !puts("Error: invalid string value '#{value}'!".colorize(:red)) && !puts(cmd.help) and exit if !opt.allowed.key?(value) && !opt.allowed.key?(value.to_sym) end elsif opt.type == Integer value = value.to_i if opt.allowed.any? !puts("Error: invalid integer value '#{value}'!".colorize(:red)) && !puts(cmd.help) and exit if !opt.allowed.key?(value) end elsif opt.type == Array value = value.split(',') if opt.allowed.any? value.each{|x| !puts("Error: invalid array value '#{x}'!".colorize(:red)) && !puts(cmd.help) and exit if !opt.allowed.key?(x) && !opt.allowed.key?(x.to_sym) } end end end return value end
Find chained options, copy and insert as needed. Globals should have already been ordered before calling this function Fail if validation doesn't pass
# File lib/nub/commander.rb, line 449 def expand_chained_options! args = ARGV[0..-1] results = {} cmd_order = [] cmd_names = @config.map{|x| x.name} chained = [] while args.any? if !(cmd = @config.find{|x| x.name == args.first}).nil? cmd_order << args.shift # Maintain oder of given commands results[cmd.name] = [] # Add the command to the results cmd_names.reject!{|x| x == cmd.name} # Remove command from possible commands # Collect command options from args to compare against opts = args.take_while{|x| !cmd_names.include?(x)} args.shift(opts.size) # Globals are not to be considered for chaining results[cmd.name].concat(opts) and next if cmd.name == @k.global # Chained case is when no options are given but the command has options cmd_options = cmd.nodes.select{|x| x.class == Option} if opts.size == 0 && cmd_options.any? chained << cmd else # Add cmd with options results[cmd.name].concat(opts) # Add applicable options to chained as well chained.each{|other| _opts = opts[0..-1] named_results = [] positional_results = [] # Add all matching named options #------------------------------------------------------------------- i = 0 while i < _opts.size if (match = match_named(_opts[i], other)).hit? named_results << _opts[i]; _opts.delete_at(i) # Get the next option to as the value was separate if i < _opts.size && !(match.flag? || match.value) named_results << _opts[i] _opts.delete_at(i) end else i += 1 end end # Add all matching positional options #------------------------------------------------------------------- i = 0 other_positional = other.nodes.select{|x| x.class == Option && x.key.nil?} while i < _opts.size if !_opts[i].start_with?('-') && other_positional.any? positional_results << _opts[i] other_positional.shift _opts.delete_at(i) else i += 1 end end positional_results.each{|x| results[other.name] << x} named_results.each{|x| results[other.name] << x} } end end end # Set results as new ARGV command line expression ARGV.clear and cmd_order.each{|x| ARGV << x; ARGV.concat(results[x]) } end
Match the given command line arg with a configured named option or match configured named option against a list of command line args @param arg [String/Option] the command line argument or configured Option
@param other [Command/Array] configured command or command line args @return [OptionMatch]] struct with some helper functions
# File lib/nub/commander.rb, line 531 def match_named(arg, other) match = OptionMatch.new # Match command line arg against command options if arg.class == String && other.class == Command match.arg = arg options = other.nodes.select{|x| x.class == Option && !x.key.nil? } if arg.start_with?('-') short = arg[@short_regex, 1] long = arg[@long_regex, 1] match.value = arg[@value_regex, 1] # Set symbol converting dashes to underscores for named options if (match.opt = options.find{|x| (short && short == x.short) || (long && long == x.long)}) match.sym = match.opt.to_sym end end # Match command option against command line args elsif arg.class == Option && other.class == Array match.arg = arg.key other.select{|x| x.start_with?('-')}.any?{|x| short = x[@short_regex, 1] long = x[@long_regex, 1] value = x[@value_regex, 1] if (short && short == arg.short) || (long && long == arg.long) match.opt = arg match.value = value match.sym = match.opt.to_sym end } end return match end
Parses the command line, moving all global options to the begining and inserting the global command
# File lib/nub/commander.rb, line 413 def move_globals_to_front! if !(global_cmd = @config.find{|x| x.name == @k.global}).nil? ARGV.delete(@k.global) # Collect positional and named options from begining globals = ARGV.take_while{|x| !@config.any?{|y| y.name == x}} ARGV.shift(globals.size) # Collect named options throughout i = -1 cmd = nil while (i += 1) < ARGV.size # Set command and skip command and matching options if !(_cmd = @config.find{|x| x.name == ARGV[i]}).nil? cmd = _cmd; next end next if cmd && match_named(ARGV[i], cmd).hit? # Collect global matches if (match = match_named(ARGV[i], global_cmd)).hit? globals << ARGV.delete_at(i) globals << ARGV.delete_at(i) if !match.flag? i -= 1 end end # Re-insert options in correct order at end with command globals.reverse.each{|x| ARGV.unshift(x)} ARGV.unshift(@k.global) end end
Parse the given args recursively @param cmd [Command] command to work with @param parent [Command] command to work with @param others [Array] sibling cmds to cmd @param args [Array] array of arguments @param results [Hash] of cmd results
# File lib/nub/commander.rb, line 300 def parse_commands(cmd, parent, others, args, results) results[cmd.to_sym] = {} # Create command results entry cmd_names = others.map{|x| x.name} # Get other command names as markers subcmds = cmd.nodes.select{|x| x.class == Command} # Get sub-commands for this command # Collect all params until the next sibling command #--------------------------------------------------------------------------- params = args.take_while{|x| !cmd_names.include?(x)} args.shift(params.size) # Strip off this command's preceeding options opts = subcmds.any? ? params.take_while{|x| !subcmds.any?{|y| x == y.name}} : params otherparams = params[opts.size..-1] #--------------------------------------------------------------------------- # Handle sub-commands recursively first #--------------------------------------------------------------------------- while subcmds.any? && (subcmd = subcmds.find{|x| x.name == otherparams.first}) otherparams.shift # Consume sub-cmd from opts subcmds.reject!{|x| x.name == subcmd.name} # Drop sub-command from further use parse_commands(subcmd, cmd, subcmds, otherparams, results[cmd.to_sym]) end # Account for any left over options otherparams.reverse.each{|x| opts.unshift(x)} #--------------------------------------------------------------------------- # Base case: dealing with options for a given command. # Only consume options for this command and bubble up unused to parent #--------------------------------------------------------------------------- # Handle help upfront before anything else #--------------------------------------------------------------------------- if opts.any?{|x| m = match_named(x, cmd); m.hit? && m.sym == :help } !puts(help) and exit if cmd.name == @k.global !puts(cmd.help) and exit end # Parse/consume named options first #--------------------------------------------------------------------------- # Check that all required named options were given cmd.nodes.select{|x| x.class == Option && !x.key.nil? && x.required}.each{|x| !puts("Error: required option #{x.key} not given!".colorize(:red)) && !puts(cmd.help) and exit if !match_named(x, opts).hit? } # Consume and set all named options i = 0 while i < opts.size if (match = match_named(opts[i], cmd)).hit? value = match.flag? || match.value # Inline or Flag value # Separate value separate = false if !value && i + 1 < opts.size separate = true value = opts[i + 1] elsif !value !puts("Error: named option '#{opts[i]}' value not found!".colorize(:red)) and !puts(cmd.help) and exit end # Set result and consume options results[cmd.to_sym][match.sym] = convert_value(value, cmd, match.opt) opts.delete_at(i) # Consume option opts.delete_at(i) if separate # Consume separate value else i += 1 end end # Parse/consume positional options next #--------------------------------------------------------------------------- cmd_pos_opts = cmd.nodes.select{|x| x.class == Option && x.key.nil?} # Check that all required positionals were given !puts("Error: positional option required!".colorize(:red)) && !puts(cmd.help) and exit if opts.select{|x| !x.start_with?('-')}.size < cmd_pos_opts.select{|x| x.required}.size # Consume and set all positional options i = 0 pos = -1 while i < opts.size && cmd_pos_opts.any? if !opts[i].start_with?('-') pos += 1 cmd_opt = cmd_pos_opts.shift !puts("Error: invalid positional option '#{opts[i]}'!".colorize(:red)) and !puts(cmd.help) and exit if cmd_opt.nil? # Set result and consume options results[cmd.to_sym]["#{cmd.to_sym}#{pos}".to_sym] = convert_value(opts[i], cmd, cmd_opt) opts.delete_at(i) # Consume option else i += 1 end end # Add any unconsumed options back to parent to ensure everything is accounted for if parent opts.reverse.each{|x| args.unshift(x)} else opts.each{|x| !puts("Error: invalid positional option '#{x}'!".colorize(:red)) and !puts(cmd.help) and exit if !x.start_with?('-') !puts("Error: invalid named option '#{x}'!".colorize(:red)) and !puts(cmd.help) and exit if x.start_with?('-') } end end