Skip to content

Instantly share code, notes, and snippets.

@paulsonkoly
Last active January 24, 2019 14:44
Show Gist options
  • Save paulsonkoly/6d92b32bdfa55bd7d6be7a96e83f93f6 to your computer and use it in GitHub Desktop.
Save paulsonkoly/6d92b32bdfa55bd7d6be7a96e83f93f6 to your computer and use it in GitHub Desktop.
require 'active_support/inflector'
# Argument parsing
# My solution to http://codingdojo.org/kata/Args/
module Args
# Error occured during argument parsing. {StandardError#message} should give
# a description about the error.
class ArgsError < StandardError; end
# An argument schema.
#
# Describes what arguments we take and what properties each argument have.
#
# @example
# schema = Args::Schema.new do |s|
# s.flag 'x'
# s.integer 'p'
# s.string 'h', default: 'blah'
# s.integer_list 't', default: [13]
# end
#
# schema
# # => #<Args::Schema:0x0000556b0562fe88
# # @items=[
# # #<Args::Schema::Item::Flag:0x0000556b0562fb18 @letter="x">,
# # #<Args::Schema::Item::Integer:0x0000556b0562f898 @letter="p",
# # @default=0>,
# # #<Args::Schema::Item::String:0x0000556b0562f5f0 @letter="h",
# # @default="blah">,
# # #<Args::Schema::Item::IntegerList:0x0000556b0562f168 @letter="t",
# # @default=[13]>]>
class Schema
# A single argument that may or may not have sub arguments
class Item
# @param letter [::String] the selector for the argument (the letter after
# the -)
def initialize(letter)
@letter = letter
end
# @!attribute [r] letter
# @return [::String] the letter for the argument
attr_reader :letter
alias_method :key, :letter
# Does our {letter} match the given letter
# @param letter [::String] the letter to match against
def match?(letter)
@letter == letter
end
# Single flag type {Item}
#
# Does not have any additional sub arguments. When present it's true when
# not present it's false. Defaults to false.
class Flag < Item
# @return [Boolean] false
def default; false; end
# @return [Boolean] true
def value(_token_list); true; end
# @return [::Integer] 1
def token_count; 1; end
end
# Abstract class for {Item} with a single argument
#
# Sub classes have to implement #convert. Use {default} to define the
# default value.
class ArgumentItem < Item
# @!attribute [r] default
# @return [Object] the default value of the argument
attr_reader :default
# Read the value from the token_list
# @param token_list [[::String]] list of tokens produced by the parser
# @raise ArgsError if token_list doesn't have enough items
def value(token_list)
if token_list.count < token_count
raise ArgsError, "Premature end of tokens #{token_list}"
end
convert(token_list[1])
end
# Count of tokens from a token_list this item is responsible for
# @return [::Integer] 2
def token_count; 2; end
# Defines the initialize for the sub-classes using the default value
# provided
# @param value [Object] default value for the argument
def self.default(value)
define_method(:initialize) do |letter, default: value|
super(letter)
@default = default
end
end
end
# Integer argument with a default value of 0
class Integer < ArgumentItem
# @!method initialize(letter, default: 0)
# @param default [::Integer] default value when the argument is not
# present
default 0
# converts token to integer
# @param token [::String] token from the parser
# @return [::Integer] token converted
def convert(token)
token.to_i
end
end
# String argument with a default value of ''
class String < ArgumentItem
# @!method initialize(letter, default: '')
# @param default [::String] default value when the argument is not
# present
default ''
# converts token to string
# @param token [::String] token from the parser
# @return [::String] token unchanged
def convert(token)
token
end
end
# Integer list argument with a default value of []
class IntegerList < ArgumentItem
# @!method initialize(letter, default: [])
# @param default [[::Integer]] default value when the argument is not
# present
default []
# converts token to an integer list
# @param token [::String] token from the parser
# @return [[::Integer]] token converted to a list of Integers
def convert(token)
token.split(',').map(&:to_i)
end
end
end
def initialize
@items = []
yield(self) if block_given?
end
# @!method flag(letter)
# Constructs an {Item::Flag} and registers it in self
# @see Item::Flag#initialize
# @!method string(letter, default: '')
# Constructs an {Item::String} and registers it in self
# @see Item::String#initialize
# @!method integer(letter, default: 0)
# Constructs an {Item::Integer} and registers it in self
# @see Item::Integer#initialize
# @!method integer_list(letter, default: [])
# Constructs an {Item::IntegerList} and registers it in self
# @see Item::IntegerList#initialize
%i[flag integer string integer_list].each do |sym|
define_method(sym) do |*args|
klass = Item.const_get(ActiveSupport::Inflector.classify(sym))
@items << klass.new(*args)
end
end
# returns the matching {Item} registered for the prefix of token_list
# @param token_list [[::String]] the tokens to match
# @return [Item|nil] Item that matches
# @raise ArgsError if token_list doesn't start with a - and letter token
def match_token_list(token_list)
token = token_list[0]
unless token.match?(/\A-\w\z/)
raise ArgsError, "- and letter expected, got #{token}"
end
match(token[1]).tap do |item|
raise ArgsError, 'No matching item' if item.nil?
end
end
# returns matching {Item} registered for a letter
def match(letter)
@items.find { |item| item.match?(letter) }
end
end
end
module Args
# Command line arguments parser
# @see Schema example on constructing schemas
# @example
# parser = Args::Parser.new(schema)
# parser.parse('-x -p 10 -t 1,2,2') # => nil
# parser[?x] # => true
# parser[?p] # => 10
# parser[?h] # => "blah"
# parser[?t] # => [1, 2, 2]
# parser.errors # => []
class Parser
# @param schema [Schema] A valid schema with {Args::Schema::Item} items
# registered.
def initialize(schema)
@schema = schema
reset
end
# @!attribute [r] errors
# @return [[String]] List of errors that occured during parse
attr_reader :errors
# Parses the string. You can retrospect the result of the parse with
# {errors}, and {[]}
def parse(string)
reset
@token_list = tokenize(string)
parse_schema until @token_list.empty?
end
# the value of the given {Schema::Item} that is registered in the schema
# with letter
# @return [Object] The value (either default or if present converted from
# the input string).
def [](letter)
@values.fetch(letter, @schema.match(letter).default)
rescue NoMethodError
raise ArgsError, 'no such flag in schema'
end
private
def reset
@values = {}
@errors = []
end
def tokenize(string)
@token_list = string.split(' ') # => ["-x", "-p", "10", "-t", "1,2,2"]
end
def parse_schema
item = @schema.match_token_list(@token_list)
@values[item.key] = item.value(@token_list)
@token_list.shift(item.token_count)
rescue ArgsError => error
@errors << error.message
@token_list.shift
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment