Made several modifications:

- Added more complex date/time parsing for Time objects. This includes
  the ability to provide times in epoch format and several others with
  the ability to provide inline timezone offsets without external
  codebase works to convert them. Alongside this I added the ability to
  do relative time (ex: [+-]2 hours|minutes|days) (along with the ability
  to specify timezone if needed)
- Littered the entire codebase with Log object calls to show state of
  everything as the framework does it's stuff. Just needs Log to be
  configured in the codebase consuming my shard
- removed flag collection for use in displaying all flags in the app
  help output. So it will only show global flags & subcommands
- Changed CommandNode#get(short:) & get(long:) & flag?(arg : String)
  methods to check @flags -> @commands.@flags -> CliGen::GLOBAL_FLAGS
  when finding flags. Making it possible to not PRINT out the flag
  output but still be able to set values to a child flag recursively.
This commit is contained in:
2026-08-29 18:51:08 -05:00
parent fd4c0eb171
commit fe6112795a
9 changed files with 259 additions and 41 deletions
+7
View File
@@ -19,13 +19,20 @@ module CliGen
end end
def self.handle_command_raises(&) : Nil def self.handle_command_raises(&) : Nil
# doing Fiber.yield to allow Log to print log output before shoving data to screen
begin begin
# Had to use yeild because doing .call on a provided block had the compiler
# freaking out about not being able to determine a type of a variable in the
# context it was being called from.
yield yield
rescue e : CliGen::RuntimeError rescue e : CliGen::RuntimeError
Fiber.yield
abort e.message abort e.message
rescue e : CliGen::ConfigurationError rescue e : CliGen::ConfigurationError
Fiber.yield
abort e.message abort e.message
rescue e : CliGen::HelpRequestedError rescue e : CliGen::HelpRequestedError
Fiber.yield
puts e.message puts e.message
exit 0 exit 0
end end
-3
View File
@@ -36,9 +36,6 @@ module CliGen
{% debug if env("DEBUG") %} {% debug if env("DEBUG") %}
{% end %} {% end %}
# Keep a copy of every flag on the root for global matching
app_flags += cmd_flags
app_commands << CommandNode({{cmd}}).new( app_commands << CommandNode({{cmd}}).new(
name: {{cmd_name}}, name: {{cmd_name}},
flags: cmd_flags, flags: cmd_flags,
@@ -3,23 +3,30 @@ module CliGen
macro define_command_initializer macro define_command_initializer
def initialize(*, handler : CliGen::BaseCommandNode) def initialize(*, handler : CliGen::BaseCommandNode)
{% verbatim do %} {% verbatim do %}
Log.debug { "#{self.class.name}#initialize : Initializing class" }
{% for var in @type.instance_vars %} {% for var in @type.instance_vars %}
Log.debug { "{{@type.name}}#initialize : Checking {{var.name}}" }
{% anno = (var.annotation(CliGen::Argument) || var.annotation(CliGen::Selection)) %} {% anno = (var.annotation(CliGen::Argument) || var.annotation(CliGen::Selection)) %}
{% if anno %} {% if anno %}
{% raise "ERROR : #{@type.name}#initialize : Argument '#{var.name}' cannot be a nilable type (#{var.type}) — flags always resolve to a concrete value" if var.type.union? %} {% raise "ERROR : #{@type.name}#initialize : Argument '#{var.name}' cannot be a nilable type (#{var.type}) — flags always resolve to a concrete value" if var.type.union? %}
Log.debug { "{{@type.name}}#initialize : {{var.name}} is a CliGen managed ivar. Will attempt to gather from associated CliGen::Flag" }
if flg = handler.flags.find{|f| f.var == {{var.name.stringify}} && f.long == {{anno[:long]}}} if flg = handler.flags.find{|f| f.var == {{var.name.stringify}} && f.long == {{anno[:long]}}}
Log.debug { "{{@type.name}}#initialize : {{var.name}} : Found Flag(long: #{flg.long}). Calling validate! to make sure data provided (in whatever format) is valid" }
flg.validate! flg.validate!
Log.debug { "{{@type.name}}#initialize : {{var.name}} : Found Flag(long: #{flg.long}). Data was valid seems like (or at least a default was set)" }
@{{var.id}} = flg.as(CliGen::Flag({{var.type}})).value! @{{var.id}} = flg.as(CliGen::Flag({{var.type}})).value!
else else
raise CliGen::FlagNotFoundError.new("{{@type.name}}\#{{@def.name}} : No flag found for \"{{var.name}}\"") raise CliGen::FlagNotFoundError.new("{{@type.name}}\#{{@def.name}} : No flag found for \"{{var.name}}\"")
end end
{% else %} {% else %}
Log.debug { "{{@type.name}}#initialize : {{var.name}} is not a CliGen managed ivar. Will initialize to default defined in class" }
{% raise "ERROR : #{@type.name}#{@def.name} : Instance Variable(#{var.name}) is not handled by CliGen and does not have a default value" if var.default_value.nil? %} {% raise "ERROR : #{@type.name}#{@def.name} : Instance Variable(#{var.name}) is not handled by CliGen and does not have a default value" if var.default_value.nil? %}
@{{var.id}} = {{var.default_value}} @{{var.id}} = {{var.default_value}}
{% end %} {% end %}
{% end %} {% end %}
{% if @type.has_method? :after_initialize %} {% if @type.has_method? :after_initialize %}
Log.debug { "{{@type.name}}#initialize : Developer defined 'after_initialize' so going to call it" }
after_initialize after_initialize
{% end %} {% end %}
{% end %} {% end %}
+58 -16
View File
@@ -2,6 +2,7 @@ require "./global_flag"
require "./match_type" require "./match_type"
require "./flag" require "./flag"
require "./arg" require "./arg"
require "log"
require "ecr" require "ecr"
module CliGen module CliGen
@@ -22,18 +23,20 @@ module CliGen
@pre_run_commands : Array(RunCommand) @pre_run_commands : Array(RunCommand)
@post_run_commands : Array(RunCommand) @post_run_commands : Array(RunCommand)
Log = ::Log.for(CliGen::CommandInfo)
def initialize( def initialize(
@name : String, @name : String,
flags : Array(BaseFlag), @flags : Array(BaseFlag),
@commands : Array(BaseCommandNode), @commands : Array(BaseCommandNode),
@pre_run_commands : Array(RunCommand), @pre_run_commands : Array(RunCommand),
@post_run_commands : Array(RunCommand), @post_run_commands : Array(RunCommand),
@description : String? = nil @description : String? = nil
) )
@flags = (flags + CliGen::GLOBAL_FLAGS + @commands.flat_map(&.flags)).uniq
end end
def check_for_duplicates!(flags : Array(BaseFlag)) : Nil def check_for_duplicates!(flags : Array(BaseFlag)) : Nil
Log.trace { "CommandNode(#{@name})#check_for_duplicates! : entered with #{flags.map(&.long_key)}" }
shorts = flags.compact_map(&.short) shorts = flags.compact_map(&.short)
short_duplicates = [] of String short_duplicates = [] of String
longs = flags.compact_map { |f| f.long_key unless f.long_key.empty? } longs = flags.compact_map { |f| f.long_key unless f.long_key.empty? }
@@ -68,11 +71,13 @@ module CliGen
end end
def get(*, long : String) : BaseFlag? def get(*, long : String) : BaseFlag?
@flags.find{|f| f.long_key == long} Log.trace { "CommandNode(#{@name})#get(long: #{long}) : entered" }
@flags.find{|f| f.long_key == long} || @commands.find(&.flag?(long)).try(&.get(long: long)) || CliGen::GLOBAL_FLAGS.find(&.long_key.==(long))
end end
def get(*, short : String) : BaseFlag? def get(*, short : String) : BaseFlag?
@flags.find{|f| f.short == short} Log.trace { "CommandNode(#{@name})#get(short: #{short}) : entered" }
@flags.find{|f| f.short == short} || @commands.find(&.flag?(short)).try(&.get(short: short)) || CliGen::GLOBAL_FLAGS.find(&.short.==(short))
end end
def handle_flag_raises(&) : Nil def handle_flag_raises(&) : Nil
@@ -84,27 +89,38 @@ module CliGen
end end
def find_match(arg : String) def find_match(arg : String)
Log.trace { "CommandNode(#{@name})#find_match(#{arg}) : Entered" }
if subcommand?(arg) if subcommand?(arg)
Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : arg found to be subcommand" }
return CliGen::MatchType::SubCommand return CliGen::MatchType::SubCommand
end end
case arg case arg
when "-h", "--help" when "-h", "--help"
Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : was found to be a help flag" }
CliGen::MatchType::Help CliGen::MatchType::Help
when CliGen::Regex::FLAG_REGEX when CliGen::Regex::FLAG_REGEX
if flg = @flags.find(&.matches?(arg)) Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : arg found to match the format of a flag" }
if flg = flag?(arg)
Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : arg found to be a Flag(long: #{flg.long_key})" }
flg flg
else else
Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : arg found not to have a flag associated with it" }
CliGen::MatchType::NoMatch CliGen::MatchType::NoMatch
end end
when CliGen::Regex::FLAG_WITH_ARG when CliGen::Regex::FLAG_WITH_ARG
Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : arg found to match the format of a flag with an arg <flag>=<arg>" }
CliGen::MatchType::FlagWithArg CliGen::MatchType::FlagWithArg
when CliGen::Regex::FLAG_MULTIPLE_SHORT when CliGen::Regex::FLAG_MULTIPLE_SHORT
Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : arg found to match the clumped flag format" }
CliGen::MatchType::FlagMultipleShort CliGen::MatchType::FlagMultipleShort
else else
if cmd = @commands.find { |c| c.name == arg } Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : Found no obvious match format wise. Checking if arg is a command" }
if cmd = @commands.find(&.name.== arg)
Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : Looks like the arg matched a defined command" }
cmd cmd
else else
Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : No match found for arg" }
CliGen::MatchType::NoMatch CliGen::MatchType::NoMatch
end end
end end
@@ -115,15 +131,18 @@ module CliGen
end end
def subcommand?(arg : String) : Bool def subcommand?(arg : String) : Bool
subcommands.any?{|f| f.name == arg} Log.trace { "CommandNode(#{@name})#subcommand?(#{arg}) : Entered" }
subcommands.any?(&.name.== arg)
end end
def flag?(arg : String) : Bool def flag?(arg : String) : BaseFlag?
@flags.any?(&.matches?(arg)) Log.trace { "CommandNode(#{@name})#flag?(#{arg}) : Entered" }
get(short: arg) || get(long: arg)
end end
# Converts String array to Arg array and hands off to the typed process method # Converts String array to Arg array and hands off to the typed process method
def process(args : Array(String)) : Nil def process(args : Array(String)) : Nil
Log.trace { "CommandNode(#{@name})#process(#{args}) : Entered" }
new_args = args.each_with_index.map { |arg, i| CliGen::Arg.new(value: arg, index: i) }.to_a new_args = args.each_with_index.map { |arg, i| CliGen::Arg.new(value: arg, index: i) }.to_a
process(new_args) process(new_args)
end end
@@ -134,6 +153,7 @@ module CliGen
end end
class CommandNode(T) < BaseCommandNode class CommandNode(T) < BaseCommandNode
def subcommands : Array(SubCommandInfo) def subcommands : Array(SubCommandInfo)
{% begin %} {% begin %}
{% subcmds = T.methods.select(&.annotation(CliGen::SubCommand)) %} {% subcmds = T.methods.select(&.annotation(CliGen::SubCommand)) %}
@@ -177,15 +197,16 @@ module CliGen
def check! : Nil def check! : Nil
@flags.each(&.check!) @flags.each(&.check!)
check_for_duplicates!(@flags) check_for_duplicates!(@flags + CliGen::GLOBAL_FLAGS)
@commands.each(&.check!) @commands.each(&.check!)
{% unless T == Nil %} {% unless T == Nil %}
raise CliGen::MissingDispatchError.new("CommandNode({{T}})#check! : {{T}} has no subcommands and no #main defined") \ raise CliGen::MissingDispatchError.new("CommandNode(#{@name})#check! : {{T}} has no subcommands and no #main defined") \
if subcommands.empty? && !{{T.has_method?(:main)}} if subcommands.empty? && !{{T.has_method?(:main)}}
{% end %} {% end %}
end end
def process(args : Array(CliGen::Arg)) : Nil def process(args : Array(CliGen::Arg)) : Nil
Log.trace { "CommandNode(#{@name})#process(#{args.map(&.value)}) : Entered" }
check! check!
passed_execution = false passed_execution = false
matched_subcommand : String? = nil matched_subcommand : String? = nil
@@ -193,11 +214,20 @@ module CliGen
@pre_run_commands.each(&.call) @pre_run_commands.each(&.call)
args.each do |arg| args.each do |arg|
next if arg.processed? Log.trace { "CommandNode(#{@name})#process : Iterating with arg Arg(index: #{arg.index}, value: #{arg.value})" }
if arg.processed?
Log.debug { "CommandNode(#{@name})#process : Arg(#{arg.value}) was already processed. Skipping" }
next
end
Log.trace { "CommandNode(#{@name})#process : Arg(#{arg.value}) wasn't processed yet. Continuing and marking arg as processed" }
arg.processed arg.processed
case match = find_match(arg.value) case match = find_match(arg.value)
when BaseCommandNode when BaseCommandNode
Log.trace {
"CommandNode(#{@name})#process : Arg(#{arg.value}) was found to be a child command. " \
"Handing off rest of execution & parsing to it"
}
# Hand off remainder to the child; we're done at this level # Hand off remainder to the child; we're done at this level
{% begin %} {% begin %}
case match case match
@@ -207,54 +237,66 @@ module CliGen
exit 0 exit 0
{% end %} {% end %}
else else
raise CliGen::UnknownCommandNodeError.new("CommandNode({{T}})#process : matched a BaseCommandNode that isn't a known CommandNode(T)") raise CliGen::UnknownCommandNodeError.new("CommandNode(#{@name})#process : matched a BaseCommandNode that isn't a known CommandNode(T)")
end end
{% end %} {% end %}
passed_execution = true passed_execution = true
when BaseFlag when BaseFlag
Log.trace { "CommandNode(#{@name})#process : Arg(#{arg.value}) was found to be a flag" }
# Ensuring we catch any exceptions to have them abort with the # Ensuring we catch any exceptions to have them abort with the
# error message if caught # error message if caught
handle_flag_raises do handle_flag_raises do
if match.requires_arg? if match.requires_arg?
Log.trace { "CommandNode(#{@name})#process : Arg(#{arg.value}) flag requires values so handing it the data without a match or that matches it's valid options" }
match.process(args.reject(&.processed?).take_while{|v| match.process(args.reject(&.processed?).take_while{|v|
find_match(v.value) == CliGen::MatchType::NoMatch || !!match.meta.options.try(&.includes?(v.value)) find_match(v.value) == CliGen::MatchType::NoMatch || !!match.meta.options.try(&.includes?(v.value))
}) })
else else
Log.trace { "CommandNode(#{@name})#process : Arg(#{arg.value}) flag does not require an argument so just calling process" }
match.process match.process
end end
end end
when MatchType::SubCommand when MatchType::SubCommand
raise CliGen::InternalError.new("CommandNode({{T}})#process : subcommand '#{matched_subcommand}' was already matched — duplicate subcommand token") if matched_subcommand Log.trace { "CommandNode(#{@name})#process : Arg(#{arg.value}) was found to be a subcomand. Marking it as the matched sub-command" }
raise CliGen::InternalError.new("CommandNode(#{@name})#process : subcommand '#{matched_subcommand}' was already matched — duplicate subcommand token") if matched_subcommand
matched_subcommand = arg.value matched_subcommand = arg.value
when MatchType::Help when MatchType::Help
Log.trace { "CommandNode(#{@name})#process : Arg(#{arg.value}) is a help option. Raising to have App print out help output" }
raise CliGen::HelpRequestedError.new(help) raise CliGen::HelpRequestedError.new(help)
when MatchType::FlagWithArg when MatchType::FlagWithArg
Log.debug { "CommandNode(#{@name})#process : Arg(#{arg.value}) was found to be a flag with an arg <flag>=<arg>" }
if regex_match = CliGen::Regex::FLAG_WITH_ARG.match(arg.value) if regex_match = CliGen::Regex::FLAG_WITH_ARG.match(arg.value)
Log.trace { "CommandNode(#{@name})#process : Arg(#{arg.value}) flag: #{regex_match["flag"]} & arg: #{regex_match["arg"]}" }
case flag_match = find_match(regex_match["flag"]) case flag_match = find_match(regex_match["flag"])
when BaseFlag when BaseFlag
Log.debug { "CommandNode(#{@name})#process : Arg(#{arg.value}) flag: #{regex_match["flag"]} is actually a flag" }
handle_flag_raises do handle_flag_raises do
flag_match.process([CliGen::Arg.new(value: regex_match["arg"], index: arg.index)]) flag_match.process([CliGen::Arg.new(value: regex_match["arg"], index: arg.index)])
end end
else else
Log.debug { "CommandNode(#{@name})#process : Arg(#{arg.value}) flag: #{regex_match["flag"]} had no flag matches" }
raise CliGen::UnknownCommandNodeError.new("CommandNode(#{@name}).process : No flag matched '#{regex_match["flag"]}'") raise CliGen::UnknownCommandNodeError.new("CommandNode(#{@name}).process : No flag matched '#{regex_match["flag"]}'")
end end
else else
raise CliGen::RegexInvariantError.new("CommandNode(#{@name}).process : FLAG_WITH_ARG matched in find_match but failed on re-match — this is a framework bug") raise CliGen::RegexInvariantError.new("CommandNode(#{@name})#process : FLAG_WITH_ARG matched in find_match but failed on re-match — this is a framework bug")
end end
when MatchType::FlagMultipleShort when MatchType::FlagMultipleShort
Log.debug { "CommandNode(#{@name})#process : Arg(#{arg.value}) was found to be an combined short flag" }
val = arg.value.gsub(/^-/,"") val = arg.value.gsub(/^-/,"")
# If the next character in the series is a flag assume the remaining are flags as well. # If the next character in the series is a flag assume the remaining are flags as well.
if flag?("-#{val[1]}") if flag?("-#{val[1]}")
chars = val.chars chars = val.chars
chars.map { |c| "-#{c}" }.each_with_index do |flag, index| chars.map { |c| "-#{c}" }.each_with_index do |flag, index|
Log.trace { "CommandNode(#{@name})#process : Arg(#{arg.value}) : char(flag: #{flag}, index: #{index}) being processed" }
case match = find_match(flag) case match = find_match(flag)
when BaseFlag when BaseFlag
Log.trace { "CommandNode(#{@name})#process : Arg(#{arg.value}) : char(flag: #{flag}, index: #{index}) was actually found to be a flag" }
# If this is the last flag in the series let it process others # If this is the last flag in the series let it process others
handle_flag_raises do handle_flag_raises do
## If this is the last flag provided in the clump do the thing ## If this is the last flag provided in the clump do the thing
@@ -304,7 +346,7 @@ module CliGen
{% if T.has_method?(:main) %} {% if T.has_method?(:main) %}
cls.main cls.main
{% else %} {% else %}
puts "ERROR : CommandNode({{T}})\#{{@def.name}} : No subcommand matched and no #main defined" puts "ERROR : CommandNode(#{@name})#process : No subcommand matched and no #main defined"
puts help puts help
{% end %} {% end %}
exit 0 exit 0
+145 -16
View File
@@ -19,6 +19,8 @@ module CliGen
getter delimiter : String getter delimiter : String
getter meta : FlagMeta getter meta : FlagMeta
Log = ::Log.for(CliGen::Flag)
def initialize( def initialize(
@var : String, @var : String,
@short : String?, @short : String?,
@@ -28,6 +30,15 @@ module CliGen
@delimiter : String, @delimiter : String,
@meta : FlagMeta @meta : FlagMeta
) )
Log.trace {
"Flag was initialized:\n" \
"\t@var : #{@var}\n" \
"\t@short : #{@short}\n" \
"\t@long : #{@long}\n" \
"\t@env_var : #{@env_var}\n" \
"\t@description : #{@description}\n" \
"\t@delimiter : #{@delimiter}\n"
}
# if the user provides just a "--long" I want the @long_key to match it # if the user provides just a "--long" I want the @long_key to match it
if @long =~ /\s|=/ if @long =~ /\s|=/
@long_key = @long.split(/\s|=/).first @long_key = @long.split(/\s|=/).first
@@ -37,6 +48,7 @@ module CliGen
end end
def matches?(token : String) : Bool def matches?(token : String) : Bool
Log.trace { "Flag(#{@long})#matches?(#{token}) : entered" }
token == @short || (!@long_key.empty? && token == @long_key) token == @short || (!@long_key.empty? && token == @long_key)
end end
@@ -51,7 +63,7 @@ module CliGen
@default : T? @default : T?
@options : Array(T)? @options : Array(T)?
@validate : (T -> Bool)? @validate : (T -> Bool)?
@on_match : Proc(Nil)? @on_match : Proc(T, Nil)?
@format : ::Regex? @format : ::Regex?
def initialize( def initialize(
@@ -64,7 +76,7 @@ module CliGen
@default : T? = nil, @default : T? = nil,
@options : Array(T)? = nil, @options : Array(T)? = nil,
@validate : (T -> Bool)? = nil, @validate : (T -> Bool)? = nil,
@on_match : Proc(Nil)? = nil, @on_match : Proc(T, Nil)? = nil,
@format : ::Regex? = nil @format : ::Regex? = nil
) )
{% unless T.class.has_method? :to_s %} {% unless T.class.has_method? :to_s %}
@@ -98,6 +110,7 @@ module CliGen
end end
def process(argv : Array(Arg) = [] of Arg) : Nil def process(argv : Array(Arg) = [] of Arg) : Nil
Log.trace { "Flag(#{@long}, type: #{@meta.type})#process : entered with args #{argv.map(&.value)}" }
if requires_arg? if requires_arg?
raise CliGen::FlagMissingArgumentError.new("Flag(#{T}, long: #{@long_key}) : requires an argument but provided array is empty") if argv.empty? raise CliGen::FlagMissingArgumentError.new("Flag(#{T}, long: #{@long_key}) : requires an argument but provided array is empty") if argv.empty?
raise CliGen::FlagArgumentError.new("Flag(#{T}, long: #{@long_key}) : a flag token was provided where a value was expected (got: #{argv.first.value})") if argv.first.flag? raise CliGen::FlagArgumentError.new("Flag(#{T}, long: #{@long_key}) : a flag token was provided where a value was expected (got: #{argv.first.value})") if argv.first.flag?
@@ -109,12 +122,18 @@ module CliGen
{% elsif T < Array %} {% elsif T < Array %}
{% raise "ERROR : Flag(#{T}) : You cannot define multiple types of array entries" if T.type_vars.size > 1 %} {% raise "ERROR : Flag(#{T}) : You cannot define multiple types of array entries" if T.type_vars.size > 1 %}
{% elem = T.type_vars.first %} {% elem = T.type_vars.first %}
Log.debug { "Flag(long: #{@long}, type: #{@meta.type})#process : Beginning iteration of arguments" }
argv.each do |arg| argv.each do |arg|
break if arg.flag? Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#process : Iterating with Arg(index: #{arg.index}, value: #{arg.value})" }
if arg.flag?
Log.debug { "Flag(long: #{@long}, type: #{@meta.type})#process : Arg(index: #{arg.index}, value: #{arg.value}) was a flag. Breaking loop" }
break
end
unless @format.nil? unless @format.nil?
Log.debug { "Flag(long: #{@long}, type: #{@meta.type}) : Arg(index: #{arg.index}, value: #{arg.value}) format regex was provided. Going to check argument value against it" }
unless arg.value.includes?(@delimiter) unless arg.value.includes?(@delimiter)
unless arg.value =~ @format unless arg.value =~ @format
puts "DEBUG : Flag({{T}}, long: #{@long_key}) : #{arg.value} was not found to be matching the defined filter #{@format}. So breaking from parse loop" if ENV["DEBUG"]? Log.debug { "Flag(long: #{@long}, type: #{@meta.type}) : Arg(index: #{arg.index}, value: #{arg.value}) was not found to be matching the defined filter #{@format}. So breaking from parse loop" }
break break
end end
end end
@@ -122,7 +141,9 @@ module CliGen
{% if elem < Int %} {% if elem < Int %}
{% int_case = elem.stringify =~ /^UInt/ ? "uint?".id : "int?".id %} {% int_case = elem.stringify =~ /^UInt/ ? "uint?".id : "int?".id %}
if arg.value.includes?(@delimiter) if arg.value.includes?(@delimiter)
Log.debug { "Flag(long: #{@long}, type: #{@meta.type})#process : Arg(index: #{arg.index}, value: #{arg.value}) argument provided is delimited with provided delimiter . Splitting and parsing individual values" }
@value = (@value || T.new) + arg.value.split(@delimiter).map do |val| @value = (@value || T.new) + arg.value.split(@delimiter).map do |val|
Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#process : Arg(index: #{arg.index}, value: #{arg.value}) : Indexing with Value(#{val})" }
val = val.strip val = val.strip
unless arg.{{int_case}}(val) unless arg.{{int_case}}(val)
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{val}' is not a valid {{elem}}") raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{val}' is not a valid {{elem}}")
@@ -152,8 +173,11 @@ module CliGen
end end
{% elsif elem == String %} {% elsif elem == String %}
if arg.value.includes?(@delimiter) if arg.value.includes?(@delimiter)
Log.debug { "Flag(long: #{@long}, type: #{@meta.type})#process : Arg(index: #{arg.index}, value: #{arg.value}) argument provided is delimited with provided delimiter . Splitting and parsing individual values" }
@value = (@value || [] of String) + arg.value.split(@delimiter).map { |v| @value = (@value || [] of String) + arg.value.split(@delimiter).map { |v|
Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#process : Arg(index: #{arg.index}, value: #{arg.value}) : Indexing with Value(#{v})" }
unless @format.nil? unless @format.nil?
Log.debug { "Flag(long: #{@long}, type: #{@meta.type})#process : Arg(index: #{arg.index}, value: #{arg.value}) format regex (#{@format.not_nil!.source}) was provided. Checking value against it" }
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{v}' does not match required format /#{@format.not_nil!.source}/") unless v =~ @format raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{v}' does not match required format /#{@format.not_nil!.source}/") unless v =~ @format
end end
v v
@@ -199,10 +223,14 @@ module CliGen
@value = argv.first.value @value = argv.first.value
argv.first.processed argv.first.processed
{% elsif T.class < CliGen::Parsable %} {% elsif T.class < CliGen::Parsable %}
processed = argv.select(&.processed?) unprocessed = argv.reject(&.processed?)
Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#process : unprocessed before : #{unprocessed.map(&.value)}" }
@value = T.parse_args(argv) @value = T.parse_args(argv)
post_processed = argv.select(&.processed?) Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#process : unprocessed after : #{unprocessed.reject(&.processed?).map(&.value)}" }
if processed == post_processed # Essentially if the unprocessed array stays the same (aka if it shows the same number of unprocessed
# arguments it will complain and raise. Only possible because the array holds references to the objects
# in case the user (for some reason) shifts/pops options out when getting/parsing data from argv)
if unprocessed.size == unprocessed.reject(&.processed?).size
raise CliGen::ParseableInvariantError.new("Flag({{T}}, long: #{@long_key})#process : {{T}}#parse_args did not mark any args as processed") raise CliGen::ParseableInvariantError.new("Flag({{T}}, long: #{@long_key})#process : {{T}}#parse_args did not mark any args as processed")
end end
{% else %} {% else %}
@@ -210,10 +238,12 @@ module CliGen
{% end %} {% end %}
validate! validate!
@on_match.try(&.call) @on_match.try(&.call(value!))
end end
def value! : T def value! : T
Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#value! : called" }
# Essentially if the unprocessed array stays the same (aka if it shows the same number of unprocessed
v = @value v = @value
# Priority: provided arg → env var → default → abort # Priority: provided arg → env var → default → abort
@@ -230,6 +260,7 @@ module CliGen
end end
def raw_value : String? def raw_value : String?
Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#raw_value : called" }
{% if T == Bool %} {% if T == Bool %}
@value.try(&.to_s) @value.try(&.to_s)
{% elsif T <= Array %} {% elsif T <= Array %}
@@ -240,6 +271,7 @@ module CliGen
end end
def satisfied? : Bool def satisfied? : Bool
Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#satisfied? : called" }
return true if !@value.nil? return true if !@value.nil?
return true if @env_var && ENV[@env_var]? return true if @env_var && ENV[@env_var]?
return true if !@default.nil? return true if !@default.nil?
@@ -247,6 +279,7 @@ module CliGen
end end
def validate! : Nil def validate! : Nil
Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#validate! : called" }
v = value! v = value!
if opts = @options if opts = @options
@@ -265,11 +298,13 @@ module CliGen
end end
def check! : Nil def check! : Nil
Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#check! : called" }
raise CliGen::ReservedFlagError.new("Flag({{T}}, long: #{@long})#check! : -h is reserved for internal help") if @short == "-h" raise CliGen::ReservedFlagError.new("Flag({{T}}, long: #{@long})#check! : -h is reserved for internal help") if @short == "-h"
raise CliGen::ReservedFlagError.new("Flag({{T}}, long: #{@long})#check! : --help is reserved for internal help") if @long_key == "--help" raise CliGen::ReservedFlagError.new("Flag({{T}}, long: #{@long})#check! : --help is reserved for internal help") if @long_key == "--help"
end end
private def coerce(raw : String) : T private def coerce(raw : String) : T
Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#coerce : entered with \"#{raw}\"" }
{% if T == Bool %} {% if T == Bool %}
case raw case raw
when /^(t|true|y|yes|1)$/i when /^(t|true|y|yes|1)$/i
@@ -353,15 +388,109 @@ module CliGen
end end
private def parse_time(raw : String) : Time private def parse_time(raw : String) : Time
formats = [ get_location = -> (tz : String) {
CliGen::Regex::INPUT_DATETIME_REGEX, sign = tz.starts_with?("-") ? -1 : 1
CliGen::Regex::INPUT_DATE_REGEX hour = tz[1..2].to_i
] min = tz[3..4].to_i
raise CliGen::InvalidFlagValueError.new("#{CliGen::APPNAME}: invalid date/time format '#{raw}' (expected YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)") unless formats.any? { |f| raw.match(f) } Time::Location.fixed(tz, sign * ((hour * 3600) + (min * 60)))
if raw.match(formats[0]) }
Time.parse_local(raw, CliGen::Format::INPUT_DATETIME_FORMAT)
get_time = ->(sign : String, quantity : Int32, unit : String) {
time = Time.local
diff = case unit
when /year/
quantity.year
when /month/
quantity.month
when /day/
quantity.day
when /hour/
quantity.hour
when /minute/
quantity.minute
when /second/
quantity.second
else
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : #{unit} is not a valid unit (valid: year, month, day, hour, minute, second)")
end
case sign
when "+"
time + diff
when "-"
time - diff
else
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : #{sign} is not a valid modifier (valid: - or +)")
end
}
case raw
when CliGen::Regex::INPUT_DATE_EPOCH_WITH_TIMEZONE
epoch = $1
tz = $2
::Time.unix(epoch.to_i).in(get_location.call(tz))
when CliGen::Regex::INPUT_DATE_EPOCH
::Time.unix($1.to_i)
when CliGen::Regex::INPUT_DATE_FULL
::Time.parse!(raw, CliGen::Format::INPUT_DATE_FULL)
when CliGen::Regex::INPUT_DATE_PARTIAL
::Time.parse_local(raw, CliGen::Format::INPUT_DATE_PARTIAL)
when CliGen::Regex::INPUT_DATE_SIMPLE_WITH_TIMEZONE
::Time.parse!(raw, CliGen::Format::INPUT_DATE_SIMPLE_WITH_TIMEZONE)
when CliGen::Regex::INPUT_DATE_SIMPLE
::Time.parse_local(raw, CliGen::Format::INPUT_DATE_SIMPLE)
when CliGen::Regex::INPUT_DATE_RELATIVE_WITH_TIMEZONE
if match = raw.match(CliGen::Regex::INPUT_DATE_RELATIVE_WITH_TIMEZONE)
sign = match["sign"]
quantity = match["quantity"].to_i32
unit = match["unit"]
tz = match["timezone"]
get_time.call(sign, quantity, unit).in(get_location.call(tz))
else
raise CliGen::InvalidFlagValueError.new("#{CliGen::APPNAME}: How did regex break?")
end
when CliGen::Regex::INPUT_DATE_RELATIVE
if match = raw.match(CliGen::Regex::INPUT_DATE_RELATIVE)
sign = match["sign"]
quantity = match["quantity"].to_i32
unit = match["unit"]
get_time.call(sign, quantity, unit)
else
raise CliGen::InvalidFlagValueError.new("#{CliGen::APPNAME}: How did regex break?")
end
else else
Time.parse_local(raw, CliGen::Format::INPUT_DATE_FORMAT) raise CliGen::InvalidFlagValueError.new(<<-EOF
#{CliGen::APPNAME}: Flag(type: #{@meta.type}, long: #{@long_key}) : invalid date/time format "#{raw}".
Valid are:
1) %Y-%m-%d %H:%M:%S %z
2) %Y-%m-%d %H:%M:%S
3) %Y-%m-%d %z
4) %Y-%m-%d
5) %s %z
6) %s
7) [+-][0-9]+ [years|months|days|hours|minutes|seconds] %z
8) [+-][0-9]+ [years|months|days|hours|minutes|seconds]
Note on format:
# Timezone Offset (ex: -0500 == CST)
%z == [-+][0-9]{4}
# Year (ex: 2026)
%Y == [0-9]{4}
# month
%m == [0-9]{2}
# day
%d == [0-9]{2}
# hour
%H == [0-9]{2}
# minute
%S == [0-9]{2}
# epoch time
%s == @[0-9]+
EOF
)
end end
end end
end end
+4 -2
View File
@@ -1,4 +1,6 @@
module CliGen::Format module CliGen::Format
INPUT_DATE_FORMAT = "%Y-%m-%d" INPUT_DATE_PARTIAL = "%Y-%m-%d %H:%M:%S"
INPUT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" INPUT_DATE_FULL = "%Y-%m-%d %H:%M:%S %z"
INPUT_DATE_SIMPLE_WITH_TIMEZONE = "%Y-%m-%d %z"
INPUT_DATE_SIMPLE = "%Y-%m-%d"
end end
+1 -1
View File
@@ -33,7 +33,7 @@ module CliGen
{% else %} {% else %}
{% env_var = long.gsub(/--/, "").upcase %} {% env_var = long.gsub(/--/, "").upcase %}
{% end %} {% end %}
CliGen::GLOBAL_FLAGS << CliGen::Flag({{type}}).new( ::CliGen::GLOBAL_FLAGS << CliGen::Flag({{type}}).new(
var: "", var: "",
short: {{short}}, short: {{short}},
long: {{long}}, long: {{long}},
+11 -3
View File
@@ -1,10 +1,18 @@
module CliGen::Regex module CliGen::Regex
FLAG_REGEX=/^(-[a-zA-Z]|--[a-zA-Z-_0-9]+)$/ FLAG_REGEX=/^(-[a-zA-Z]|--[a-zA-Z-_0-9]+)$/
FLAG_WITH_ARG=/^(?<flag>(-[a-zA-Z]|--[a-zA-Z-_]+))="?(?<arg>\S+?)"?$/ FLAG_WITH_ARG=/^(?<flag>(-[a-zA-Z]|--[a-zA-Z-_]+))="?(?<arg>\S+?)"?$/
FLAG_MULTIPLE_SHORT=/^-[a-zA-Z0-9]+/ FLAG_MULTIPLE_SHORT=/^-[a-zA-Z0-9]+$/
INPUT_DATETIME_REGEX = /^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/ INPUT_TIMEZONE = /(?<timezone>[-+][0-9]{4})/
INPUT_DATE_REGEX = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/ INPUT_TIME = /(?<time>(?<hour>[0-9]{2}):(?<minute>[0-9]{2}):(?<second>[0-9]{2}))/
INPUT_DATE_SIMPLE = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/
INPUT_DATE_SIMPLE_WITH_TIMEZONE = /^#{INPUT_DATE_SIMPLE}\s+#{INPUT_TIMEZONE}$/
INPUT_DATE_FULL = /^(?<date>#{INPUT_DATE_SIMPLE})\s+#{INPUT_TIME}\s+#{INPUT_TIMEZONE}$/
INPUT_DATE_PARTIAL = /^(?<date>#{INPUT_DATE_SIMPLE})\s+#{INPUT_TIME}$/
INPUT_DATE_RELATIVE = /(?<sign>[+-])?(?<quantity>[0-9]+)\s+(?<unit>seconds?|days?|hours?|minutes?|years?)/
INPUT_DATE_RELATIVE_WITH_TIMEZONE = /#{INPUT_DATE_RELATIVE}\s+#{INPUT_TIMEZONE}/
INPUT_DATE_EPOCH = /@(?<epoch>[0-9]+)/
INPUT_DATE_EPOCH_WITH_TIMEZONE = /#{INPUT_DATE_EPOCH}\s+#{INPUT_TIMEZONE}/
FLOAT = /^[-+]?[[:digit:]]+(\.[[:digit:]]+)?$/ FLOAT = /^[-+]?[[:digit:]]+(\.[[:digit:]]+)?$/
UINT = /^[[:digit:]]+$/ UINT = /^[[:digit:]]+$/
+26
View File
@@ -27,6 +27,32 @@ Flags:
<%- end -%> <%- end -%>
<%- end -%> <%- end -%>
<%- end -%>
<%- unless CliGen::GLOBAL_FLAGS.empty? -%>
<%- len = CliGen::GLOBAL_FLAGS.map{|f| f.short.nil? ? f.long.size : "#{f.short},#{f.long}".size}.max + 5 -%>
Global Flags
---------------------------------------------------------------
<%- CliGen::GLOBAL_FLAGS.each do |flag| -%>
<%- if flag.short.nil? -%>
<%- flags = [flag.long.strip] -%>
<%- else -%>
<%- flags = [flag.short,flag.long.strip] -%>
<%- end -%>
<%= "%-#{len}s %s" % [flags.join(","), flag.description.strip] %><%= flag.meta.options.nil? ? "" : " (valid: #{flag.meta.options.not_nil!.join(", ")})" %><%= flag.meta.default.empty? ? "" : " (default: #{flag.meta.default.not_nil!})" %>
<%- if verbose? -%>
<%= "%-#{len}s %s" % ["", "Type: #{flag.meta.type}"] %>
<%= "%-#{len}s %s" % ["", "ENV VAR: #{flag.env_var}"] %>
<%- unless flag.meta.format.nil? -%>
<%= "%-#{len}s %s" % ["", "Valid Format: #{flag.meta.format}"] %>
<%- end -%>
<%- if flag.meta.array -%>
<%= "%-#{len}s %s" % ["", "Delimiter: #{flag.delimiter}"] %>
<%- end -%>
<%- end -%>
<%- end -%>
<%- end -%> <%- end -%>
<%- unless @commands.empty? -%> <%- unless @commands.empty? -%>
Other Commands Other Commands