commit b91ecad7e5219755721bfcce45abb40530b24a8a Author: Tristan Ancelet Date: Mon Feb 23 10:30:03 2026 -0600 Initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ae84d39 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tristan Ancelet + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..70a6d16 --- /dev/null +++ b/README.md @@ -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 diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..78ba823 --- /dev/null +++ b/shard.yml @@ -0,0 +1,9 @@ +name: cli_generator +version: 0.1.0 + +authors: + - Tristan Ancelet + +crystal: '>= 1.19.1' + +license: MIT diff --git a/spec/cli_generator_spec.cr b/spec/cli_generator_spec.cr new file mode 100644 index 0000000..a8a50d0 --- /dev/null +++ b/spec/cli_generator_spec.cr @@ -0,0 +1,9 @@ +require "./spec_helper" + +describe CliGenerator do + # TODO: Write tests + + it "works" do + false.should eq(true) + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..0611842 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/cli_generator" diff --git a/src/cli_generator.cr b/src/cli_generator.cr new file mode 100644 index 0000000..13fce6f --- /dev/null +++ b/src/cli_generator.cr @@ -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" diff --git a/src/command.cr b/src/command.cr new file mode 100644 index 0000000..14fd518 --- /dev/null +++ b/src/command.cr @@ -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 diff --git a/src/command/parser.cr b/src/command/parser.cr new file mode 100644 index 0000000..2e790c0 --- /dev/null +++ b/src/command/parser.cr @@ -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 diff --git a/src/format.cr b/src/format.cr new file mode 100644 index 0000000..b0d1009 --- /dev/null +++ b/src/format.cr @@ -0,0 +1,4 @@ +module CliGenerator::Format + INPUT_DATETIME_FORMAT = "%Y-%m-%d" + INPUT_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" +end diff --git a/src/regex.cr b/src/regex.cr new file mode 100644 index 0000000..eaf8ebf --- /dev/null +++ b/src/regex.cr @@ -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