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:
+67
@@ -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:
|
||||
|
||||
----
|
||||
# <AppName>
|
||||
|
||||
## Commands
|
||||
|
||||
### mycommand
|
||||
<CommandInfo description>
|
||||
|
||||
#### Flags
|
||||
| Flag | Short | Type | Default | Description |
|
||||
|------|-------|------|---------|-------------|
|
||||
| --myvar VAR | -m | Int32 | 23 | ... |
|
||||
|
||||
#### Subcommands
|
||||
- `do_thing` — <description>
|
||||
- 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
|
||||
|
||||
+1
-1
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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? } -%>
|
||||
|
||||
Reference in New Issue
Block a user