object_rework: working MVP with runtime CommandNode tree

- BaseCommandNode (abstract) / CommandNode(T) split for heterogeneous tree
- Flag(T) with compile-time type branching, Parsable/Coercable modules
- Arg double-process invariant enforcement
- App.generate macro builds CommandNode tree from Command subclasses
- Command#initialize generated via macro finished, populates ivars from handler
- Reserved -h/--help enforcement in Flag#check!
- MatchType::Help added, SubCommand dispatch fixed
- design.adoc: added Planned Features (markdown docs, bash completion)
- Makefile: tabs, .DEFAULT_GOAL, doc_show target

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 22:49:55 -05:00
parent f5571cde73
commit 6f31a5d27c
9 changed files with 130 additions and 29 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ require "./app/generate"
module CliGen
# Root entry point. Holds a flattened copy of all flags from every command
# for global-flag matching, then hands off to the matched child CommandNode.
class App < CliGen::BaseCommandNode
class App < CliGen::CommandNode(Nil)
@@instance : self?
def initialize(@name, flags : Array(BaseFlag), commands : Array(BaseCommandNode), pre_run_commands : Array(RunCommand), post_run_commands : Array(RunCommand))
+7 -3
View File
@@ -5,6 +5,7 @@ module CliGen
app_flags = [] of BaseFlag
app_commands = [] of BaseCommandNode
{% verbatim do %}
{% for cmd in CliGen::Command.subclasses %}
{% cmd_info = cmd.annotation(CliGen::CommandInfo) %}
{% raise "ERROR : CliGen::App.generate : No CliGen::CommandInfo annotation made for #{cmd.name}" unless cmd_info %}
@@ -23,12 +24,13 @@ module CliGen
var: {{ var.name.stringify }},
short: {% if anno[:short] %} {{anno[:short]}} {% else %} nil {% end %},
long: {{ anno[:long] }},
env_var: {% if anno[:env_var] %} {{anno[:env_var]}} {% else %} {{"#{cmd.name.upcase}_#{var.name.upcase}"}} {% end %},
env_var: {% if anno[:env_var] %} {{anno[:env_var]}} {% else %} {{"#{cmd.name.split("::").last.upcase.id}_#{var.name.upcase}"}} {% end %},
description: {{ anno[:description] }},
default: {% if anno[:default] %} {{anno[:default]}} {% else %} nil {% end %},
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 %}
)
{% debug %}
{% end %}
# Keep a copy of every flag on the root for global matching
@@ -42,10 +44,12 @@ module CliGen
post_run_commands: cmd_post_run_cmds,
description: {{cmd_info[:description]}}
)
{% debug %}
{% end %}
{% end %}
new(
name: ::PROGRAM_NAME,
name: File.basename(::PROGRAM_NAME),
flags: app_flags,
commands: app_commands,
pre_run_commands: [] of Proc(Nil),
+5 -3
View File
@@ -10,6 +10,7 @@ require "./command/subcommand"
module CliGen
class Command
macro inherited
{% verbatim do %}
macro finished
def initialize(*, handler : CliGen::BaseCommandNode)
{% verbatim do %}
@@ -20,16 +21,17 @@ module CliGen
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}}\"?"
raise "ERROR : {{@type.name}}\#{{@def.name}} : No flag found for \"{{var.name}}\"?"
end
{% else %}
{% raise "ERROR : #{@type.name}#{@def.name} : Instance Variable(#{var.name}) is not handled by CliGen and does not have a default value" unless var.default %}
@{{var.id}} = {{var.default}}
{% raise "ERROR : #{@type.name}#{@def.name} : Instance Variable(#{var.name}) is not handled by CliGen and does not have a default value" unless var.default_value %}
@{{var.id}} = {{var.default_value}}
{% end %}
{% end %}
{% end %}
end
end
{% end %}
end
end
end
+11 -4
View File
@@ -1,14 +1,21 @@
module CliGen
class Command
macro argument(variable, short, long, description, validation = nil)
macro argument(variable, long, description, short = nil, validation = nil, on_match = nil)
{% raise "ERROR : CliGen::Command.argument : First argument (#{variable}) must be a TypeDeclaration (ex: '<var> : <type> [= val]')" unless variable.is_a? TypeDeclaration %}
{% name = variable.name %}
{% name = variable.var %}
{% type = variable.type %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided short must be a string" unless short.is_a? StringLiteral || string == nil %}
{% 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 %}
{% 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 %}
{% 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 %}
{% end %}
{% unless validation.nil? %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided validation must be a Proc" unless validation.is_a? ProcLiteral %}
{% raise "ERROR : CliGen::Command.argument(#{name}) : Provided validation return type must be a Bool" unless validation.return_type == Bool %}
@@ -23,7 +30,7 @@ module CliGen
@[CliGen::Argument(short: {{short}}, long: {{long}}, description: {{description}}, validation: {{validation}}, on_match: {{on_match}})]
@{{variable}}
def {{variable.name}}= (value : {{type}})
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 %}
+1 -1
View File
@@ -1,7 +1,7 @@
module CliGen
class Command
macro subcommand(func, description, examples = nil, &block)
{% raise "ERROR : CliGen::Command.subcommand : First argument must be a TypeDeclaration (ex: '<var> : <type>')" unless variable.is_a? TypeDeclaration %}
{% raise "ERROR : CliGen::Command.subcommand : First argument must be a TypeDeclaration, or Call (ex: '<var> : <type>' or <var>)" unless func.is_a? TypeDeclaration || func.is_a? Call %}
{% raise "ERROR : CliGen::Command.subcommand : You must provide a description" unless description %}
{% raise "ERROR : CliGen::Command.subcommand : Provided description must be a String" unless description.is_a? StringLiteral %}
{% unless examples.nil? %}
+24 -10
View File
@@ -1,4 +1,5 @@
require "./global_flag"
require "./match_type"
require "./flag"
require "./arg"
require "ecr"
@@ -29,7 +30,7 @@ module CliGen
@post_run_commands : Array(RunCommand),
@description : String? = nil
)
@flags = flags + CliGen::GLOBAL_FLAGS + @commands.flat_map(&.flags)
@flags = (flags + CliGen::GLOBAL_FLAGS + @commands.flat_map(&.flags)).uniq
end
def help : String
@@ -112,17 +113,18 @@ module CliGen
# Converts String array to Arg array and hands off to the typed process method
def process(args : Array(String)) : Nil
new_args = args.each_with_index.map { |arg, i| Arg.new(value: arg, index: i) }.to_a
new_args = args.each_with_index.map { |arg, i| CliGen::Arg.new(value: arg, index: i) }.to_a
process(new_args)
end
abstract def subcommands : Array(SubCommandInfo)
abstract def check! : Nil
abstract def process(args : Array(Arg)) : Nil
abstract def process(args : Array(CliGen::Arg)) : Nil
end
class CommandNode(T) < BaseCommandNode
def subcommands : Array(SubCommandInfo)
{% begin %}
{% subcmds = T.methods.select(&.annotation(CliGen::SubCommand)) %}
{% if subcmds.empty? %}
[] of SubCommandInfo
@@ -138,6 +140,7 @@ module CliGen
{% end %}
]
{% end %}
{% end %}
end
def check! : Nil
@@ -148,7 +151,7 @@ module CliGen
if subcommands.empty? && !{{T.has_method?(:main)}}
end
def process(args : Array(Arg)) : Nil
def process(args : Array(CliGen::Arg)) : Nil
check!
passed_execution = false
matched_subcommand : String? = nil
@@ -162,7 +165,16 @@ module CliGen
case match = find_match(arg.value)
when BaseCommandNode
# Hand off remainder to the child; we're done at this level
match.process(args.reject(&.processed?))
{% begin %}
case match
{% for cls in CliGen::Command.subclasses %}
when CliGen::CommandNode({{cls.name}})
match.as(CommandNode({{cls}})).process(args.reject(&.processed?))
{% end %}
else
raise "ERROR : Couldn't find thing"
end
{% end %}
passed_execution = true
when BaseFlag
@@ -183,9 +195,9 @@ module CliGen
if regex_match = CliGen::Regex::FLAG_WITH_ARG.match(arg.value)
case flag_match = find_match(regex_match["flag"])
when BaseFlag
flag_match.process([Arg.new(value: regex_match["arg"], index: arg.index)])
flag_match.process([CliGen::Arg.new(value: regex_match["arg"], index: arg.index)])
else
raise "ERROR : CommandNode(#{@name}).run : No flag matched '#{regex_match["flag"]}'"
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"
@@ -201,19 +213,20 @@ module CliGen
abort "#{CliGen::APPNAME}: cannot bundle flag that requires an argument: #{flag}" if match.requires_arg?
match.process
when MatchType::NoMatch
raise "ERROR : CommandNode(#{@name}).run : No match for '#{flag}'"
raise "ERROR : CommandNode(#{@name}).process : No flag match for '#{flag}'"
end
end
when MatchType::NoMatch
raise "ERROR : CommandNode(#{@name}).run : No match for '#{arg.value}'"
raise "ERROR : CommandNode(#{@name}).process : No command, subcommand or flag match for '#{arg.value}'"
end
end
@post_run_commands.each(&.call)
{% unless T == Nil %}
unless passed_execution
cls = T.new(self)
cls = T.new(handler: self.as(CliGen::BaseCommandNode))
{% subcmds = T.methods.select(&.annotation(CliGen::SubCommand)) %}
{% begin %}
case matched_subcommand
@@ -230,6 +243,7 @@ module CliGen
end
{% end %}
end
{% end %}
end
end
end
+10 -3
View File
@@ -17,7 +17,7 @@ module CliGen
@description : String
)
# if the user provides just a "--long" I want the @long_key to match it
if @long.includes(" ")
if @long.includes?(" ")
@long_key = @long.split(" ").first
else
@long_key = @long
@@ -155,14 +155,21 @@ module CliGen
end
end
def check!
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"
end
private def coerce(raw : String) : T
{% if T == Bool %}
raw == "true" || raw == "1"
case raw
when "t","true","1"
true
when "f","false","0"
false
else
raise "ERROR"
end
{% elsif T == Int32 %}
raw.to_i
{% elsif T == Time %}
+4 -4
View File
@@ -1,4 +1,4 @@
Command: <% @name %>
Command: <%= @name %>
<%- unless @description.nil? -%>
Description: <%= @description %>
<%- end -%>
@@ -7,9 +7,9 @@ Flags:
---------------------------------------------------------------
<%- @flags.each do |flag| -%>
<%- unless flag.short.nil? -%>
<%= "%-10s %s" % ["#{flag.short},#{flag.long}", flag.description] %>
<%= "%-15s %s" % ["#{flag.short.not_nil!.strip},#{flag.long.strip}", flag.description.strip] %>
<%- else -%>
<%= "%-10s %s" % [flag.long, flag.description] %>
<%= "%-15s %s" % [flag.long.strip, flag.description.strip] %>
<%- end -%>
<%- end -%>
@@ -25,7 +25,7 @@ Other Commands
SubCommands of <%= @name %>:
---------------------------------------------------------------
<%- subcommands.each do |cmd| -%>
<%= "%-10 %s" % [cmd.name, cmd.description] %>
<%= "%-10s %s" % [cmd.name, cmd.description] %>
<%- end -%>
<%- cmds = subcommands.select{|c| ! c.examples.nil? } -%>