Skip to content

Instantly share code, notes, and snippets.

@manveru
Created July 16, 2021 19:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save manveru/5050af58be7e36bbc9d116eb5d8eccba to your computer and use it in GitHub Desktop.
Save manveru/5050af58be7e36bbc9d116eb5d8eccba to your computer and use it in GitHub Desktop.
Parsing configs in Crystal
require "spec"
require "json"
require "uri"
require "option_parser"
module Test
annotation Flag
end
module Configuration
def initialize(hash : Hash(String, String), file : String?)
json =
if file
JSON.parse(File.read(file))
else
JSON::Any.new({} of String => JSON::Any)
end
{% for ivar in @type.instance_vars %}
{% ann = ivar.annotation(Flag) %}
%hash_key = {{ivar.id.stringify}}
%env_key = {{ann[:env]}}
%value = hash[%hash_key]? || json[%hash_key]?.try(&.as_s)
%value = ENV[%env_key]? if %value.nil? && %env_key
{% if ivar.has_default_value? %}
%value ||= {{ ivar.default_value }}
{% end %}
{% unless ivar.type.nilable? %}
if %value.nil?
raise(
begin
notice = ["Missing value for the option '{{ivar.id}}'. Please set it one of these ways:"]
notice << "flag: '-{{ann[:short].id}}'" if {{ann[:short]}}
notice << "flag: '--{{ann[:long].id}}'" if {{ann[:long]}}
notice << "environment variable: '{{ann[:env].id}}'" if {{ann[:env]}}
notice.join("\n")
end
)
end
{% end %}
@{{ivar.id}} = convert(%value, {{ivar.type}})
{% debug %}
{% end %}
end
def convert(value : String, kind : Array(String).class)
value.split(',')
end
def convert(value : String | Nil, kind : (String | Nil).class)
value if value
end
def convert(value : String, kind : URI.class)
URI.parse(value)
end
def convert(value : URI, kind : URI.class)
value
end
def convert(value : String, kind : String.class)
value
end
def convert(value : String | Nil, kind : (Path | Nil).class)
Path.new(value) if value
end
macro included
extend OptionParserFlags
def self.configure
hash = {} of String => String
yield(hash)
new(hash, nil)
end
end
end
module OptionParserFlags
def option_parser(parser, config)
{% for ivar in @type.instance_vars %}
{% ann = ivar.annotation(Flag) %}
%short = {{ann[:short]}}
%long = {{ann[:long]}}
if %short && %long
parser.on "-#{%short}=VALUE", "--#{%long}=VALUE", {{ann[:help]}} do |value|
config[{{ivar.id.stringify}}] = value
end
elsif %short
parser.on "-#{%short}=VALUE", {{ann[:help]}} do |value|
config[{{ivar.id.stringify}}] = value
end
elsif %long
parser.on "--#{%long}=VALUE", {{ann[:help]}} do |value|
config[{{ivar.id.stringify}}] = value
end
end
{% end %}
end
end
struct Config
include Configuration
@[Flag(short: 'a', long: "aa", env: "A", help: "a")]
property a : String
@[Flag(short: 'd', env: "D", help: "d")]
property d : String = "from default"
end
struct Sub
include Configuration
@[Flag(short: 's', env: "S", help: "s")]
property s : String
end
struct Types
include Configuration
@[Flag(help: "s")]
property url : URI
end
end
Spec.before_each do
ENV.delete "A"
end
describe Test::Configuration do
empty = {} of String => String
file = File.join(__DIR__, "fixtures/tiny.json.fixture")
it "is configurable from environment" do
ENV["A"] = "from env"
c = Test::Config.new(empty, file: nil)
c.a.should eq("from env")
end
it "is configurable from a hash" do
c = Test::Config.new({"a" => "from flag"}, file: nil)
c.a.should eq("from flag")
end
it "is configurable from a file" do
c = Test::Config.new(empty, file: file)
c.a.should eq("from file")
end
it "is configurable from default" do
c = Test::Config.new(empty, file: file)
c.d.should eq("from default")
end
it "prefers file over env" do
ENV["A"] = "from env"
c = Test::Config.new(empty, file: file)
c.a.should eq("from file")
end
it "prefers flag over file" do
ENV["A"] = "from env"
c = Test::Config.new({"a" => "from flag"}, file: file)
c.a.should eq("from flag")
end
it "is configurable on the fly" do
main_config = {} of String => String
sub_config = {} of String => String
op = OptionParser.new do |parser|
Test::Config.option_parser(parser, main_config)
parser.on "sub", "first subcommand" do
Test::Sub.option_parser(parser, sub_config)
end
end
op.parse(["sub", "-s", "from flag s", "--aa", "from flag a", "-d", "from flag d"])
Test::Sub.new(sub_config, nil).s.should eq("from flag s")
Test::Config.new(main_config, nil).a.should eq("from flag a")
Test::Config.new(main_config, nil).d.should eq("from flag d")
end
it "handles different types" do
Test::Types.new({
"url" => "http://example.com",
}, nil).url.should eq(URI.parse("http://example.com"))
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment