From 6f31a5d27c40e0ac64cedc5734a2a9075ec8113e Mon Sep 17 00:00:00 2001 From: Tristan Ancelet Date: Sun, 9 Aug 2026 22:49:55 -0500 Subject: [PATCH] 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 --- design.adoc | 67 ++++++++++++++++++++++++++++++++ src/cligen/app.cr | 2 +- src/cligen/app/generate.cr | 10 +++-- src/cligen/command.cr | 8 ++-- src/cligen/command/argument.cr | 15 +++++-- src/cligen/command/subcommand.cr | 2 +- src/cligen/command_node.cr | 34 +++++++++++----- src/cligen/flag.cr | 13 +++++-- src/cligen/template/cmd_help.ecr | 8 ++-- 9 files changed, 130 insertions(+), 29 deletions(-) diff --git a/design.adoc b/design.adoc index f55182b..d2c9708 100644 --- a/design.adoc +++ b/design.adoc @@ -74,3 +74,70 @@ The examples like above provide a "DSL-esk" way of defining: ==== How it works Using crystal macros, you define the shape (arguments/flags, selections, work functions/subcommands, etc + +== Planned Features + +=== Markdown Documentation Generation + +Since all command metadata is present in annotations at compile time (`@[CliGen::CommandInfo]`, `@[CliGen::SubCommand]`, `@[CliGen::Argument]`, `@[CliGen::Selection]`), the framework can walk the same structures that `generate.cr` already walks and render them into a Markdown document instead of a `CommandNode` tree. + +The generation would be driven by a `macro finished` block (similar to `generate.cr`) that emits a `self.generate_docs` class method on `App`. This method walks every `Command` subclass and its annotations to produce a structured document. + +Proposed output structure: + +---- +# + +## Commands + +### mycommand + + +#### Flags +| Flag | Short | Type | Default | Description | +|------|-------|------|---------|-------------| +| --myvar VAR | -m | Int32 | 23 | ... | + +#### Subcommands +- `do_thing` — + - Examples: ... +---- + +Implementation notes: + +* Driven by a `--generate-docs` global flag or a dedicated class method +* ECR templates (already pulled in) are the natural rendering mechanism +* The same annotation data powers both runtime help output and the doc generator, keeping them in sync automatically + +=== Bash Autocompletion Generation + +Since all command names and flag names are known at compile time, a complete bash completion script can be generated statically — no runtime `--completions` endpoint needed. + +The approach is a hidden `--generate-completion bash` flag (potentially extended to `zsh`/`fish` later) that prints a ready-to-install completion script to stdout. + +Proposed completion script shape: + +[source,bash] +---- +_myapp() { + local cur="${COMP_WORDS[COMP_CWORD]}" + local prev="${COMP_WORDS[COMP_CWORD-1]}" + + case "${COMP_WORDS[1]}" in + mycommand) + COMPREPLY=($(compgen -W "--myvar -m --format -f" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "mycommand myothercommand" -- "$cur")) + ;; + esac +} +complete -F _myapp myapp +---- + +Implementation notes: + +* Script body generated at compile time via a `macro finished` walk of `Command.subclasses` +* `@[CliGen::Selection]` options (`%w[json yaml ecr]`) can be included as valid completions for their flag +* Install path: `myapp --generate-completion bash > ~/.bash_completion.d/myapp` or printed with instructions +* Same annotation data used by the doc generator, so both stay in sync with the command definition diff --git a/src/cligen/app.cr b/src/cligen/app.cr index fe3d24a..35446e1 100644 --- a/src/cligen/app.cr +++ b/src/cligen/app.cr @@ -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)) diff --git a/src/cligen/app/generate.cr b/src/cligen/app/generate.cr index e9de1d1..31e01d3 100644 --- a/src/cligen/app/generate.cr +++ b/src/cligen/app/generate.cr @@ -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), diff --git a/src/cligen/command.cr b/src/cligen/command.cr index 89f3ca6..a160c40 100644 --- a/src/cligen/command.cr +++ b/src/cligen/command.cr @@ -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 diff --git a/src/cligen/command/argument.cr b/src/cligen/command/argument.cr index b27c668..2b5e552 100644 --- a/src/cligen/command/argument.cr +++ b/src/cligen/command/argument.cr @@ -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: ' : [= 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 %} diff --git a/src/cligen/command/subcommand.cr b/src/cligen/command/subcommand.cr index 44d61d5..fa6227f 100644 --- a/src/cligen/command/subcommand.cr +++ b/src/cligen/command/subcommand.cr @@ -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: ' : ')" unless variable.is_a? TypeDeclaration %} + {% raise "ERROR : CliGen::Command.subcommand : First argument must be a TypeDeclaration, or Call (ex: ' : ' or )" 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? %} diff --git a/src/cligen/command_node.cr b/src/cligen/command_node.cr index 2371f36..d663b33 100644 --- a/src/cligen/command_node.cr +++ b/src/cligen/command_node.cr @@ -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 diff --git a/src/cligen/flag.cr b/src/cligen/flag.cr index 825120a..80840aa 100644 --- a/src/cligen/flag.cr +++ b/src/cligen/flag.cr @@ -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 %} diff --git a/src/cligen/template/cmd_help.ecr b/src/cligen/template/cmd_help.ecr index c019ea0..37af0ff 100644 --- a/src/cligen/template/cmd_help.ecr +++ b/src/cligen/template/cmd_help.ecr @@ -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? } -%>