Committing before test

This commit is contained in:
2026-08-09 15:20:06 -05:00
parent 3217750714
commit f5571cde73
49 changed files with 9659 additions and 455 deletions
+8 -75
View File
@@ -1,11 +1,17 @@
require "option_parser"
require "./cligen/regex"
require "./cligen/parsable"
require "./cligen/annotations"
require "./cligen/format"
require "./cligen/regex"
require "./cligen/flag"
require "./cligen/command"
require "./cligen/command_node"
require "./cligen/app"
module CliGen
VERSION = "0.1.0"
APPNAME = File.basename(PROGRAM_NAME)
record AdditionalDefaultFlag,
short : String,
long : String,
@@ -26,78 +32,5 @@ module CliGen
annotation DefaultFlag
end
macro define_section(section_name, parser)
{{parser}}.separator ""
{{parser}}.separator "{{section_name.id}}".colorize(:green)
{{parser}}.separator "-------------------------------------------------------------------------------".colorize(:blue)
end
alias OnType = Tuple(String, String) | Tuple(String, String, String)
macro define_default_flags(parser)
CliGen.define_section("Misc Flags", {{parser}})
{{parser}}.on("-h", "--help", "Print out this help output"){ abort {{parser}} }
{{parser}}.invalid_option{|opt|
abort "#{CliGen::APPNAME} : invalid_option : Inavlid option provided #{opt}"
}
{{parser}}.invalid_option{|opt|
case opt
when /^-+[a-z-]+/
STDERR.puts "#{CliGen::APPNAME} : ERROR : {{@type.name}} : {{parser}} : invalid_option : Inavlid option provided #{opt}"
else
abort "#{CliGen::APPNAME} : ERROR : {{@type.name}} : {{parser}} : invalid_option : Inavlid option provided #{opt}"
end
}
{{parser}}.missing_option{|opt|
abort "ERROR : {{@type.name}} : {{parser}} : missing_option : Argument was not provided to #{opt}"
}
{{parser}}.unknown_args{|opt|
unless opt.empty?
case opt.first
when /^-+[a-z-]+$/
STDERR.puts "ERROR : {{@type.name}} : {{parser}} : unknown_args : #{opt} not a configured argument"
else
abort "ERROR : {{@type.name}} : {{parser}} : unknown_args : #{opt} not a configured argument"
end
end
}
CliGen::ADDITIONAL_DEFAULT_FLAGS.each do |flag|
if [ flag.short, flag.long ].all?(&.!= "")
{{parser}}.on(flag.short, flag.long, flag.description){|var| flag.work.call(var) }
elsif flag.short != ""
{{parser}}.on(flag.short, flag.description){|var| flag.work.call(var) }
elsif flag.long != ""
{{parser}}.on(flag.long, flag.description){|var| flag.work.call(var) }
end
end
end
macro define_root_parser
ROOT_COMMAND = OptionParser.new do |parser|
parser.banner = "#{CliGen::APPNAME} [command] [flags]"
{% commands = ::CliGen::Command.subclasses %}
{% raise "ERROR : No commands defined (no subclasses of ::CliGen::Command were found)" if commands.empty? %}
CliGen.define_section("Commands", parser)
{% for command in commands %}
{{command.name}}.make_parser(parser)
{% end %}
CliGen.define_default_flags(parser)
abort parser if ARGV.empty?
end
end
macro finished
define_root_parser
end
def self.parse
ROOT_COMMAND.parse
end
end
require "./cligen/command"
+19
View File
@@ -0,0 +1,19 @@
module CliGen
annotation ProxyCommand
end
annotation CommandInfo
end
annotation Argument
end
annotation Trigger
end
annotation Selection
end
annotation SubCommand
end
end
+30
View File
@@ -0,0 +1,30 @@
require "./arg"
require "./command_node"
require "./flag"
require "./app/generate"
module CliGen
# Root entry point. Holds a flattened copy of all flags from every command
# for global-flag matching, then hands off to the matched child CommandNode.
class App < CliGen::BaseCommandNode
@@instance : self?
def initialize(@name, flags : Array(BaseFlag), commands : Array(BaseCommandNode), pre_run_commands : Array(RunCommand), post_run_commands : Array(RunCommand))
super(name: @name, flags: flags, commands: commands, pre_run_commands: pre_run_commands, post_run_commands: post_run_commands)
@@instance = self
end
def check! : Nil
CliGen::GLOBAL_FLAGS.each(&.check!)
# @flags is the amalgamation of all child flags — checked by the commands themselves
check_for_duplicates!([CliGen::GLOBAL_FLAGS, @flags].flatten)
@commands.each(&.check!)
end
# Convenience entry point; defaults to ARGV
def self.process(args : Array(String) = ARGV.to_a) : Nil
generate if @@instance.nil?
@@instance.not_nil!.process(args)
end
end
end
+57
View File
@@ -0,0 +1,57 @@
module CliGen
macro finished
class App
private def self.generate
app_flags = [] of BaseFlag
app_commands = [] of BaseCommandNode
{% for cmd in CliGen::Command.subclasses %}
{% cmd_info = cmd.annotation(CliGen::CommandInfo) %}
{% raise "ERROR : CliGen::App.generate : No CliGen::CommandInfo annotation made for #{cmd.name}" unless cmd_info %}
{% raise "ERROR : CliGen::App.generate : Description must be provided for #{cmd.name}" unless cmd_info[:description].is_a? StringLiteral %}
{% cmd_name = cmd.name.split("::").last.downcase %}
{% cmd_flags = cmd.instance_vars.select{|v| v.annotation(CliGen::Argument) || v.annotation(CliGen::Selection) } %}
cmd_flags = [] of BaseFlag
cmd_sub_commands = [] of BaseCommandNode
cmd_pre_run_cmds = [] of Proc(Nil)
cmd_post_run_cmds = [] of Proc(Nil)
{% for var in cmd_flags %}
{% anno = var.annotation(CliGen::Argument) || var.annotation(CliGen::Selection) %}
cmd_flags << Flag({{var.type}}).new(
var: {{ var.name.stringify }},
short: {% if anno[:short] %} {{anno[:short]}} {% else %} nil {% end %},
long: {{ anno[:long] }},
env_var: {% if anno[:env_var] %} {{anno[:env_var]}} {% else %} {{"#{cmd.name.upcase}_#{var.name.upcase}"}} {% end %},
description: {{ anno[:description] }},
default: {% if anno[:default] %} {{anno[:default]}} {% else %} nil {% end %},
validate: {% if anno[:validate] %} {{anno[:validate]}} {% else %} nil {% end %},
on_match: {% if anno[:on_match] %} {{anno[:on_match]}} {% else %} nil {% end %}
)
{% 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,
commands: cmd_sub_commands,
pre_run_commands: cmd_pre_run_cmds,
post_run_commands: cmd_post_run_cmds,
description: {{cmd_info[:description]}}
)
{% end %}
new(
name: ::PROGRAM_NAME,
flags: app_flags,
commands: app_commands,
pre_run_commands: [] of Proc(Nil),
post_run_commands: [] of Proc(Nil)
)
end
end
end
end
+36
View File
@@ -0,0 +1,36 @@
module CliGen
# This class serves as a "argument wrapper" to force a fail-fast approach to
# arg-parsing.
#
# It wraps around the argument + index of the argument to do state tracking
# and ensure that each argument is only processed once (plus allows for
# easier filtering of processed arguments to avoid having to do index math)
#
# args.reject(&.processed?) # returns the args that haven't been processed yet
#
# It expects each argument to only be processed once and will force a raise
# if the argument has Arg#processed called a second time. This is to force
# the developer (me) to fix any processing issues during the development of
# this framework.
class Arg
# The raw string argument provided from the user
getter value : String
# The index of the argument in the array it was in
getter index : Int32
# The "flag"/variable that tracks is the Arg has been processed yet
getter? processed : Bool = false
def initialize(@value, @index)
end
# This serves as a trigger that tells the object that it has been processed
#
# This will raise an exception if it is re-called after already having been
# processed.
def processed
raise "ERROR : CliGen::Arg(index: #{@index}, value: #{@value})#processed : This arg was re-processed" if @processed
@processed = true
end
end
end
+3
View File
@@ -0,0 +1,3 @@
module CliGen::Coercable
abstract def coerce(arg : String) : self
end
+21 -179
View File
@@ -1,193 +1,35 @@
require "colorize"
require "time"
require "./command/parser"
require "./annotations"
require "./command/argument"
require "./command/selection"
require "./command/trigger"
require "./command/subcommand"
module CliGen
annotation CommandPreRun
end
annotation CommandInfo
end
annotation CommandArgument
end
annotation CommandSelection
end
annotation SubCommand
end
class Command
extend CliGen::Parser
macro inherited
{% verbatim do %}
macro finished
define_actions
define_header
{% anno = @type.annotation(::CliGen::CommandInfo) %}
{% unless anno[:def_runner] == false %}
define_runner
{% end %}
define_parser
define_action_setter
end
{% end %}
end
macro define_actions
@@action : String = ""
ACTIONS = {{@type.class.methods.select(&.annotation(::CliGen::SubCommand)).map(&.name.stringify)}} of String
end
macro define_header
{% name = @type.name.split("::").last.downcase.id %}
{% info_annos = @type.class.methods.select(&.annotation(::CliGen::SubCommand)).map(&.annotation(::CliGen::SubCommand)) %}
{% info_annos += @type.class.methods.select(&.annotation(::CliGen::CommandSelection)).map(&.annotation(::CliGen::CommandSelection)) %}
{% examples = [] of StringLiteral %}
{% info_annos.select(&.[](:examples)).map(&.[](:examples).resolve).each(&.each{|example| examples << example}) %}
HEADER = [
"#{CliGen::APPNAME} {{name}} [[flags]]",
"",
{% unless examples.empty? %}
"Examples:".colorize(:green),
"-------------------------------------------------------------------------------".colorize(:blue),
{% for example in examples %}
{{example}},
{% end %}
{% end %}
].join("\n")
end
macro define_selection(selection_name, short = "", long = "", description = nil, default = "", values = [] of StringLiteral)
{% if values.is_a? Path %}
{% values = values.resolve %}
{% end %}
{% raise "ERROR : define_selection : You MUST provide a description" unless description %}
{% raise "ERROR : define_selection : You must provide a valid set of values in Array(String) format" if values.empty? %}
{% description = "#{description.id} (valid: #{values.join(", ").id})" %}
{% unless default.empty? %}
{% description = "#{description.id} (default: #{default.id})" %}
{% end %}
@@{{selection_name}} : String = {{default}}
@[::CliGen::CommandArgument(short: {{short}}, long: {{long}}, description: {{description}} )]
def self.{{selection_name}}= (selection : String)
raise "ERROR : {{selection_name}} : Invalid Selection(#{selection}) (valid: {{values.map(&.id).join(", ").id}})" unless {{values}}.includes?(selection)
@@{{selection_name}} = selection
end
end
macro define_argument(arg_name, variable = nil, type = String, short = "", long = "", description = nil, subtype = Nil, format = nil, check = nil, def_getter = false, default = nil, logger = nil, &block?)
{% raise "ERROR : define_argument : You must provide a format or set it to \"auto\" to use builtin formats when defining a Time argument" if type.id.stringify == "Time" && format == nil %}
{% raise "ERROR : define_argument : You MUST provide a description" unless description %}
{% variable = arg_name unless variable %}
{% _type = type.id.stringify %}
{% _subtype = subtype.id.stringify %}
{% if _type == "String" %}
@@{{variable}} : {{type}} = ""
{% elsif _type == "Bool" %}
@@{{variable}} : {{type}} = false
{% elsif _type == "Int32" %}
@@{{variable}} : {{type}} = 0
{% elsif _type == "Array" %}
{% raise "define_argument : subtype can only be Int32 or String" unless %w[ Int32 String ].includes?(_subtype) %}
@@{{variable}} : {{type}}({{subtype}}) = [] of {{subtype}}
{% elsif _type == "Time" %}
@@{{variable}} : {{type}} = Time.unix(seconds: 0)
{% end %}
{% if def_getter %}
def self.get_{{arg_name}} : {{type}}
@@{{variable}}
end
{% end %}
@[::CliGen::CommandArgument(type: {{type}}, short: {{short}}, long: {{long}}, description: {{description}})]
def self.{{arg_name}}(var : String) : Nil
{% if check %}
## If the check exists go ahead and call it
{{check}}(var)
{% end %}
## If a logger method has been provided by the user
{% if logger %}
{{logger.id}} "Command : subclass : argument_setter : {{arg_name}} : Entered with #{var}"
{% end %}
{% if _type == "Time" %}
formats = [
CliGen::Regex::INPUT_DATETIME_REGEX,
CliGen::Regex::INPUT_DATE_REGEX
]
abort "ERROR : Command : {{arg_name}} : Incorrect format for provided data #{var}" unless formats.any?{|f| var.match(f)}
if var =~ formats[0]
@@{{variable}} = Time.parse_local(var, CliGen::Format::INPUT_DATETIME_FORMAT)
elsif var =~ formats[1]
@@{{variable}} = Time.parse_local(var, CliGen::Format::INPUT_DATE_FORMAT)
end
{% elsif _type == "Array" %}
{% if _subtype == "Int32" %}
var : Int32 = var.to_i
{% elsif _subtype == "String" %}
{% else %}
var = {{subtype}}.new(var)
def initialize(*, handler : CliGen::BaseCommandNode)
{% verbatim do %}
{% for var in @type.instance_vars %}
{% 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? %}
if flg = handler.flags.find{|f| f.var == {{var.name.stringify}} && f.long == {{anno[:long]}}}
@{{var.id}} = flg.as(CliGen::Flag({{var.type}})).value!
else
raise "ERROR : {{@type.name}}#{{@def.name}} : No flag found for \"{{var.name}}\"?"
end
{% else %}
{% raise "ERROR : #{@type.name}#{@def.name} : Instance Variable(#{var.name}) is not handled by CliGen and does not have a default value" unless var.default %}
@{{var.id}} = {{var.default}}
{% end %}
{% end %}
{% end %}
@@{{variable}} << var
{% elsif _type == "Bool" %}
@@{{variable}} = true
{% elsif _type == "Int32" %}
@@{{variable}} = var.to_i
{% elsif _type == "String" %}
@@{{variable}} = var
{% end %}
end
end
macro define_runner
def self.run
{% pre_run_commands = @type.class.methods.select(&.annotation(::CliGen::CommandPreRun)) %}
{% unless pre_run_commands.empty? %}
## Ensuring that all runs that are tagged with the the CommandPreRun annotation are run
{% for method in pre_run_commands %}
{{method.name}}
{% end %}
{% end %}
{% selections = @type.class.methods.select(&.annotation(::CliGen::CommandSelection)) %}
{% methods = @type.class.methods.select(&.annotation(::CliGen::SubCommand)) %}
{% raise "ERROR : No commands or selections defined for #{@type.name}" if methods.empty? && selections.empty? %}
{% raise "ERROR : Can't define both selections and commands for work in #{@type.name}" if ! methods.empty? && ! selections.empty? %}
{% var = methods.empty? ? nil : "action" %}
{% targets = methods.empty? ? selections : methods %}
{% if targets == selections %}
{% selectors = selections.map(&.annotation(::CliGen::CommandSelection)).map(&.[:selector]).uniq %}
{% raise "ERROR : Can't provide more than a single selector for runner" if selectors.size > 1 %}
{% var = selectors.first %}
{% end %}
{% begin %}
case @@{{var.id}}
{% for target in targets %}
when {{target.name.stringify}}
{{target.name}}
{% end %}
else
abort "ERROR : No action provided to command {{@type.name}}"
end
{% end %}
end
end
macro define_action_setter
def self.action=(action : String)
abort "ERROR : Action(#{action}) is not valid. Only #{ACTIONS.join(", ")} are acceptable" unless ACTIONS.includes?(action)
@@action = action
end
end
end
end
+34
View File
@@ -0,0 +1,34 @@
module CliGen
class Command
macro argument(variable, short, long, description, validation = nil)
{% raise "ERROR : CliGen::Command.argument : First argument (#{variable}) must be a TypeDeclaration (ex: '<var> : <type> [= val]')" unless variable.is_a? TypeDeclaration %}
{% name = variable.name %}
{% type = variable.type %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided short must be a string" unless short.is_a? StringLiteral || string == nil %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided long must be a string" unless long.is_a? StringLiteral || long == nil %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : You must provide a short or long" unless long || short %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : You must provide a description" unless description %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided description must be a String" unless description.is_a? StringLiteral %}
{% unless validation.nil? %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided validation must be a Proc" unless validation.is_a? ProcLiteral %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided validation return type must be a Bool" unless validation.return_type == Bool %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided validation provided validation must have an input variable" if validation.args.empty? %}
{% arg = validation.args.first %}
{% unless arg.restriction == type %}
{% example = "->(#{arg.name} : #{type}) : #{type} { #{validation.body} }" %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided validation input value must be #{type}. EX: #{example}" %}
{% end %}
{% end %}
@[CliGen::Argument(short: {{short}}, long: {{long}}, description: {{description}}, validation: {{validation}}, on_match: {{on_match}})]
@{{variable}}
def {{variable.name}}= (value : {{type}})
{% unless validation.nil? %}
raise "ERROR : #{@type.name}##{@def.name} : Provided value #{value} is not passing validation" unless {{validation}}.call(value)
{% end %}
@{{name}} = value
end
end
end
end
-70
View File
@@ -1,70 +0,0 @@
module CliGen::Parser
macro extended
{% verbatim do %}
macro define_parser
def self.make_parser(parent_parser : OptionParser) : Nil
{% puts "#{@type.name} OptionParser is being generated" %}
{% name = @type.name.split("::").last %}
{% var = name.downcase %}
{% methods = @type.class.methods %}
{% info = @type.annotation(::CliGen::CommandInfo) %}
{% raise "ERROR : No CommandInfo annotation provided to {{@type.name}}" unless info %}
subparser = OptionParser.new do |parser|
parser.banner = {{@type.name}}::HEADER
{% selections = methods.select(&.annotation(::CliGen::CommandSelection)) %}
{% if selections.size > 0 %}
CliGen.define_section("Selections", parser)
{% for selection in selections %}
{% selection_anno = selection.annotation(::CliGen::CommandSelection) %}
parser.on({{selection.name.stringify}}, {{selection_anno[:description]}}){
{{@type.name}}.{{selection_anno[:selector]}}= {{selection.name.stringify}}
}
{% end %}
{% end %}
{% subcommands = methods.select(&.annotation(::CliGen::SubCommand)) %}
{% if subcommands.size > 0 %}
CliGen.define_section("Subcommands", parser)
{% for subcommand in subcommands %}
{% subcommand_anno = subcommand.annotation(::CliGen::SubCommand) %}
parser.on({{subcommand.name.stringify}}, {{subcommand_anno[:description]}}){
{{@type.name}}.action= {{subcommand.name.stringify}}
}
{% end %}
{% end %}
{% arguments = methods.select(&.annotation(::CliGen::CommandArgument)) %}
{% if arguments.size > 0 %}
CliGen.define_section("Provide Arguments", parser)
{% for argument in arguments %}
{% argument_anno = argument.annotation(::CliGen::CommandArgument) %}
{% if argument_anno[:short] == "" && argument_anno[:long] != "" %}
parser.on({{argument_anno[:long]}}, {{argument_anno[:description]}}){|val|
{% elsif argument_anno[:short] != "" && argument_anno[:long] == "" %}
parser.on({{argument_anno[:short]}}, {{argument_anno[:description]}}){|val|
{% else %}
parser.on({{argument_anno[:short]}}, {{argument_anno[:long]}}, {{argument_anno[:description]}}){|val|
{% end %}
{{@type.name}}.{{argument.name}}(val)
}
{% end %}
{% end %}
CliGen.define_default_flags(parser)
end
parent_parser.on({{var}}, {{info[:description]}}){
raise "ERROR : {{var.id}} not found in ARGV" unless i = ARGV.index({{var}})
## Removing all preceeding arguments so that only the actual required
## args for the next command are present
ARGV.shift(i+1)
abort subparser if ARGV.empty?
subparser.parse
{{@type.name}}.run
}
end
end
{% end %}
end
end
+14
View File
@@ -0,0 +1,14 @@
module CliGen
class Command
macro selection(variable, short, long, description, options)
{% raise "ERROR : CliGen::Command.selection : First selection must be a TypeDeclaration (ex: '<var> : <type> [= val]')" unless variable.is_a? TypeDeclaration %}
{% raise "ERROR : CliGen::Command.selection : Provided short must be a string" unless short.is_a? StringLiteral || string == nil %}
{% raise "ERROR : CliGen::Command.selection : Provided long must be a string" unless long.is_a? StringLiteral || long == nil %}
{% raise "ERROR : CliGen::Command.selection : You must provide a short or long" unless long || short %}
{% raise "ERROR : CliGen::Command.selection : You must provide a description" unless description %}
{% raise "ERROR : CliGen::Command.selection : Provided description must be a String" unless description.is_a? StringLiteral %}
{% raise "ERROR : CliGen::Command.selection : You must provide options" unless options %}
{% raise "ERROR : CliGen::Command.selection : Provided options must be " unless options %}
end
end
end
+19
View File
@@ -0,0 +1,19 @@
module CliGen
class Command
macro subcommand(func, description, examples = nil, &block)
{% raise "ERROR : CliGen::Command.subcommand : First argument must be a TypeDeclaration (ex: '<var> : <type>')" unless variable.is_a? TypeDeclaration %}
{% raise "ERROR : CliGen::Command.subcommand : You must provide a description" unless description %}
{% raise "ERROR : CliGen::Command.subcommand : Provided description must be a String" unless description.is_a? StringLiteral %}
{% unless examples.nil? %}
{% examples = examples.resolve if examples.is_a? Path %}
{% raise "ERROR : CliGen::Command.subcommand : Provided example must be an Array" unless examples.is_a? ArrayLiteral %}
{% end %}
{% raise "ERROR : CliGen::Command.subcommand : You MUST provide a function body" unless block %}
@[CliGen::SubCommand(description: {{description}}, examples: {{examples}})]
def {{func}}
{{block.body}}
end
end
end
end
+21
View File
@@ -0,0 +1,21 @@
module CliGen
class Command
macro trigger(short, long, argument = nil, &on_match)
{% raise "ERROR : CliGen::Command.trigger : Provided short must be a string" unless short.is_a? StringLiteral %}
{% raise "ERROR : CliGen::Command.trigger : Provided long must be a string" unless long.is_a? StringLiteral %}
{% raise "ERROR : CliGen::Command.trigger : Must provide a block for on_match trigger" unless on_match %}
{% name = long.gsub(/--/, "") %}
@[CliGen::Trigger(short: {{short}}, long: {{long}}, argument: {{argument}})]
{% if argument %}
def self.__cligen_trigger__{{name}}__({{name}} : {{argument}}) : Nil
{{on_match.body}}
end
{% else %}
def self.__cligen_trigger__{{name}}__ : Nil
{{on_match.body}}
end
{% end %}
end
end
end
+235
View File
@@ -0,0 +1,235 @@
require "./global_flag"
require "./flag"
require "./arg"
require "ecr"
module CliGen
record SubCommandInfo,
name : String,
description : String,
examples : Array(String)?
alias RunCommand = Proc(Nil)
# Non-generic base that lets the tree hold heterogeneous CommandNode(T) children.
# Everything that doesn't depend on T lives here.
abstract class BaseCommandNode
getter name : String
getter flags : Array(BaseFlag)
getter commands : Array(BaseCommandNode)
getter description : String?
@pre_run_commands : Array(RunCommand)
@post_run_commands : Array(RunCommand)
def initialize(
@name : String,
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)
end
def help : String
ECR.render("src/cligen/template/cmd_help.ecr")
end
def check_for_duplicates!(flags : Array(BaseFlag)) : Nil
shorts = flags.compact_map(&.short)
short_duplicates = [] of String
longs = flags.compact_map { |f| f.long_key unless f.long_key.empty? }
long_duplicates = [] of String
last_short : String = ""
shorts.sort.each do |short|
short_duplicates << short if last_short == short
last_short = short
end
last_long : String = ""
longs.sort.each do |long|
long_duplicates << long if last_long == long
last_long = long
end
unless long_duplicates.empty? && short_duplicates.empty?
error_buffer = "ERROR : CommandNode(%s)#check! : Found Duplicates : %s"
message = ""
unless long_duplicates.empty?
message += "\nLong:\n%s\n" % long_duplicates.map { |f| "- #{f}" }.join("\n")
end
unless short_duplicates.empty?
message += "\nShort:\n%s" % short_duplicates.map { |f| "- #{f}" }.join("\n")
end
raise error_buffer % [@name, message]
end
end
def get(flag_long : String) : BaseFlag?
@flags.find{|f| f.long_key == flag_long}
end
def find_match(arg : String)
if subcommand?(arg)
return CliGen::MatchType::SubCommand
end
case arg
when "-h", "--help"
CliGen::MatchType::Help
when CliGen::Regex::FLAG_REGEX
if flg = @flags.find(&.matches?(arg))
flg
else
CliGen::MatchType::NoMatch
end
when CliGen::Regex::FLAG_WITH_ARG
CliGen::MatchType::FlagWithArg
when CliGen::Regex::SHORT_WITH_INLINE_ARG
CliGen::MatchType::ShortWithInlineArg
when CliGen::Regex::FLAG_MULTIPLE_SHORT
CliGen::MatchType::FlagMultipleShort
else
if cmd = @commands.find { |c| c.name == arg }
cmd
else
CliGen::MatchType::NoMatch
end
end
end
def subcommands? : Bool
subcommands.size > 0
end
def subcommand?(arg : String) : Bool
subcommands.any?{|f| f.name == arg}
end
# Converts String array to Arg array and hands off to the typed process method
def process(args : Array(String)) : Nil
new_args = args.each_with_index.map { |arg, i| Arg.new(value: arg, index: i) }.to_a
process(new_args)
end
abstract def subcommands : Array(SubCommandInfo)
abstract def check! : Nil
abstract def process(args : Array(Arg)) : Nil
end
class CommandNode(T) < BaseCommandNode
def subcommands : Array(SubCommandInfo)
{% subcmds = T.methods.select(&.annotation(CliGen::SubCommand)) %}
{% if subcmds.empty? %}
[] of SubCommandInfo
{% else %}
[
{% for cmd in subcmds %}
{% anno = cmd.annotation(CliGen::SubCommand) %}
SubCommandInfo.new(
name: {{cmd.name.stringify}},
description: {{anno[:description]}},
examples: {% if anno[:examples] %} {{anno[:examples]}} {% else %} nil {% end %}
),
{% end %}
]
{% end %}
end
def check! : Nil
@flags.each(&.check!)
check_for_duplicates!(@flags)
@commands.each(&.check!)
raise "ERROR : CommandNode({{T}})#check! : {{T}} has no subcommands and no #main defined" \
if subcommands.empty? && !{{T.has_method?(:main)}}
end
def process(args : Array(Arg)) : Nil
check!
passed_execution = false
matched_subcommand : String? = nil
@pre_run_commands.each(&.call)
args.each do |arg|
next if arg.processed?
arg.processed
case match = find_match(arg.value)
when BaseCommandNode
# Hand off remainder to the child; we're done at this level
match.process(args.reject(&.processed?))
passed_execution = true
when BaseFlag
if match.requires_arg?
match.process(args.reject(&.processed?))
else
match.process
end
when MatchType::SubCommand
raise "ERROR : CommandNode({{T}})#process : Subcommand(#{matched_subcommand}) was already matched" if matched_subcommand
matched_subcommand = arg.value
when MatchType::Help
abort help
when MatchType::FlagWithArg
if regex_match = CliGen::Regex::FLAG_WITH_ARG.match(arg.value)
case flag_match = find_match(regex_match["flag"])
when BaseFlag
flag_match.process([Arg.new(value: regex_match["arg"], index: arg.index)])
else
raise "ERROR : CommandNode(#{@name}).run : No flag matched '#{regex_match["flag"]}'"
end
else
raise "Oh good, you broke regex. How the hell did it match in find_match but not above? What the hell is going on"
end
when MatchType::ShortWithInlineArg
abort "#{CliGen::APPNAME}: invalid flag '#{arg.value}' — inline values are not supported. Did you mean '#{arg.value[0..1]} #{arg.value[2..]}' ?"
when MatchType::FlagMultipleShort
arg.value.gsub(/^-/, "").chars.map { |c| "-#{c}" }.each do |flag|
case match = find_match(flag)
when BaseFlag
abort "#{CliGen::APPNAME}: cannot bundle flag that requires an argument: #{flag}" if match.requires_arg?
match.process
when MatchType::NoMatch
raise "ERROR : CommandNode(#{@name}).run : No match for '#{flag}'"
end
end
when MatchType::NoMatch
raise "ERROR : CommandNode(#{@name}).run : No match for '#{arg.value}'"
end
end
@post_run_commands.each(&.call)
unless passed_execution
cls = T.new(self)
{% subcmds = T.methods.select(&.annotation(CliGen::SubCommand)) %}
{% begin %}
case matched_subcommand
{% for cmd in subcmds %}
when {{cmd.name.stringify}}
cls.{{cmd.name}}
{% end %}
else
{% if T.has_method?(:main) %}
cls.main
{% else %}
raise "ERROR : CommandNode({{T}})#{{@def.name}} : No subcommand matched and no #main defined"
{% end %}
end
{% end %}
end
end
end
end
+203
View File
@@ -0,0 +1,203 @@
require "./arg"
module CliGen
abstract class BaseFlag
getter var : String
getter short : String?
getter long : String
getter long_key : String
getter env_var : String
getter description : String
def initialize(
@var : String,
@short : String?,
@long : String,
@env_var : String,
@description : String
)
# if the user provides just a "--long" I want the @long_key to match it
if @long.includes(" ")
@long_key = @long.split(" ").first
else
@long_key = @long
end
end
def matches?(token : String) : Bool
token == @short || (!@long_key.empty? && token == @long_key)
end
abstract def satisfied? : Bool
abstract def validate! : Nil
abstract def raw_value : String?
abstract def check! : Nil
end
class Flag(T) < BaseFlag
@value : T?
@default : T?
@options : Array(T)?
@validate : (T -> Bool)?
@on_match : Proc(Nil)?
def initialize(
var : String,
short : String?,
long : String,
env_var : String,
description : String,
@default : T? = nil,
@options : Array(T)? = nil,
@validate : (T -> Bool)? = nil,
@on_match : Proc(Nil)? = nil
)
super(var, short, long, env_var, description)
end
def requires_arg? : Bool
{{T}} != Bool
end
def process(argv : Array(Arg) = [] of Arg) : Nil
if requires_arg?
raise "ERROR : Flag({{T}}) : Array requires an argument but provided array is empty" if argv.empty?
end
{% if T == Bool %}
@value = true
{% 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 %}
argv.each do |arg|
break if arg.value.starts_with('-')
{% if elem == Int32 %}
(@value ||= [] of Int32) << arg.value.to_i
{% elsif elem == String %}
(@value ||= [] of String) << arg.value
{% elsif elem < CliGen::Coercable %}
if arg.value.includes?(",")
(@value ||= [] of {{elem}}) += arg.value.split(",").map{|i| {{elem}}.coerce(i)}
else
(@value ||= [] of {{elem}}) << {{elem}}.coerce(arg.value)
end
{% else %}
{% raise "ERROR : Flag(#{T}) : #{elem} is not a coercable type. If you wish to coerce it from a bare string include CliGen::Coercable & implement the class method" %}
{% end %}
arg.processed
end
{% elsif T == Int32 %}
@value = argv.first.value.to_i
argv.first.processed
{% elsif T == Time %}
@value = parse_time(argv.first.value)
argv.first.processed
{% elsif T == String %} # String
@value = argv.first.value
argv.first.processed
{% elsif T < CliGen::Parsable %}
processed = argv.select(&.processed?)
@value = T.parse_args(argv)
post_processed = argv.select(&.processed?)
if processed == post_processed
raise "ERROR : Flag({{T}}, long: #{@long_key})#process : Your {{T}}#process_args did not mark processed args as processed. Please check your code"
end
{% else %}
{% raise "ERROR : Flag({{T}}, long: #{@long_key})#process : Generic Type #{T} is not supported. To add support you must include CliGen::Parsable & implement the class method" %}
{% end %}
validate!
@on_match.try(&.call)
end
def value! : T
v = @value
# Priority: provided arg → env var → default → abort
if v.nil?
if raw = ENV[@env_var]?
v = coerce(raw)
end
end
v ||= @default
raise "#{CliGen::APPNAME}: required flag #{@long_key} was not provided" if v.nil?
v.not_nil!
end
def raw_value : String?
{% if T == Bool %}
@value.try(&.to_s)
{% elsif T <= Array %}
@value.try(&.join(","))
{% else %}
@value.try(&.to_s)
{% end %}
end
def satisfied? : Bool
return true if !@value.nil?
return true if @env_var && ENV[@env_var]?
return true if !@default.nil?
false
end
def validate! : Nil
v = value!
if opts = @options
abort "#{CliGen::APPNAME}: '#{v}' is not a valid value for #{@long_key} (valid: #{opts.join(", ")})" unless opts.includes?(v)
end
if check = @validate
abort "#{CliGen::APPNAME}: validation failed for #{@long_key}" unless check.call(v)
end
end
def check!
raise "ERROR : Flag({{T}}, long: #{@long})#check! : -h is reserved for internal help usage" if @short == "-h"
raise "ERROR : Flag({{T}}, long: #{@long})#check! : --help is reserved for internal help usage" if @long_key == "--help"
end
private def coerce(raw : String) : T
{% if T == Bool %}
raw == "true" || raw == "1"
{% elsif T == Int32 %}
raw.to_i
{% elsif T == Time %}
parse_time(raw)
{% elsif T <= Array %}
{% elem = T.type_vars.first %}
{% if elem == Int32 %}
raw.split(',').map(&.to_i)
{% elsif elem == String %}
raw.split(',')
{% elsif elem < CliGen::Coercable %}
raw.split(',').map{|i| {{elem}}.coerce(i)}
{% else %}
{% raise "ERROR : Flag(#{T}) : #{elem} is not a coercable type. If you wish to coerce it from a bare string include CliGen::Coercable & implement the class method" %}
{% end %}
{% elsif T == String %} # String
raw
{% elsif T < CliGen::Coercable %}
T.coerce(raw)
{% else %}
{% raise "ERROR : Flag(#{T}) : #{T} is not a coercable type. If you wish to coerce it from a bare string include CliGen::Coercable & implement the class method" %}
{% end %}
end
private def parse_time(raw : String) : Time
formats = [
CliGen::Regex::INPUT_DATETIME_REGEX,
CliGen::Regex::INPUT_DATE_REGEX
]
abort "#{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)
else
Time.parse_local(raw, CliGen::Format::INPUT_DATE_FORMAT)
end
end
end
end
+37
View File
@@ -0,0 +1,37 @@
require "./flag"
module CliGen
GLOBAL_FLAGS = [] of BaseFlag
macro add_global_flag(type, long, description, env_var = nil, short = nil, validation = nil, &on_match)
{% raise "ERROR : CliGen.add_global_flag : type must be a TypeNode" unless type.is_a? TypeNode %}
{% raise "ERROR : CliGen.add_global_flag : long must begin a StringLiteral" unless long.is_a? StringLiteral %}
{% raise "ERROR : CliGen.add_global_flag : long must begin with --" unless long =~ /^--/ %}
{% if short %}
{% raise "ERROR : CliGen.add_global_flag : Short must be a StringLiteral" unless short.is_a? StringLiteral %}
{% raise "ERROR : CliGen.add_global_flag : Short must be a - with single char (ex: -a)" unless short =~ /^-[a-zA-Z]/ %}
{% end %}
{% if validation %}
{% raise "ERROR : CliGen.add_global_flag : validation must be a Proc" unless validation.is_a? ProcLiteral %}
{% raise "ERROR : CliGen.add_global_flag : validation proc return type must be nil \"->(...) : Nil {...}\"" unless validation.is_a? ProcLiteral %}
{% raise "ERROR : CliGen.add_global_flag : validation proc must have a single input variable" unless validation.args.size == 1%}
{% arg = validation.args.first %}
{% raise "ERROR : CliGen.add_global_flag : validation proc input variable MUST be typed to match the flag type \"->(#{arg.name} : #{type}) : Nil { ... }\"" unless arg.restriction == type %}
{% end %}
{% raise "ERROR : CliGen.add_global_flag : decription must be a StringLiteral" unless description.is_a? StringLiteral %}
{% if env_var %}
{% raise "ERROR : CliGen.add_global_flag : env_var must be a StringLiteral" unless env_var.is_a? StringLiteral %}
{% else %}
{% env_var = long.gsub(/--/, "").upcase %}
{% end %}
CliGen::GLOBAL_FLAGS << CliGen::Flag({{type}}).new(
var: "",
short: {{short}},
long: {{long}},
description: {{description}},
env_var: {{env_var}},
on_match: {% if on_match %} ->() : Nil { {{on_match.body}} } {% else %} nil {% end %},
validation: {% if validation %} {{validation}} {% else %} nil {% end %}
)
end
end
+10
View File
@@ -0,0 +1,10 @@
module CliGen
enum MatchType
FlagWithArg
FlagMultipleShort
ShortWithInlineArg
SubCommand
Help
NoMatch
end
end
+3
View File
@@ -0,0 +1,3 @@
module CliGen::Parseable
abstract def parse_args(args : Array(CliGen::Arg)) : self
end
+5
View File
@@ -1,4 +1,9 @@
module CliGen::Regex
FLAG_REGEX=/^(-[a-zA-Z]|--[a-zA-Z-_]+)$/
FLAG_WITH_ARG=/^(?<flag>(-[a-zA-Z]|--[a-zA-Z-_]+))="?(?<arg>\S+?)"?$/
FLAG_MULTIPLE_SHORT=/^-[a-zA-Z]+$/
SHORT_WITH_INLINE_ARG=/^-[a-zA-Z][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}$/
end
+42
View File
@@ -0,0 +1,42 @@
Command: <% @name %>
<%- unless @description.nil? -%>
Description: <%= @description %>
<%- end -%>
Flags:
---------------------------------------------------------------
<%- @flags.each do |flag| -%>
<%- unless flag.short.nil? -%>
<%= "%-10s %s" % ["#{flag.short},#{flag.long}", flag.description] %>
<%- else -%>
<%= "%-10s %s" % [flag.long, flag.description] %>
<%- end -%>
<%- end -%>
<%- unless @commands.empty? -%>
Other Commands
---------------------------------------------------------------
<%- @commands.each do |command| -%>
<%= "%-10s %s" % [ command.name, command.description ] %>
<%- end -%>
<%- end -%>
<%- if subcommands? -%>
SubCommands of <%= @name %>:
---------------------------------------------------------------
<%- subcommands.each do |cmd| -%>
<%= "%-10 %s" % [cmd.name, cmd.description] %>
<%- end -%>
<%- cmds = subcommands.select{|c| ! c.examples.nil? } -%>
<%- unless cmds.empty? -%>
Examples:
---------------------------------------------------------------
<%- cmds.map(&.examples).flatten.each do |example| -%>
<%= example %>
<%- end -%>
<%- end -%>
<%- end -%>
Note: When wanting help output of any command you can provide the -h/--help flags or help command