Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 777a1b992a | |||
| a17053f45c | |||
| 6535753fa3 | |||
| 09389cb5e9 | |||
| d1746602b1 | |||
| 21462029e3 | |||
| 4a0dcb8b59 | |||
| 8780be3a42 | |||
| fd510469ac | |||
| f55cc25f2d | |||
| b05f49c2e8 | |||
| d3ca70ea9e | |||
| 697a0db3af | |||
| a1a9c6fc04 | |||
| ffde5165f7 | |||
| 53f510278c | |||
| e6d9306e69 | |||
| 18c5a559b1 | |||
| 28c45fae9e | |||
| f889aae5e1 | |||
| e90d89689c | |||
| 9b3d823bc7 | |||
| d02eded273 | |||
| fc592fee37 | |||
| 47b4bfa494 | |||
| 1a2da86048 | |||
| 37c5083ae2 | |||
| 976ed10f75 | |||
| ea8ca62a63 | |||
| 0c7849dc7c | |||
| 47df89a6c1 | |||
| afd7ba7c7b | |||
| 8447a1ffbb | |||
| 4af08f8efe | |||
| 1085335c88 | |||
| 19ea00ef72 | |||
| 8d17de7434 | |||
| 405303e4f8 | |||
| 29327a3e83 | |||
| ac5711c0df | |||
| 519c8dcd44 | |||
| bc0f56fda9 |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(crystal build*)",
|
||||
"Bash(crystal spec*)",
|
||||
"Bash(crystal run*)",
|
||||
"Bash(shards install*)",
|
||||
"Bash(shards update*)",
|
||||
"Bash(git status*)",
|
||||
"Bash(git diff*)",
|
||||
"Bash(git log*)",
|
||||
"Bash(git add*)",
|
||||
"Bash(git commit*)",
|
||||
"Bash(git push*)",
|
||||
"Bash(git rm*)",
|
||||
"Bash(rm -f test)",
|
||||
"Bash(ls*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,13 @@
|
||||
*
|
||||
!CLAUDE.md
|
||||
!lib/
|
||||
!lib/cligen
|
||||
!src/
|
||||
!src/**
|
||||
!shard.yml
|
||||
!README.md
|
||||
!design.adoc
|
||||
!docs/
|
||||
!docs/**
|
||||
!Makefile
|
||||
!LICENSE
|
||||
@@ -1,6 +1,10 @@
|
||||
# CliGenerator
|
||||
# cligen
|
||||
|
||||
This is a crystal project to manage setting up OptionParser objects based around "Command" objects and arguments you define inside them.
|
||||
A Crystal shard that generates CLI parsers from class definitions using annotations and macros. Define your commands as classes; cligen builds the runtime parse tree.
|
||||
|
||||
## How It Works
|
||||
|
||||
Subclass `CliGen::Command`, annotate your instance variables with `@[CliGen::Argument]` or `@[CliGen::Selection]`, and register the command with an `CliGen::App`. At compile time, macros inspect the annotations and generate typed `Flag(T)` objects; at runtime, `CliGen::App.process` walks the `CommandNode` tree to route arguments, populate your command instance, and dispatch to the right method.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -14,12 +18,47 @@ This is a crystal project to manage setting up OptionParser objects based around
|
||||
|
||||
2. Run `shards install`
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
```crystal
|
||||
require "cligen"
|
||||
|
||||
@[CliGen::CommandInfo(description: "Greet someone")]
|
||||
class Greet < CliGen::Command
|
||||
@[CliGen::Argument(short: "-n", long: "--name VALUE", description: "Name to greet")]
|
||||
@name : String = "world"
|
||||
|
||||
def main
|
||||
puts "Hello, #{@name}!"
|
||||
end
|
||||
end
|
||||
|
||||
CliGen::App.process
|
||||
```
|
||||
|
||||
```
|
||||
$ myapp --name Alice
|
||||
Hello, Alice!
|
||||
```
|
||||
|
||||
Full API documentation and design notes are in [`design.adoc`](design.adoc).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Type-check without running
|
||||
crystal build src/cligen.cr --no-codegen
|
||||
|
||||
# Run specs
|
||||
crystal spec
|
||||
```
|
||||
|
||||
## AI Assistance Disclosure
|
||||
|
||||
This project uses [Claude Code](https://claude.ai/code) as a development aid — specifically for catching bugs, spotting typos, reviewing implementations, and talking through design decisions. All architecture decisions, code, and design are written by the author. Claude is used the way one might use a second pair of eyes on a diff, not as a code generator.
|
||||
|
||||
`CLAUDE.md` at the repo root documents the project structure for Claude's context. `.claude/` holds project-level Claude Code settings.
|
||||
|
||||
## Contributors
|
||||
|
||||
- [Tristan Ancelet](https://git.arcanium.tech/tristan) - creator and maintainer
|
||||
|
||||
+4
-6
@@ -44,10 +44,8 @@ module MyModule
|
||||
"myutil mycommand do_thing --myvar 5"
|
||||
]
|
||||
|
||||
subcommand do_thing : Int32,
|
||||
description: "Do the THING",
|
||||
examples: MyModule::MyCommand::DO_THING_EXAMPLES \
|
||||
do
|
||||
subcommand do_thing, description: "Do the THING", examples: MyModule::MyCommand::DO_THING_EXAMPLES do
|
||||
|
||||
output = 0
|
||||
|
||||
@myvar.times do |i|
|
||||
@@ -55,11 +53,11 @@ module MyModule
|
||||
output += i
|
||||
end
|
||||
|
||||
output
|
||||
puts "Total : %i" % output
|
||||
end
|
||||
end
|
||||
|
||||
CliGen::App.process(ARGV)
|
||||
CliGen::App.process
|
||||
end
|
||||
----
|
||||
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
..
|
||||
@@ -1,3 +1,5 @@
|
||||
require "./cligen/exceptions"
|
||||
require "./cligen/coercable"
|
||||
require "./cligen/parsable"
|
||||
require "./cligen/annotations"
|
||||
require "./cligen/format"
|
||||
@@ -10,6 +12,11 @@ require "./cligen/app"
|
||||
module CliGen
|
||||
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)
|
||||
|
||||
record AdditionalDefaultFlag,
|
||||
|
||||
@@ -14,6 +14,9 @@ module CliGen
|
||||
annotation Selection
|
||||
end
|
||||
|
||||
annotation PreRunCommand
|
||||
end
|
||||
|
||||
annotation SubCommand
|
||||
end
|
||||
end
|
||||
|
||||
+17
-5
@@ -14,17 +14,29 @@ module CliGen
|
||||
@@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!)
|
||||
def check!
|
||||
super
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
# Convenience entry point; defaults to ARGV
|
||||
def self.process(args : Array(String) = ARGV.to_a) : Nil
|
||||
generate if @@instance.nil?
|
||||
handle_command_raises do
|
||||
@@instance.not_nil!.process(args)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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 %},
|
||||
description: {{ anno[:description] }},
|
||||
default: {% unless var.default_value.nil? %} {{var.default_value}} {% else %} nil {% end %},
|
||||
validate: {% if anno[:validate] %} {{anno[:validate]}} {% else %} nil {% end %},
|
||||
on_match: {% if anno[:on_match] %} {{anno[:on_match]}} {% else %} nil {% end %}
|
||||
validate: {% if anno[:validation] %} {{anno[:validation]}} {% 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 %}
|
||||
|
||||
# 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,
|
||||
description: {{cmd_info[:description]}}
|
||||
)
|
||||
{% debug %}
|
||||
{% debug if env("DEBUG") %}
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
|
||||
+49
-1
@@ -24,12 +24,60 @@ module CliGen
|
||||
def initialize(@value, @index)
|
||||
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 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
|
||||
raise CliGen::ArgReprocessedError.new("CliGen::Arg(index: #{@index}, value: #{@value})#processed : This arg was re-processed") if @processed
|
||||
@processed = true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module CliGen::Coercable
|
||||
abstract def coerce(arg : String) : self
|
||||
abstract def coerce(arg : String)
|
||||
end
|
||||
|
||||
+8
-18
@@ -4,32 +4,22 @@ require "time"
|
||||
require "./annotations"
|
||||
require "./command/argument"
|
||||
require "./command/selection"
|
||||
require "./command/trigger"
|
||||
require "./command/help_template"
|
||||
require "./command/subcommand"
|
||||
require "./command/def_init"
|
||||
require "./command/define_command_initializer"
|
||||
|
||||
module CliGen
|
||||
class Command
|
||||
macro inherited
|
||||
{% verbatim do %}
|
||||
macro finished
|
||||
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_value %}
|
||||
@{{var.id}} = {{var.default_value}}
|
||||
{% anno = @type.annotation(CliGen::CommandInfo) %}
|
||||
{% raise "" unless anno %}
|
||||
{% if anno[:def_init] %}
|
||||
def_init
|
||||
{% end %}
|
||||
{% end %}
|
||||
{% end %}
|
||||
end
|
||||
define_command_initializer
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
@@ -1,41 +1,80 @@
|
||||
module CliGen
|
||||
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 %}
|
||||
{% name = variable.var %}
|
||||
{% type = variable.type %}
|
||||
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided delimiter must be a string" unless delimiter.is_a? StringLiteral %}
|
||||
{% if short %}
|
||||
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided short must be a string" unless short.is_a? StringLiteral %}
|
||||
{% end %}
|
||||
{% 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 %}
|
||||
{% if long %}
|
||||
{% 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}) : 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? %}
|
||||
{% 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 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 %}
|
||||
{% 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 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? %}
|
||||
{% arg = validation.args.first %}
|
||||
{% 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}" %}
|
||||
{% 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}}
|
||||
|
||||
{% if def_getter %}
|
||||
def {{variable.var}}
|
||||
@{{name}}
|
||||
end
|
||||
{% end %}
|
||||
|
||||
{% if def_setter %}
|
||||
def {{variable.var}}= (value : {{type}})
|
||||
{% 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 %}
|
||||
@{{name}} = value
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,14 +1,21 @@
|
||||
module CliGen
|
||||
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 : 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 %}
|
||||
{% if short %}
|
||||
{% 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 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 %}
|
||||
{% options = options.resolve if options.is_a? Path %}
|
||||
{% 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
|
||||
|
||||
@@ -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
@@ -33,10 +33,6 @@ module CliGen
|
||||
@flags = (flags + CliGen::GLOBAL_FLAGS + @commands.flat_map(&.flags)).uniq
|
||||
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
|
||||
@@ -67,18 +63,31 @@ module CliGen
|
||||
message += "\nShort:\n%s" % short_duplicates.map { |f| "- #{f}" }.join("\n")
|
||||
end
|
||||
|
||||
raise error_buffer % [@name, message]
|
||||
raise CliGen::DuplicateFlagError.new(error_buffer % [@name, message])
|
||||
end
|
||||
end
|
||||
|
||||
def get(flag_long : String) : BaseFlag?
|
||||
@flags.find{|f| f.long_key == flag_long}
|
||||
def get(*, long : String) : BaseFlag?
|
||||
@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
|
||||
|
||||
def find_match(arg : String)
|
||||
if subcommand?(arg)
|
||||
return CliGen::MatchType::SubCommand
|
||||
end
|
||||
|
||||
case arg
|
||||
when "-h", "--help"
|
||||
CliGen::MatchType::Help
|
||||
@@ -90,8 +99,6 @@ module CliGen
|
||||
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
|
||||
@@ -111,6 +118,10 @@ module CliGen
|
||||
subcommands.any?{|f| f.name == arg}
|
||||
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
|
||||
def process(args : Array(String)) : Nil
|
||||
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
|
||||
|
||||
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
|
||||
@flags.each(&.check!)
|
||||
check_for_duplicates!(@flags)
|
||||
@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)}}
|
||||
{% end %}
|
||||
end
|
||||
|
||||
def process(args : Array(CliGen::Arg)) : Nil
|
||||
@@ -170,55 +204,80 @@ module CliGen
|
||||
{% for cls in CliGen::Command.subclasses %}
|
||||
when CliGen::CommandNode({{cls.name}})
|
||||
match.as(CommandNode({{cls}})).process(args.reject(&.processed?))
|
||||
exit 0
|
||||
{% end %}
|
||||
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 %}
|
||||
passed_execution = true
|
||||
|
||||
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?
|
||||
match.process(args.reject(&.processed?))
|
||||
match.process(args.reject(&.processed?).take_while{|v| find_match(v.value) == CliGen::MatchType::NoMatch})
|
||||
else
|
||||
match.process
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
when MatchType::Help
|
||||
abort help
|
||||
raise CliGen::HelpRequestedError.new(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
|
||||
handle_flag_raises do
|
||||
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
|
||||
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
|
||||
|
||||
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|
|
||||
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)
|
||||
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
|
||||
when MatchType::NoMatch
|
||||
raise "ERROR : CommandNode(#{@name}).process : No flag match for '#{flag}'"
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -227,6 +286,9 @@ module CliGen
|
||||
{% unless T == Nil %}
|
||||
unless passed_execution
|
||||
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)) %}
|
||||
{% begin %}
|
||||
case matched_subcommand
|
||||
@@ -238,11 +300,16 @@ module CliGen
|
||||
{% if T.has_method?(:main) %}
|
||||
cls.main
|
||||
{% 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 %}
|
||||
exit 0
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
{% else %}
|
||||
puts help
|
||||
exit 0
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
@@ -1,6 +1,14 @@
|
||||
require "./arg"
|
||||
|
||||
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
|
||||
getter var : String
|
||||
getter short : String?
|
||||
@@ -8,17 +16,21 @@ module CliGen
|
||||
getter long_key : String
|
||||
getter env_var : String
|
||||
getter description : String
|
||||
getter delimiter : String
|
||||
getter meta : FlagMeta
|
||||
|
||||
def initialize(
|
||||
@var : String,
|
||||
@short : String?,
|
||||
@long : 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 @long.includes?(" ")
|
||||
@long_key = @long.split(" ").first
|
||||
if @long =~ /\s|=/
|
||||
@long_key = @long.split(/\s|=/).first
|
||||
else
|
||||
@long_key = @long
|
||||
end
|
||||
@@ -40,6 +52,7 @@ module CliGen
|
||||
@options : Array(T)?
|
||||
@validate : (T -> Bool)?
|
||||
@on_match : Proc(Nil)?
|
||||
@format : ::Regex?
|
||||
|
||||
def initialize(
|
||||
var : String,
|
||||
@@ -47,12 +60,37 @@ module CliGen
|
||||
long : String,
|
||||
env_var : String,
|
||||
description : String,
|
||||
delimiter : String = ",",
|
||||
@default : T? = nil,
|
||||
@options : Array(T)? = 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
|
||||
|
||||
def requires_arg? : Bool
|
||||
@@ -61,49 +99,114 @@ module CliGen
|
||||
|
||||
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?
|
||||
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
|
||||
|
||||
|
||||
{% if T == Bool %}
|
||||
@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 %}
|
||||
{% 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)}
|
||||
break if arg.flag?
|
||||
unless @format.nil?
|
||||
unless arg.value.includes?(@delimiter)
|
||||
unless arg.value =~ @format
|
||||
puts "DEBUG : Flag({{T}}, long: #{@long_key}) : #{arg.value} was not found to be matching the defined filter #{@format}. So breaking from parse loop" if ENV["DEBUG"]?
|
||||
break
|
||||
end
|
||||
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
|
||||
(@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
|
||||
{% 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 %}
|
||||
arg.processed
|
||||
end
|
||||
{% elsif T == Int32 %}
|
||||
@value = argv.first.value.to_i
|
||||
{% elsif T < Int %}
|
||||
{% 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
|
||||
{% elsif T == Time %}
|
||||
@value = parse_time(argv.first.value)
|
||||
argv.first.processed
|
||||
{% 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
|
||||
argv.first.processed
|
||||
{% elsif T < CliGen::Parsable %}
|
||||
{% elsif T.class < 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"
|
||||
raise CliGen::ParseableInvariantError.new("Flag({{T}}, long: #{@long_key})#process : {{T}}#parse_args did not mark any args as processed")
|
||||
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" %}
|
||||
{% 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 %}
|
||||
|
||||
validate!
|
||||
@@ -122,7 +225,7 @@ module CliGen
|
||||
|
||||
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!
|
||||
end
|
||||
|
||||
@@ -147,50 +250,105 @@ module CliGen
|
||||
v = value!
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def check! : Nil
|
||||
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"
|
||||
raise CliGen::ReservedFlagError.new("Flag({{T}}, long: #{@long})#check! : -h is reserved for internal help") if @short == "-h"
|
||||
raise CliGen::ReservedFlagError.new("Flag({{T}}, long: #{@long})#check! : --help is reserved for internal help") if @long_key == "--help"
|
||||
end
|
||||
|
||||
private def coerce(raw : String) : T
|
||||
{% if T == Bool %}
|
||||
case raw
|
||||
when "t","true","1"
|
||||
when /^(t|true|y|yes|1)$/i
|
||||
true
|
||||
when "f","false","0"
|
||||
when /^(f|false|n|no|0)$/i
|
||||
false
|
||||
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
|
||||
{% elsif T == Int32 %}
|
||||
raw.to_i
|
||||
{% elsif T < Int %}
|
||||
{% 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 %}
|
||||
parse_time(raw)
|
||||
{% elsif T <= Array %}
|
||||
{% elsif T < Array %}
|
||||
if raw.includes?(@delimiter)
|
||||
{% elem = T.type_vars.first %}
|
||||
{% if elem == Int32 %}
|
||||
raw.split(',').map(&.to_i)
|
||||
raw.split(@delimiter).map do |val|
|
||||
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 %}
|
||||
raw.split(',')
|
||||
{% elsif elem < CliGen::Coercable %}
|
||||
raw.split(',').map{|i| {{elem}}.coerce(i)}
|
||||
val
|
||||
{% elsif elem.class < CliGen::Coercable %}
|
||||
{{elem}}.coerce(val)
|
||||
{% 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
|
||||
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
|
||||
raw
|
||||
{% elsif T < CliGen::Coercable %}
|
||||
{% elsif T.class < 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" %}
|
||||
{% 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
|
||||
|
||||
@@ -199,7 +357,7 @@ module CliGen
|
||||
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) }
|
||||
raise CliGen::InvalidFlagValueError.new("#{CliGen::APPNAME}: invalid date/time format '#{raw}' (expected YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)") unless formats.any? { |f| raw.match(f) }
|
||||
if raw.match(formats[0])
|
||||
Time.parse_local(raw, CliGen::Format::INPUT_DATETIME_FORMAT)
|
||||
else
|
||||
|
||||
@@ -3,6 +3,15 @@ require "./flag"
|
||||
module CliGen
|
||||
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)
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module CliGen::Parseable
|
||||
abstract def parse_args(args : Array(CliGen::Arg)) : self
|
||||
module CliGen::Parsable
|
||||
abstract def parse_args(args : Array(CliGen::Arg))
|
||||
end
|
||||
|
||||
+6
-3
@@ -1,9 +1,12 @@
|
||||
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_MULTIPLE_SHORT=/^-[a-zA-Z]+$/
|
||||
SHORT_WITH_INLINE_ARG=/^-[a-zA-Z][a-zA-Z0-9]+$/
|
||||
FLAG_MULTIPLE_SHORT=/^-[a-zA-Z0-9]+/
|
||||
|
||||
INPUT_DATETIME_REGEX = /^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/
|
||||
INPUT_DATE_REGEX = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/
|
||||
|
||||
FLOAT = /^[-+]?[[:digit:]]+(\.[[:digit:]]+)?$/
|
||||
UINT = /^[[:digit:]]+$/
|
||||
INT = /^[-+]?[[:digit:]]+$/
|
||||
end
|
||||
|
||||
@@ -3,21 +3,36 @@ Command: <%= @name %>
|
||||
Description: <%= @description %>
|
||||
<%- end -%>
|
||||
|
||||
<%- unless @flags.empty? -%>
|
||||
<%- len = @flags.map{|f| f.short.nil? ? f.long.size : "#{f.short},#{f.long}".size}.max + 5 -%>
|
||||
|
||||
Flags:
|
||||
---------------------------------------------------------------
|
||||
<%- @flags.each do |flag| -%>
|
||||
<%- unless flag.short.nil? -%>
|
||||
<%= "%-15s %s" % ["#{flag.short.not_nil!.strip},#{flag.long.strip}", flag.description.strip] %>
|
||||
<%- if flag.short.nil? -%>
|
||||
<%- flags = [flag.long.strip] -%>
|
||||
<%- 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 -%>
|
||||
<%- unless @commands.empty? -%>
|
||||
Other Commands
|
||||
---------------------------------------------------------------
|
||||
<%- @commands.each do |command| -%>
|
||||
<%= "%-10s %s" % [ command.name, command.description ] %>
|
||||
<%= "%-#{len}s %s" % [ command.name, command.description ] %>
|
||||
<%- end -%>
|
||||
|
||||
<%- end -%>
|
||||
@@ -25,7 +40,7 @@ Other Commands
|
||||
SubCommands of <%= @name %>:
|
||||
---------------------------------------------------------------
|
||||
<%- subcommands.each do |cmd| -%>
|
||||
<%= "%-10s %s" % [cmd.name, cmd.description] %>
|
||||
<%= "%-#{len}s %s" % [cmd.name, cmd.description] %>
|
||||
<%- end -%>
|
||||
|
||||
<%- cmds = subcommands.select{|c| ! c.examples.nil? } -%>
|
||||
|
||||
Reference in New Issue
Block a user