Compare commits

..

2 Commits

Author SHA1 Message Date
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
16 changed files with 133 additions and 78 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
+11 -14
View File
@@ -1,14 +1,11 @@
# Crystal build artifacts *
/.build/ !CLAUDE.md
/bin/ !src/
!src/**
# Compiled test binary (crystal build test.cr) !shard.yml
test !README.md
!design.adoc
# Shards cache !docs/
/.shards/ !docs/**
/lib/ !Makefile
!LICENSE
# Editor
tags
.DS_Store
+5 -7
View File
@@ -44,22 +44,20 @@ 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|
puts "thing done %i times" % [ i + 1 ] puts "thing done %i times" % [ i + 1 ]
output += i output += i
end end
output puts "Total : %i" % output
end end
end end
CliGen::App.process(ARGV) CliGen::App.process
end end
---- ----
+6
View File
@@ -1,3 +1,4 @@
require "./cligen/coercable"
require "./cligen/parsable" require "./cligen/parsable"
require "./cligen/annotations" require "./cligen/annotations"
require "./cligen/format" require "./cligen/format"
@@ -10,6 +11,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,
+2 -2
View File
@@ -30,7 +30,7 @@ module CliGen
validate: {% if anno[:validate] %} {{anno[:validate]}} {% else %} nil {% end %}, validate: {% if anno[:validate] %} {{anno[:validate]}} {% 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 %}
) )
{% 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 +44,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 %}
+24
View File
@@ -24,6 +24,30 @@ module CliGen
def initialize(@value, @index) def initialize(@value, @index)
end end
def flag? : Bool
if @value =~ CliGen::Regex::FLAG_REGEX
true
else
false
end
end
def int? : Bool
if @value =~ /^[[:digit:]]+$/
true
else
false
end
end
def float? : Bool
if @value =~ /^[[:digit:]]+(\.[[:digit:]]+)?$/
true
else
false
end
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
+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
+1 -1
View File
@@ -4,7 +4,7 @@ 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"
module CliGen module CliGen
+15 -7
View File
@@ -1,28 +1,34 @@
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)
{% 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 %}
{% 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 %}
{% 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 %}
@@ -30,12 +36,14 @@ module CliGen
@[CliGen::Argument(short: {{short}}, long: {{long}}, description: {{description}}, validation: {{validation}}, on_match: {{on_match}})] @[CliGen::Argument(short: {{short}}, long: {{long}}, description: {{description}}, validation: {{validation}}, on_match: {{on_match}})]
@{{variable}} @{{variable}}
{% 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 "ERROR : #{@type.name}##{@def.name} : Provided value #{value} is not passing validation" unless {{validation}}.call(value)
{% end %} {% end %}
@{{name}} = value @{{name}} = value
end end
{% end %}
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
-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
+22 -5
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
@@ -79,6 +75,7 @@ module CliGen
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
@@ -111,6 +108,10 @@ module CliGen
subcommands.any?{|f| f.name == arg} subcommands.any?{|f| f.name == arg}
end end
def flag?(arg : String) : BaseFlag
@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,6 +144,22 @@ module CliGen
{% end %} {% end %}
end 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("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)
@@ -238,7 +255,7 @@ 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" raise "ERROR : CommandNode({{T}})\#{{@def.name}} : No subcommand matched and no #main defined"
{% end %} {% end %}
end end
{% end %} {% end %}
+23 -16
View File
@@ -17,8 +17,8 @@ module CliGen
@description : String @description : String
) )
# 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
@@ -51,7 +51,7 @@ module CliGen
@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
) )
super(var, short, long, env_var, description) super(var, short, long, env_var, description)
end end
@@ -64,25 +64,31 @@ module CliGen
raise "ERROR : Flag({{T}}) : Array requires an argument but provided array is empty" if argv.empty? raise "ERROR : Flag({{T}}) : Array requires an argument but provided array is empty" if argv.empty?
end end
if argv.first.flag?
abort "ERROR : Flag({{T}}) : Provided argument was a flag (#{argv.first.value})"
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 %} {% if elem == Int32 %}
abort "ERROR : Flag({{T}}) : Provided arguemnt(#{arg.value}) was not an integer" unless arg.int?
(@value ||= [] of Int32) << arg.value.to_i (@value ||= [] of Int32) << arg.value.to_i
{% elsif elem == String %} {% elsif elem == String %}
(@value ||= [] of String) << arg.value (@value ||= [] of String) << arg.value
{% elsif elem < CliGen::Coercable %} {% elsif elem.class < CliGen::Coercable %}
if arg.value.includes?(",") {% delim = elem.has_constant?("DELIMITER") ? elem.constant("DELIMITER") : ',' %}
(@value ||= [] of {{elem}}) += arg.value.split(",").map{|i| {{elem}}.coerce(i)} if arg.value.includes?({{delim}})
@value = (@value || [] of {{elem}}) + arg.value.split({{delim}}).map{|i| {{elem}}.coerce(i)}
else else
(@value ||= [] of {{elem}}) << {{elem}}.coerce(arg.value) @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
@@ -95,7 +101,7 @@ module CliGen
{% elsif T == String %} # String {% elsif T == String %} # String
@value = argv.first.value @value = argv.first.value
argv.first.processed argv.first.processed
{% elsif T < CliGen::Parsable %} {% elsif T.class < CliGen::Parseable %}
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?)
@@ -103,7 +109,7 @@ module CliGen
raise "ERROR : Flag({{T}}, long: #{@long_key})#process : Your {{T}}#process_args did not mark processed args as processed. Please check your code" raise "ERROR : Flag({{T}}, long: #{@long_key})#process : Your {{T}}#process_args did not mark processed args as processed. Please check your code"
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 include CliGen::Parsable & implement the class method" %}
{% end %} {% end %}
validate! validate!
@@ -180,17 +186,18 @@ module CliGen
raw.split(',').map(&.to_i) raw.split(',').map(&.to_i)
{% elsif elem == String %} {% elsif elem == String %}
raw.split(',') raw.split(',')
{% elsif elem < CliGen::Coercable %} {% elsif elem.class < CliGen::Coercable %}
raw.split(',').map{|i| {{elem}}.coerce(i)} {% delim = elem.has_constant?("DELIMITER") ? elem.constant("DELIMITER") : ',' %}
raw.split({{delim}}).map{|i| {{elem}}.coerce(i)}
{% 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 %}
{% 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
+1 -1
View File
@@ -1,3 +1,3 @@
module CliGen::Parseable module CliGen::Parseable
abstract def parse_args(args : Array(CliGen::Arg)) : self abstract def parse_args(args : Array(CliGen::Arg))
end end
+1 -1
View File
@@ -1,5 +1,5 @@
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-Z]+$/
SHORT_WITH_INLINE_ARG=/^-[a-zA-Z][a-zA-Z0-9]+$/ SHORT_WITH_INLINE_ARG=/^-[a-zA-Z][a-zA-Z0-9]+$/
+2 -2
View File
@@ -17,7 +17,7 @@ Flags:
Other Commands Other Commands
--------------------------------------------------------------- ---------------------------------------------------------------
<%- @commands.each do |command| -%> <%- @commands.each do |command| -%>
<%= "%-10s %s" % [ command.name, command.description ] %> <%= "%-15s %s" % [ command.name, command.description ] %>
<%- end -%> <%- end -%>
<%- end -%> <%- end -%>
@@ -25,7 +25,7 @@ Other Commands
SubCommands of <%= @name %>: SubCommands of <%= @name %>:
--------------------------------------------------------------- ---------------------------------------------------------------
<%- subcommands.each do |cmd| -%> <%- subcommands.each do |cmd| -%>
<%= "%-10s %s" % [cmd.name, cmd.description] %> <%= "%-15s %s" % [cmd.name, cmd.description] %>
<%- end -%> <%- end -%>
<%- cmds = subcommands.select{|c| ! c.examples.nil? } -%> <%- cmds = subcommands.select{|c| ! c.examples.nil? } -%>