class Commander

An implementation of git like command syntax for ruby applications: see github.com/phR0ze/ruby-nub

Constants

OptionMatch

Attributes

banner[R]
cmds[RW]
config[R]

Public Class Methods

new(app:nil, version:nil, examples:nil) click to toggle source

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

[](key) click to toggle source

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(cmd, desc, nodes:[], examples:nil) click to toggle source

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(key, desc, type:nil, required:false, allowed:{}) click to toggle source

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
help() click to toggle source

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
key?(key) click to toggle source

Test if the key exists

# File lib/nub/commander.rb, line 182
def key?(key)
  return @cmds.key?(key)
end
parse!() click to toggle source

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_cmd(name, desc, nodes, examples:nil, hierarchy:[]) click to toggle source

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_value(value, cmd, opt) click to toggle source

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
expand_chained_options!() click to toggle source

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_named(arg, other) click to toggle source

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
move_globals_to_front!() click to toggle source

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_commands(cmd, parent, others, args, results) click to toggle source

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