Created
June 20, 2018 11:26
-
-
Save eth-p/461e2f41f51bb9efd0853d9f280f34fa to your computer and use it in GitHub Desktop.
A (messy) Ruby command-line argument parser.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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