Compare commits

...

39 Commits

Author SHA1 Message Date
Tristan Ancelet 777a1b992a Had to fix block.call to yield so that the compiler could be happy 2026-08-17 09:25:42 -05:00
tristan a17053f45c Added a specs for flag & arg. As well as changed a few ways I as handling cli args and determining how they're to be passed to each flag 2026-08-16 17:19:10 -05:00
Tristan Ancelet 6535753fa3 Had to fix some things in coerce 2026-08-14 15:55:28 -05:00
Tristan Ancelet 09389cb5e9 Added differnt check for default. Set it to empty string as a way of determining if a default was set. Ignoring empty strings 2026-08-14 15:01:04 -05:00
Tristan Ancelet d1746602b1 modified the way that the FlagMeta default string gets generated based on Array vs LITERALLY anything else 2026-08-14 14:57:05 -05:00
Tristan Ancelet 21462029e3 Fixed template 2026-08-14 14:34:10 -05:00
Tristan Ancelet 4a0dcb8b59 Had to fix expression to show type as {{...}} isn't expanded in macro expression betweeh {% ... %} 2026-08-14 14:19:38 -05:00
Tristan Ancelet 8780be3a42 Removed 'long' from macro expression 2026-08-14 14:18:02 -05:00
Tristan Ancelet fd510469ac Added allow_no_verification for users to bypass the 'must have options or validation' check. However, not doing so can cause unexpected failures where flags will begin consuming preceeding arguments 2026-08-14 14:14:53 -05:00
Tristan Ancelet f55cc25f2d Modified Command.argument to let the user provide a method call for runtime learning of options value. This will let the compiler check at compile time 2026-08-14 14:10:25 -05:00
Tristan Ancelet b05f49c2e8 Fixed something maybe 2026-08-14 14:00:31 -05:00
Tristan Ancelet d3ca70ea9e Added options for array checking of base types Strings/Int32/etc 2026-08-14 13:55:10 -05:00
Tristan Ancelet 697a0db3af Added debug message to print out a missing match + the format being matched 2026-08-12 16:34:16 -05:00
Tristan Ancelet a1a9c6fc04 Added format filter to enforce formatting on array objects 2026-08-12 16:26:35 -05:00
Tristan Ancelet ffde5165f7 Added format support for filtering arguments 2026-08-12 16:12:59 -05:00
Tristan Ancelet 53f510278c added thing 2026-08-12 13:12:09 -05:00
Tristan Ancelet e6d9306e69 Added exit & default to help if no main is specified 2026-08-12 13:10:30 -05:00
Tristan Ancelet 18c5a559b1 Added exit & default to help if no main is specified 2026-08-12 13:08:24 -05:00
Tristan Ancelet 28c45fae9e Added guard for empty @flags array in template 2026-08-12 13:01:41 -05:00
Tristan Ancelet f889aae5e1 added dynamic resizing of description for flag & the like 2026-08-12 12:56:59 -05:00
Tristan Ancelet e90d89689c Increased default spacing in commands and flags 2026-08-12 12:50:42 -05:00
Tristan Ancelet 9b3d823bc7 Comitting for testing 2026-08-12 12:43:44 -05:00
Tristan Ancelet d02eded273 Trying again 2026-08-12 12:40:08 -05:00
Tristan Ancelet fc592fee37 Trying again 2026-08-12 12:38:55 -05:00
Tristan Ancelet 47b4bfa494 had to fix syntax 2026-08-12 12:37:28 -05:00
Tristan Ancelet 1a2da86048 Allowed delim in macros 2026-08-12 12:36:23 -05:00
Tristan Ancelet 37c5083ae2 added def_init 2026-08-12 11:59:49 -05:00
Tristan Ancelet 976ed10f75 Added def_getter 2026-08-12 11:00:56 -05:00
Tristan Ancelet ea8ca62a63 Added StringIterpolation 2026-08-12 10:55:22 -05:00
Tristan Ancelet 0c7849dc7c Added options 2026-08-11 12:24:30 -05:00
Tristan Ancelet 47df89a6c1 Had to fix the selection macro. Was part of the way throught and skipped finishing it 2026-08-11 11:58:04 -05:00
Tristan Ancelet afd7ba7c7b Added options to Command.argument macro 2026-08-11 11:53:38 -05:00
Tristan Ancelet 8447a1ffbb Added prerun command annotation & implementation 2026-08-11 11:37:22 -05:00
Tristan Ancelet 4af08f8efe had to fix again 2026-08-11 11:24:22 -05:00
Tristan Ancelet 1085335c88 Had to add for lib setup 2026-08-11 11:22:02 -05:00
Tristan Ancelet 19ea00ef72 added a few things 2026-08-11 11:17:34 -05:00
tristan 8d17de7434 Fix annotation key mismatch: validate -> validation in generate.cr
anno[:validate] was always nil; validation procs were never wired
into Flag(T).new. Correct key is :validation to match the field
name set by the argument macro in command/argument.cr.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATnpoEGXj4RyuMoBLGWcjs
2026-08-10 13:12:12 -05:00
Tristan Ancelet 405303e4f8 updated .gitignor 2026-08-10 13:00:23 -05:00
Tristan Ancelet 29327a3e83 Got Array(T) working in flag.cr. Hacky stuff it is to convince the compiler that a value isn't going to be nil if I set it 2026-08-10 12:57:31 -05:00
24 changed files with 662 additions and 177 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
+13 -14
View File
@@ -1,14 +1,13 @@
# Crystal build artifacts *
/.build/ !CLAUDE.md
/bin/ !lib/
!lib/cligen
# Compiled test binary (crystal build test.cr) !src/
test !src/**
!shard.yml
# Shards cache !README.md
/.shards/ !design.adoc
/lib/ !docs/
!docs/**
# Editor !Makefile
tags !LICENSE
.DS_Store
+4 -6
View File
@@ -44,10 +44,8 @@ module MyModule
"myutil mycommand do_thing --myvar 5" "myutil mycommand do_thing --myvar 5"
] ]
subcommand do_thing : Int32, subcommand do_thing, description: "Do the THING", examples: MyModule::MyCommand::DO_THING_EXAMPLES do
description: "Do the THING",
examples: MyModule::MyCommand::DO_THING_EXAMPLES \
do
output = 0 output = 0
@myvar.times do |i| @myvar.times do |i|
@@ -55,11 +53,11 @@ module MyModule
output += i output += i
end end
output puts "Total : %i" % output
end end
end end
CliGen::App.process(ARGV) CliGen::App.process
end end
---- ----
Symlink
+1
View File
@@ -0,0 +1 @@
..
+7
View File
@@ -1,3 +1,5 @@
require "./cligen/exceptions"
require "./cligen/coercable"
require "./cligen/parsable" require "./cligen/parsable"
require "./cligen/annotations" require "./cligen/annotations"
require "./cligen/format" require "./cligen/format"
@@ -10,6 +12,11 @@ require "./cligen/app"
module CliGen module CliGen
VERSION = "0.1.0" VERSION = "0.1.0"
macro override_help_template(filepath)
{% raise "ERROR : CliGen.override_help_template : File(#{filepath}) doesn't exist" unless file_exists?(filepath) %}
CliGen::HELP_OVERRIDE_TEMPLATE = {{`readlink -f #{filepath}`.strip.stringify}}
end
APPNAME = File.basename(PROGRAM_NAME) APPNAME = File.basename(PROGRAM_NAME)
record AdditionalDefaultFlag, record AdditionalDefaultFlag,
+3
View File
@@ -14,6 +14,9 @@ module CliGen
annotation Selection annotation Selection
end end
annotation PreRunCommand
end
annotation SubCommand annotation SubCommand
end end
end end
+17 -5
View File
@@ -14,17 +14,29 @@ module CliGen
@@instance = self @@instance = self
end end
def check! : Nil def check!
CliGen::GLOBAL_FLAGS.each(&.check!) super
# @flags is the amalgamation of all child flags — checked by the commands themselves end
check_for_duplicates!([CliGen::GLOBAL_FLAGS, @flags].flatten)
@commands.each(&.check!) def self.handle_command_raises(&) : Nil
begin
yield
rescue e : CliGen::RuntimeError
abort e.message
rescue e : CliGen::ConfigurationError
abort e.message
rescue e : CliGen::HelpRequestedError
puts e.message
exit 0
end
end end
# Convenience entry point; defaults to ARGV # Convenience entry point; defaults to ARGV
def self.process(args : Array(String) = ARGV.to_a) : Nil def self.process(args : Array(String) = ARGV.to_a) : Nil
generate if @@instance.nil? generate if @@instance.nil?
handle_command_raises do
@@instance.not_nil!.process(args) @@instance.not_nil!.process(args)
end end
end end
end
end end
+7 -4
View File
@@ -27,10 +27,13 @@ module CliGen
env_var: {% if anno[:env_var] %} {{anno[:env_var]}} {% else %} {{"#{cmd.name.split("::").last.upcase.id}_#{var.name.upcase}"}} {% end %}, env_var: {% if anno[:env_var] %} {{anno[:env_var]}} {% else %} {{"#{cmd.name.split("::").last.upcase.id}_#{var.name.upcase}"}} {% end %},
description: {{ anno[:description] }}, description: {{ anno[:description] }},
default: {% unless var.default_value.nil? %} {{var.default_value}} {% else %} nil {% end %}, default: {% unless var.default_value.nil? %} {{var.default_value}} {% else %} nil {% end %},
validate: {% if anno[:validate] %} {{anno[:validate]}} {% else %} nil {% end %}, validate: {% if anno[:validation] %} {{anno[:validation]}} {% else %} nil {% end %},
on_match: {% if anno[:on_match] %} {{anno[:on_match]}} {% else %} nil {% end %} on_match: {% if anno[:on_match] %} {{anno[:on_match]}} {% else %} nil {% end %},
options: {% if anno[:options] %} {{anno[:options]}} {% else %} nil {% end %},
delimiter: {% if anno[:delimiter] %} {{anno[:delimiter]}} {% else %} "," {% end %},
format: {% if anno[:format] %} {{anno[:format]}} {% else %} nil {% end %}
) )
{% debug %} {% debug if env("DEBUG") %}
{% end %} {% end %}
# Keep a copy of every flag on the root for global matching # Keep a copy of every flag on the root for global matching
@@ -44,7 +47,7 @@ module CliGen
post_run_commands: cmd_post_run_cmds, post_run_commands: cmd_post_run_cmds,
description: {{cmd_info[:description]}} description: {{cmd_info[:description]}}
) )
{% debug %} {% debug if env("DEBUG") %}
{% end %} {% end %}
{% end %} {% end %}
+49 -1
View File
@@ -24,12 +24,60 @@ module CliGen
def initialize(@value, @index) def initialize(@value, @index)
end end
def self.flag?(val : String) : Bool
if val =~ CliGen::Regex::FLAG_REGEX
true
else
false
end
end
def flag?(val : String = @value) : Bool
Arg.flag?(val)
end
def self.int?(val : String) : Bool
if val =~ CliGen::Regex::INT
true
else
false
end
end
def int?(val : String = @value) : Bool
Arg.int?(val)
end
def self.uint?(val : String) : Bool
if val =~ CliGen::Regex::UINT
true
else
false
end
end
def uint?(val : String = @value) : Bool
Arg.uint?(val)
end
def self.float?(val : String) : Bool
if val =~ CliGen::Regex::FLOAT
true
else
false
end
end
def float?(val : String = @value) : Bool
Arg.float?(val)
end
# This serves as a trigger that tells the object that it has been processed # 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 # This will raise an exception if it is re-called after already having been
# processed. # processed.
def processed def processed
raise "ERROR : CliGen::Arg(index: #{@index}, value: #{@value})#processed : This arg was re-processed" if @processed raise CliGen::ArgReprocessedError.new("CliGen::Arg(index: #{@index}, value: #{@value})#processed : This arg was re-processed") if @processed
@processed = true @processed = true
end end
end end
+1 -1
View File
@@ -1,3 +1,3 @@
module CliGen::Coercable module CliGen::Coercable
abstract def coerce(arg : String) : self abstract def coerce(arg : String)
end end
+8 -18
View File
@@ -4,32 +4,22 @@ require "time"
require "./annotations" require "./annotations"
require "./command/argument" require "./command/argument"
require "./command/selection" require "./command/selection"
require "./command/trigger" require "./command/help_template"
require "./command/subcommand" require "./command/subcommand"
require "./command/def_init"
require "./command/define_command_initializer"
module CliGen module CliGen
class Command class Command
macro inherited macro inherited
{% verbatim do %} {% verbatim do %}
macro finished macro finished
def initialize(*, handler : CliGen::BaseCommandNode) {% anno = @type.annotation(CliGen::CommandInfo) %}
{% verbatim do %} {% raise "" unless anno %}
{% for var in @type.instance_vars %} {% if anno[:def_init] %}
{% anno = (var.annotation(CliGen::Argument) || var.annotation(CliGen::Selection)) %} def_init
{% 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_value %}
@{{var.id}} = {{var.default_value}}
{% end %} {% end %}
{% end %} define_command_initializer
{% end %}
end
end end
{% end %} {% end %}
end end
+49 -10
View File
@@ -1,41 +1,80 @@
module CliGen module CliGen
class Command class Command
macro argument(variable, long, description, short = nil, validation = nil, on_match = nil) macro argument(variable, description, long = nil, short = nil, validation = nil, on_match = nil, def_setter = false, def_getter = false, options = nil, delimiter = ",", format = nil, allow_no_verification = false)
{% raise "ERROR : CliGen::Command.argument : def_setter must be a Bool" unless def_setter.is_a? BoolLiteral %}
{% raise "ERROR : CliGen::Command.argument : First argument (#{variable}) must be a TypeDeclaration (ex: '<var> : <type> [= val]')" unless variable.is_a? TypeDeclaration %} {% raise "ERROR : CliGen::Command.argument : First argument (#{variable}) must be a TypeDeclaration (ex: '<var> : <type> [= val]')" unless variable.is_a? TypeDeclaration %}
{% name = variable.var %} {% name = variable.var %}
{% type = variable.type %} {% type = variable.type %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided delimiter must be a string" unless delimiter.is_a? StringLiteral %}
{% if short %} {% if short %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided short must be a string" unless short.is_a? StringLiteral %} {% raise "ERROR : CliGen::Command.argument(#{name}) : Provided short must be a string" unless short.is_a? StringLiteral %}
{% end %} {% end %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided long must be a string" unless long.is_a? StringLiteral || long == nil %} {% if long %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : You must provide a short or long" unless long || short %} {% raise "ERROR : CliGen::Command.argument(#{name}) : Provided long must be a string" unless long.is_a? StringLiteral %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided long must match --[a-zA-Z0-9-_]+" unless long =~ /^--[a-zA-Z0-9-_]+/ %}
{% else %}
{% long = "--#{name.downcase}" %}
{% end %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : You must provide a description" unless description %} {% 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 %} {% raise "ERROR : CliGen::Command.argument(#{name}) : Provided description must be a String" unless description.is_a? StringLiteral || description.is_a? StringInterpolation %}
{% unless on_match.nil? %} {% unless on_match.nil? %}
{% puts on_match %} {% puts "DEBUG : #{@type.name}.argument(#{name}) : OnMatch:\n\tid: #{on_match}\n\treturn_type: #{on_match.return_type}\n\tinput_vars: #{on_match.args}" if env("DEBUG")%}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided on_match must be a Proc" unless on_match.is_a? ProcLiteral %} {% raise "ERROR : CliGen::Command.argument(#{name}) : Provided on_match must be a Proc" unless on_match.is_a? ProcLiteral %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided on_match return type must be a Bool" unless on_match.return_type == Nil %} {% raise "ERROR : CliGen::Command.argument(#{name}) : Provided on_match return type must be Nil" unless on_match.return_type.resolve == Nil %}
{% end %} {% end %}
{% unless validation.nil? %} {% unless validation.nil? %}
{% puts "DEBUG : #{@type.name}.argument(#{name}) : Validation:\n\tid: #{validation}\n\treturn_type: #{validation.return_type}\n\tinput_vars: #{validation.args}" if env("DEBUG")%}
{% 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 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 return type must be a Bool" unless validation.return_type.resolve == Bool %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided validation provided validation must have an input variable" if validation.args.empty? %} {% raise "ERROR : CliGen::Command.argument(#{name}) : Provided validation provided validation must have an input variable" if validation.args.empty? %}
{% arg = validation.args.first %} {% arg = validation.args.first %}
{% unless arg.restriction == type %} {% unless arg.restriction == type %}
{% example = "->(#{arg.name} : #{type}) : #{type} { #{validation.body} }" %} {% example = "->(#{arg.name} : #{type}) : Bool { #{validation.body} }" %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided validation input value must be #{type}. EX: #{example}" %} {% raise "ERROR : CliGen::Command.argument(#{name}) : Provided validation input value must be #{type}. EX: #{example}" %}
{% end %} {% end %}
{% end %} {% end %}
{% if options %}
{% options = options.resolve if options.is_a? Path %}
{% if options.is_a? Call %}
{% elsif options.is_a? ArrayLiteral %}
{% else %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided options must be an ArrayLiteral or a runtime method call to retrieve data" %}
{% end %}
{% end %}
{% if format %}
{% format = format.resolve if format.is_a? Path %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided format must be a RegexLiteral" unless format.is_a? RegexLiteral %}
{% end %}
{% if type.resolve <= Array && ! allow_no_verification %}
{% elem = type.type_vars.first %}
{% unless elem == Int32 %}
{% if format.nil? && options.nil? %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : When providing custom data types for Array(T) or using Array(String) you must provide a format or options for argument filtering so that parsing can be done deterministically" %}
{% end %}
{% end %}
{% end %}
@[CliGen::Argument(short: {{short}}, long: {{long}}, description: {{description}}, validation: {{validation}}, on_match: {{on_match}})] {% if type.resolve < Array && ! options.nil? %}
@[CliGen::Argument(short: {{short}}, long: {{long}}, description: {{description}}, validation: {{validation}}, on_match: {{on_match}}, options: [{{options}}], delimiter: {{delimiter}}, format: {{format}})]
{% else %}
@[CliGen::Argument(short: {{short}}, long: {{long}}, description: {{description}}, validation: {{validation}}, on_match: {{on_match}}, options: {{options}}, delimiter: {{delimiter}}, format: {{format}})]
{% end %}
@{{variable}} @{{variable}}
{% if def_getter %}
def {{variable.var}}
@{{name}}
end
{% end %}
{% if def_setter %}
def {{variable.var}}= (value : {{type}}) def {{variable.var}}= (value : {{type}})
{% unless validation.nil? %} {% unless validation.nil? %}
raise "ERROR : #{@type.name}##{@def.name} : Provided value #{value} is not passing validation" unless {{validation}}.call(value) raise CliGen::ValidationError.new("#{@type.name}##{@def.name} : Provided value #{value} failed validation") unless {{validation}}.call(value)
{% end %} {% end %}
@{{name}} = value @{{name}} = value
end end
{% end %}
end end
end end
end end
+22
View File
@@ -0,0 +1,22 @@
module CliGen
class Command
macro def_init
def initialize
{% verbatim do %}
{% for var in @type.instance_vars %}
{% raise "ERROR : Can't define a default initializer if #{var.name} doesn't have a default" if var.default_value.nil? && !var.type.nilable? %}
@{{var.name}} = {{var.default_value}}
{% end %}
{% end %}
end
def after_initialize
@@instance = self
end
def self.get
@@instance ||= new
end
end
end
end
@@ -0,0 +1,29 @@
module CliGen
class Command
macro define_command_initializer
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]}}}
flg.validate!
@{{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 %}
{% 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_value %}
@{{var.id}} = {{var.default_value}}
{% end %}
{% end %}
{% if @type.has_method? :after_initialize %}
after_initialize
{% end %}
{% end %}
end
end
end
end
+10
View File
@@ -0,0 +1,10 @@
module CliGen
class Command
macro help_template(filepath)
{% raise "ERROR : CliGen::Command.help_template : #{filepath} does not exist" unless file_exists? filepath %}
HELP_TEMPLATE = {{`readlink -f #{filepath}`.strip.stringify}}
{% puts "DEBUG : #{@type.name} : Set HELP_TEMPLATE to #{filepath}" if env("DEBUG") %}
{% debug if env("DEBUG") %}
end
end
end
+12 -5
View File
@@ -1,14 +1,21 @@
module CliGen module CliGen
class Command class Command
macro selection(variable, short, long, description, options) macro selection(variable, description, options, short = nil, long = nil, validation = nil, on_match = nil)
{% 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 : 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 %} {% if short %}
{% raise "ERROR : CliGen::Command.selection : Provided long must be a string" unless long.is_a? StringLiteral || long == nil %} {% raise "ERROR : CliGen::Command.selection : Provided short must be a string" unless short.is_a? StringLiteral %}
{% end %}
{% long = "--#{variable.var}" if long.nil? %}
{% raise "ERROR : CliGen::Command.selection : Provided long must be a flag format" unless long =~ /^--[a-zA-Z0-9-_]+/ %}
{% raise "ERROR : CliGen::Command.selection : Provided long must be a string" unless long.is_a? StringLiteral %}
{% raise "ERROR : CliGen::Command.selection : You must provide a short or long" unless long || short %} {% 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 : 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 : Provided description must be a String" unless description.is_a? StringLiteral %}
{% raise "ERROR : CliGen::Command.selection : You must provide options" unless options %} {% options = options.resolve if options.is_a? Path %}
{% raise "ERROR : CliGen::Command.selection : Provided options must be " unless options %} {% raise "ERROR : CliGen::Command.selection : Provided options must be an ArrayLiteral" unless options.is_a? ArrayLiteral %}
@[CliGen::Argument(short: {{short}}, long: {{long}}, description: {{description}}, validation: {{validation}}, on_match: {{on_match}}, options: {{options}}, delimiter: "-")]
@{{variable}}
end end
end end
end end
-21
View File
@@ -1,21 +0,0 @@
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
+93 -26
View File
@@ -33,10 +33,6 @@ module CliGen
@flags = (flags + CliGen::GLOBAL_FLAGS + @commands.flat_map(&.flags)).uniq @flags = (flags + CliGen::GLOBAL_FLAGS + @commands.flat_map(&.flags)).uniq
end end
def help : String
ECR.render("src/cligen/template/cmd_help.ecr")
end
def check_for_duplicates!(flags : Array(BaseFlag)) : Nil def check_for_duplicates!(flags : Array(BaseFlag)) : Nil
shorts = flags.compact_map(&.short) shorts = flags.compact_map(&.short)
short_duplicates = [] of String short_duplicates = [] of String
@@ -67,18 +63,31 @@ module CliGen
message += "\nShort:\n%s" % short_duplicates.map { |f| "- #{f}" }.join("\n") message += "\nShort:\n%s" % short_duplicates.map { |f| "- #{f}" }.join("\n")
end end
raise error_buffer % [@name, message] raise CliGen::DuplicateFlagError.new(error_buffer % [@name, message])
end end
end end
def get(flag_long : String) : BaseFlag? def get(*, long : String) : BaseFlag?
@flags.find{|f| f.long_key == flag_long} @flags.find{|f| f.long_key == long}
end
def get(*, short : String) : BaseFlag?
@flags.find{|f| f.short == short}
end
def handle_flag_raises(&) : Nil
begin
yield
rescue e : CliGen::RuntimeError
abort e.message
end
end end
def find_match(arg : String) def find_match(arg : String)
if subcommand?(arg) if subcommand?(arg)
return CliGen::MatchType::SubCommand return CliGen::MatchType::SubCommand
end end
case arg case arg
when "-h", "--help" when "-h", "--help"
CliGen::MatchType::Help CliGen::MatchType::Help
@@ -90,8 +99,6 @@ module CliGen
end end
when CliGen::Regex::FLAG_WITH_ARG when CliGen::Regex::FLAG_WITH_ARG
CliGen::MatchType::FlagWithArg CliGen::MatchType::FlagWithArg
when CliGen::Regex::SHORT_WITH_INLINE_ARG
CliGen::MatchType::ShortWithInlineArg
when CliGen::Regex::FLAG_MULTIPLE_SHORT when CliGen::Regex::FLAG_MULTIPLE_SHORT
CliGen::MatchType::FlagMultipleShort CliGen::MatchType::FlagMultipleShort
else else
@@ -111,6 +118,10 @@ module CliGen
subcommands.any?{|f| f.name == arg} subcommands.any?{|f| f.name == arg}
end end
def flag?(arg : String) : Bool
@flags.any?(&.matches?(arg))
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
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
@@ -143,12 +154,35 @@ module CliGen
{% end %} {% end %}
end end
def verbose? : Bool
@verbose_flag ||= get(long: "--verbose").not_nil!.as(Flag(Bool))
@verbose_flag.not_nil!.value!
end
def help : String
{% begin %}
{% if T.has_constant? "HELP_TEMPLATE" %}
{% puts "#{T} was found to have HELP_TEMPLATE defined using this instead" if env("DEBUG") %}
ECR.render({{T.constant("HELP_TEMPLATE")}})
{% elsif CliGen.has_constant? "HELP_OVERRIDE_TEMPLATE" %} # If we have a global override use it
{% puts "Global override found. Using" if env("DEBUG") %}
ECR.render({{CliGen::HELP_OVERRIDE_TEMPLATE}})
{% else %}
{% puts "No type overrided help output. Using default" if env("DEBUG") %}
ECR.render("lib/cligen/src/cligen/template/cmd_help.ecr")
{% end %} # otherwise
{% debug if env("DEBUG") %}
{% end %}
end
def check! : Nil def check! : Nil
@flags.each(&.check!) @flags.each(&.check!)
check_for_duplicates!(@flags) check_for_duplicates!(@flags)
@commands.each(&.check!) @commands.each(&.check!)
raise "ERROR : CommandNode({{T}})#check! : {{T}} has no subcommands and no #main defined" \ {% unless T == Nil %}
raise CliGen::MissingDispatchError.new("CommandNode({{T}})#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
def process(args : Array(CliGen::Arg)) : Nil def process(args : Array(CliGen::Arg)) : Nil
@@ -170,55 +204,80 @@ module CliGen
{% for cls in CliGen::Command.subclasses %} {% for cls in CliGen::Command.subclasses %}
when CliGen::CommandNode({{cls.name}}) when CliGen::CommandNode({{cls.name}})
match.as(CommandNode({{cls}})).process(args.reject(&.processed?)) match.as(CommandNode({{cls}})).process(args.reject(&.processed?))
exit 0
{% end %} {% end %}
else else
raise "ERROR : Couldn't find thing" raise CliGen::UnknownCommandNodeError.new("CommandNode({{T}})#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
# Ensuring we catch any exceptions to have them abort with the
# error message if caught
handle_flag_raises do
if match.requires_arg? if match.requires_arg?
match.process(args.reject(&.processed?)) match.process(args.reject(&.processed?).take_while{|v| find_match(v.value) == CliGen::MatchType::NoMatch})
else else
match.process match.process
end end
end
when MatchType::SubCommand when MatchType::SubCommand
raise "ERROR : CommandNode({{T}})#process : Subcommand(#{matched_subcommand}) was already matched" if matched_subcommand raise CliGen::InternalError.new("CommandNode({{T}})#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
abort help raise CliGen::HelpRequestedError.new(help)
when MatchType::FlagWithArg when MatchType::FlagWithArg
if regex_match = CliGen::Regex::FLAG_WITH_ARG.match(arg.value) if regex_match = CliGen::Regex::FLAG_WITH_ARG.match(arg.value)
case flag_match = find_match(regex_match["flag"]) case flag_match = find_match(regex_match["flag"])
when BaseFlag when BaseFlag
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)])
else
raise "ERROR : CommandNode(#{@name}).process : No flag matched '#{regex_match["flag"]}'"
end end
else 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" 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")
end 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 when MatchType::FlagMultipleShort
arg.value.gsub(/^-/, "").chars.map { |c| "-#{c}" }.each do |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|
case match = find_match(flag) case match = find_match(flag)
when BaseFlag when BaseFlag
abort "#{CliGen::APPNAME}: cannot bundle flag that requires an argument: #{flag}" if match.requires_arg? # 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
if index == chars.size - 1
if match.requires_arg?
match.process(args.reject(&.processed?).take_while{|v| find_match(v.value) == CliGen::MatchType::NoMatch})
else
match.process match.process
when MatchType::NoMatch
raise "ERROR : CommandNode(#{@name}).process : No flag match for '#{flag}'"
end end
else
raise CliGen::FlagBundleError.new("#{CliGen::APPNAME}: cannot bundle '#{flag}' — it requires an argument") if match.requires_arg?
match.process
end
end
when MatchType::NoMatch
raise CliGen::UnknownFlagError.new("#{CliGen::APPNAME}: unknown flag '#{flag}'")
end
end
else
# In this case we assume the characters following the flag is the argument
raise CliGen::FlagArgumentError.new("#{CliGen::APPNAME}: inline flag arguments are not supported — did you mean '-#{val[0]} #{val[1..]}'?")
end end
when MatchType::NoMatch when MatchType::NoMatch
raise "ERROR : CommandNode(#{@name}).process : No command, subcommand or flag match for '#{arg.value}'" raise CliGen::HelpRequestedError.new("#{CliGen::APPNAME}: unknown token '#{arg.value}'\n\n#{help}")
end end
end end
@@ -227,6 +286,9 @@ module CliGen
{% unless T == Nil %} {% unless T == Nil %}
unless passed_execution unless passed_execution
cls = T.new(handler: self.as(CliGen::BaseCommandNode)) cls = T.new(handler: self.as(CliGen::BaseCommandNode))
{% for cmd in T.methods.select(&.annotation(CliGen::PreRunCommand)) %}
cls.{{cmd.name}}
{% end %}
{% subcmds = T.methods.select(&.annotation(CliGen::SubCommand)) %} {% subcmds = T.methods.select(&.annotation(CliGen::SubCommand)) %}
{% begin %} {% begin %}
case matched_subcommand case matched_subcommand
@@ -238,11 +300,16 @@ module CliGen
{% if T.has_method?(:main) %} {% if T.has_method?(:main) %}
cls.main cls.main
{% else %} {% else %}
raise "ERROR : CommandNode({{T}})#{{@def.name}} : No subcommand matched and no #main defined" puts "ERROR : CommandNode({{T}})\#{{@def.name}} : No subcommand matched and no #main defined"
puts help
{% end %} {% end %}
exit 0
end end
{% end %} {% end %}
end end
{% else %}
puts help
exit 0
{% end %} {% end %}
end end
end end
+77
View File
@@ -0,0 +1,77 @@
module CliGen
# Base for all CliGen exceptions
class Error < Exception; end
# -------------------------------------------------------------------------
# Internal errors — framework invariant violations, should never reach users
# -------------------------------------------------------------------------
class InternalError < Error; end
# Arg#processed was called a second time on the same Arg
class ArgReprocessedError < InternalError; end
# A token matched FLAG_WITH_ARG in find_match but the regex failed on re-match
class RegexInvariantError < InternalError; end
# A BaseCommandNode was matched but couldn't be cast to any known CommandNode(T)
class UnknownCommandNodeError < InternalError; end
# -------------------------------------------------------------------------
# Configuration errors — shard consumer wired something up incorrectly
# -------------------------------------------------------------------------
class ConfigurationError < Error; end
# -h or --help was used as a flag short/long (reserved for internal help)
class ReservedFlagError < ConfigurationError; end
# Duplicate short or long flags detected during check!
class DuplicateFlagError < ConfigurationError; end
# A CommandNode(T) has no subcommands and no #main defined
class MissingDispatchError < ConfigurationError; end
# No flag was found in the handler for a Command ivar during initialize
class FlagNotFoundError < ConfigurationError; end
# A flag that requires an argument was processed with an empty argv
class FlagMissingArgumentError < ConfigurationError; end
# A Parsable type's parse_args did not mark any args as processed
class ParseableInvariantError < ConfigurationError; end
# -------------------------------------------------------------------------
# Runtime errors — bad user input at the CLI level
# -------------------------------------------------------------------------
class RuntimeError < Error; end
# A required flag was not provided and has no env var or default to fall back on
class MissingRequiredFlagError < RuntimeError; end
# A setter's validation proc rejected the provided value
class ValidationError < RuntimeError; end
# A flag token was provided where a value argument was expected
class FlagArgumentError < RuntimeError; end
# A provided value doesn't satisfy type or format requirements (wrong type, bad format, invalid bool/date string)
class InvalidFlagValueError < RuntimeError; end
# A provided value is not in the flag's allowed options list
class InvalidOptionError < RuntimeError; end
# An unrecognised flag token was encountered during parsing
class UnknownFlagError < RuntimeError; end
# A flag that requires an argument was included in a short-flag bundle
class FlagBundleError < RuntimeError; end
# -------------------------------------------------------------------------
# Help signal — not an error; exit 0 after printing
# -------------------------------------------------------------------------
# Raised when -h/--help is matched; carries the rendered help string
class HelpRequestedError < Error; end
end
+200 -42
View File
@@ -1,6 +1,14 @@
require "./arg" require "./arg"
module CliGen module CliGen
# To be able to store metadata for use in the help output
record FlagMeta,
type : String,
array : Bool,
format : String?,
default : String,
options : Array(String)?
abstract class BaseFlag abstract class BaseFlag
getter var : String getter var : String
getter short : String? getter short : String?
@@ -8,17 +16,21 @@ module CliGen
getter long_key : String getter long_key : String
getter env_var : String getter env_var : String
getter description : String getter description : String
getter delimiter : String
getter meta : FlagMeta
def initialize( def initialize(
@var : String, @var : String,
@short : String?, @short : String?,
@long : String, @long : String,
@env_var : String, @env_var : String,
@description : String @description : String,
@delimiter : String,
@meta : FlagMeta
) )
# 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.includes?(" ") if @long =~ /\s|=/
@long_key = @long.split(" ").first @long_key = @long.split(/\s|=/).first
else else
@long_key = @long @long_key = @long
end end
@@ -40,6 +52,7 @@ module CliGen
@options : Array(T)? @options : Array(T)?
@validate : (T -> Bool)? @validate : (T -> Bool)?
@on_match : Proc(Nil)? @on_match : Proc(Nil)?
@format : ::Regex?
def initialize( def initialize(
var : String, var : String,
@@ -47,12 +60,37 @@ module CliGen
long : String, long : String,
env_var : String, env_var : String,
description : String, description : String,
delimiter : String = ",",
@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(Nil)? = nil,
@format : ::Regex? = nil
) )
super(var, short, long, env_var, description) {% unless T.class.has_method? :to_s %}
{% raise "ERROR : Flag(#{T}) : Error your flag type must have a to_s method" %}
{% end %}
{% if T < Array %}
{% elem = T.type_vars.first %}
{% raise "ERROR : Flag(#{T}) : Error your flag subtype #{elem} must have a to_s method" unless elem.class.has_method? :to_s %}
{% end %}
unless @default.nil?
{% if T < Array %}
default = "[" + @default.not_nil!.map(&.to_s).join(", ") + "]"
{% else %}
default = @default.not_nil!.to_s
{% end %}
else
default = ""
end
meta = FlagMeta.new(
type: {{T.stringify}},
array: {{ T < Array ? true : false }},
options: {% if T < Array %} @options.try(&.first.map(&.to_s)) {% else %} @options.try(&.map(&.to_s)) {% end %},
default: default,
format: @format.try(&.source)
)
super(var, short, long, env_var, description, delimiter, meta)
end end
def requires_arg? : Bool def requires_arg? : Bool
@@ -61,49 +99,114 @@ module CliGen
def process(argv : Array(Arg) = [] of Arg) : Nil def process(argv : Array(Arg) = [] of Arg) : Nil
if requires_arg? if requires_arg?
raise "ERROR : Flag({{T}}) : Array 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?
end end
{% if T == Bool %} {% if T == Bool %}
@value = true @value = true
{% 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 %}
argv.each do |arg| argv.each do |arg|
break if arg.value.starts_with('-') break if arg.flag?
{% if elem == Int32 %} unless @format.nil?
(@value ||= [] of Int32) << arg.value.to_i unless arg.value.includes?(@delimiter)
{% elsif elem == String %} unless arg.value =~ @format
(@value ||= [] of String) << arg.value 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"]?
{% elsif elem < CliGen::Coercable %} break
if arg.value.includes?(",") end
(@value ||= [] of {{elem}}) += arg.value.split(",").map{|i| {{elem}}.coerce(i)} end
end
{% if elem < Int %}
{% int_case = elem.stringify =~ /^UInt/ ? "uint?".id : "int?".id %}
if arg.value.includes?(@delimiter)
@value = (@value || T.new) + arg.value.split(@delimiter).map do |val|
val = val.strip
unless arg.{{int_case}}(val)
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{val}' is not a valid {{elem}}")
end
{{elem}}.new(val)
end
else else
(@value ||= [] of {{elem}}) << {{elem}}.coerce(arg.value) unless arg.{{int_case}}
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{arg.value}' is not a valid {{T}}")
end
(@value ||= T.new) << {{elem}}.new(arg.value)
end
{% elsif elem < Float %}
if arg.value.includes?(@delimiter)
@value = (@value || T.new) + arg.value.split(@delimiter).map do |val|
val = val.strip
unless CliGen::Arg.float?(val)
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{val}' is not a valid {{T}}")
end
{{elem}}.new(val)
end
else
unless arg.float?
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{arg.value}' is not a float")
end
(@value ||= T.new) << {{elem}}.new(arg.value)
end
{% elsif elem == String %}
if arg.value.includes?(@delimiter)
@value = (@value || [] of String) + arg.value.split(@delimiter).map { |v|
unless @format.nil?
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{v}' does not match required format /#{@format.not_nil!.source}/") unless v =~ @format
end
v
}
else
if ! @format.nil? && arg.value !~ @format
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{arg.value}' does not match required format /#{@format.not_nil!.source}/")
end
(@value ||= [] of String) << arg.value
end
{% elsif elem.class < CliGen::Coercable %}
if arg.value.includes?(@delimiter)
@value = (@value || [] of {{elem}}) + arg.value.split(@delimiter).map { |i| {{elem}}.coerce(i) }
else
@value = (@value || [] of {{elem}}) + [({{elem}}.coerce(arg.value))]
end end
{% else %} {% 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" %} {% raise "ERROR : Flag(#{T}) : #{elem} is not a coercable type. If you wish to coerce it from a bare string extend with CliGen::Coercable & implement the class method" %}
{% end %} {% end %}
arg.processed arg.processed
end end
{% elsif T == Int32 %} {% elsif T < Int %}
@value = argv.first.value.to_i {% int_case = T.stringify =~ /^UInt/ ? "uint?".id : "int?".id %}
unless argv.first.{{int_case}}
raise CliGen::InvalidFlagValueError.new("Flag(#{T}, long: #{@long_key}) : '#{argv.first.value}' is not a valid {{T}}")
end
@value = T.new(argv.first.value)
argv.first.processed
{% elsif T < Float %}
unless argv.first.float?
raise CliGen::InvalidFlagValueError.new("Flag(#{T}, long: #{@long_key}) : '#{argv.first.value}' is not a valid {{T}}")
end
@value = T.new(argv.first.value)
argv.first.processed argv.first.processed
{% elsif T == Time %} {% elsif T == Time %}
@value = parse_time(argv.first.value) @value = parse_time(argv.first.value)
argv.first.processed argv.first.processed
{% elsif T == String %} # String {% elsif T == String %} # String
if ! @format.nil? && argv.first.value !~ @format
raise CliGen::InvalidFlagValueError.new("Flag(#{T}, long: #{@long_key}) : '#{argv.first.value}' does not match required format /#{@format.not_nil!.source}/")
end
@value = argv.first.value @value = argv.first.value
argv.first.processed argv.first.processed
{% elsif T < CliGen::Parsable %} {% elsif T.class < CliGen::Parsable %}
processed = argv.select(&.processed?) processed = argv.select(&.processed?)
@value = T.parse_args(argv) @value = T.parse_args(argv)
post_processed = argv.select(&.processed?) post_processed = argv.select(&.processed?)
if processed == post_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" raise CliGen::ParseableInvariantError.new("Flag({{T}}, long: #{@long_key})#process : {{T}}#parse_args did not mark any args as processed")
end end
{% else %} {% 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" %} {% raise "ERROR : Flag({{T}}#process : Generic Type #{T} is not supported. To add support you must extend with CliGen::Parsable & implement the class method" %}
{% end %} {% end %}
validate! validate!
@@ -122,7 +225,7 @@ module CliGen
v ||= @default v ||= @default
raise "#{CliGen::APPNAME}: required flag #{@long_key} was not provided" if v.nil? raise CliGen::MissingRequiredFlagError.new("#{CliGen::APPNAME}: required flag #{@long_key} was not provided") if v.nil?
v.not_nil! v.not_nil!
end end
@@ -147,50 +250,105 @@ module CliGen
v = value! v = value!
if opts = @options if opts = @options
abort "#{CliGen::APPNAME}: '#{v}' is not a valid value for #{@long_key} (valid: #{opts.join(", ")})" unless opts.includes?(v) {% if T < Array %}
v.each do |v2|
raise CliGen::InvalidOptionError.new("#{CliGen::APPNAME}: '#{v2}' is not a valid value for #{@long_key} (valid: #{opts.join(", ")})") unless opts.first.includes?(v2)
end
{% else %}
raise CliGen::InvalidOptionError.new("#{CliGen::APPNAME}: '#{v}' is not a valid value for #{@long_key} (valid: #{opts.join(", ")})") unless opts.includes?(v)
{% end %}
end end
if check = @validate if check = @validate
abort "#{CliGen::APPNAME}: validation failed for #{@long_key}" unless check.call(v) raise CliGen::ValidationError.new("#{CliGen::APPNAME}: validation failed for #{@long_key} (got: #{v})") unless check.call(v)
end end
end end
def check! : Nil def check! : Nil
raise "ERROR : Flag({{T}}, long: #{@long})#check! : -h is reserved for internal help usage" if @short == "-h" raise CliGen::ReservedFlagError.new("Flag({{T}}, long: #{@long})#check! : -h is reserved for internal help") if @short == "-h"
raise "ERROR : Flag({{T}}, long: #{@long})#check! : --help is reserved for internal help usage" 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
{% if T == Bool %} {% if T == Bool %}
case raw case raw
when "t","true","1" when /^(t|true|y|yes|1)$/i
true true
when "f","false","0" when /^(f|false|n|no|0)$/i
false false
else else
raise "ERROR" raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{raw}' is not a valid boolean (expected: t/f/true/false/y/n/yes/no/1/0)")
end end
{% elsif T == Int32 %} {% elsif T < Int %}
raw.to_i {% int_case = T.stringify =~ /^UInt/ ? "uint?".id : "int?".id %}
unless CliGen::Arg.{{int_case}}(raw)
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{raw}' is not a valid {{T}}")
end
T.new(raw)
{% elsif T < Float %}
unless CliGen::Arg.float?(raw)
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{raw}' is not a valid {{T}}")
end
T.new(raw)
{% elsif T == Time %} {% elsif T == Time %}
parse_time(raw) parse_time(raw)
{% elsif T <= Array %} {% elsif T < Array %}
if raw.includes?(@delimiter)
{% elem = T.type_vars.first %} {% elem = T.type_vars.first %}
{% if elem == Int32 %} raw.split(@delimiter).map do |val|
raw.split(',').map(&.to_i) unless @format.nil?
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{val}' does not match required format /#{@format.not_nil!.source}/") unless val =~ @format
end
{% if elem < Int %}
val = val.strip
{% int_case = elem.stringify =~ /^UInt/ ? "uint?".id : "int?".id %}
unless CliGen::Arg.{{int_case}}(val)
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{val}' is not a valid {{elem}}")
end
{{elem}}.new(val)
{% elsif elem < Float %}
unless CliGen::Arg.float?(val)
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{val}' is not a valid {{elem}}")
end
{{elem}}.new(val)
{% elsif elem == String %} {% elsif elem == String %}
raw.split(',') val
{% elsif elem < CliGen::Coercable %} {% elsif elem.class < CliGen::Coercable %}
raw.split(',').map{|i| {{elem}}.coerce(i)} {{elem}}.coerce(val)
{% else %} {% 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" %} {% raise "ERROR : Flag(#{T}) : #{elem} is not a coercable type. If you wish to coerce it from a bare string extend CliGen::Coercable & implement the class method" %}
{% end %} {% end %}
end
else
if ! @format.nil? && raw !~ @format
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{raw}' does not match required format /#{@format.not_nil!.source}/")
end
{% if elem < Int %}
{% int_case = elem.stringify =~ /^UInt/ ? "uint?".id : "int?".id %}
unless CliGen::Arg.{{int_case}}(raw)
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{raw}' is not a valid {{elem}}")
end
[ {{elem}}.new(raw) ]
{% elsif elem < Float %}
unless CliGen::Arg.float?(raw)
raise CliGen::InvalidFlagValueError.new("Flag({{T}}, long: #{@long_key}) : '#{raw}' is not a valid {{elem}}")
end
[ {{elem}}.new(raw) ]
{% elsif elem == String %}
[ raw ]
{% elsif elem.class < CliGen::Coercable %}
[ {{elem}}.coerce(raw) ]
{% else %}
{% raise "ERROR : Flag(#{T}) : #{T} is not a coercable type. If you wish to coerce it from a bare string extend CliGen::Coercable & implement the class method" %}
{% end %}
end
{% elsif T == String %} # String {% elsif T == String %} # String
raw raw
{% elsif T < CliGen::Coercable %} {% elsif T.class < CliGen::Coercable %}
T.coerce(raw) T.coerce(raw)
{% else %} {% 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" %} {% raise "ERROR : Flag(#{T}) : #{T} is not a coercable type. If you wish to coerce it from a bare string extend CliGen::Coercable & implement the class method" %}
{% end %} {% end %}
end end
@@ -199,7 +357,7 @@ module CliGen
CliGen::Regex::INPUT_DATETIME_REGEX, CliGen::Regex::INPUT_DATETIME_REGEX,
CliGen::Regex::INPUT_DATE_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) } 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]) if raw.match(formats[0])
Time.parse_local(raw, CliGen::Format::INPUT_DATETIME_FORMAT) Time.parse_local(raw, CliGen::Format::INPUT_DATETIME_FORMAT)
else else
+9
View File
@@ -3,6 +3,15 @@ require "./flag"
module CliGen module CliGen
GLOBAL_FLAGS = [] of BaseFlag GLOBAL_FLAGS = [] of BaseFlag
GLOBAL_FLAGS << Flag(Bool).new(
var: "",
short: "-v",
long: "--verbose",
env_var: "VERBOSE",
default: false,
description: "Enable verbose output from program & help output"
)
macro add_global_flag(type, long, description, env_var = nil, short = nil, validation = nil, &on_match) 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 : 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 a StringLiteral" unless long.is_a? StringLiteral %}
+2 -2
View File
@@ -1,3 +1,3 @@
module CliGen::Parseable module CliGen::Parsable
abstract def parse_args(args : Array(CliGen::Arg)) : self abstract def parse_args(args : Array(CliGen::Arg))
end end
+6 -3
View File
@@ -1,9 +1,12 @@
module CliGen::Regex module CliGen::Regex
FLAG_REGEX=/^(-[a-zA-Z]|--[a-zA-Z-_]+)$/ 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-Z]+$/ FLAG_MULTIPLE_SHORT=/^-[a-zA-Z0-9]+/
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_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_DATE_REGEX = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/
FLOAT = /^[-+]?[[:digit:]]+(\.[[:digit:]]+)?$/
UINT = /^[[:digit:]]+$/
INT = /^[-+]?[[:digit:]]+$/
end end
+20 -5
View File
@@ -3,21 +3,36 @@ Command: <%= @name %>
Description: <%= @description %> Description: <%= @description %>
<%- end -%> <%- end -%>
<%- unless @flags.empty? -%>
<%- len = @flags.map{|f| f.short.nil? ? f.long.size : "#{f.short},#{f.long}".size}.max + 5 -%>
Flags: Flags:
--------------------------------------------------------------- ---------------------------------------------------------------
<%- @flags.each do |flag| -%> <%- @flags.each do |flag| -%>
<%- unless flag.short.nil? -%> <%- if flag.short.nil? -%>
<%= "%-15s %s" % ["#{flag.short.not_nil!.strip},#{flag.long.strip}", flag.description.strip] %> <%- flags = [flag.long.strip] -%>
<%- else -%> <%- else -%>
<%= "%-15s %s" % [flag.long.strip, flag.description.strip] %> <%- 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 -%> <%- end -%>
<%- unless @commands.empty? -%> <%- unless @commands.empty? -%>
Other Commands Other Commands
--------------------------------------------------------------- ---------------------------------------------------------------
<%- @commands.each do |command| -%> <%- @commands.each do |command| -%>
<%= "%-10s %s" % [ command.name, command.description ] %> <%= "%-#{len}s %s" % [ command.name, command.description ] %>
<%- end -%> <%- end -%>
<%- end -%> <%- end -%>
@@ -25,7 +40,7 @@ Other Commands
SubCommands of <%= @name %>: SubCommands of <%= @name %>:
--------------------------------------------------------------- ---------------------------------------------------------------
<%- subcommands.each do |cmd| -%> <%- subcommands.each do |cmd| -%>
<%= "%-10s %s" % [cmd.name, cmd.description] %> <%= "%-#{len}s %s" % [cmd.name, cmd.description] %>
<%- end -%> <%- end -%>
<%- cmds = subcommands.select{|c| ! c.examples.nil? } -%> <%- cmds = subcommands.select{|c| ! c.examples.nil? } -%>