initial commit

This commit is contained in:
Chris Watson 2023-01-27 01:22:47 -07:00
commit c90629ed3d
19 changed files with 757 additions and 0 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
/docs/
/lib/
/bin/
/.shards/
*.dwarf
# Libraries don't need dependency lock
# Dependencies will be locked in applications that use them
/shard.lock

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Chris Watson <cawatson1993@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

175
README.md Normal file
View File

@ -0,0 +1,175 @@
# ArgParser
A powerful argument parser which uses a class or struct to define the arguments and their types.
## Installation
1. Add the dependency to your `shard.yml`:
```yaml
dependencies:
arg_parser:
github: watzon/arg_parser
```
2. Run `shards install`
## Usage
ArgParser works very similarly to `JSON::Serializable` and works on both classes and structs. To use it, simply include ArgParser in your class or struct, and define the arguments you want to parse as instance variables.
```crystal
struct MyArgs
include ArgParser
getter name : String
getter age : Int32
getter active : Bool
end
```
ArgParser parses arguments such as those that come from `ARGV`, though in reality all that really matters is that it's given an array of strings. ArgParser defines an initializer for your type which takes the array of strings, and parses it into your type.
```crystal
args = MyArgs.new(["--name", "John Doe", "--age", "20", "--active"])
args.name # => "John Doe"
args.age # => 20
args.active # => true
```
Positional arguments are supported as well. To keep things in your struct clean, all instance variables added by ArgParser itself are prefixed with an underscore.
```crystal
args = MyArgs.new(["\"Hello world\"", --name", "John Doe", "--age", "20", "--active"])
args._positional_args # => ["Hello world"]
```
## Supported Types
By default ArgParser supports the following types:
* `String`
* `Int`
* `UInt`
* `Float`
* `BigInt`
* `BigFloat`
* `BigDecimal`
* `BigRational`
* `Bool`
* `URI`
* `UUID`
Any type which implements `from_arg` can be used as an argument type.
For types which don't implement `from_arg`, you can define a converter
which implements `from_arg` as a proxy for that type.
## Converters
Converers are simply modules which have a `self.from_arg` method which takes
a value string, and returns the converted value. For Example:
```
module MyConverter
def self.from_arg(value : String)
# do something with value
end
end
```
Converters can be used through the `ArgParser::Field` annotation.
```crystal
struct MyArgs
include ArgParser
@[ArgParser::Field(converter: MyConverter)]
getter name : SomeType
end
```
# Aliases
Aliases are simply other names for an argument. For example, if you want to use
`-n` as an alias for `--name`, you can do so with the `ArgParser::Field` annotation.
```crystal
struct MyArgs
include ArgParser
@[ArgParser::Field(alias: "-n")]
getter name : String
end
```
Currently only a single alias is supported.
## Default Values
Default values can be specified in the same way you would normally specify them in Crystal. For example, if you want to set a default value for `name`:
```crystal
struct MyArgs
include ArgParser
getter name : String = "John Doe"
end
```
## Validators
Validators allow you to validate user input. For example, if you want to make sure that the user's input matches a pattern, you can do so with a validator.
```crystal
struct MyArgs
include ArgParser
@[ArgParser::Validate::Format(/[a-zA-Z]+/)]
getter name : String
end
```
On invalid input, the method `on_validation_error` is called. By default, this method raises an `ArgParser::ValidationError`, but you can override it to do whatever you want.
```crystal
struct MyArgs
include ArgParser
@[ArgParser::Validate::Format(/[a-zA-Z]+/)]
getter name : String
def on_validation_error(field : Symbol, value, errors : Array(String))
# allow it, but print a warning
puts "Invalid value for #{field}: #{value}"
end
end
```
All validation errors are also added to the `_validation_errors` hash. This can be useful if you want to do something with the errors after parsing.
```crystal
args = MyArgs.new(["--name", "John Doe", "--age", "foo", "--active"])
args._validation_errors # => {"age" => ["must be an integer"]}
```
## Modifying the Behavior of ArgParser
ArgParser is designed to be configurable so it can handle a wide variety of use cases. As such, it includes several overridable methods which can be used to modify its behavior. These are:
- `on_validation_error` - called when a validation error occurs
- `on_unknown_attribute` - called when an unknown attribute is encountered
- `on_missing_attribute` - called when a required attribute is missing
- `on_conversion_error` - called when a value isn't able to be converted to the specified type
In addition, the way keys are parsed can be modified by overriding the `parse_key` method. By default, it simply removes one or two dashes from the beginning of the key. For example, `--name` becomes `name`, and `-n` becomes `n`.
## Contributing
1. Fork it (<https://github.com/your-github-user/arg_parser/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request
## Contributors
- [Chris Watson](https://github.com/your-github-user) - creator and maintainer

14
shard.yml Normal file
View File

@ -0,0 +1,14 @@
name: arg_parser
version: 0.1.0
authors:
- Chris Watson <cawatson1993@gmail.com>
development_dependencies:
spectator:
gitlab: arctic-fox/spectator
branch: master
crystal: 1.7.1
license: MIT

37
spec/arg_parser_spec.cr Normal file
View File

@ -0,0 +1,37 @@
require "./spec_helper"
Spectator.describe ArgParser do
context "supported arguments" do
let(args) { ["--name", "John Doe", "--age", "32", "--height", "163.2", "--is_human", "true", "--website", "https://example.com", "--id", "39aacd14-9e1b-11ed-91ad-b44506ca30d5"] }
let(parser) { TestSupportedArgs.new(args) }
it "parses name" do
expect(parser.name).to eq("John Doe")
end
it "parses age" do
expect(parser.age).to be_a(Int32)
expect(parser.age).to eq(32)
end
it "parses height" do
expect(parser.height).to be_a(Float64)
expect(parser.height).to eq(163.2)
end
it "parses is_human" do
expect(parser.is_human).to be_a(Bool)
expect(parser.is_human).to eq(true)
end
it "parses website" do
expect(parser.website).to be_a(URI)
expect(parser.website.to_s).to eq("https://example.com")
end
it "parses id" do
expect(parser.id).to be_a(UUID)
expect(parser.id.to_s).to eq("39aacd14-9e1b-11ed-91ad-b44506ca30d5")
end
end
end

21
spec/spec_helper.cr Normal file
View File

@ -0,0 +1,21 @@
require "../src/arg_parser"
require "spectator"
require "uri"
require "uuid"
struct TestSupportedArgs
include ArgParser
getter name : String
getter age : Int32
getter height : Float64
getter is_human : Bool
getter website : URI
getter id : UUID
end

243
src/arg_parser.cr Normal file
View File

@ -0,0 +1,243 @@
require "json"
require "./arg_parser/*"
require "./arg_parser/validators/*"
require "./arg_parser/converters/*"
# A powerful argument parser which uses a class or struct to
# define the arguments and their types.
module ArgParser
annotation Field; end
@[ArgParser::Field(ignore: true)]
getter _positional_args : Array(String)
@[ArgParser::Field(ignore: true)]
getter _validation_errors : Hash(String, Array(String))
@[ArgParser::Field(ignore: true)]
getter _field_names : Array(String)
# Create a new {{@type}} from an array of arguments.
# See: https://github.com/watzon/arg_parser for more information.
def initialize(args : Array(String))
@_positional_args = [] of String
@_validation_errors = {} of String => Array(String)
@_field_names = [] of String
{% begin %}
%args = args.clone
{% properties = {} of Nil => Nil %}
{% for ivar in @type.instance_vars %}
{% ann = ivar.annotation(ArgParser::Field) %}
{% unless ann && ann[:ignore] %}
@_field_names << {{ivar.id.stringify}}
{% if ann && ann[:alias] %}
@_field_names << {{ann[:alias].id.stringify}}
{% end %}
{%
properties[ivar.id] = {
type: ivar.type,
key: ((ann && ann[:key]) || ivar).id.stringify,
has_default: ivar.has_default_value?,
default: ivar.default_value,
nilable: ivar.type.nilable?,
converter: ann && ann[:converter],
presence: ann && ann[:presence],
alias: ann && ann[:alias],
validators: [] of Nil,
}
%}
{% for validator in ArgParser::Validator.all_subclasses.reject { |k| k.abstract? } %}
{% v_ann = validator.constant("ANNOTATION").resolve %}
{% if ann = ivar.annotation(v_ann) %}
{% properties[ivar.id][:validators] << {ann, validator} %}
{% end %}
{% end %}
{% end %}
{% end %}
{% for name, value in properties %}
%var{name} = {% if value[:type] < Array %}[] of {{value[:type].type_vars[0]}}{% else %}nil{% end %}
%found{name} = false
{% end %}
i = 0
while !%args.empty?
arg = %args.shift
key = parse_key(arg)
next unless key
value = %args.shift rescue "true"
if value.starts_with?("-")
%args.unshift(value)
value = "true"
end
case key
{% for name, value in properties %}
when {{ value[:key].id.stringify }}{% if value[:alias] %}, {{ value[:alias].id.stringify }}{% end %}
%found{name} = true
begin
{% if value[:type] == String %}
%var{name} = value
{% elsif value[:type] < Array %}
%var{name} ||= [] of {{value[:type].type_vars[0]}}
{% if value[:converter] %}
%var{name} << {{ value[:converter] }}.from_arg(value)
{% else %}
%var{name} << ::Union({{value[:type].type_vars[0]}}).from_arg(value)
{% end %}
{% else %}
{% if value[:converter] %}
%var{name} = {{ value[:converter] }}.from_arg(value)
{% else %}
%var{name} = ::Union({{value[:type]}}).from_arg(value)
{% end %}
{% end %}
rescue
on_conversion_error({{value[:key].id.stringify}}, value, {{value[:type]}})
end
{% end %}
else
on_unknown_attribute(key)
end
end
{% for name, value in properties %}
{% unless value[:nilable] || value[:has_default] %}
if %var{name}.nil? && !%found{name} && !::Union({{value[:type]}}).nilable?
on_missing_attribute({{value[:key].id.stringify}})
end
{% end %}
{% if value[:nilable] %}
{% if value[:has_default] != nil %}
@{{name}} = %found{name} ? %var{name} : {{value[:default]}}
{% else %}
@{{name}} = %var{name}
{% end %}
{% elsif value[:has_default] %}
if %found{name} && !%var{name}.nil?
@{{name}} = %var{name}
end
{% else %}
@{{name}} = (%var{name}).as({{value[:type]}})
{% end %}
{% if value[:presence] %}
@{{name}}_present = %found{name}
{% end %}
{% for v in value[:validators] %}
{%
ann = v[0]
validator = v[1]
args = [] of String
%}
{% for arg in ann.args %}
{% args << arg.stringify %}
{% end %}
{% for k, v in ann.named_args %}
{% args << "#{k.id}: #{v.stringify}" %}
{% end %}
%validator{name} = {{ validator.name(generic_args: false) }}.new({{ args.join(", ").id }})
if %found{name} && !%validator{name}.validate({{name.id.stringify}}, @{{name}})
@_validation_errors[{{name.stringify}}] ||= [] of String
@_validation_errors[{{name.stringify}}] += %validator{name}.errors
on_validation_error({{name.stringify}}, @{{name}}, %validator{name}.errors)
end
{% end %}
{% end %}
{% end %}
end
# Parse the argument key.
# Standard arg names start with a `--`
# Aliases start with a single `-`
#
# Note: You can override this method to change the way keys are parsed.
def parse_key(arg : String) : String?
if arg.starts_with?("--")
key = arg[2..-1]
elsif arg.starts_with?("-")
key = arg[1..-1]
else
@_positional_args << arg
nil
end
end
# Called when an unknown attribute is found.
#
# Note: You can override this method to change the way unknown attributes are handled.
def on_unknown_attribute(key : String)
raise UnknownAttributeError.new(key)
end
# Called when a required attribute is missing.
#
# Note: You can override this method to change the way missing attributes are handled.
def on_missing_attribute(key : String)
raise MissingAttributeError.new(key)
end
# Called when a validation error occurs.
#
# Note: You can override this method to change the way validation errors are handled.
def on_validation_error(key : String, value, errors : Array(String))
raise ValidationError.new(key, errors)
end
# Called when a value cannot be converted to the expected type.
#
# Note: You can override this method to change the way conversion errors are handled.
def on_conversion_error(key : String, value : String, type)
raise ConversionError.new(key, value, type)
end
# https://en.wikipedia.org/wiki/Quotation_mark#Summary_table
QUOTE_CHARS = {'"' => '"', '“' => '”', '' => '', '«' => '»', '' => '', '❛' => '❜', '❝' => '❞', '' => '', '' => ''}
# Convert the string input into an array of tokens.
# Quoted values should be considered one token, but everything
# else should be split by spaces.
# Should work with all types of quotes, and handle nested quotes.
# Unmatched quotes should be considered part of the token.
#
# Example:
# ```
# input = %q{foo "bar baz" "qux \\"quux" "corge grault}
# tokenize(input) # => ["foo", "bar baz", "qux \"quux", "\"corge", "grault"]
# ```
def self.tokenize(input : String)
tokens = [] of String
current_token = [] of Char
quote = nil
input.each_char do |char|
if quote
if char == quote
quote = nil
else
current_token << char
end
else
if QUOTE_CHARS.has_key?(char)
quote = QUOTE_CHARS[char]
elsif char == ' '
if current_token.any?
tokens << current_token.join
current_token.clear
end
else
current_token << char
end
end
end
tokens << current_token.join if current_token.any?
tokens.reject(&.empty?)
end
end

View File

@ -0,0 +1,9 @@
module ArgParser
annotation Field; end
module Validate
annotation Format; end
annotation InRange; end
end
end

View File

@ -0,0 +1,7 @@
module ArgParser::CommaSeparatedArrayConverter(SubConverter)
def self.from_arg(arg)
arg.split(/,\s*/).map do |a|
SubConverter.from_arg(a)
end
end
end

View File

@ -0,0 +1,5 @@
module ArgParser::EnumNameConverter(E)
def self.from_arg(arg)
E.parse(arg)
end
end

View File

@ -0,0 +1,5 @@
module ArgParser::EnumValueConverter(E)
def self.from_arg(arg)
E.new(arg)
end
end

View File

@ -0,0 +1,5 @@
module ArgParser::EpochConverter
def self.from_arg(arg)
Time.epoch(arg.to_i64)
end
end

View File

@ -0,0 +1,5 @@
module ArgParser::EpochMillisConverter
def self.from_arg(arg)
Time.epoch_ms(arg.to_i64)
end
end

31
src/arg_parser/errors.cr Normal file
View File

@ -0,0 +1,31 @@
module ArgParser
class Error < Exception; end
class UnknownAttributeError < Error
getter attr : String
def initialize(@attr : String)
raise "Unknown attribute: #{@attr}"
end
end
class MissingAttributeError < Error
getter attr : String
def initialize(@attr : String)
raise "Missing required attribute: #{@attr}"
end
end
class ValidationError < Error
def initialize(name, errors)
super("Validation failed for #{name}: #{errors.join(", ")}")
end
end
class ConversionError < Error
def initialize(name, value, type)
super("Failed to convert #{value} to #{type} for field :#{name}")
end
end
end

125
src/arg_parser/from_arg.cr Normal file
View File

@ -0,0 +1,125 @@
class String
def self.from_arg(arg : String)
arg
end
end
struct Int8
def self.from_arg(arg : String)
arg.to_i8
end
end
struct Int16
def self.from_arg(arg : String)
arg.to_i16
end
end
struct Int32
def self.from_arg(arg : String)
arg.to_i32
end
end
struct Int64
def self.from_arg(arg : String)
arg.to_i64
end
end
struct Int128
def self.from_arg(arg : String)
arg.to_i128
end
end
struct UInt8
def self.from_arg(arg : String)
arg.to_u8
end
end
struct UInt16
def self.from_arg(arg : String)
arg.to_u16
end
end
struct UInt32
def self.from_arg(arg : String)
arg.to_u32
end
end
struct UInt64
def self.from_arg(arg : String)
arg.to_u64
end
end
struct UInt128
def self.from_arg(arg : String)
arg.to_u128
end
end
struct Float32
def self.from_arg(arg : String)
arg.to_f32
end
end
struct Float64
def self.from_arg(arg : String)
arg.to_f64
end
end
struct Bool
def self.from_arg(arg : String)
arg.downcase.in?(%w(true t yes y 1))
end
end
class BigInt
def self.from_arg(arg : String)
BigInt.new(arg)
end
end
class BigFloat
def self.from_arg(arg : String)
BigFloat.new(arg)
end
end
class BigRational
def self.from_arg(arg : String)
# BigRational can be instantiated with:
# - a numerator and a denominator
# - a single integer
# - a single float
# We need to try them all
if arg.includes?('/')
numerator, denominator = arg.split('/')
BigRational.new(numerator.to_i64, denominator.to_i64)
elsif arg.includes?('.')
BigRational.new(arg.to_f64)
else
BigRational.new(arg.to_i64)
end
end
end
class URI
def self.from_arg(arg : String)
URI.parse(arg)
end
end
struct UUID
def self.from_arg(arg : String)
UUID.new(arg)
end
end

View File

@ -0,0 +1,7 @@
module ArgParser
abstract class Validator
getter errors = [] of String
abstract def validate(name, input) : Bool
end
end

View File

@ -0,0 +1,14 @@
module ArgParser::Validators
class FormatValidator < ArgParser::Validator
ANNOTATION = Validate::Format
def initialize(@regex : Regex)
end
def validate(name, input) : Bool
return true if @regex =~ input.to_s
errors << "#{name} must match pattern /#{@regex.source}/"
false
end
end
end

View File

@ -0,0 +1,15 @@
module ArgParser::Validators
class RangeValidator(B, E) < ArgParser::Validator
ANNOTATION = Validate::InRange
def initialize(b : B, e : E)
@range = Range(B, E).new(b, e)
end
def validate(name, input) : Bool
return true if @range.includes?(input)
errors << "input for #{name.to_s} must be in range #{@range}"
false
end
end
end