├── src ├── arg_parser │ ├── converters │ │ ├── epoch_converter.cr │ │ ├── enum_name_converter.cr │ │ ├── enum_value_converter.cr │ │ ├── epoch_ms_converter.cr │ │ └── comma_separated_array_converter.cr │ ├── validator.cr │ ├── annotations.cr │ ├── validators │ │ ├── format_validator.cr │ │ └── range_validator.cr │ ├── errors.cr │ └── from_arg.cr └── arg_parser.cr ├── .gitignore ├── .editorconfig ├── spec ├── unit │ ├── from_arg_spec.cr │ ├── converter_spec.cr │ ├── validator_spec.cr │ └── arg_parser_spec.cr └── spec_helper.cr ├── shard.yml ├── .github └── workflows │ └── crystal.yml ├── LICENSE └── README.md /src/arg_parser/converters/epoch_converter.cr: -------------------------------------------------------------------------------- 1 | module ArgParser::EpochConverter 2 | def self.from_arg(arg) 3 | Time.unix(arg.to_i64) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /src/arg_parser/converters/enum_name_converter.cr: -------------------------------------------------------------------------------- 1 | module ArgParser::EnumNameConverter(E) 2 | def self.from_arg(arg) 3 | E.parse(arg) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /src/arg_parser/converters/enum_value_converter.cr: -------------------------------------------------------------------------------- 1 | module ArgParser::EnumValueConverter(E) 2 | def self.from_arg(arg) 3 | E.from_value(arg.to_i64) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /src/arg_parser/converters/epoch_ms_converter.cr: -------------------------------------------------------------------------------- 1 | module ArgParser::EpochMillisConverter 2 | def self.from_arg(arg) 3 | Time.unix_ms(arg.to_i64) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /src/arg_parser/validator.cr: -------------------------------------------------------------------------------- 1 | module ArgParser 2 | abstract class Validator 3 | getter errors = [] of String 4 | 5 | abstract def validate(name, input) : Bool 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /src/arg_parser/annotations.cr: -------------------------------------------------------------------------------- 1 | module ArgParser 2 | annotation Field; end 3 | 4 | module Validate 5 | annotation Format; end 6 | 7 | annotation InRange; end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in applications that use them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /spec/unit/from_arg_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | Spectator.describe "ArgParser from_arg" do 4 | it "should parse a union" do 5 | expect(Union(String, Nil).from_arg("foo")).to eq("foo") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /src/arg_parser/converters/comma_separated_array_converter.cr: -------------------------------------------------------------------------------- 1 | module ArgParser::CommaSeparatedArrayConverter(SubConverter) 2 | def self.from_arg(arg) 3 | arg.split(/,\s*/).map do |a| 4 | SubConverter.from_arg(a) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: arg_parser 2 | version: 0.1.0 3 | 4 | authors: 5 | - Chris Watson 6 | 7 | development_dependencies: 8 | spectator: 9 | gitlab: arctic-fox/spectator 10 | branch: master 11 | 12 | crystal: 1.7.1 13 | 14 | license: MIT 15 | -------------------------------------------------------------------------------- /src/arg_parser/validators/format_validator.cr: -------------------------------------------------------------------------------- 1 | module ArgParser::Validators 2 | class FormatValidator < ArgParser::Validator 3 | ANNOTATION = Validate::Format 4 | 5 | def initialize(@regex : Regex) 6 | end 7 | 8 | def validate(name, input) : Bool 9 | return true if @regex =~ input.to_s 10 | errors << "#{name} must match pattern /#{@regex.source}/" 11 | false 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /src/arg_parser/validators/range_validator.cr: -------------------------------------------------------------------------------- 1 | module ArgParser::Validators 2 | class RangeValidator(B, E) < ArgParser::Validator 3 | ANNOTATION = Validate::InRange 4 | 5 | def initialize(b : B, e : E) 6 | @range = Range(B, E).new(b, e) 7 | end 8 | 9 | def validate(name, input) : Bool 10 | return true if @range.includes?(input) 11 | errors << "input for #{name.to_s} must be in range #{@range}" 12 | false 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.github/workflows/crystal.yml: -------------------------------------------------------------------------------- 1 | name: Crystal CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | container: 15 | image: crystallang/crystal 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Check formatting 20 | run: crystal tool format --check 21 | - name: Install dependencies 22 | run: shards install 23 | - name: Run tests 24 | run: crystal spec --error-trace 25 | -------------------------------------------------------------------------------- /spec/unit/converter_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | Spectator.describe "ArgParser converters" do 4 | let(args) { ["--ids", "1,2,3,4", "--unix", "1674843165", "--unix-ms", "1674843241159", "--color", "red", "--ncolor", "2"] } 5 | let(parser) { TestConverters.new(args) } 6 | 7 | it "converts comma separated list to array" do 8 | expect(parser.ids).to eq [1, 2, 3, 4] 9 | end 10 | 11 | it "converts unix time to time" do 12 | expect(parser.unix).to eq Time.unix(1674843165) 13 | end 14 | 15 | it "converts unix time to time" do 16 | expect(parser.unix_ms).to eq Time.unix_ms(1674843241159) 17 | end 18 | 19 | it "converts string to color" do 20 | expect(parser.color).to eq Color::Red 21 | end 22 | 23 | it "converts string to color" do 24 | expect(parser.ncolor).to eq Color::Blue 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /src/arg_parser/errors.cr: -------------------------------------------------------------------------------- 1 | module ArgParser 2 | class Error < Exception; end 3 | 4 | class UnknownAttributeError < Error 5 | getter attr : String 6 | 7 | def initialize(@attr : String) 8 | raise "Unknown attribute: #{@attr}" 9 | end 10 | end 11 | 12 | class MissingAttributeError < Error 13 | getter attr : String 14 | 15 | def initialize(@attr : String) 16 | raise "Missing required attribute: #{@attr}" 17 | end 18 | end 19 | 20 | class ValidationError < Error 21 | def initialize(name, value, errors) 22 | super("Validation failed for field :#{name} with value #{value.inspect}:\n" + 23 | errors.map { |e| " - #{e}" }.join("\n")) 24 | end 25 | end 26 | 27 | class ConversionError < Error 28 | def initialize(name, value, type) 29 | super("Failed to convert #{value} to #{type} for field :#{name}") 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/unit/validator_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | Spectator.describe "ArgParser validators" do 4 | let(valid_args) { ["positional stuff", "--first_name", "John", "--age", "32"] } 5 | let(invalid_args) { ["positional stuff", "--first_name", "John^", "--last_name", "Doe1", "--age", "101"] } 6 | 7 | context "valid arguments" do 8 | it "should not raise an error" do 9 | expect { TestValidators.new(valid_args) }.not_to raise_error 10 | end 11 | 12 | it "should set the name" do 13 | opts = TestValidators.new(valid_args) 14 | expect(opts.first_name).to eq("John") 15 | end 16 | 17 | it "should set the age" do 18 | opts = TestValidators.new(valid_args) 19 | expect(opts.age).to eq(32) 20 | end 21 | end 22 | 23 | context "invalid arguments" do 24 | it "should raise an error" do 25 | expect { TestValidators.new(invalid_args) }.to raise_error(ArgParser::ValidationError) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/unit/arg_parser_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | Spectator.describe ArgParser do 4 | let(args) { ["--name", "John Doe", "--age", "32", "--height", "163.2", "--is_human", "--website", "https://example.com", "--id", "39aacd14-9e1b-11ed-91ad-b44506ca30d5"] } 5 | let(parser) { TestSupportedArgs.new(args) } 6 | 7 | it "parses string values" do 8 | expect(parser.name).to eq("John Doe") 9 | end 10 | 11 | it "parses int32 values" do 12 | expect(parser.age).to be_a(Int32) 13 | expect(parser.age).to eq(32) 14 | end 15 | 16 | it "parses float64 value" do 17 | expect(parser.height).to be_a(Float64) 18 | expect(parser.height).to eq(163.2) 19 | end 20 | 21 | it "parses boolean values" do 22 | expect(parser.is_human).to be_a(Bool) 23 | expect(parser.is_human).to eq(true) 24 | end 25 | 26 | it "parses uri values" do 27 | expect(parser.website).to be_a(URI) 28 | expect(parser.website.to_s).to eq("https://example.com") 29 | end 30 | 31 | it "parses uuid values" do 32 | expect(parser.id).to be_a(UUID) 33 | expect(parser.id.to_s).to eq("39aacd14-9e1b-11ed-91ad-b44506ca30d5") 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Chris Watson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "../src/arg_parser" 2 | require "spectator" 3 | 4 | require "uri" 5 | require "uuid" 6 | 7 | struct TestSupportedArgs 8 | include ArgParser 9 | 10 | getter name : String 11 | 12 | getter age : Int32 13 | 14 | getter height : Float64 15 | 16 | getter is_human : Bool 17 | 18 | getter website : URI 19 | 20 | getter id : UUID 21 | end 22 | 23 | enum Color 24 | Red 25 | Green 26 | Blue 27 | end 28 | 29 | struct TestConverters 30 | include ArgParser 31 | 32 | @[ArgParser::Field(converter: ArgParser::CommaSeparatedArrayConverter(Int32))] 33 | getter ids : Array(Int32) 34 | 35 | @[ArgParser::Field(converter: ArgParser::EpochConverter)] 36 | getter unix : Time 37 | 38 | @[ArgParser::Field(converter: ArgParser::EpochMillisConverter, key: "unix-ms")] 39 | getter unix_ms : Time 40 | 41 | @[ArgParser::Field(converter: ArgParser::EnumNameConverter(Color))] 42 | getter color : Color 43 | 44 | @[ArgParser::Field(converter: ArgParser::EnumValueConverter(Color))] 45 | getter ncolor : Color 46 | end 47 | 48 | struct TestValidators 49 | include ArgParser 50 | 51 | @[ArgParser::Validate::Format(/^[a-zA-Z]+$/)] 52 | getter first_name : String 53 | 54 | @[ArgParser::Validate::Format(/^[a-zA-Z]+$/)] 55 | getter last_name : String? 56 | 57 | @[ArgParser::Validate::InRange(1, 100)] 58 | getter age : Int32 59 | end 60 | -------------------------------------------------------------------------------- /src/arg_parser/from_arg.cr: -------------------------------------------------------------------------------- 1 | class String 2 | def self.from_arg(arg : String) 3 | arg 4 | end 5 | end 6 | 7 | struct Int8 8 | def self.from_arg(arg : String) 9 | arg.to_i8 10 | end 11 | end 12 | 13 | struct Int16 14 | def self.from_arg(arg : String) 15 | arg.to_i16 16 | end 17 | end 18 | 19 | struct Int32 20 | def self.from_arg(arg : String) 21 | arg.to_i32 22 | end 23 | end 24 | 25 | struct Int64 26 | def self.from_arg(arg : String) 27 | arg.to_i64 28 | end 29 | end 30 | 31 | struct Int128 32 | def self.from_arg(arg : String) 33 | arg.to_i128 34 | end 35 | end 36 | 37 | struct UInt8 38 | def self.from_arg(arg : String) 39 | arg.to_u8 40 | end 41 | end 42 | 43 | struct UInt16 44 | def self.from_arg(arg : String) 45 | arg.to_u16 46 | end 47 | end 48 | 49 | struct UInt32 50 | def self.from_arg(arg : String) 51 | arg.to_u32 52 | end 53 | end 54 | 55 | struct UInt64 56 | def self.from_arg(arg : String) 57 | arg.to_u64 58 | end 59 | end 60 | 61 | struct UInt128 62 | def self.from_arg(arg : String) 63 | arg.to_u128 64 | end 65 | end 66 | 67 | struct Float32 68 | def self.from_arg(arg : String) 69 | arg.to_f32 70 | end 71 | end 72 | 73 | struct Float64 74 | def self.from_arg(arg : String) 75 | arg.to_f64 76 | end 77 | end 78 | 79 | struct Bool 80 | def self.from_arg(arg : String) 81 | arg.downcase.in?(%w(true t yes y 1)) 82 | end 83 | end 84 | 85 | class BigInt 86 | def self.from_arg(arg : String) 87 | BigInt.new(arg) 88 | end 89 | end 90 | 91 | class BigFloat 92 | def self.from_arg(arg : String) 93 | BigFloat.new(arg) 94 | end 95 | end 96 | 97 | class BigRational 98 | def self.from_arg(arg : String) 99 | # BigRational can be instantiated with: 100 | # - a numerator and a denominator 101 | # - a single integer 102 | # - a single float 103 | # We need to try them all 104 | if arg.includes?('/') 105 | numerator, denominator = arg.split('/') 106 | BigRational.new(numerator.to_i64, denominator.to_i64) 107 | elsif arg.includes?('.') 108 | BigRational.new(arg.to_f64) 109 | else 110 | BigRational.new(arg.to_i64) 111 | end 112 | end 113 | end 114 | 115 | class URI 116 | def self.from_arg(arg : String) 117 | URI.parse(arg) 118 | end 119 | end 120 | 121 | struct UUID 122 | def self.from_arg(arg : String) 123 | UUID.new(arg) 124 | end 125 | end 126 | 127 | struct Nil 128 | def self.from_arg(arg : String) 129 | nil 130 | end 131 | end 132 | 133 | def Union.from_arg(arg : String) 134 | {% begin %} 135 | {% for type in T %} 136 | {% if type != Nil %} 137 | begin 138 | %val = {{type}}.from_arg(arg) 139 | return %val if %val.is_a?({{type}}) 140 | rescue 141 | end 142 | {% end %} 143 | {% end %} 144 | 145 | {% if T.includes?(Nil) %} 146 | nil 147 | {% else %} 148 | raise ArgParser::Error.new("Argument '#{arg}' cannot be converted to any of the union types: {{T}}") 149 | {% end %} 150 | {% end %} 151 | end 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ArgParser 2 | 3 | [![Tests](https://github.com/watzon/arg_parser/actions/workflows/crystal.yml/badge.svg)](https://github.com/watzon/arg_parser/actions/workflows/crystal.yml) 4 | 5 | A powerful argument parser which uses a class or struct to define the arguments and their types. 6 | 7 | ## Installation 8 | 9 | 1. Add the dependency to your `shard.yml`: 10 | 11 | ```yaml 12 | dependencies: 13 | arg_parser: 14 | github: watzon/arg_parser 15 | ``` 16 | 17 | 2. Run `shards install` 18 | 19 | ## Usage 20 | 21 | 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. 22 | 23 | ```crystal 24 | struct MyArgs 25 | include ArgParser 26 | 27 | getter name : String 28 | getter age : Int32 29 | getter active : Bool 30 | end 31 | ``` 32 | 33 | 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. 34 | 35 | ```crystal 36 | args = MyArgs.new(["--name", "John Doe", "--age", "20", "--active"]) 37 | args.name # => "John Doe" 38 | args.age # => 20 39 | args.active # => true 40 | ``` 41 | 42 | 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. 43 | 44 | ```crystal 45 | args = MyArgs.new(["\"Hello world\"", --name", "John Doe", "--age", "20", "--active"]) 46 | args._positional_args # => ["Hello world"] 47 | ``` 48 | 49 | ## Supported Types 50 | 51 | By default ArgParser supports the following types: 52 | * `String` 53 | * `Int` 54 | * `UInt` 55 | * `Float` 56 | * `BigInt` 57 | * `BigFloat` 58 | * `BigDecimal` 59 | * `BigRational` 60 | * `Bool` 61 | * `URI` 62 | * `UUID` 63 | 64 | Any type which implements `from_arg` can be used as an argument type. 65 | For types which don't implement `from_arg`, you can define a converter 66 | which implements `from_arg` as a proxy for that type. 67 | 68 | ## Converters 69 | 70 | Converters are simply modules which have a `self.from_arg` method which takes 71 | a value string, and returns the converted value. For Example: 72 | 73 | ``` 74 | module MyConverter 75 | def self.from_arg(value : String) 76 | # do something with value 77 | end 78 | end 79 | ``` 80 | 81 | Converters can be used through the `ArgParser::Field` annotation. 82 | 83 | ```crystal 84 | struct MyArgs 85 | include ArgParser 86 | 87 | @[ArgParser::Field(converter: MyConverter)] 88 | getter name : SomeType 89 | end 90 | ``` 91 | 92 | # Aliases 93 | 94 | Aliases are simply other names for an argument. For example, if you want to use 95 | `-n` as an alias for `--name`, you can do so with the `ArgParser::Field` annotation. 96 | 97 | ```crystal 98 | struct MyArgs 99 | include ArgParser 100 | 101 | @[ArgParser::Field(alias: "-n")] 102 | getter name : String 103 | end 104 | ``` 105 | 106 | Currently only a single alias is supported. 107 | 108 | ## Default Values 109 | 110 | 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`: 111 | 112 | ```crystal 113 | struct MyArgs 114 | include ArgParser 115 | 116 | getter name : String = "John Doe" 117 | end 118 | ``` 119 | 120 | ## Validators 121 | 122 | 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. 123 | 124 | ```crystal 125 | struct MyArgs 126 | include ArgParser 127 | 128 | @[ArgParser::Validate::Format(/[a-zA-Z]+/)] 129 | getter name : String 130 | end 131 | ``` 132 | 133 | 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. 134 | 135 | ```crystal 136 | struct MyArgs 137 | include ArgParser 138 | 139 | @[ArgParser::Validate::Format(/[a-zA-Z]+/)] 140 | getter name : String 141 | 142 | def on_validation_error(field : Symbol, value, errors : Array(String)) 143 | # allow it, but print a warning 144 | puts "Invalid value for #{field}: #{value}" 145 | end 146 | end 147 | ``` 148 | 149 | 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. 150 | 151 | ```crystal 152 | args = MyArgs.new(["--name", "John Doe", "--age", "foo", "--active"]) 153 | args._validation_errors # => {"age" => ["must be an integer"]} 154 | ``` 155 | 156 | ## Modifying the Behavior of ArgParser 157 | 158 | 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: 159 | 160 | - `on_validation_error` - called when a validation error occurs; by default calls `add_validation_error` and then raises `ArgParser::ValidationError` 161 | - `on_unknown_attribute` - called when an unknown attribute is encountered; by default raises `ArgParser::UnknownAttributeError` 162 | - `on_missing_attribute` - called when a required attribute is missing; by default raises `ArgParser::MissingAttributeError` 163 | - `on_conversion_error` - called when a value isn't able to be converted to the specified type; by default raises `ArgParser::ConversionError` 164 | 165 | 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`. 166 | 167 | ## Contributing 168 | 169 | 1. Fork it () 170 | 2. Create your feature branch (`git checkout -b my-new-feature`) 171 | 3. Commit your changes (`git commit -am 'Add some feature'`) 172 | 4. Push to the branch (`git push origin my-new-feature`) 173 | 5. Create a new Pull Request 174 | 175 | ## Contributors 176 | 177 | - [Chris Watson](https://github.com/your-github-user) - creator and maintainer 178 | -------------------------------------------------------------------------------- /src/arg_parser.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "./arg_parser/*" 3 | require "./arg_parser/validators/*" 4 | require "./arg_parser/converters/*" 5 | 6 | # A powerful argument parser which uses a class or struct to 7 | # define the arguments and their types. 8 | module ArgParser 9 | annotation Field; end 10 | 11 | @[ArgParser::Field(ignore: true)] 12 | getter _positional_args : Array(String) 13 | 14 | @[ArgParser::Field(ignore: true)] 15 | getter _validation_errors : Hash(String, Array(String)) 16 | 17 | @[ArgParser::Field(ignore: true)] 18 | getter _field_names : Array(String) 19 | 20 | # Create a new {{@type}} from an array of arguments. 21 | # See: https://github.com/watzon/arg_parser for more information. 22 | def initialize(args : Array(String)) 23 | @_positional_args = [] of String 24 | @_validation_errors = {} of String => Array(String) 25 | @_field_names = [] of String 26 | {% begin %} 27 | %args = args.clone 28 | {% properties = {} of Nil => Nil %} 29 | {% for ivar in @type.instance_vars %} 30 | {% ann = ivar.annotation(ArgParser::Field) %} 31 | {% unless ann && ann[:ignore] %} 32 | @_field_names << {{ivar.id.stringify}} 33 | {% if ann && ann[:alias] %} 34 | @_field_names << {{ann[:alias].id.stringify}} 35 | {% end %} 36 | 37 | {% 38 | properties[ivar.id] = { 39 | type: ivar.type, 40 | key: ((ann && ann[:key]) || ivar).id.stringify, 41 | has_default: ivar.has_default_value?, 42 | default: ivar.default_value, 43 | nilable: ivar.type.nilable?, 44 | converter: ann && ann[:converter], 45 | presence: ann && ann[:presence], 46 | alias: ann && ann[:alias], 47 | validators: [] of Nil, 48 | } 49 | %} 50 | 51 | {% for validator in ArgParser::Validator.all_subclasses.reject { |k| k.abstract? } %} 52 | {% v_ann = validator.constant("ANNOTATION").resolve %} 53 | {% if ann = ivar.annotation(v_ann) %} 54 | {% properties[ivar.id][:validators] << {ann, validator} %} 55 | {% end %} 56 | {% end %} 57 | {% end %} 58 | {% end %} 59 | 60 | {% for name, value in properties %} 61 | %var{name} = {% if value[:type] < Array %}[] of {{value[:type].type_vars[0]}}{% else %}nil{% end %} 62 | %found{name} = false 63 | {% end %} 64 | 65 | i = 0 66 | while !%args.empty? 67 | arg = %args.shift 68 | next unless key = parse_key(arg) 69 | 70 | # TODO: Find a different way to handle this 71 | value = %args.shift rescue "true" 72 | if value && parse_key(value) 73 | %args.unshift(value) 74 | value = "true" 75 | end 76 | 77 | next unless value 78 | 79 | case key 80 | {% for name, value in properties %} 81 | when {{ value[:key].id.stringify }}{% if value[:alias] %}, {{ value[:alias].id.stringify }}{% end %} 82 | %found{name} = true 83 | begin 84 | {% if value[:type] < Array %} 85 | %var{name} ||= [] of {{value[:type].type_vars[0]}} 86 | {% if value[:converter] %} 87 | %var{name}.concat {{ value[:converter] }}.from_arg(value) 88 | {% else %} 89 | %var{name} << {{value[:type].type_vars[0]}}.from_arg(value) 90 | {% end %} 91 | {% else %} 92 | {% if value[:converter] %} 93 | %var{name} = {{ value[:converter] }}.from_arg(value) 94 | {% else %} 95 | %var{name} = {{value[:type]}}.from_arg(value) 96 | {% end %} 97 | {% end %} 98 | rescue 99 | on_conversion_error({{value[:key].id.stringify}}, value, {{value[:type]}}) 100 | end 101 | {% end %} 102 | else 103 | on_unknown_attribute(key) 104 | end 105 | end 106 | 107 | {% for name, value in properties %} 108 | {% unless value[:nilable] || value[:has_default] %} 109 | if %var{name}.nil? && !%found{name} && !{{value[:type]}}.nilable? 110 | on_missing_attribute({{value[:key].id.stringify}}) 111 | end 112 | {% end %} 113 | 114 | {% if value[:nilable] %} 115 | {% if value[:has_default] != nil %} 116 | @{{name}} = %found{name} ? %var{name} : {{value[:default]}} 117 | {% else %} 118 | @{{name}} = %var{name} 119 | {% end %} 120 | {% elsif value[:has_default] %} 121 | if %found{name} && !%var{name}.nil? 122 | @{{name}} = %var{name} 123 | end 124 | {% else %} 125 | @{{name}} = (%var{name}).as({{value[:type]}}) 126 | {% end %} 127 | 128 | {% if value[:presence] %} 129 | @{{name}}_present = %found{name} 130 | {% end %} 131 | 132 | {% for v in value[:validators] %} 133 | {% 134 | ann = v[0] 135 | validator = v[1] 136 | args = [] of String 137 | %} 138 | 139 | {% for arg in ann.args %} 140 | {% args << arg.stringify %} 141 | {% end %} 142 | 143 | {% for k, v in ann.named_args %} 144 | {% args << "#{k.id}: #{v.stringify}" %} 145 | {% end %} 146 | 147 | %validator{name} = {{ validator.name(generic_args: false) }}.new({{ args.join(", ").id }}) 148 | if %found{name} && !%var{name}.nil? && !%validator{name}.validate({{name.id.stringify}}, %var{name}) 149 | on_validation_error({{name.stringify}}, %var{name}, %validator{name}.errors) 150 | end 151 | {% end %} 152 | {% end %} 153 | {% end %} 154 | end 155 | 156 | # Parse the argument key. 157 | # Standard arg names start with a `--. 158 | # Aliases start with a single `-`. 159 | # 160 | # Arguments without a value become boolean true values. 161 | # 162 | # Note: You can override this method to change the way keys are parsed. 163 | def parse_key(arg : String) : String? 164 | if arg.starts_with?("--") 165 | key = arg[2..-1] 166 | elsif arg.starts_with?("-") 167 | key = arg[1..-1] 168 | else 169 | @_positional_args << arg 170 | nil 171 | end 172 | end 173 | 174 | # Called when an unknown attribute is found. 175 | # 176 | # Note: You can override this method to change the way unknown attributes are handled. 177 | def on_unknown_attribute(key : String) 178 | raise UnknownAttributeError.new(key) 179 | end 180 | 181 | # Called when a required attribute is missing. 182 | # 183 | # Note: You can override this method to change the way missing attributes are handled. 184 | def on_missing_attribute(key : String) 185 | raise MissingAttributeError.new(key) 186 | end 187 | 188 | # Called when a validation error occurs. 189 | # 190 | # Note: You can override this method to change the way validation errors are handled. 191 | def on_validation_error(key : String, value, errors : Array(String)) 192 | add_validation_error(key, errors) 193 | raise ValidationError.new(key, value, errors) 194 | end 195 | 196 | # Called when a value cannot be converted to the expected type. 197 | # 198 | # Note: You can override this method to change the way conversion errors are handled. 199 | def on_conversion_error(key : String, value : String, type) 200 | raise ConversionError.new(key, value, type) 201 | end 202 | 203 | def add_validation_error(key : String, errors : Array(String)) 204 | @_validation_errors[key] ||= [] of String 205 | @_validation_errors[key].concat errors 206 | end 207 | 208 | # https://en.wikipedia.org/wiki/Quotation_mark#Summary_table 209 | QUOTE_CHARS = {'"' => '"', '“' => '”', '‘' => '’', '«' => '»', '‹' => '›', '❛' => '❜', '❝' => '❞', '❮' => '❯', '"' => '"'} 210 | 211 | # Convert the string input into an array of tokens. 212 | # Quoted values should be considered one token, but everything 213 | # else should be split by spaces. 214 | # Should work with all types of quotes, and handle nested quotes. 215 | # Unmatched quotes should be considered part of the token. 216 | # 217 | # Example: 218 | # ``` 219 | # input = %q{foo "bar baz" "qux \\"quux" "corge grault} 220 | # tokenize(input) # => ["foo", "bar baz", "qux \"quux", "\"corge", "grault"] 221 | # ``` 222 | def self.tokenize(input : String) 223 | tokens = [] of String 224 | current_token = [] of Char 225 | quote = nil 226 | input.each_char do |char| 227 | if quote 228 | if char == quote 229 | quote = nil 230 | else 231 | current_token << char 232 | end 233 | else 234 | if QUOTE_CHARS.has_key?(char) 235 | quote = QUOTE_CHARS[char] 236 | elsif char == ' ' 237 | if current_token.any? 238 | tokens << current_token.join 239 | current_token.clear 240 | end 241 | else 242 | current_token << char 243 | end 244 | end 245 | end 246 | tokens << current_token.join if current_token.any? 247 | tokens.reject(&.empty?) 248 | end 249 | end 250 | --------------------------------------------------------------------------------