Skip to content

Instantly share code, notes, and snippets.

@eth-p
Created June 20, 2018 11:26
Show Gist options
  • Save eth-p/461e2f41f51bb9efd0853d9f280f34fa to your computer and use it in GitHub Desktop.
Save eth-p/461e2f41f51bb9efd0853d9f280f34fa to your computer and use it in GitHub Desktop.
A (messy) Ruby command-line argument parser.
# Copyright (c) 2018 eth-p
# ----------------------------------------------------------------------------------------------------------------------
# Argument:
# A command line argument.
# This is used to describe and validate arguments.
class Argument
# ----- Types -----
# A string argument.
# Accepts any non-empty value.
STRING = {
:name => :string,
:default => nil,
:parse => lambda { |value| value },
:validate => lambda { |value| true }
}
# A switch argument.
# Accepts no value.
SWITCH = {
:name => :switch,
:default => false,
:parse => lambda { |value| value == "true" || value == "yes" || value == nil },
:validate => lambda { |value| value == "true" || value == "yes" || value == "false" || value == "no" || value == nil }
}
# A boolean argument.
# Accepts "true"/"yes", "false"/"no", or an empty value (true).
BOOLEAN = {
:name => :boolean,
:default => false,
:parse => lambda { |value| value == "true" || value == "yes" || value == nil },
:validate => lambda { |value| value == "true" || value == "yes" || value == "false" || value == "no" || value == nil }
}
# An integer argument.
# Accepts any valid integral number.
INTEGER = {
:name => :integer,
:default => nil,
:parse => lambda { |value| Integer(value, 10) },
:validate => lambda { |value| Integer(value, 10) && true rescue false }
}
# A number argument.
# Accepts any valid floating-point or integral number.
NUMBER = {
:name => :number,
:default => nil,
:parse => lambda { |value| Float(value) },
:validate => lambda { |value| Float(value) && true rescue false }
}
# A custom argument.
# The validation function (:validate) must be provided.
CUSTOM = {
:name => :custom,
:default => nil,
:parse => nil,
:validate => nil
}
# ----- Fields -----
attr_reader :name
attr_reader :type
attr_reader :default
attr_reader :description
attr_reader :aliases
# ----- Constructor -----
# Creates a new Argument object.
#
# Arguments can either be options (e.g. "--option"/"-o"), switches (e.g. "--help"), or arguments (e.g. "file.txt")
# The type is determined by the name and type parameters.
#
# Available attributes:
# * [Lambda(String)->Boolean] :validate - Validates the argument.
# * [Lambda(String)->any] :parse - Parses the argument.
# * [String|nil] :default - The default value of the argument.
# * [String] :description - The description for the argument.
# * [String|String[]] :aliases - Aliases for the argument.
#
# @param [String] name The argument name.
# @param [Hash] type The argument type. See the class constants.
# @param [Hash] attributes The argument attributes.
#
# @see Argument::STRING
# @see Argument::BOOLEAN
# @see Argument::INTEGER
# @see Argument::NUMBER
# @see Argument::CUSTOM
public
def initialize(name, type, attributes = nil)
# Validate name.
raise ArgumentError, "invalid argument name" unless name.is_a? String
raise ArgumentError, "invalid argument name" unless name =~ /^(?:(?:\.\.\.)|(?:--[a-z0-9][a-z0-9\-]+)|(?:-[a-zA-Z0-9])|(?:[a-z0-9][a-z0-9\-]*))$/
# Validate type.
if name.start_with? "-"
case type when STRING, BOOLEAN, SWITCH, INTEGER, NUMBER, CUSTOM; else
raise ArgumentError, "invalid argument type"
end
else
case type when STRING, BOOLEAN, INTEGER, NUMBER; else
raise ArgumentError, "invalid argument type for non-option argument"
end
end
# Fields.
@name = name
@type = type
@default = type[:default]
@description = "[no description provided]"
@fn_validate = type[:validate]
@fn_parse = type[:parse]
@aliases = []
# Attributes.
if attributes != nil
raise ArgumentError, "invalid attributes" unless attributes.is_a? Hash
# Attribute: validate.
if attributes.has_key? :validate
@fn_validate = attributes.delete(:validate)
raise ArgumentError, "invalid validate function" unless @fn_validate.respond_to? :call
end
# Attribute: parse.
if attributes.has_key? :parse
@fn_parse = attributes.delete(:parse)
raise ArgumentError, "invalid parse function" unless @fn_validate.respond_to? :call
end
# Attribute: default.
if attributes.has_key? :default
raise ArgumentError, "catch-all argument cannot have a default value" unless name != "..."
@default = attributes.delete(:default)
if @default != nil
@default = @default.to_s
raise ArgumentError, "invalid default value" unless @fn_validate.call @default.to_s
end
end
# Attribute: aliases.
if attributes.has_key? :aliases
raise ArgumentError, "value arguments cannot have aliases" unless name.start_with? "-"
@aliases = attributes.delete(:aliases)
@aliases.each do |name|
raise ArgumentError, "invalid alias name" unless name.is_a? String
raise ArgumentError, "invalid alias name" unless name =~ /^(?:(?:--[a-z0-9][a-z0-9\-]+)|(?:-[a-zA-Z0-9])|(?:[a-z0-9][a-z0-9\-]*))$/
end
end
# Attribute: description.
if attributes.has_key? :description
@description = attributes.delete(:description).to_s
end
end
# Validate attributes.
raise ArgumentError, "missing validate function" unless @fn_validate != nil
raise ArgumentError, "missing parse function" unless @fn_parse != nil
raise ArgumentError, "unknown attribute: #{attributes.first[0]}" unless attributes.length == 0
end
# ----- Methods -----
# Validates the argument.
#
# @param [String] string The string to validate.
# @return [Boolean] True if the string is valid for the argument type.
public
def validate(string)
return @fn_validate.call(string)
end
# Parses the argument.
#
# @param [String] string The string to parse.
# @return [mixed]
public
def parse(string)
return @fn_parse.call(string)
end
# Checks if the argument is an option (key/value) argument.
# Examples:
# * "--help"
# * "-k val"
#
# @return [Boolean]
public
def is_option?
return @name.start_with? "-"
end
# Checks if the argument is a value argument.
# Examples:
# * "file"
#
# @return [Boolean]
public
def is_argument?
return ! (@name.start_with? "-")
end
end
# Copyright (c) 2018 eth-p
# ----------------------------------------------------------------------------------------------------------------------
# Imports:
require_relative "./Argument.rb"
require_relative "./ArgumentListParser.rb"
# ----------------------------------------------------------------------------------------------------------------------
# ArgumentList:
# A list of command line arguments.
# This is used to describe and parse command line parameters.
class ArgumentList
# ----- Fields -----
attr_reader :options
attr_reader :aliases
attr_reader :arguments
# ----- Constructor -----
def initialize()
@options = Hash.new
@aliases = Hash.new
@arguments = Array.new
end
# ----- Methods -----
# Add an argument to the argument list.
#
# @param [Argument|any] argument The argument to add, or the parameters for Argument.initialize.
public
def add(*argument)
raise ArgumentError, "wrong number of arguments (given #{argument.length}, expected 1)" unless argument.length > 0
# Reference argument (or create it).
if argument[0].is_a? Argument
argument = argument[0]
else
argument = Argument.new(*argument)
end
# Add option argument and aliases.
if argument.is_option?
@options[argument.name] = argument
argument.aliases.each do |name|
@aliases[name] = argument.name
end
end
# Add catch-all argument.
if argument.name == "..."
@options["..."] = argument
return
end
# Add value argument.
if argument.is_argument?
@arguments.push argument
end
end
public
def parse(argv, stop_early = true)
parser = ArgumentListParser.new(self, stop_early)
argv.each do |arg|
parser.next arg
end
return parser.finish
end
end
# Copyright (c) 2018 eth-p
# ----------------------------------------------------------------------------------------------------------------------
# Imports:
require_relative "./Argument.rb"
require_relative "./ParseError.rb"
# ----------------------------------------------------------------------------------------------------------------------
# ArgumentListParser:
# A parser for an argument list.
# This is used to parse command line parameters in an ArgumentList.
class ArgumentListParser
# ----- Fields -----
attr_reader :current_key
attr_reader :current_value
attr_reader :current_argument
# ----- Constructor -----
# Creates a new ArgumentList parser.
#
# @param [ArgumentList] list The ArgumentList.
# @param [Boolean] stop_early Stop parsing options at the first non-option argument encountered.
def initialize(list, stop_early = true)
@list = list
@results = Hash.new
@results["..."] = Array.new
@finished = false
@current_key = nil
@current_value = nil
@current_value_negated = false
@current_argument = nil
@current_index = 0
@allow_options = true
@stop_early = stop_early
end
# ----- Methods -----
# Looks up an argument in the argument list.
#
# @param [String] name The argument name.
# @return [Argument|nil] The associated argument, or nil if not found.
protected
def lookup_argument(name)
options = @list.options
aliases = @list.aliases
return options[name] if options.has_key? name
return options[aliases[name]] if aliases.has_key? name
return nil
end
# Converts the value string into a type appropriate for the argument.
#
# @param [Argument] argument The argument object.
# @param [String] key The argument key (used for error messages).
# @param [String] str The value to convert.
#
# @return [any] The converted value.
# @raise ParseError When the value cannot be converted.
protected
def cast_value(argument, key, str)
if not argument.validate(str)
raise ParseError, "argument \"#{key}\" only accepts #{argument.type[:name]} values"
end
return argument.parse(str)
end
# Commits a value argument to the parsing results.
#
# This expects the following conditions:
# * @current_argument != nil
# * @current_key == nil
# * @current_value != nil
#
# @raise ParseError When all value arguments have been set already.
# @raise ParseError When an invalid argument value is used.
protected
def commit_value_argument
# If early stopping is enabled, treat option arguments as value arguments.
@allow_options = false if @stop_early
# Add argument to overflow.
if @current_index >= @list.arguments.length
if not @list.options.has_key? "..."
raise ParseError, "no matching argument for value \"#{@current_value}\""
end
@current_argument = @list.options["..."]
@results["..."].push cast_value(@list.options["..."], "...", @current_value)
return
end
# Set argument.
@current_argument = @list.arguments[@current_index]
@current_index += 1
@results[@current_argument.name] = cast_value(@current_argument, @current_argument.name, @current_value)
return nil
end
# Commits a switch argument to the parsing results.
#
# This expects the following conditions:
# * @current_argument != nil
# * @current_key != nil
#
# @raise ParseError When a non-boolean type argument is used.
protected
def commit_switch_argument
if @current_argument.type != Argument::SWITCH && @current_argument.type != Argument::BOOLEAN
raise ParseError, "argument \"#{@current_key}\" requires a value"
end
@results[@current_argument.name] = !@current_value_negated
return nil
end
# Commits an option argument to the parsing results.
#
# This expects the following conditions:
# * @current_argument != nil
# * @current_key != nil
# * @current_value != nil
#
# @raise ParseError When an invalid argument value is used.
protected
def commit_option_argument
@results[@current_argument.name] = cast_value(@current_argument, @current_key, @current_value)
return nil
end
# Commits an argument to the parsing results.
#
# This will automatically look up the argument object and call the appropriate commit function based on
# what variables are and aren't nil.
#
# @see commit_value_argument
# @see commit_switch_argument
# @see commit_option_argument
#
# @raise ParseError When an unknown argument is provided.
# @raise ParseError When any of the commit_*_argument functions raise an error.
protected
def commit_argument
# Do nothing if neither a key nor value are available.
return nil if @current_key == nil && @current_value == nil
# Call the appropriate function.
if @current_key == nil
commit_value_argument
else
if @current_argument == nil
@current_argument = lookup_argument(@current_key)
raise ParseError, "unknown argument \"#{@current_key}\"" unless @current_argument != nil
end
if @current_value == nil
commit_switch_argument
else
commit_option_argument
end
end
# Clear the old variables.
@current_argument = nil
@current_key = nil
@current_value = nil
@current_value_negated = false
return nil
end
# Commits default argument values to the parsing results.
protected
def commit_defaults
@list.options.each do |key, arg|
if not (@results.has_key? arg.name)
val = arg.default
val = arg.parse val unless val == nil
@results[arg.name] = val
end
end
@list.arguments.each do |arg|
if not (@results.has_key? arg.name)
val = arg.default
val = arg.parse val unless val == nil
@results[arg.name] = val
end
end
end
# Parses a long option.
#
# This function accounts for <code>--key=value</code> syntax as well as --no-[flag] syntax.
#
# @param str The option to parse.
#
# @raise ParseError When an unknown argument is provided.
# @raise ParseError When the commit_argument function raises an error.
protected
def parse_long_option(str)
key, value = str.match(/^(--[a-z0-9\-]+)(?:=(.*))?$/).captures
@current_key = key
@current_value = value
# Commit and return if both key and value are present.
if @current_key != nil && @current_value != nil
commit_argument
return nil
end
# Attempt argument lookup, or attempt negated argument if not found.
@current_argument = lookup_argument(@current_key)
if @current_argument == nil && (@current_key.start_with? "--no-")
@current_key = "--#{@current_key[5..-1]}"
@current_argument = lookup_argument(@current_key)
@current_value_negated = true
end
raise ParseError, "unknown argument \"#{key}\"" unless @current_argument != nil
# Handle switch or boolean option.
case @current_argument.type
when Argument::SWITCH
commit_argument
when Argument::BOOLEAN
# Do nothing.
else
raise ParseError, "argument \"#{p_key}\" is not a switch" if @current_value_negated
end
# Return.
return nil
end
# Parses a short option.
#
# @param str The option to parse.
#
# @raise ParseError When an unknown argument is provided.
# @raise ParseError When the commit_argument function raises an error.
protected
def parse_short_option(str)
@current_key = str
@current_argument = lookup_argument(@current_key)
raise ParseError, "unknown argument \"#{key}\"" unless @current_argument != nil
# Handle switch option.
if @current_argument.type == Argument::SWITCH
commit_argument
end
# Return.
return nil
end
# Parses the next token.
#
# @param str The token to parse.
#
# @raise ParseError When the argument is unknown.
# @raise ParseError When the argument value is invalid.
# @raise ParseError When the command-line option syntax is invalid.
# @raise RuntimeError When the finish method was called beforehand.
public
def next(str)
raise RuntimeError, "cannot operate on a finished parser object" if @finished
if @allow_options
if str == "--"
@allow_options = false
commit_argument
return
elsif str.start_with? "--"
commit_argument
parse_long_option str
return
elsif str.start_with? "-"
commit_argument
if str.length > 2
str[1..-1].chars.each do |switch|
@current_key = "-#{switch}"
@current_value
commit_argument
end
else
parse_short_option str
end
return
end
end
@current_value = str
commit_argument
end
# Finishes parsing and returns a hash containing the results.
#
# @return [Hash] The results.
public
def finish
if not @finished
commit_argument
commit_defaults
@finished = true
end
return @results
end
end
require_relative "./lib/Argument.rb"
require_relative "./lib/ArgumentList.rb"
list = ArgumentList.new
# Long: --number
# Short: -n
# Type: Will raise an error if not given a number.
# Example:
# ruby example.rb --number=3
# ruby example.rb --number 3
# ruby example.rb -n 3
list.add "--number", Argument::NUMBER, {
:description => "a simple description",
:aliases => [
"-n"
]
}
# Long: --string
# Short: -s
# Example:
# ruby example.rb --string="Hello world"
# ruby example.rb --string "Hello world"
# ruby example.rb -s "Hello world"
list.add "--string", Argument::STRING, {
:default => "my default value",
:aliases => [
"-s"
]
}
# Long: --switch
# Short: -S
# Type: Will raise an error if not given a boolean.
# Does not use the next token as a value.
# Example:
# ruby example.rb --switch
# ruby example.rb --switch=yes
# ruby example.rb --switch=false
# ruby example.rb -S
list.add "--switch", Argument::SWITCH, {
:aliases => ["-S"]
}
# Long: --boolean
# Short: -b
# Type: Will raise an error if not given a boolean.
# Does not use the next token as a value.
# Example:
# ruby example.rb --boolean
# ruby example.rb --boolean yes
# ruby example.rb --boolean=false
# ruby example.rb -bS
list.add "--boolean", Argument::BOOLEAN, {
:aliases => ["-b"]
}
# Long: --boolean-starts-on
# Type: Will raise an error if not given a boolean.
# Example:
# ruby example.rb --boolean-starts-on=no
# ruby example.rb --no-boolean-starts-on
list.add "--boolean-starts-on", Argument::BOOLEAN, {
:default => true
}
# Example:
# ruby example.rb "I'm the first argument"
# ruby example.rb -- "--I'm the first argument"
list.add "first", Argument::STRING, {
:default => nil
}
# This is a special argument that catches all extra arguments.
# Example:
# ruby example.rb "first" "extra 1" "extra 2"
list.add "...", Argument::STRING, {
}
puts list.parse(ARGV)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment