From 29327a3e83b1542626e617b81b5670256c40ba39 Mon Sep 17 00:00:00 2001 From: Tristan Ancelet Date: Mon, 10 Aug 2026 12:57:31 -0500 Subject: [PATCH] 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 --- .editorconfig | 9 +++++ design.adoc | 12 +++---- src/cligen.cr | 6 ++++ src/cligen/app/generate.cr | 4 +-- src/cligen/arg.cr | 24 +++++++++++++ src/cligen/coercable.cr | 2 +- src/cligen/command.cr | 2 +- src/cligen/command/argument.cr | 22 ++++++++---- src/cligen/command/help_template.cr | 10 ++++++ src/cligen/command/trigger.cr | 21 ------------ src/cligen/command_node.cr | 27 ++++++++++++--- src/cligen/flag.cr | 39 +++++++++++++--------- src/cligen/parsable.cr | 2 +- src/cligen/regex.cr | 2 +- src/cligen/template/cmd_help.ecr | 4 +-- test.cr | 52 +++++++++++++++++++++++++++++ test.ecr | 3 ++ 17 files changed, 177 insertions(+), 64 deletions(-) create mode 100644 .editorconfig create mode 100644 src/cligen/command/help_template.cr delete mode 100644 src/cligen/command/trigger.cr create mode 100644 test.cr create mode 100644 test.ecr diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/design.adoc b/design.adoc index d2c9708..20ab1b2 100644 --- a/design.adoc +++ b/design.adoc @@ -44,22 +44,20 @@ 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| puts "thing done %i times" % [ i + 1 ] output += i end - - output + + puts "Total : %i" % output end end - CliGen::App.process(ARGV) + CliGen::App.process end ---- diff --git a/src/cligen.cr b/src/cligen.cr index 7e7e477..398414a 100644 --- a/src/cligen.cr +++ b/src/cligen.cr @@ -1,3 +1,4 @@ +require "./cligen/coercable" require "./cligen/parsable" require "./cligen/annotations" require "./cligen/format" @@ -10,6 +11,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, diff --git a/src/cligen/app/generate.cr b/src/cligen/app/generate.cr index 31e01d3..315e1f2 100644 --- a/src/cligen/app/generate.cr +++ b/src/cligen/app/generate.cr @@ -30,7 +30,7 @@ module CliGen validate: {% if anno[:validate] %} {{anno[:validate]}} {% else %} nil {% end %}, on_match: {% if anno[:on_match] %} {{anno[:on_match]}} {% else %} nil {% end %} ) - {% debug %} + {% debug if env("DEBUG") %} {% end %} # 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, description: {{cmd_info[:description]}} ) - {% debug %} + {% debug if env("DEBUG") %} {% end %} {% end %} diff --git a/src/cligen/arg.cr b/src/cligen/arg.cr index 43b35fe..70f2e42 100644 --- a/src/cligen/arg.cr +++ b/src/cligen/arg.cr @@ -24,6 +24,30 @@ module CliGen def initialize(@value, @index) 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 will raise an exception if it is re-called after already having been diff --git a/src/cligen/coercable.cr b/src/cligen/coercable.cr index d87e0f2..70112f3 100644 --- a/src/cligen/coercable.cr +++ b/src/cligen/coercable.cr @@ -1,3 +1,3 @@ module CliGen::Coercable - abstract def coerce(arg : String) : self + abstract def coerce(arg : String) end diff --git a/src/cligen/command.cr b/src/cligen/command.cr index a160c40..d62f630 100644 --- a/src/cligen/command.cr +++ b/src/cligen/command.cr @@ -4,7 +4,7 @@ require "time" require "./annotations" require "./command/argument" require "./command/selection" -require "./command/trigger" +require "./command/help_template" require "./command/subcommand" module CliGen diff --git a/src/cligen/command/argument.cr b/src/cligen/command/argument.cr index 2b5e552..6879f67 100644 --- a/src/cligen/command/argument.cr +++ b/src/cligen/command/argument.cr @@ -1,28 +1,34 @@ 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) + {% 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: ' : [= val]')" unless variable.is_a? TypeDeclaration %} {% name = variable.var %} {% type = variable.type %} {% 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 %} {% 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 %} @@ -30,12 +36,14 @@ module CliGen @[CliGen::Argument(short: {{short}}, long: {{long}}, description: {{description}}, validation: {{validation}}, on_match: {{on_match}})] @{{variable}} + {% 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) {% end %} @{{name}} = value end + {% end %} end end end diff --git a/src/cligen/command/help_template.cr b/src/cligen/command/help_template.cr new file mode 100644 index 0000000..f91c79c --- /dev/null +++ b/src/cligen/command/help_template.cr @@ -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 diff --git a/src/cligen/command/trigger.cr b/src/cligen/command/trigger.cr deleted file mode 100644 index da04f1a..0000000 --- a/src/cligen/command/trigger.cr +++ /dev/null @@ -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 diff --git a/src/cligen/command_node.cr b/src/cligen/command_node.cr index d663b33..3947075 100644 --- a/src/cligen/command_node.cr +++ b/src/cligen/command_node.cr @@ -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 @@ -79,6 +75,7 @@ module CliGen if subcommand?(arg) return CliGen::MatchType::SubCommand end + case arg when "-h", "--help" CliGen::MatchType::Help @@ -111,6 +108,10 @@ module CliGen subcommands.any?{|f| f.name == arg} 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 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,6 +144,22 @@ module CliGen {% 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 @flags.each(&.check!) check_for_duplicates!(@flags) @@ -238,7 +255,7 @@ module CliGen {% if T.has_method?(:main) %} cls.main {% 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 %} diff --git a/src/cligen/flag.cr b/src/cligen/flag.cr index 80840aa..6710198 100644 --- a/src/cligen/flag.cr +++ b/src/cligen/flag.cr @@ -17,8 +17,8 @@ module CliGen @description : String ) # if the user provides just a "--long" I want the @long_key to match it - if @long.includes?(" ") - @long_key = @long.split(" ").first + if @long =~ /\s|=/ + @long_key = @long.split(/\s|=/).first else @long_key = @long end @@ -51,7 +51,7 @@ module CliGen @options : Array(T)? = nil, @validate : (T -> Bool)? = nil, @on_match : Proc(Nil)? = nil - ) + ) super(var, short, long, env_var, description) end @@ -64,25 +64,31 @@ module CliGen raise "ERROR : Flag({{T}}) : Array requires an argument but provided array is empty" if argv.empty? end + if argv.first.flag? + abort "ERROR : Flag({{T}}) : Provided argument was a flag (#{argv.first.value})" + end + {% if T == Bool %} @value = true {% elsif T <= Array %} {% raise "ERROR : Flag(#{T}) : You cannot define multiple types of array entries" if T.type_vars.size > 1 %} {% elem = T.type_vars.first %} argv.each do |arg| - break if arg.value.starts_with('-') + break if arg.flag? {% 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 {% 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)} + {% elsif elem.class < CliGen::Coercable %} + {% delim = elem.has_constant?("DELIMITER") ? elem.constant("DELIMITER") : ',' %} + if arg.value.includes?({{delim}}) + @value = (@value || [] of {{elem}}) + arg.value.split({{delim}}).map{|i| {{elem}}.coerce(i)} else - (@value ||= [] of {{elem}}) << {{elem}}.coerce(arg.value) + @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 @@ -95,7 +101,7 @@ module CliGen {% elsif T == String %} # String @value = argv.first.value argv.first.processed - {% elsif T < CliGen::Parsable %} + {% elsif T.class < CliGen::Parseable %} processed = argv.select(&.processed?) @value = T.parse_args(argv) 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" 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 include CliGen::Parsable & implement the class method" %} {% end %} validate! @@ -180,17 +186,18 @@ module CliGen raw.split(',').map(&.to_i) {% elsif elem == String %} raw.split(',') - {% elsif elem < CliGen::Coercable %} - raw.split(',').map{|i| {{elem}}.coerce(i)} + {% elsif elem.class < CliGen::Coercable %} + {% delim = elem.has_constant?("DELIMITER") ? elem.constant("DELIMITER") : ',' %} + raw.split({{delim}}).map{|i| {{elem}}.coerce(i)} {% else %} - {% raise "ERROR : Flag(#{T}) : #{elem} is not a coercable type. If you wish to coerce it from a bare string include CliGen::Coercable & implement the class method" %} + {% 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 %} {% 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 diff --git a/src/cligen/parsable.cr b/src/cligen/parsable.cr index ebe5c6a..533bd7d 100644 --- a/src/cligen/parsable.cr +++ b/src/cligen/parsable.cr @@ -1,3 +1,3 @@ module CliGen::Parseable - abstract def parse_args(args : Array(CliGen::Arg)) : self + abstract def parse_args(args : Array(CliGen::Arg)) end diff --git a/src/cligen/regex.cr b/src/cligen/regex.cr index 9d63c85..a5a2395 100644 --- a/src/cligen/regex.cr +++ b/src/cligen/regex.cr @@ -1,5 +1,5 @@ module CliGen::Regex - FLAG_REGEX=/^(-[a-zA-Z]|--[a-zA-Z-_]+)$/ + FLAG_REGEX=/^(-[a-zA-Z]|--[a-zA-Z-_0-9]+)/ FLAG_WITH_ARG=/^(?(-[a-zA-Z]|--[a-zA-Z-_]+))="?(?\S+?)"?$/ FLAG_MULTIPLE_SHORT=/^-[a-zA-Z]+$/ SHORT_WITH_INLINE_ARG=/^-[a-zA-Z][a-zA-Z0-9]+$/ diff --git a/src/cligen/template/cmd_help.ecr b/src/cligen/template/cmd_help.ecr index 37af0ff..aa1cf58 100644 --- a/src/cligen/template/cmd_help.ecr +++ b/src/cligen/template/cmd_help.ecr @@ -17,7 +17,7 @@ Flags: Other Commands --------------------------------------------------------------- <%- @commands.each do |command| -%> - <%= "%-10s %s" % [ command.name, command.description ] %> + <%= "%-15s %s" % [ command.name, command.description ] %> <%- end -%> <%- end -%> @@ -25,7 +25,7 @@ Other Commands SubCommands of <%= @name %>: --------------------------------------------------------------- <%- subcommands.each do |cmd| -%> - <%= "%-10s %s" % [cmd.name, cmd.description] %> + <%= "%-15s %s" % [cmd.name, cmd.description] %> <%- end -%> <%- cmds = subcommands.select{|c| ! c.examples.nil? } -%> diff --git a/test.cr b/test.cr new file mode 100644 index 0000000..9f28ac7 --- /dev/null +++ b/test.cr @@ -0,0 +1,52 @@ +require "./src/cligen" + +#CliGen.override_help_template "test.ecr" + +module MyModule + class MyData + extend CliGen::Coercable + extend CliGen::Parseable + + DELIMITER = '.' + + getter val : Int32 + + def initialize(@val : Int32) + end + + def self.parse_args(args : Array(CliGen::Arg)) : MyData + arg = args.first + arg.processed + new(val: arg.value.to_i32) + end + + def self.coerce(arg : String) : MyData + if arg =~ /^[[:digit:]]+$/ + new(val: arg.to_i32) + else + raise "ERROR parsing #{arg}" + end + end + end + + @[CliGen::CommandInfo(description: "Test")] + class MyClass < CliGen::Command + + + argument(myvar3 : Array(MyModule::MyData), description: "Custom data") + + argument(myvar2 : MyModule::MyData, description: "Custom data", validation: ->(v : MyModule::MyData) : Bool { v.val != 2 }) + + argument(myvar : Int32 = 123, description: "Set myvar", validation: ->(v : Int32) : Bool { (0..123).includes?(v) }, on_match: ->() : Nil { puts "Was called" }) + + subcommand dothing : Nil, description: "DOING ALL THE THINGS" do + puts "DO THE THING : myvar : #{@myvar}" + puts @myvar2.val + @myvar3.each do |var| + puts var.val + end + end + end + + CliGen::App.process +end diff --git a/test.ecr b/test.ecr new file mode 100644 index 0000000..84d6492 --- /dev/null +++ b/test.ecr @@ -0,0 +1,3 @@ +Command: <%= @name %> + +This was a test