Added new macro features. resolve_value + default: key. Added raises to prevent CliGen features from being used outside of a already parsed state, and worked on specs. Will be working on specs later on as well to finish covering regex & redo timeparse & relative_operations

This commit is contained in:
2026-09-07 16:40:02 -05:00
parent 3fa77f5707
commit 8fab6912fc
89 changed files with 3615 additions and 515 deletions
+107
View File
@@ -0,0 +1,107 @@
require "../spec_helper"
@[CliGen::CommandInfo(description: "Abc", singleton_init: true)]
class MyCmd < CliGen::Command
argument(abcdef : String = "123",
description: "This is a thing"
)
argument(ghijkl : Int32 = 666,
description: "The number of the devil"
)
argument(badvar : String,
description: "This will fail on init"
)
def main
puts "abc"
end
def get_badvar
resolve_value badvar, default: "was unset"
end
end
@[CliGen::CommandInfo(description: "Def", parent: ::MyCmd, singleton_init: true)]
class MySubCmd < CliGen::Command
argument(dfdfdf : String = "abc",
description: "ALKJSDFLSKDFJ"
)
def main
resolve_value abcdef
end
end
@[CliGen::CommandInfo(description: "Def", parent: ::MySubCmd, singleton_init: true)]
class MySubSubCmd < CliGen::Command
def main
resolve_value ghijkl
end
def main2
resolve_value badvar, default: "unset"
end
end
def get_handler_for(cls : String)
if handler = CliGen::App.get.all_commands.find(&.meta.cls.== cls)
handler
else
raise "ERROR"
end
end
describe CliGen::Command do
before_each do
ENV.delete("MYCMD_BADVAR")
end
describe "#resolve_value" do
it "does work 1 level deep" do
MySubCmd.new.main.should eq("123")
end
it "works 2 levels deep" do
MySubSubCmd.new.main.should eq(666)
end
it "If a argument is without a default value it will fall back to the default" do
MySubSubCmd.new.main2.should eq("unset")
end
it "will return the provided value if the Flag(T) has an ENVVAR to match" do
ENV["MYCMD_BADVAR"]="ABC"
MySubSubCmd.new.main2.should eq("ABC")
end
it "works on instance variables" do
a = "the cake was a lie"
ENV["MYCMD_BADVAR"] = a
handler = get_handler_for("MyCmd")
MyCmd.new(handler: handler).get_badvar.should eq(a)
end
end
describe "#initialize" do
it "will raise CliGen::MissingRequiredFlagError on handler initialize if no flag value is set for badvar" do
handler = get_handler_for("MyCmd")
expect_raises(CliGen::MissingRequiredFlagError) do
MyCmd.new(handler: handler)
end
end
it "will not raise if badvar is set via ENV VAR" do
ENV["MYCMD_BADVAR"]="the cake was a lie"
handler = get_handler_for("MyCmd")
MyCmd.new(handler: handler).get_badvar.should eq("the cake was a lie")
end
end
describe ".get" do
it "will raise CliGen::AppNotProcessedError if a user attempts to use .get before CliGen::App has processed commandline arguments" do
expect_raises(CliGen::AppNotProcessedError) do
MySubCmd.get
end
end
end
end
+9 -9
View File
@@ -104,21 +104,21 @@ describe CliGen::Flag do
before_each { ENV.delete("TEST") }
describe "#check!" do
it "will throw CliGen::ReservedFlagError if -h is used as short" do
expect_raises(CliGen::ReservedFlagError) do
make_flag(type: Bool, short: "-h", long: "--not-help").check!
it "will throw CliGen::ConfigurationError if a short is provided as a long" do
expect_raises(CliGen::ConfigurationError) do
make_flag(type: Bool, short: nil, long: "-n").check!
end
end
it "will throw CliGen::ReservedFlagError if --help is used in long" do
expect_raises(CliGen::ReservedFlagError) do
make_flag(type: Bool, short: "-b", long: "--help TOPIC").check!
it "will throw CliGen::ConfigurationError if a long is provided as a short" do
expect_raises(CliGen::ConfigurationError) do
make_flag(type: Bool, short: "--b", long: "--help TOPIC").check!
end
end
it "will throw CliGen::ReservedFlagError if --help is used as long_key" do
expect_raises(CliGen::ReservedFlagError) do
make_flag(type: Bool, short: "-b", long: "--help").check!
it "will throw CliGen::ConfigurationError if long flag does not have enough characters" do
expect_raises(CliGen::ConfigurationError) do
make_flag(type: Bool, short: "-b", long: "--h").check!
end
end
end
+344
View File
@@ -0,0 +1,344 @@
require "../spec_helper"
macro get_match(a, b, &work)
match = CliGen::Regex::{{a}}.match({{b}}).not_nil!
{{work.body}}
end
macro does_match(a, b)
CliGen::Regex::{{a}}.matches?({{b}}).should be_true
end
macro doesnt_match(a, b)
CliGen::Regex::{{a}}.matches?({{b}}).should be_false
end
describe CliGen::Regex do
describe "FLAG_REGEX" do
it "will match LONG (--long) format" do
does_match(FLAG_REGEX, "--long")
end
it "will match a SHORT (-s) format" do
does_match(FLAG_REGEX, "-s")
end
it "will not match a LONG with an arg (--long=abc)" do
doesnt_match(FLAG_REGEX, "--long=abc")
end
it "will not match a SHORT with an arg (-s=abc)" do
doesnt_match(FLAG_REGEX, "-s=abc")
end
end
describe "FLAG_WITH_ARG" do
it "will match a LONG with arg without quotes (--long=arg)" do
get_match(FLAG_WITH_ARG, "--long=arg") do
match["flag"].should eq("--long")
match["arg"].should eq("arg")
end
end
it "will match a LONG with arg with quotes (--long=\"arg\")" do
get_match(FLAG_WITH_ARG, "--long=\"arg\"") do
match["flag"].should eq("--long")
match["arg"].should eq("arg")
end
end
it "will match a LONG with arg with quotes and spaces in the arg (--long=\"arg abcd ef\")" do
get_match(FLAG_WITH_ARG, "--long=\"arg abcd ef\"") do
match["flag"].should eq("--long")
match["arg"].should eq("arg abcd ef")
end
end
it "will match a LONG with arg without quotes and with spaces in the arg (--long=arg abcd ef)" do
get_match(FLAG_WITH_ARG, "--long=arg abcd ef") do
match["flag"].should eq("--long")
match["arg"].should eq("arg abcd ef")
end
end
end
describe "FLAG_MULTIPLE_SHORT" do
it "does match a valid multi-short flag (-abcdef)" do
does_match(FLAG_MULTIPLE_SHORT, "-abcdef")
end
it "does not match a singleshort flag (-a)" do
doesnt_match(FLAG_MULTIPLE_SHORT, "-a")
end
{% for int in (0..9) %}
it "does not match a multi-short flag with a digit in it (-abc{{int}}) as -{{int}} is not a valid short" do
doesnt_match(FLAG_MULTIPLE_SHORT, "-abc{{int}}")
end
{% end %}
it "does not match a long flag (--long)" do
doesnt_match(FLAG_MULTIPLE_SHORT, "--long")
end
end
describe "FLAG_LONG" do
it "matches a valid long flag (--long)" do
does_match(FLAG_LONG, "--long")
end
it "does not match a short flag (-s)" do
doesnt_match(FLAG_LONG, "-s")
end
it "doesn't match a long flag with an arg" do
doesnt_match(FLAG_LONG, "--long=abc")
end
end
describe "FLAG_SHORT" do
it "matches a valid short flag (-s)" do
does_match(FLAG_SHORT, "-s")
end
{% for int in (0..9) %}
it "does not match a digit flag (-{{int}})" do
doesnt_match(FLAG_SHORT, "-{{int}}")
end
{% end %}
end
describe "TIMEZONE" do
{% for sign in %w[ - + ] %}
{% for i in [ 2, 4, 7 ] %}
{% for j in [ 0, 7, 59 ] %}
{% format = "%s%02d%02d".id %}
it "will match a valid offset ({{format}})" % [ {{sign}}, {{i}}, {{j}} ] do
does_match(TIMEZONE, {{format.stringify}} % [ {{sign}}, {{i}}, {{j}} ])
end
{% end %}
{% end %}
{% end %}
it "won't accept any offsets above \"+2359\"" do
doesnt_match(TIMEZONE, "-2400")
end
it "won't accept any invalid minute values (+2361)" do
doesnt_match(TIMEZONE, "-2361")
end
it "able to parse fields out of the offset (-2330)" do
get_match(TIMEZONE, "-2330") do
match["offset_sign"].should eq("-")
match["offset_hour"].should eq("23")
match["offset_minute"].should eq("30")
match["timezone"].should eq("-2330")
end
end
end
describe "TIME" do
it "should be able to match a valid time (22:23:24) and parse the fields" do
does_match(TIME, "22:23:24")
get_match(TIME, "22:23:24") do
match["hour"].should eq("22")
match["minute"].should eq("23")
match["second"].should eq("24")
match["time"].should eq("22:23:24")
end
end
end
describe "DATE" do
it "should be able to match a valid date (2026-08-10) and parse the fields" do
does_match(DATE, "2026-08-10")
get_match(DATE, "2026-08-10") do
match["date"].should eq("2026-08-10")
match["year"].should eq("2026")
match["month"].should eq("08")
match["day"].should eq("10")
end
end
end
describe "EPOCH" do
it "should match a valid epoch time, based on the pattern, and parse it's fields (@1788748305)" do
does_match(EPOCH, "@1788748305")
get_match(EPOCH, "@1788748305") do
match["epoch"].should eq("1788748305")
end
end
end
describe "RELATIVE" do
it "should match a valid operation" do
{% for sign in %w[ - + ].map(&.id) %}
{% for token in %w[ day days week weeks hour hours month months second seconds year years ].map(&.id) %}
does_match(RELATIVE, "{{sign}}1 {{token}}")
{% end %}
{% end %}
end
it "should not match invalid units (taco, tuesday, misfit, really?)" do
tokens = %w[ taco tuesday misfit really? ]
tokens.each do |token|
doesnt_match(RELATIVE, "+1 #{token}")
end
end
end
describe "RELATIVE_OPERATION" do
it "should match a valid operation and we can parse the fields" do
{% for sign in %w[ - + ].map(&.id) %}
{% for int in %w[ 1 3 10 ].map(&.id) %}
{% for token in %w[ day days week weeks hour hours month months second seconds year years ].map(&.id) %}
get_match(RELATIVE_OPERATION, "{{sign}}{{int}} {{token}}") do
match["sign"].should eq("{{sign}}")
match["quantity"].should eq("{{int}}")
match["unit"].should eq("{{token}}")
end
{% end %}
{% end %}
{% end %}
end
end
describe "INPUT_DATE_FULL" do
it "will match a bounded full date with an offset (2024-08-13 22:23:24 -0700)" do
get_match(INPUT_DATE_FULL, "2024-08-13 22:23:24 -0700") do
match["date"].should eq("2024-08-13")
match["year"].should eq("2024")
match["month"].should eq("08")
match["day"].should eq("13")
match["time"].should eq("22:23:24")
match["hour"].should eq("22")
match["minute"].should eq("23")
match["second"].should eq("24")
match["timezone"].should eq("-0700")
match["offset_sign"].should eq("-")
match["offset_hour"].should eq("07")
match["offset_minute"].should eq("00")
end
end
it "will match a bounded full date without an offset (2024-08-13 22:23:24)" do
get_match(INPUT_DATE_FULL, "2024-08-13 22:23:24") do
match["date"].should eq("2024-08-13")
match["year"].should eq("2024")
match["month"].should eq("08")
match["day"].should eq("13")
match["time"].should eq("22:23:24")
match["hour"].should eq("22")
match["minute"].should eq("23")
match["second"].should eq("24")
match["timezone"]?.should eq(nil)
end
end
end
describe "INPUT_DATE_SIMPLE" do
it "will match a bounded simple date without an offset (2024-08-13)" do
get_match(INPUT_DATE_SIMPLE, "2024-08-13") do
match["date"].should eq("2024-08-13")
match["year"].should eq("2024")
match["month"].should eq("08")
match["day"].should eq("13")
match["timezone"]?.should eq(nil)
end
end
it "will match a bounded simple date with an offset (2024-08-13 -0700)" do
get_match(INPUT_DATE_SIMPLE, "2024-08-13 -0700") do
match["date"].should eq("2024-08-13")
match["year"].should eq("2024")
match["month"].should eq("08")
match["day"].should eq("13")
match["timezone"].should eq("-0700")
match["offset_sign"].should eq("-")
match["offset_hour"].should eq("07")
match["offset_minute"].should eq("00")
end
end
end
describe "INPUT_DATE_EPOCH" do
it "will match a bounded epoch time without an offset (@1788748305)" do
get_match(INPUT_DATE_EPOCH, "@1788748305") do
match["epoch"].should eq("1788748305")
match["timezone"]?.should eq(nil)
end
end
it "will match a bounded simple date with an offset (@1788748305 -0700)" do
get_match(INPUT_DATE_EPOCH, "@1788748305 -0700") do
match["epoch"].should eq("1788748305")
match["timezone"].should eq("-0700")
match["offset_sign"].should eq("-")
match["offset_hour"].should eq("07")
match["offset_minute"].should eq("00")
end
end
end
describe "INPUT_RELATIVE_OPERATIONS" do
it "matches a single relative operation without timzone (+1 day)" do
get_match(INPUT_RELATIVE_OPERATIONS, "+1 day") do
match["operations"].should eq("+1 day")
match["timezone"]?.should be_nil
end
end
it "matches a single relative operation with timzone (+1 day -0700)" do
get_match(INPUT_RELATIVE_OPERATIONS, "+1 day -0700") do
match["operations"].should eq("+1 day")
match["timezone"].should eq("-0700")
match["offset_sign"].should eq("-")
match["offset_hour"].should eq("07")
match["offset_minute"].should eq("00")
end
end
it "matches multiple relative operations without timzone (+1 day -2 years)" do
get_match(INPUT_RELATIVE_OPERATIONS, "+1 day -2 years") do
match["operations"].should eq("+1 day -2 years")
match["timezone"]?.should be_nil
end
end
it "matches multiple relative operations without timzone (+1 day -2 years -0700)" do
get_match(INPUT_RELATIVE_OPERATIONS, "+1 day -2 years -0700") do
match["operations"].should eq("+1 day -2 years")
match["timezone"].should eq("-0700")
match["offset_sign"].should eq("-")
match["offset_hour"].should eq("07")
match["offset_minute"].should eq("00")
end
end
end
describe "FLOAT" do
it "will match a traditional float (1.1)" do
does_match(FLOAT, "1.1")
end
it "will match an int (1)" do
does_match(FLOAT, "1")
end
it "will match a negative float (-1.1)" do
does_match(FLOAT, "-1.1")
end
it "will match a negative int (-1)" do
does_match(FLOAT, "-1")
end
end
describe "INT" do
end
describe "UINT" do
end
end
+102
View File
@@ -0,0 +1,102 @@
require "../spec_helper"
def get_ops(raw : String)
CliGen::Timeparse::RelativeOperation.get_operations(raw)
end
describe CliGen::Timeparse::RelativeOperation do
describe ".get_operations" do
it "will correctly parse a single relative operation & provide an array of 1" do
op = "+1 seconds"
ops = get_ops(op)
ops.size.should eq(1)
my_op = ops.first
my_op.sign.should eq(1)
my_op.quantity.should eq(1)
my_op.unit.should eq(CliGen::Timeparse::OperationUnit::SECOND)
end
it "will correctly parse multiple relative operations & provide an array of 2" do
op = "+1 seconds -1 minute"
ops = get_ops(op)
ops.size.should eq(2)
op1 = ops.shift
op1.sign.should eq(1)
op1.quantity.should eq(1)
op1.unit.should eq(CliGen::Timeparse::OperationUnit::SECOND)
op2 = ops.shift
op2.sign.should eq(-1)
op2.quantity.should eq(1)
op2.unit.should eq(CliGen::Timeparse::OperationUnit::MINUTE)
end
it "will parse every unit in both singular and plural form" do
{
"year" => CliGen::Timeparse::OperationUnit::YEAR,
"month" => CliGen::Timeparse::OperationUnit::MONTH,
"week" => CliGen::Timeparse::OperationUnit::WEEK,
"day" => CliGen::Timeparse::OperationUnit::DAY,
"hour" => CliGen::Timeparse::OperationUnit::HOUR,
"minute" => CliGen::Timeparse::OperationUnit::MINUTE,
"second" => CliGen::Timeparse::OperationUnit::SECOND,
}.each do |word, unit|
[word, "#{word}s"].each do |form|
ops = get_ops("+1 #{form}")
ops.size.should eq(1)
ops.first.unit.should eq(unit)
end
end
end
it "will correctly parse a negative sign and a multi-digit quantity" do
op = get_ops("-42 days").first
op.sign.should eq(-1)
op.quantity.should eq(42)
op.unit.should eq(CliGen::Timeparse::OperationUnit::DAY)
end
end
describe "#apply" do
it "will apply a single operation correctly" do
time = Time.local
atime = time + 2.minute
get_ops("+2 minutes").each{|op| time = op.apply(time)}
time.should eq(atime)
end
it "will apply multiple operations correctly" do
time = Time.local
atime = time + 2.minute - 3.minute + 2.year
get_ops("+2 minutes -3 minutes +2 years").each{|op| time = op.apply(time)}
time.should eq(atime)
end
it "will apply each Time::Span unit correctly" do
time = Time.utc(2026, 4, 24, 10, 20, 30)
get_ops("+3 weeks").first.apply(time).should eq(time + 3.weeks)
get_ops("+3 days").first.apply(time).should eq(time + 3.days)
get_ops("+3 hours").first.apply(time).should eq(time + 3.hours)
get_ops("+3 minutes").first.apply(time).should eq(time + 3.minutes)
get_ops("+3 seconds").first.apply(time).should eq(time + 3.seconds)
end
it "will apply MONTH and YEAR as calendar spans, not fixed durations" do
# Jan 31 + 1 month clamps to Feb 28 - a fixed 30.days span would give Mar 02
jan31 = Time.utc(2026, 1, 31, 12, 0, 0)
get_ops("+1 month").first.apply(jan31).should eq(Time.utc(2026, 2, 28, 12, 0, 0))
get_ops("+1 year").first.apply(jan31).should eq(Time.utc(2027, 1, 31, 12, 0, 0))
# leap year: Feb 29 2028 exists, so +2 years from 2026-02-28 stays on the 28th
feb28 = Time.utc(2026, 2, 28, 12, 0, 0)
get_ops("+2 years").first.apply(feb28).should eq(Time.utc(2028, 2, 28, 12, 0, 0))
end
it "will apply a negative operation correctly" do
time = Time.utc(2026, 3, 15, 8, 0, 0)
get_ops("-1 month").first.apply(time).should eq(Time.utc(2026, 2, 15, 8, 0, 0))
get_ops("-10 days").first.apply(time).should eq(time - 10.days)
end
end
end
+139
View File
@@ -0,0 +1,139 @@
require "../spec_helper"
module CliGen::Timeparse
def self.test_get_location(raw : String)
get_location(raw)
end
end
describe CliGen::Timeparse do
describe ".parse" do
it "accepts time in %Y-%m-%d format" do
time = "2026-04-24"
a = ::Time.parse_local(time, "%Y-%m-%d")
CliGen::Timeparse.parse(time).should eq(a)
end
it "accepts time in %Y-%m-%d %z format and will respect the provided offset" do
time = "2026-04-24 -0900"
a = ::Time.parse!(time, "%Y-%m-%d %z")
CliGen::Timeparse.parse(time).should eq(a)
end
it "strips surrounding whitespace before parsing" do
a = ::Time.parse_local("2026-04-24", "%Y-%m-%d")
CliGen::Timeparse.parse(" 2026-04-24 ").should eq(a)
end
it "accepts time in %Y-%m-%d %H:%M:%S format and defaults to the local timezone" do
time = "2026-04-24 10:20:30"
a = ::Time.parse(time, "%Y-%m-%d %H:%M:%S", location: ::Time::Location.local)
CliGen::Timeparse.parse(time).should eq(a)
end
it "accepts time in %Y-%m-%d %H:%M:%S %z format and will respect the provided offset" do
time = "2026-04-24 10:20:30 -0900"
a = ::Time.parse!(time, "%Y-%m-%d %H:%M:%S %z")
CliGen::Timeparse.parse(time).should eq(a)
end
it "accepts epoch time in the @%s format and will default to UTC" do
time = "@1788742653"
a = ::Time.unix(time.lchop.to_i)
CliGen::Timeparse.parse(time).should eq(a)
end
it "accepts epoch time in the @%s %z format and will set the timezone to match the offset provided" do
time = "@1788742653 -0700"
a = ::Time.unix(time.lchop.split(" ").first.to_i).in(Time::Location.fixed(-1 * (7 * 3600)))
CliGen::Timeparse.parse(time).should eq(a)
end
it "accepts a single relative operation and will return the time" do
time = CliGen::Timeparse.parse("+10 minutes")
b_time = Time.local + 9.minute
a_time = Time.local + 11.minute
(b_time..a_time).includes?(time).should be_true
end
it "accepts a single relative operation + offset and will return the time" do
time = CliGen::Timeparse.parse("+10 minutes -0900")
b_time = Time.local + 9.minute
a_time = Time.local + 11.minute
(b_time..a_time).includes?(time).should be_true
time.location.should eq(Time::Location.fixed("-0900", -1 * (9 * 3600)))
end
it "accepts multiple relative operations and will return the time" do
time = CliGen::Timeparse.parse("+20 minutes +2 minute")
b_time = Time.local + 21.minute
a_time = Time.local + 23.minute
(b_time..a_time).includes?(time).should be_true
end
it "accepts a multiple relative operation + offset and will return the time" do
time = CliGen::Timeparse.parse("+10 minutes +2 minutes -0900")
b_time = Time.local + 11.minute
a_time = Time.local + 13.minute
(b_time..a_time).includes?(time).should be_true
time.location.should eq(Time::Location.fixed("-0900", -1 * (9 * 3600)))
end
end
describe ".parse error handling" do
it "raises CliGen::TimeParseError when the input matches no known format" do
expect_raises(CliGen::TimeParseError) do
CliGen::Timeparse.parse("not-a-date")
end
end
it "includes the offending input in the unknown-format message" do
ex = expect_raises(CliGen::TimeParseError) do
CliGen::Timeparse.parse("nonsense")
end
ex.message.to_s.should contain("nonsense")
end
it "raises CliGen::TimeParseError for a shape-valid but out-of-range month/day" do
expect_raises(CliGen::TimeParseError) do
CliGen::Timeparse.parse("2026-13-45")
end
end
it "raises CliGen::TimeParseError for an out-of-range hour" do
expect_raises(CliGen::TimeParseError) do
CliGen::Timeparse.parse("2026-01-15 25:00:00")
end
end
it "raises CliGen::TimeParseError for a day that does not exist in that month" do
expect_raises(CliGen::TimeParseError) do
CliGen::Timeparse.parse("2026-02-30")
end
end
# Guards the catch-all rescue: this path raises OverflowError, not ArgumentError.
# Narrowing the rescue back to specific stdlib types would let it escape
# App#handle_command_raises and reach the user as a stack trace.
it "raises CliGen::TimeParseError for an epoch large enough to overflow" do
expect_raises(CliGen::TimeParseError) do
CliGen::Timeparse.parse("@99999999999999999999")
end
end
it "raises CliGen::TimeParseError rather than an offset error for an out-of-range timezone" do
expect_raises(CliGen::TimeParseError) do
CliGen::Timeparse.parse("2026-01-15 -9999")
end
end
end
describe ".get_location" do
it "correctly generates an offest" do
a = "-0900"
b = Time::Location.fixed(a, (-1) * ((9 * 3600) + (0 * 60)))
CliGen::Timeparse.test_get_location(a).should eq(b)
end
end
end