Initial commit

This commit is contained in:
Tristan Ancelet
2026-02-23 10:30:03 -06:00
commit b91ecad7e5
10 changed files with 339 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tristan Ancelet <tristan.ancelet@acumera.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

12
README.md Normal file
View File

@@ -0,0 +1,12 @@
# CliGenerator
This is a crystal project to manage setting up OptionParser objects based around "Command" objects and arguments you define inside them.
## Usage
```crystal
require "cli_generator"
```
## Contributors
- [Tristan Ancelet](https://github.com/your-github-user) - creator and maintainer

9
shard.yml Normal file
View File

@@ -0,0 +1,9 @@
name: cli_generator
version: 0.1.0
authors:
- Tristan Ancelet <tristan.ancelet@acumera.com>
crystal: '>= 1.19.1'
license: MIT

View File

@@ -0,0 +1,9 @@
require "./spec_helper"
describe CliGenerator do
# TODO: Write tests
it "works" do
false.should eq(true)
end
end

2
spec/spec_helper.cr Normal file
View File

@@ -0,0 +1,2 @@
require "spec"
require "../src/cli_generator"

70
src/cli_generator.cr Normal file
View File

@@ -0,0 +1,70 @@
require "option_parser"
module CliGenerator
VERSION = "0.1.0"
annotation DefaultFlag
end
macro define_section(section_name, parser)
{{parser}}.separator ""
{{parser}}.separator "{{section_name.id}}".colorize(:green)
{{parser}}.separator "-------------------------------------------------------------------------------".colorize(:blue)
end
macro define_default_flags(parser)
define_section("Misc Flags", {{parser}})
{{parser}}.on("-h", "--help", "Print out this help output"){ abort {{parser}} }
{{parser}}.invalid_option{|opt|
abort "#{PROGRAM_NAME} : invalid_option : Inavlid option provided #{opt}"
}
{{parser}}.invalid_option{|opt|
case opt
when /^-+[a-z-]+/
Logger.debug "#{PROGRAM_NAME} : ERROR : {{parser}} : invalid_option : Inavlid option provided #{opt}"
else
abort "#{PROGRAM_NAME} : ERROR : {{parser}} : invalid_option : Inavlid option provided #{opt}"
end
}
{{parser}}.missing_option{|opt|
abort "ERROR : {{parser}} : missing_option : Argument was not provided to #{opt}"
}
{{parser}}.unknown_args{|opt|
unless opt.empty?
case opt.first
when /^-+[a-z-]+$/
Logger.debug "ERROR : {{parser}} : unknown_args : #{opt} not a configured argument"
else
abort "ERROR : {{parser}} : unknown_args : #{opt} not a configured argument"
end
end
}
end
macro define_root_parser
ROOT_COMMAND = OptionParser.new do |parser|
parser.banner = "#{PROGRAM_NAME} [command] [flags]"
{% commands = ::CliGenerator::Command.subclasses %}
{% raise "ERROR : No commands defined (no subclasses of ::CliGenerator::Command were found)" if commands.empty? %}
{% for command in commands %}
{{command.name}}.make_parser(parser)
{% end %}
define_default_flags(parser)
abort parser if ARGV.empty?
end
end
macro finished
define_root_parser
end
def self.parse
ROOT_COMMAND.parse
end
end
require "./command"

157
src/command.cr Normal file
View File

@@ -0,0 +1,157 @@
require "colorize"
require "time"
require "./command/parser"
module CliGenerator
annotation CommandPreRun
end
annotation CommandInfo
end
annotation CommandArgument
end
annotation SubCommand
end
class Command
extend CliGenerator::Parser
@@action : String = ""
macro inherited
macro finished
define_actions
define_header
define_runner
define_parser
end
end
macro define_actions
ACTIONS : Array(String) = {{@type.class.methods.select(&.annotation(::CliGenerator::SubCommand)).map(&.name.stringify)}}
end
macro define_header
{% name = @type.name.split("::").last.downcase.id %}
{% info_annos = @type.class.methods.select(&.annotation(::CliGenerator::SubCommand)).map(&.annotation(::CliGenerator::SubCommand)) %}
HEADER = [
"#{PROGRAM_NAME} {{name}} [[flags]]",
"",
{% unless info_annos.empty? %}
"Examples:".colorize(:green),
"-------------------------------------------------------------------------------".colorize(:blue),
{% for anno in info_annos %}
{% examples = anno[:examples].resolve %}
{% for example in examples %}
{{example}},
{% end %}
{% end %}
{% end %}
].join("\n")
end
macro define_argument(arg_name, variable = nil, type = String, short = "", long = "", description = nil, subtype = Nil, format = nil, check = nil, def_getter = false, default = nil, logger = nil)
{% raise "define_argument : You must provide a format or set it to \"auto\" to use builtin formats when defining a Time argument" if type.id.stringify == "Time" && format == nil %}
{% raise "define_argument : You MUST provide a description" unless description %}
{% variable = arg_name unless variable %}
{% _type = type.id.stringify %}
{% _subtype = subtype.id.stringify %}
{% if _type == "String" %}
@@{{variable}} : {{type}} = ""
{% elsif _type == "Bool" %}
@@{{variable}} : {{type}} = false
{% elsif _type == "Int32" %}
@@{{variable}} : {{type}} = 0
{% elsif _type == "Array" %}
{% raise "define_argument : subtype can only be Int32 or String" unless %w[ Int32 String ].includes?(_subtype) %}
@@{{variable}} : {{type}}({{subtype}}) = [] of {{subtype}}
{% elsif _type == "Time" %}
@@{{variable}} : {{type}} = Time.unix(seconds: 0)
{% end %}
{% if def_getter %}
def self.get_{{arg_name}} : {{type}}
@@{{variable}}
end
{% end %}
@[CommandArgument(type: {{type}}, short: {{short}}, long: {{long}}, description: {{description}})]
def self.{{arg_name}}(var : String) : Nil
{% if check %}
## If the check exists go ahead and call it
{{check}}(var)
{% end %}
{% if format %}
abort "ERROR : Action : {{arg_name}} : Incorrect format for provided data #{var}" unless var =~ {{format}}
{% end %}
## If a logger method has been provided by the user
{% if logger %}
{{logger.id}} "Command : subclass : argument_setter : {{arg_name}} : Entered with #{var}"
{% end %}
{% if _type == "Time" %}
formats = [
CliGenerator::Regex::INPUT_DATETIME_REGEX,
CliGenerator::Regex::INPUT_DATE_REGEX
]
abort "ERROR : Command : {{arg_name}} : Incorrect format for provided data #{var}" unless formats.any?{|f| var.match(f)}
if var =~ formats[0]
@@{{variable}} = Time.parse_local(var, CliGenerator::Format::INPUT_DATETIME_FORMAT)
elsif var =~ formats[1]
@@{{variable}} = Time.parse_local(var, CliGenerator::Format::INPUT_DATE_FORMAT)
end
{% elsif _type == "Array" %}
{% if _subtype == "Int32" %}
var : Int32 = var.to_i
{% elsif _subtype == "String" %}
{% else %}
var = {{subtype}}.new(var)
{% end %}
@@{{variable}} << var
{% elsif _type == "Bool" %}
@@{{variable}} = true
{% elsif _type == "Int32" %}
@@{{variable}} = var.to_i
{% elsif _type == "String" %}
@@{{variable}} = var
{% end %}
end
end
macro define_runner
def self.run
{% pre_run_commands = @type.class.methods.select(&.annotation(::CliGenerator::CommandPreRun)) %}
{% unless pre_run_commands.empty? %}
## Ensuring that all runs that are tagged with the the CommandPreRun annotation are run
{% for method in pre_run_commands %}
{{method.name}}
{% end %}
{% end %}
{% methods = @type.class.methods.select(&.annotation(::CliGenerator::SubCommand)) %}
{% raise "ERROR : No commands defined for #{@type.name}" if methods.empty? %}
{% begin %}
case @@action
{% for method in methods %}
when {{method.name.stringify}}
{{method.name}}
{% end %}
else
abort "ERROR : No action provided to command #{@type.name}"
end
{% end %}
end
end
def self.action=(action : String)
abort "ERROR : Action(#{action}) is not valid. Only #{ACTIONS.join(", ")} are acceptable" unless ACTIONS.includes?(action)
@@action = action
end
end
end

51
src/command/parser.cr Normal file
View File

@@ -0,0 +1,51 @@
module CliGenerator::Parser
macro extended
macro define_parser
def self.make_parser(parent_parser : OptionParser) : OptionParser
{% puts "#{@type.name} OptionParser is being generated" %}
{% name = @type.name.split("::").last %}
{% var = name.downcase %}
{% info = @type.annotation(::CliGenerator::CommandInfo) %}
subparser = OptionParser.new do |parser|
parser.banner = {{@type.name}}::HEADER
{% subcommands = @type.class.methods.select(&.annotation(::CliGenerator::SubCommand)) %}
{% if subcommands.size > 0 %}
define_section("Subcommands", parser)
{% for subcommand in subcommands %}
{% subcommand_anno = subcommand.annotation(::CliGenerator::SubCommand) %}
parser.on({{subcommand.name.stringify}}, {{subcommand_anno[:description]}}){
{{@type.name}}.action= {{subcommand.name.stringify}}
}
{% end %}
{% end %}
{% arguments = @type.class.methods.select(&.annotation(::CliGenerator::CommandArgument)) %}
{% if arguments.size > 0 %}
define_section("Provide Arguments", parser)
{% for argument in arguments %}
{% argument_anno = argument.annotation(::CliGenerator::CommandArgument) %}
{% if argument_anno[:short] == "" && argument_anno[:long] != "" %}
parser.on({{argument_anno[:long]}}, {{argument_anno[:description]}}){|val|
{% elsif argument_anno[:short] != "" && argument_anno[:long] == "" %}
parser.on({{argument_anno[:short]}}, {{argument_anno[:description]}}){|val|
{% else %}
parser.on({{argument_anno[:short]}}, {{argument_anno[:long]}}, {{argument_anno[:description]}}){|val|
{% end %}
{{@type.name}}.{{argument.name}}(val)
}
{% end %}
{% end %}
define_default_flags(parser)
end
parent_parser.on({{var}}, {{info[:description]}}){
ARGV.delete({{var}})
abort {{var.id}} if ARGV.empty?
subparser.parse
{{@type.name}}.run
}
end
end
end
end

4
src/format.cr Normal file
View File

@@ -0,0 +1,4 @@
module CliGenerator::Format
INPUT_DATETIME_FORMAT = "%Y-%m-%d"
INPUT_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
end

4
src/regex.cr Normal file
View File

@@ -0,0 +1,4 @@
module CliGenerator::Regex
INPUT_DATETIME_REGEX = /^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/
INPUT_DATE_REGEX = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/
end