From fe6112795afaea82008858253b55d621e32aac78 Mon Sep 17 00:00:00 2001 From: Tristan Ancelet Date: Sat, 29 Aug 2026 18:51:08 -0500 Subject: [PATCH] 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. --- src/cligen/app.cr | 7 + src/cligen/app/generate.cr | 3 - .../command/define_command_initializer.cr | 7 + src/cligen/command_node.cr | 74 ++++++-- src/cligen/flag.cr | 161 ++++++++++++++++-- src/cligen/format.cr | 6 +- src/cligen/global_flag.cr | 2 +- src/cligen/regex.cr | 14 +- src/cligen/template/cmd_help.ecr | 26 +++ 9 files changed, 259 insertions(+), 41 deletions(-) diff --git a/src/cligen/app.cr b/src/cligen/app.cr index 19fec0f..714e502 100644 --- a/src/cligen/app.cr +++ b/src/cligen/app.cr @@ -19,13 +19,20 @@ module CliGen end def self.handle_command_raises(&) : Nil + # doing Fiber.yield to allow Log to print log output before shoving data to screen 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 rescue e : CliGen::RuntimeError + Fiber.yield abort e.message rescue e : CliGen::ConfigurationError + Fiber.yield abort e.message rescue e : CliGen::HelpRequestedError + Fiber.yield puts e.message exit 0 end diff --git a/src/cligen/app/generate.cr b/src/cligen/app/generate.cr index df1691b..24a9959 100644 --- a/src/cligen/app/generate.cr +++ b/src/cligen/app/generate.cr @@ -36,9 +36,6 @@ module CliGen {% debug if env("DEBUG") %} {% end %} - # Keep a copy of every flag on the root for global matching - app_flags += cmd_flags - app_commands << CommandNode({{cmd}}).new( name: {{cmd_name}}, flags: cmd_flags, diff --git a/src/cligen/command/define_command_initializer.cr b/src/cligen/command/define_command_initializer.cr index bc3de6e..b7e5100 100644 --- a/src/cligen/command/define_command_initializer.cr +++ b/src/cligen/command/define_command_initializer.cr @@ -3,23 +3,30 @@ module CliGen macro define_command_initializer def initialize(*, handler : CliGen::BaseCommandNode) {% verbatim do %} + Log.debug { "#{self.class.name}#initialize : Initializing class" } {% for var in @type.instance_vars %} + Log.debug { "{{@type.name}}#initialize : Checking {{var.name}}" } {% anno = (var.annotation(CliGen::Argument) || var.annotation(CliGen::Selection)) %} {% 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? %} + 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]}}} + 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! + 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! else raise CliGen::FlagNotFoundError.new("{{@type.name}}\#{{@def.name}} : No flag found for \"{{var.name}}\"") end {% 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? %} @{{var.id}} = {{var.default_value}} {% end %} {% end %} {% if @type.has_method? :after_initialize %} + Log.debug { "{{@type.name}}#initialize : Developer defined 'after_initialize' so going to call it" } after_initialize {% end %} {% end %} diff --git a/src/cligen/command_node.cr b/src/cligen/command_node.cr index e250c14..f6317a3 100644 --- a/src/cligen/command_node.cr +++ b/src/cligen/command_node.cr @@ -2,6 +2,7 @@ require "./global_flag" require "./match_type" require "./flag" require "./arg" +require "log" require "ecr" module CliGen @@ -22,18 +23,20 @@ module CliGen @pre_run_commands : Array(RunCommand) @post_run_commands : Array(RunCommand) + Log = ::Log.for(CliGen::CommandInfo) + def initialize( @name : String, - flags : Array(BaseFlag), + @flags : Array(BaseFlag), @commands : Array(BaseCommandNode), @pre_run_commands : Array(RunCommand), @post_run_commands : Array(RunCommand), @description : String? = nil ) - @flags = (flags + CliGen::GLOBAL_FLAGS + @commands.flat_map(&.flags)).uniq end 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) short_duplicates = [] of String longs = flags.compact_map { |f| f.long_key unless f.long_key.empty? } @@ -68,11 +71,13 @@ module CliGen end 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 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 def handle_flag_raises(&) : Nil @@ -84,27 +89,38 @@ module CliGen end def find_match(arg : String) + Log.trace { "CommandNode(#{@name})#find_match(#{arg}) : Entered" } if subcommand?(arg) + Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : arg found to be subcommand" } return CliGen::MatchType::SubCommand end case arg when "-h", "--help" + Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : was found to be a help flag" } CliGen::MatchType::Help 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 else + Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : arg found not to have a flag associated with it" } CliGen::MatchType::NoMatch end 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 =" } CliGen::MatchType::FlagWithArg when CliGen::Regex::FLAG_MULTIPLE_SHORT + Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : arg found to match the clumped flag format" } CliGen::MatchType::FlagMultipleShort 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 else + Log.debug { "CommandNode(#{@name})#find_match(#{arg}) : No match found for arg" } CliGen::MatchType::NoMatch end end @@ -115,15 +131,18 @@ module CliGen end def subcommand?(arg : String) : Bool - subcommands.any?{|f| f.name == arg} + Log.trace { "CommandNode(#{@name})#subcommand?(#{arg}) : Entered" } + subcommands.any?(&.name.== arg) end - def flag?(arg : String) : Bool - @flags.any?(&.matches?(arg)) + def flag?(arg : String) : BaseFlag? + Log.trace { "CommandNode(#{@name})#flag?(#{arg}) : Entered" } + get(short: arg) || get(long: arg) end # Converts String array to Arg array and hands off to the typed process method 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 process(new_args) end @@ -134,6 +153,7 @@ module CliGen end class CommandNode(T) < BaseCommandNode + def subcommands : Array(SubCommandInfo) {% begin %} {% subcmds = T.methods.select(&.annotation(CliGen::SubCommand)) %} @@ -177,15 +197,16 @@ module CliGen def check! : Nil @flags.each(&.check!) - check_for_duplicates!(@flags) + check_for_duplicates!(@flags + CliGen::GLOBAL_FLAGS) @commands.each(&.check!) {% 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)}} {% end %} end def process(args : Array(CliGen::Arg)) : Nil + Log.trace { "CommandNode(#{@name})#process(#{args.map(&.value)}) : Entered" } check! passed_execution = false matched_subcommand : String? = nil @@ -193,11 +214,20 @@ module CliGen @pre_run_commands.each(&.call) 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 case match = find_match(arg.value) 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 {% begin %} case match @@ -207,54 +237,66 @@ module CliGen exit 0 {% end %} 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 %} passed_execution = true 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 # error message if caught handle_flag_raises do 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| find_match(v.value) == CliGen::MatchType::NoMatch || !!match.meta.options.try(&.includes?(v.value)) }) else + Log.trace { "CommandNode(#{@name})#process : Arg(#{arg.value}) flag does not require an argument so just calling process" } match.process end end 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 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) when MatchType::FlagWithArg + Log.debug { "CommandNode(#{@name})#process : Arg(#{arg.value}) was found to be a flag with an arg =" } 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"]) when BaseFlag + Log.debug { "CommandNode(#{@name})#process : Arg(#{arg.value}) flag: #{regex_match["flag"]} is actually a flag" } handle_flag_raises do flag_match.process([CliGen::Arg.new(value: regex_match["arg"], index: arg.index)]) end 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"]}'") end 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 when MatchType::FlagMultipleShort + Log.debug { "CommandNode(#{@name})#process : Arg(#{arg.value}) was found to be an combined short flag" } val = arg.value.gsub(/^-/,"") # If the next character in the series is a flag assume the remaining are flags as well. if flag?("-#{val[1]}") chars = val.chars 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) 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 handle_flag_raises do ## If this is the last flag provided in the clump do the thing @@ -304,7 +346,7 @@ module CliGen {% if T.has_method?(:main) %} cls.main {% 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 {% end %} exit 0 diff --git a/src/cligen/flag.cr b/src/cligen/flag.cr index 2dc0f9f..d206161 100644 --- a/src/cligen/flag.cr +++ b/src/cligen/flag.cr @@ -19,6 +19,8 @@ module CliGen getter delimiter : String getter meta : FlagMeta + Log = ::Log.for(CliGen::Flag) + def initialize( @var : String, @short : String?, @@ -28,6 +30,15 @@ module CliGen @delimiter : String, @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 @long =~ /\s|=/ @long_key = @long.split(/\s|=/).first @@ -37,6 +48,7 @@ module CliGen end def matches?(token : String) : Bool + Log.trace { "Flag(#{@long})#matches?(#{token}) : entered" } token == @short || (!@long_key.empty? && token == @long_key) end @@ -51,7 +63,7 @@ module CliGen @default : T? @options : Array(T)? @validate : (T -> Bool)? - @on_match : Proc(Nil)? + @on_match : Proc(T, Nil)? @format : ::Regex? def initialize( @@ -64,7 +76,7 @@ module CliGen @default : T? = nil, @options : Array(T)? = nil, @validate : (T -> Bool)? = nil, - @on_match : Proc(Nil)? = nil, + @on_match : Proc(T, Nil)? = nil, @format : ::Regex? = nil ) {% unless T.class.has_method? :to_s %} @@ -98,6 +110,7 @@ module CliGen end 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? 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? @@ -109,12 +122,18 @@ module CliGen {% elsif T < Array %} {% raise "ERROR : Flag(#{T}) : You cannot define multiple types of array entries" if T.type_vars.size > 1 %} {% elem = T.type_vars.first %} + Log.debug { "Flag(long: #{@long}, type: #{@meta.type})#process : Beginning iteration of arguments" } 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? + 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 =~ @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 end end @@ -122,7 +141,9 @@ module CliGen {% if elem < Int %} {% int_case = elem.stringify =~ /^UInt/ ? "uint?".id : "int?".id %} 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| + Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#process : Arg(index: #{arg.index}, value: #{arg.value}) : Indexing with Value(#{val})" } val = val.strip unless arg.{{int_case}}(val) raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{val}' is not a valid {{elem}}") @@ -152,8 +173,11 @@ module CliGen end {% elsif elem == String %} 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| + Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#process : Arg(index: #{arg.index}, value: #{arg.value}) : Indexing with Value(#{v})" } 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 end v @@ -199,10 +223,14 @@ module CliGen @value = argv.first.value argv.first.processed {% 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) - post_processed = argv.select(&.processed?) - if processed == post_processed + Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#process : unprocessed after : #{unprocessed.reject(&.processed?).map(&.value)}" } + # 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") end {% else %} @@ -210,10 +238,12 @@ module CliGen {% end %} validate! - @on_match.try(&.call) + @on_match.try(&.call(value!)) end 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 # Priority: provided arg → env var → default → abort @@ -230,6 +260,7 @@ module CliGen end def raw_value : String? + Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#raw_value : called" } {% if T == Bool %} @value.try(&.to_s) {% elsif T <= Array %} @@ -240,6 +271,7 @@ module CliGen end def satisfied? : Bool + Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#satisfied? : called" } return true if !@value.nil? return true if @env_var && ENV[@env_var]? return true if !@default.nil? @@ -247,6 +279,7 @@ module CliGen end def validate! : Nil + Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#validate! : called" } v = value! if opts = @options @@ -265,11 +298,13 @@ module CliGen end 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! : --help is reserved for internal help") if @long_key == "--help" end private def coerce(raw : String) : T + Log.trace { "Flag(long: #{@long}, type: #{@meta.type})#coerce : entered with \"#{raw}\"" } {% if T == Bool %} case raw when /^(t|true|y|yes|1)$/i @@ -353,15 +388,109 @@ module CliGen end private def parse_time(raw : String) : Time - formats = [ - CliGen::Regex::INPUT_DATETIME_REGEX, - CliGen::Regex::INPUT_DATE_REGEX - ] - 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) } - if raw.match(formats[0]) - Time.parse_local(raw, CliGen::Format::INPUT_DATETIME_FORMAT) + get_location = -> (tz : String) { + sign = tz.starts_with?("-") ? -1 : 1 + hour = tz[1..2].to_i + min = tz[3..4].to_i + Time::Location.fixed(tz, sign * ((hour * 3600) + (min * 60))) + } + + 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 - 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 diff --git a/src/cligen/format.cr b/src/cligen/format.cr index 9b66647..f76b82c 100644 --- a/src/cligen/format.cr +++ b/src/cligen/format.cr @@ -1,4 +1,6 @@ module CliGen::Format - INPUT_DATE_FORMAT = "%Y-%m-%d" - INPUT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" + INPUT_DATE_PARTIAL = "%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 diff --git a/src/cligen/global_flag.cr b/src/cligen/global_flag.cr index 9242ff4..90ae559 100644 --- a/src/cligen/global_flag.cr +++ b/src/cligen/global_flag.cr @@ -33,7 +33,7 @@ module CliGen {% else %} {% env_var = long.gsub(/--/, "").upcase %} {% end %} - CliGen::GLOBAL_FLAGS << CliGen::Flag({{type}}).new( + ::CliGen::GLOBAL_FLAGS << CliGen::Flag({{type}}).new( var: "", short: {{short}}, long: {{long}}, diff --git a/src/cligen/regex.cr b/src/cligen/regex.cr index f3c7587..d713df6 100644 --- a/src/cligen/regex.cr +++ b/src/cligen/regex.cr @@ -1,10 +1,18 @@ module CliGen::Regex FLAG_REGEX=/^(-[a-zA-Z]|--[a-zA-Z-_0-9]+)$/ FLAG_WITH_ARG=/^(?(-[a-zA-Z]|--[a-zA-Z-_]+))="?(?\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_DATE_REGEX = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/ + INPUT_TIMEZONE = /(?[-+][0-9]{4})/ + INPUT_TIME = /(?