Login
Artifact [e5ca18fa8d]
Login

Artifact e5ca18fa8d0dac3ab6d00c7d6423a5a5c0b20c84ae81a901a41fb93c62e4e027:


#### libremiliacr
#### Copyright(C) 2020-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify it
#### under the terms of the GNU General Public License as published the Free
#### Software Foundation, either version 3 of the License, or (at your option)
#### any later version.
####
#### This program is distributed in the hope that it will be useful, but WITHOUT
#### ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#### FITNESS FOR A PARTICULAR PURPOSE.See the GNU General Public License for
#### more details.
####
#### You should have received a copy of the GNU General Public License along
#### with this program.If not, see<http:####www.gnu.org/licenses/.>
require "../strings"
require "./arg-types"

module RemiLib::Args
  # Represents an error that occurs during argument parsing.
  class ArgumentError < Exception
  end

  # An `ArgCallbackFunc` is a proc that will be called at the end of parsing if
  # the argument was called.
  alias ArgCallbackFunc = ArgParser, Argument -> Nil

  # This is used by the `ArgParser` class when a help argument
  # (e.g. `"--help"`/`"-h"`) is found on the command line.
  alias HelpPrinterFunc = ArgParser -> Nil

  # This is used by the `ArgParser` class when a version argument
  # (e.g. `"--version"`/`"-V"`) is found on the command line.
  alias VerPrinterFunc = ArgParser -> Nil

  ##############################################################################

  # The main class for parsing command line arguments.
  class ArgParser
    # The `Argument`s defined for this parser.  Do not add arguments directly to
    # this field - use one of the `add*` methods instead.
    getter args : Hash(String, Argument) = {} of String => Argument
    @argsSN : Hash(String, Argument) = {} of String => Argument

    # Any non-argument strings left on the command line after parsing.
    property positionalArgs : Array(String) = Array(String).new(1)

    # This will be called when a help argument (e.g. `"--help"`/`"-h"`) is found
    # on the command line.
    property helpPrinter : HelpPrinterFunc = ->ArgParser.defaultHelpPrinter(ArgParser)

    # This will be called when a version argument (e.g. `"--version"`/`"-V"`) is
    # found on the command line.
    property verPrinter : VerPrinterFunc  = ->ArgParser.defaultVerPrinter(ArgParser)

    # The name of the program using this parser.  Used when a version argument
    # (e.g. `"--version"`/`"-V"`) is found on the command line.
    property progName : String

    # The version of the program using this parser.  Used when a version
    # argument (e.g. `"--version"`/`"-V"`) is found on the command line.
    property progVersion : String

    # When non-nil, this will be used for the "Usage:" line instead of
    # the generated one.
    property usageLine : String? = nil

    # A string that is to be printed after the "Usage:" line but before the list
    # of arguments when a help argument (e.g. `"--help"`/`"-h"`) is found on the
    # command line.  A newline will be appended automatically.
    property preHelpText : String = ""

    # A string that is to be printed after the list of arguments when a help
    # argument (e.g. `"--help"`/`"-h"`) is found on the command line.  A newline
    # will be appended automatically.
    property postHelpText : String = ""

    # A string that is to be printed before version information when a version
    # argument (e.g. `"--version"`/`"-V"`) is found on the command line.  A
    # newline will be appended automatically.
    property preVerText : String = ""

    # A string that is to be printed after the version information when a
    # version argument (e.g. `"--version"`/`"-V"`) is found on the command line.
    # A newline will be appended automatically.
    property postVerText : String = ""

    # When `true`, `#parse` will call `exit(0)` after printing help when a help
    # argument (e.g. `"--help"`/`"-h"`) is found on the command line.
    property? quitOnHelp : Bool = true

    # When `true`, `#parse` will call `exit(0)` after printing version
    # information when a version argument (e.g. `"--version"`/`"-V"`) is found
    # on the command line.
    property? quitOnVer : Bool = true

    # When `true`, the help argument (e.g. `"--help"`/`"-h"`) will be processed.
    # Otherwise it is ignored.
    property? detectHelp : Bool = true

    # When `true`, the version argument (e.g. `"--version"`/`"-V"`) will be
    # processed.  Otherwise it is ignored.
    property? detectVer : Bool = true

    # The name of the binary using this `ArgParser`.
    getter progBinName : String

    # When true, the double dash `--` will be treated as a positional argument.
    property? doubleDashPositional : Bool = false

    # When true, a single dash `-` will be and stored as a positional argument.
    property? singleDashPositional : Bool = false

    # :nodoc:
    protected getter argv0 : String

    # Creates a new `ArgParser` instance.  A help argument and a version
    # argument will always be added as `FlagArgument`s.
    def initialize(@progName : String, @progVersion : String,
                   *, @helpLongName : String = "--help", @helpShortName : Char = 'h',
                   @verLongName : String = "--version", @verShortName : Char = 'V',
                   newProgBinName : String = PROGRAM_NAME, newArgv0 : String = PROGRAM_NAME)
      harg : FlagArgument = FlagArgument.new(@helpLongName, @helpShortName, help: "Show this help text")
      addArg(harg)

      varg : FlagArgument = FlagArgument.new(@verLongName, @verShortName, help: "Show version information")
      addArg(varg)

      @progBinName = newProgBinName
      @argv0 = newArgv0
    end

    # Sets the name of the binary using this `ArgParser`.
    @[AlwaysInline]
    def progBinName=(newName : String)
      @progBinName = newName
      @argv0 = newName
    end

    # Adds a new `Argument` to this parser.  The argument must have a
    # `Argument#longName`.  Arguments with duplicate `Argument#longName`s and
    # `Argument#shortName`s will raise an `Exception`.
    def addArg(arg : Argument)
      raise "Invalid long name: empty strings not allowed" if arg.longName.strip.empty?

      arg.called = false
      if @args.has_key?(arg.longName)
        raise "Duplicate argument: '#{arg.longName}'"
      else
        @args[arg.longName] = arg
      end

      if !arg.shortName.nil?
        raise "Duplicate argument (short name): '#{arg.longName}' ('-#{arg.shortName}')" if @argsSN.has_key?("#{arg.shortName}")
        @argsSN["#{arg.shortName}"] = arg
      end
    end

    # Adds a new `FlagArgument`.
    @[AlwaysInline]
    def addFlag(longName : String, shortName : Char? = nil,
                *, group : String = "", help : String = "") : FlagArgument
      newArg = FlagArgument.new(longName, shortName, group, help)
      addArg(newArg)
      newArg
    end

    # :ditto:
    def addFlag(longName : String, shortName : Char? = nil, &) : Nil
      yield addFlag(longName, shortName)
    end

    # Adds a new `MultiFlagArgument`.
    @[AlwaysInline]
    def addMultiFlag(longName : String, shortName : Char? = nil,
                     *, group : String = "", help : String = "") : MultiFlagArgument
      newArg = MultiFlagArgument.new(longName, shortName, group, help)
      addArg(newArg)
      newArg
    end

    # :ditto:
    def addMultiFlag(longName : String, shortName : Char? = nil, &) : Nil
      yield addMultiFlag(longName, shortName)
    end

    # Adds a new `StringArgument`.
    @[AlwaysInline]
    def addString(longName : String, shortName : Char? = nil,
                  *, group : String = "", help : String = "", default : String = "",
                  constraints : Array(String)? = nil) : StringArgument
      newArg = StringArgument.new(longName, shortName, group, help)
      newArg.setValue!(default)
      addArg(newArg)
      newArg.oneOf = constraints unless constraints.nil?
      newArg
    end

    # :ditto:
    def addString(longName : String, shortName : Char? = nil, *, group : String = "", &) : Nil
      yield addString(longName, shortName, group: group)
    end

    # Adds a new `MultiStringArgument`.
    @[AlwaysInline]
    def addMultiString(longName : String, shortName : Char? = nil,
                       *, group : String = "", help : String = "",
                       default : Array(String) = [] of String, constraints : Array(String)? = nil) : MultiStringArgument
      newArg = MultiStringArgument.new(longName, shortName, group, help)
      newArg.setValues!(default)
      addArg(newArg)
      newArg.oneOf = constraints unless constraints.nil?
      newArg
    end

    # :ditto:
    def addMultiString(longName : String, shortName : Char? = nil, *, group : String = "", &) : Nil
      yield addMultiString(longName, shortName, group: group)
    end

    # Adds a new `IntArgument`.
    @[AlwaysInline]
    def addInt(longName : String, shortName : Char? = nil,
               *, group : String = "", help : String = "", default : Int = 0,
               minimum : Int = Int64::MIN, maximum : Int = Int64::MAX) : IntArgument
      newArg = IntArgument.new(longName, shortName, group, help)
      newArg.setValue!(default.to_i64)
      newArg.minimum = minimum.to_i64
      newArg.maximum = maximum.to_i64
      addArg(newArg)
      newArg
    end

    # :ditto:
    def addInt(longName : String, shortName : Char? = nil,
               *, group : String = "", help : String = "", default : Int = 0,
               minimum : Int = Int64::MIN, maximum : Int = Int64::MAX, &) : Nil
      yield addInt(longName, shortName, default: default, group: group, help: help,
                   minimum: minimum, maximum: maximum)
    end

    # Adds a new `FloatArgument`.
    @[AlwaysInline]
    def addFloat(longName : String, shortName : Char? = nil,
                 *, group : String = "", help : String = "", default : Float64 = 0.0,
                 minimum : Float64 = Float64::MIN, maximum : Float64 = Float64::MAX) : FloatArgument
      newArg = FloatArgument.new(longName, shortName, group, help)
      newArg.setValue!(default)
      newArg.minimum = minimum
      newArg.maximum = maximum
      addArg(newArg)
      newArg
    end

    # :ditto:
    def addFloat(longName : String, shortName : Char? = nil,
                 *, group : String = "", help : String = "", default : Float64 = 0.0,
                 minimum : Float64 = Float64::MIN, maximum : Float64 = Float64::MAX, &) : Nil
      yield addFloat(longName, shortName, default: default, group: group, help: help,
                     minimum: minimum, maximum: maximum)
    end

    # Adds a new `MultiIntArgument`.
    @[AlwaysInline]
    def addMultiInt(longName : String, shortName : Char? = nil,
                    *, group : String = "", help : String = "",
                    default : Array(Int64) = [] of Int64) : MultiIntArgument
      newArg = MultiIntArgument.new(longName, shortName, group, help)
      newArg.values = default
      addArg(newArg)
      newArg
    end

    # :ditto:
    def addMultiInt(longName : String, shortName : Char? = nil,
                    *, group : String = "", help : String = "",
                    default : Array(Int64) = [] of Int64, &) : Nil
      yield addMultiInt(longName, shortName, group: group, help: help, default: default)
    end

    # Adds a new `MultiIntArgument`.
    @[AlwaysInline]
    def addMultiFloat(longName : String, shortName : Char? = nil,
                      *, group : String = "", help : String = "",
                      default : Array(Float64) = [] of Float64) : MultiIntArgument
      newArg = MultiFloatArgument.new(longName, shortName, group, help)
      newArg.values = default
      addArg(newArg)
      newArg
    end

    # :ditto:
    def addMultiInt(longName : String, shortName : Char? = nil,
                    *, group : String = "", help : String = "",
                    default : Array(Float64) = [] of Float64, &) : Nil
      yield addMultiFloat(longName, shortName, group: group, help: help, default: default)
    end

    # Returns an array of Strings containing all fo the argument groups.  Note
    # that the default argument group is an empty string.
    @[AlwaysInline]
    def allArgGroups : Array(String)
      ret : Array(String) = [] of String
      @args.each_value { |val| ret << val.group }
      ret.uniq
    end

    # Returns an array of `Argument`s that are in the given group.
    @[AlwaysInline]
    def allArgsInGroup(group : String) : Array(Argument)
      ret : Array(Argument) = [] of Argument
      @args.each_value { |val| ret << val if val.group == group }
      ret
    end

    # Returns the length of the longest `Argument#longName` found in `#args`.
    @[AlwaysInline]
    def longestArgName : Int32
      longest : Int32 = 0
      @args.each_key { |key| longest = key.size if key.size > longest }
      longest
    end

    # :nodoc:
    @[AlwaysInline]
    protected def handleArgument(arg : Argument|MultiArgument|ValArgument) : Bool
      if arg.responds_to?(:times)
        arg.times = arg.times + 1
      else
        raise ArgumentError.new("#{arg.longName} cannot be called more than once") if arg.called?
      end

      arg.called = true
      arg.responds_to?(:value) || arg.responds_to?(:<<)
    end

    # :nodoc:
    private enum Pstate
      ReadArg
      ReadToken
      ReadPositional
    end

    # Executes a block on every argument that was `Argument#called`.
    def eachCalled(&) : Nil
      @args.each_value { |arg| yield arg if arg.called? }
    end

    # Executes a block on every argument.
    def each(&) : Nil
      @args.each_value { |arg| yield arg }
    end

    # If an argument named `name` was called, then this yields that argument to
    # the block.  Otherwise this does nothing.  This returns either the result
    # of the block, or nil if it wasn't called.
    def withCalledArg(name : String, &)
      arg : Argument = self.[name]
      if arg.called?
        yield arg
      else
        nil
      end
    end

    # Looks up the argument named `name`, casts it to an `FlagArgument`, then
    # yields it to the block.  If the argument is not an `FlagArgument`, this
    # raises an exception.  Returns the last value of the block.
    def withFlagArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(FlagArgument)
        yield arg.as(FlagArgument)
      else
        raise "Attempted to call ArgParser#withFlagArg on an argument that is not an FlagArgument"
      end
    end

    # Looks up the argument named `name`, casts it to an `MultiFlagArgument`,
    # then yields it to the block.  If the argument is not an
    # `MultiFlagArgument`, this raises an exception.  Returns the last value of
    # the block.
    def withMultiFlagArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(MultiFlagArgument)
        yield arg.as(MultiFlagArgument)
      else
        raise "Attempted to call ArgParser#withFlagArg on an argument that is not an FlagArgument"
      end
    end

    # Looks up the argument named `name`, casts it to an `StringArgument`, then
    # yields it to the block.  If the argument is not an `StringArgument`, this
    # raises an exception.  Returns the last value of the block.
    def withStringArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(StringArgument)
        yield arg.as(StringArgument)
      else
        raise "Attempted to call ArgParser#withStringArg on an argument that is not an StringArgument"
      end
    end

    # Looks up the argument named `name`, casts it to an `MultiStringArgument`,
    # then yields it to the block.  If the argument is not an
    # `MultiStringArgument`, this raises an exception.  Returns the last value
    # of the block.
    def withMultiStringArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(MultiStringArgument)
        yield arg.as(MultiStringArgument)
      else
        raise "Attempted to call ArgParser#withMultiStringArg on an argument that is not an MultiStringArgument"
      end
    end

    # Looks up the argument named `name`, casts it to an `IntArgument`, then
    # yields it to the block.  If the argument is not an `IntArgument`, this
    # raises an exception.  Returns the last value of the block.
    def withIntArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(IntArgument)
        yield arg.as(IntArgument)
      else
        raise "Attempted to call ArgParser#withIntArg on an argument that is not an IntArgument"
      end
    end

    # Looks up the argument named `name`, casts it to a `MultiIntArgument`, then
    # yields it to the block.  If the argument is not a `MultiIntArgument`, this
    # raises an exception.  Returns the last value of the block.
    def withMultiIntArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(MultiIntArgument)
        yield arg.as(MultiIntArgument)
      else
        raise "Attempted to call ArgParser#withMultiIntArg on an argument that is not a " \
              "MultiIntArgument"
      end
    end

    # Looks up the argument named `name`, casts it to a `FloatArgument`, then
    # yields it to the block.  If the argument is not a `FloatArgument`, this
    # raises an exception.  Returns the last value of the block.
    def withFloatArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(FloatArgument)
        yield arg.as(FloatArgument)
      else
        raise "Attempted to call ArgParser#withFloatArg on an argument that is not a FloatArgument"
      end
    end

    # Looks up  the argument named  `name`, casts it to  a `MultiFloatArgument`,
    # then   yields   it  to   the   block.    If   the   argument  is   not   a
    # `MultiFloatArgument`, this raises an exception.  Returns the last value of
    # the block.
    def withMultiFloatArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(MultiFloatArgument)
        yield arg.as(MultiFloatArgument)
      else
        raise "Attempted to call ArgParser#withMultiFloatArg on an argument that is not a " \
              "MultiFloatArgument"
      end
    end

    # Looks up the argument named `name`, casts it to an `StringArgument`.  If
    # the argument was called, this yields it to the block, otherwise it does
    # nothing.  If the argument is not an `StringArgument`, this raises an
    # exception.  This returns either the result of the block, or nil if it
    # wasn't called.
    def withCalledStringArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(StringArgument)
        if arg.called?
          yield arg.as(StringArgument)
        else
          nil
        end
      else
        raise "Attempted to call ArgParser#withStringArg on an argument that is not an StringArgument"
      end
    end

    # Looks up the argument named `name`, casts it to an `MultiStringArgument`.
    # If the argument was called, this yields it to the block, otherwise it does
    # nothing.  If the argument is not an `MultiStringArgument`, this raises an
    # exception.  This returns either the result of the block, or nil if it
    # wasn't called.
    def withCalledMultiStringArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(MultiStringArgument)
        if arg.called?
          yield arg.as(MultiStringArgument)
        else
          nil
        end
      else
        raise "Attempted to call ArgParser#withMultiStringArg on an argument that is not an MultiStringArgument"
      end
    end

    # Looks up the argument named `name`, casts it to an `IntArgument`.  If the
    # argument was called, this yields it to the block, otherwise it does
    # nothing.  If the argument is not an `IntArgument`, this raises an
    # exception.  This returns either the result of the block, or nil if it
    # wasn't called.
    def withCalledIntArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(IntArgument)
        if arg.called?
          yield arg.as(IntArgument)
        else
          nil
        end
      else
        raise "Attempted to call ArgParser#withCalledIntArg on an argument that is not an IntArgument"
      end
    end

    # Looks up the argument named `name`, casts it to a `MultiIntArgument`.  If
    # the argument was called, this yields it to the block, otherwise it does
    # nothing.  If the argument is not a `MultiIntArgument`, this raises an
    # exception.  This returns either the result of the block, or nil if it
    # wasn't called.
    def withCalledMultiIntArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(MultiIntArgument)
        if arg.called?
          yield arg.as(MultiIntArgument)
        else
          nil
        end
      else
        raise "Attempted to call ArgParser#withCalledMultiIntArg on an argument that is not a " \
              "MultiIntArgument"
      end
    end

    # Looks up the argument named `name`, casts it to a `FloatArgument`.  If the
    # argument was called, this yields it to the block, otherwise it does
    # nothing.  If the argument is not a `FloatArgument`, this raises an
    # exception.  This returns either the result of the block, or nil if it
    # wasn't called.
    def withCalledFloatArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(FloatArgument)
        if arg.called?
          yield arg.as(FloatArgument)
        else
          nil
        end
      else
        raise "Attempted to call ArgParser#withFloatArg on an argument that is not a FloatArgument"
      end
    end

    # Looks up the argument named `name`, casts it to a `MultiFloatArgument`.
    # If the argument was called, this yields it to the block, otherwise it does
    # nothing.  If the argument is not a `MultiFloatArgument`, this raises an
    # exception.  This returns either the result of the block, or nil if it
    # wasn't called.
    def withCalledMultiFloatArg(name : String, &)
      arg : Argument = self.[name]
      if arg.is_a?(MultiFloatArgument)
        if arg.called?
          yield arg.as(MultiFloatArgument)
        else
          nil
        end
      else
        raise "Attempted to call ArgParser#withMultiFloatArg on an argument that is not a " \
              "MultiFloatArgument"
      end
    end

    # Returns the `Argument` that has the given `Argument#longName`.  The `"--"`
    # prefix is optional.
    @[AlwaysInline]
    def [](name : String) : Argument
      name.starts_with?("--") ? @args[name] : @args["--#{name}"]
    end

    # Returns the `Argument` that has the given `Argument#shortName`.
    def [](name : Char) : Argument
      @argsSN[name]
    end

    # Parses `ARGV`.
    #
    # If a help argument or version argument is found on the command line, and
    # `#detectHelp`/`#detectVer` is true, then the corresponding
    # `helpPrinter`/`#verPrinter` will be called.
    #
    # If a help argument or version argument is found on the command line, and
    # `#quitOnHelp`/`#quitOnVer` is true, then `exit(0)` will be called after
    # executing the corresponding method.
    #
    # After parsing, any argument that has a `Argument#callback` method will
    # have that method executed.
    def parse : Nil
      parse(ARGV)
    end

    # Parses an array containing command line arguments.
    #
    # If a help argument or version argument is found on the command line, and
    # `#detectHelp`/`#detectVer` is true, then the corresponding
    # `helpPrinter`/`#verPrinter` will be called.
    #
    # If a help argument or version argument is found on the command line, and
    # `#quitOnHelp`/`#quitOnVer` is true, then `exit(0)` will be called after
    # executing the corresponding method.
    #
    # After parsing, any argument that has a `Argument#callback` method will
    # have that method executed.
    def parse(theseArgs : Array(String)) : Nil
      curArg : Argument = FlagArgument.new("foo", help: "foo")
      state : Pstate = Pstate::ReadArg

      return if theseArgs.size == 0

      theseArgs.each do |argStr|
        case state
        when Pstate::ReadPositional
          @positionalArgs << argStr
          next

        when Pstate::ReadArg
          # Check for a long argument first
          if argStr.starts_with?("--")
            if argStr == "--" && @doubleDashPositional
              state = Pstate::ReadPositional
            elsif !@args.has_key?(argStr)
              raise ArgumentError.new("Invalid argument: #{argStr}")
            else
              curArg = @args[argStr]
              state = Pstate::ReadToken if self.handleArgument(curArg)
            end

          # Now check for a short argument
          elsif argStr.starts_with?("-")
            if argStr == "-"
              if @singleDashPositional
                @positionalArgs << argStr
                next
              else
                raise ArgumentError.new("Invalid argument: -")
              end
            end

            argSub : String = argStr[1..]
            shortArg : Argument? = @argsSN[argSub]?
            if shortArg.nil?
              # There's a possibility this is a positional argument
              # that's also a negative number.  Check that now.
              if argStr.to_i64? != nil || argStr.to_f64? != nil
                @positionalArgs << argStr
                next
              else
                raise ArgumentError.new("Invalid argument: #{argStr}")
              end
            end

            curArg = shortArg
            state = Pstate::ReadToken if self.handleArgument(curArg)

          else # This is a positional argument
            @positionalArgs << argStr
          end

        when Pstate::ReadToken
          # Check to see if this "value" is actually an argument
          # (meaning the user forgot to pass a value to curArg)
          raise ArgumentError.new("#{curArg.longName} expects a value passed to it") if @args.has_key?(argStr)
          case
          when curArg.responds_to?(:value) then curArg.value = argStr
          when curArg.responds_to?(:<<) then curArg << argStr
          else raise "I don't know how to set an argument of type #{typeof(curArg)}"
          end
          state = Pstate::ReadArg
        end
      end # .each do

      raise ArgumentError.new("#{curArg.longName} expects a value passed to it") if state == Pstate::ReadToken

      if @detectHelp && @args[@helpLongName].called?
        @helpPrinter.call(self)
        exit(0) if @quitOnHelp
      end

      if @detectVer && @args[@verLongName].called?
        @verPrinter.call(self)
        exit(0) if @quitOnVer
      end

      eachCalled do |arg|
        arg.callback.try(&.call(self, arg))
      end
    end # parse

    # The default printer for help arguments (e.g. `"--help"`/`"-h"`).
    #
    # The default output looks something like this (depending on the values of
    # `#preHelpText` and `#postHelpText`:
    #
    # ```
    # Usage: /path/to/binary [options]
    # General Options
    # ================================================================================
    # --help     / -h   : Show this help text
    # --version  / -V   : Show version information
    # --foo  x   / -f x : Adds a foo
    # ```
    def self.defaultHelpPrinter(parser : ArgParser) : Nil
      finalStr : String = String.build do |str|
        longestLong : Int32 = parser.longestArgName

        if parser.usageLine.nil?
          str << "Usage: #{parser.argv0} [options]"
        else
          str << parser.usageLine
        end

        unless parser.preHelpText.empty?
          str << '\n' << parser.preHelpText << '\n'
        end

        isFlag : Bool = false

        parser.allArgGroups.each do |group|
          group.empty? ? str << "\nGeneral Options\n" : str << "\n#{group}\n"
          str << "================================================================================\n"

          parser.allArgsInGroup(group).each do |arg|
            str << "#{arg.longName}"

            if arg.is_a?(FlagArgument) || arg.is_a?(MultiFlagArgument)
              isFlag = true
              str << "  "
            else
              str << " x"
            end

            str << " " * (longestLong - arg.longName.size + 1)

            if !arg.shortName.nil?
              str << " / -#{arg.shortName}"

              isFlag ? str << "   : " : str << " x : "
            else
              str << "        : "
            end

            RemiLib.buildWrapped(arg.help, str, indent: longestLong + 13)
            str << '\n'
            isFlag = false
          end
        end

        unless parser.postHelpText.empty?
          str << '\n' << parser.postHelpText << '\n'
        end
      end

      STDOUT << finalStr
    end

    # The default printer for version arguments (e.g. `"--version"`/`"-V"`).
    # The default output looks something like this (depending on the values of
    # `#preVerText` and `#postVerText`:
    #
    # ```
    # My Program v0.1.0
    # ```
    def self.defaultVerPrinter(parser : ArgParser) : Nil
      finalStr : String = String.build do |str|
        str << parser.preVerText << '\n' if parser.preVerText != ""
        str << "#{parser.progName} v#{parser.progVersion}\n"
        str << parser.postVerText << '\n' if parser.postVerText != ""
      end

      STDOUT << finalStr
    end
  end # ArgParser
end