commit c90629ed3d6b060e34adedbd22b6b38fdcffdcd9 Author: Chris Watson Date: Fri Jan 27 01:22:47 2023 -0700 initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bbd4a9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96d9a50 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Chris Watson + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..da2926b --- /dev/null +++ b/README.md @@ -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 () +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 diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..32b1ae3 --- /dev/null +++ b/shard.yml @@ -0,0 +1,14 @@ +name: arg_parser +version: 0.1.0 + +authors: + - Chris Watson + +development_dependencies: + spectator: + gitlab: arctic-fox/spectator + branch: master + +crystal: 1.7.1 + +license: MIT diff --git a/spec/arg_parser_spec.cr b/spec/arg_parser_spec.cr new file mode 100644 index 0000000..b095f47 --- /dev/null +++ b/spec/arg_parser_spec.cr @@ -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 diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..2c88dad --- /dev/null +++ b/spec/spec_helper.cr @@ -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 diff --git a/src/arg_parser.cr b/src/arg_parser.cr new file mode 100644 index 0000000..f00289f --- /dev/null +++ b/src/arg_parser.cr @@ -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 diff --git a/src/arg_parser/annotations.cr b/src/arg_parser/annotations.cr new file mode 100644 index 0000000..7328e2f --- /dev/null +++ b/src/arg_parser/annotations.cr @@ -0,0 +1,9 @@ +module ArgParser + annotation Field; end + + module Validate + annotation Format; end + + annotation InRange; end + end +end diff --git a/src/arg_parser/converters/comma_separated_array_converter.cr b/src/arg_parser/converters/comma_separated_array_converter.cr new file mode 100644 index 0000000..af0dc24 --- /dev/null +++ b/src/arg_parser/converters/comma_separated_array_converter.cr @@ -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 diff --git a/src/arg_parser/converters/enum_name_converter.cr b/src/arg_parser/converters/enum_name_converter.cr new file mode 100644 index 0000000..62ab119 --- /dev/null +++ b/src/arg_parser/converters/enum_name_converter.cr @@ -0,0 +1,5 @@ +module ArgParser::EnumNameConverter(E) + def self.from_arg(arg) + E.parse(arg) + end +end diff --git a/src/arg_parser/converters/enum_value_converter.cr b/src/arg_parser/converters/enum_value_converter.cr new file mode 100644 index 0000000..6db39b7 --- /dev/null +++ b/src/arg_parser/converters/enum_value_converter.cr @@ -0,0 +1,5 @@ +module ArgParser::EnumValueConverter(E) + def self.from_arg(arg) + E.new(arg) + end +end diff --git a/src/arg_parser/converters/epoch_converter.cr b/src/arg_parser/converters/epoch_converter.cr new file mode 100644 index 0000000..273b03d --- /dev/null +++ b/src/arg_parser/converters/epoch_converter.cr @@ -0,0 +1,5 @@ +module ArgParser::EpochConverter + def self.from_arg(arg) + Time.epoch(arg.to_i64) + end +end diff --git a/src/arg_parser/converters/epoch_ms_converter.cr b/src/arg_parser/converters/epoch_ms_converter.cr new file mode 100644 index 0000000..cebb21f --- /dev/null +++ b/src/arg_parser/converters/epoch_ms_converter.cr @@ -0,0 +1,5 @@ +module ArgParser::EpochMillisConverter + def self.from_arg(arg) + Time.epoch_ms(arg.to_i64) + end +end diff --git a/src/arg_parser/errors.cr b/src/arg_parser/errors.cr new file mode 100644 index 0000000..26680ae --- /dev/null +++ b/src/arg_parser/errors.cr @@ -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 diff --git a/src/arg_parser/from_arg.cr b/src/arg_parser/from_arg.cr new file mode 100644 index 0000000..692f85f --- /dev/null +++ b/src/arg_parser/from_arg.cr @@ -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 diff --git a/src/arg_parser/validator.cr b/src/arg_parser/validator.cr new file mode 100644 index 0000000..2413c6b --- /dev/null +++ b/src/arg_parser/validator.cr @@ -0,0 +1,7 @@ +module ArgParser + abstract class Validator + getter errors = [] of String + + abstract def validate(name, input) : Bool + end +end diff --git a/src/arg_parser/validators/format_validator.cr b/src/arg_parser/validators/format_validator.cr new file mode 100644 index 0000000..3702e6d --- /dev/null +++ b/src/arg_parser/validators/format_validator.cr @@ -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 diff --git a/src/arg_parser/validators/range_validator.cr b/src/arg_parser/validators/range_validator.cr new file mode 100644 index 0000000..99a30ef --- /dev/null +++ b/src/arg_parser/validators/range_validator.cr @@ -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