#### 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 "./arg-parsing"
module RemiLib::Args
# Represents a situation where a `CommandParser` is attempts to parse an empty
# set of arguments.
class NoCommandError < ArgumentError
end
# Represents a situation where a `CommandParser` encounters an unknown
# command.
class UnknownCommandError < ArgumentError
end
# A `CommandParser` is a way to associate command names (specified as the
# first argument on the command line) with a function. A common use case for
# this is a command line program that has multiple "modes", and each mode has
# its own `ArgParser` instance.
#
# Similar to the `ArgParser` class, the `CommandParser` will automatically
# handle "help" and "version" commands. This behavior can be customized.
class CommandParser
# A function that is called for a command.
alias CmdHandler = Proc(String, Array(String), Nil)
# This is used when a help command (e.g. `./myprogram help`) is found on the
# command line.
alias CmdHelpPrinterFunc = Proc(CommandParser, Nil)
# This is used when a version command (e.g. `./myprogram version`) is found
# on the command line.
alias CmdVerPrinterFunc = Proc(CommandParser, Nil)
@commands : Hash(String, CmdHandler) = {} of String => CmdHandler
@commandHelps : Hash(String, String?) = {} of String => String?
# When `true`, then command names are case-insensitive during parsing. Note
# that they are still case sensitive when referencing them in code.
property? caseInsensitive : Bool = false
# This will be called when a help command (e.g. `./myprogram help`) is
# found on the command line.
property helpPrinter : CmdHelpPrinterFunc = ->CommandParser.defaultHelpPrinter(CommandParser)
# This will be called when a version command (e.g. `./myprogram version`) is
# found on the command line.
property verPrinter : CmdVerPrinterFunc = ->CommandParser.defaultVerPrinter(CommandParser)
# The name of the program using this parser. Used when a version command is
# found on the command line.
property progName : String
# The version of the program using this parser. Used when a version command
# 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 when a help command is called.
property usageLine : String? = nil
# A string that is to be printed after the "Usage:" line but before the list
# of arguments when a help command is found on the command line. Two
# newlines will appear between this text and the "Commands:" line.
property preHelpText : String = ""
# A string that is to be printed after the list of arguments when a help
# command is found on the command line. A newline will be both prepended
# and appended automatically.
property postHelpText : String = ""
# A string that is to be printed before version information when a version
# command 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 command 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
# command is found on the command line.
property? quitOnHelp : Bool = true
# When `true`, `#parse` will call `exit(0)` after printing version
# information when a version command is found on the command line.
property? quitOnVer : Bool = true
# When `true`, the help command will be processed. Otherwise it is ignored.
property? detectHelp : Bool = true
# When `true`, the version command will be processed. Otherwise it is
# ignored.
property? detectVer : Bool = true
# The name of the binary using this `CommandParser`. This is used when
# printing the default `#usageLine`.
property progBinName : String
# Creates a new `CommandParser`.
def initialize(@progName : String, @progVersion : String,
*, @helpName : String = "help", @verName : String = "version",
@progBinName : String = PROGRAM_NAME)
add("help", ->doHelp(String, Array(String)), help: "Display this help.")
add("version", ->doVersion(String, Array(String)), help: "Display version information.")
end
# Returns the handler function associated with a command. If the command
# does not exist, this will raise a `KeyError`.
def [](cmd : String) : CmdHandler
@commands[cmd]
end
# Returns the handler function associated with a command. If the command
# does not exist, this returns `nil`.
def []?(cmd : String) : CmdHandler?
@commands[cmd]?
end
# Associates a `CmdHandler` function with a name, thereby adding a new
# command to this instance. This will overwrite any commands with the same
# name. If *help* is provided, then it will be saved as the help text
# associated with this command.
#
# Note that *cmd* is added in a case-sensitive way internally, regardless of
# the value of `#caseInsensitive?`.
def add(cmd : String, fn : CmdHandler, *, help : String? = nil) : Nil
@commands[cmd] = fn
@commandHelps[cmd] = help
end
# Returns all defined command names.
#
# Note that the returned names are handled in a case-sensitive way
# internally, regardless of the value of `#caseInsensitive?`.
def names : Array(String)
@commands.keys
end
# Looks up the help string, if any, associated with *cmd*.
#
# Note that *cmd* is added in a case-sensitive way internally, regardless of
# the value of `#caseInsensitive?`.
def help(cmd : String) : String?
@commandHelps[cmd]
end
# Parses the default set of arguments, as stored in the global `ARGV`. The
# command name must appear first, while all remaining arguments are passed
# verbatim to the associated `CmdHandler` function.
#
# If no command is provided, then a `NoCommandError` is raised. If the
# command is not known, then an `UnknownCommandError` is raised.
def parse : Nil
parse(ARGV)
end
# Parses the given set of arguments. The command name must appear first,
# while all remaining arguments are passed verbatim to the associated
# `CmdHandler` function.
#
# If no command is provided, then a `NoCommandError` is raised. If the
# command is not known, then an `UnknownCommandError` is raised.
def parse(theseArgs : Array(String)) : Nil
raise NoCommandError.new("No command given") if theseArgs.empty?
cmdName : String = if @caseInsensitive
theseArgs[0].downcase
else
theseArgs[0]
end
hasKey : Bool = if @caseInsensitive
@commands.keys.any? do |k|
k.downcase == cmdName
end
else
@commands.has_key?(cmdName)
end
unless hasKey
raise UnknownCommandError.new("Unknown command: #{cmdName}")
end
@commands[cmdName].call(cmdName, theseArgs[1..])
end
private def doHelp(cmdName : String, argv : Array(String)) : Nil
@helpPrinter.call(self)
end
private def doVersion(cmdName : String, argv : Array(String)) : Nil
@verPrinter.call(self)
end
# The default printer for the help command.
#
# The default output looks something like this (depending on the values of
# `#preHelpText` and `#postHelpText`:
#
# ```
# Usage: /path/to/binary <command> [options]
#
# Commands:
# * help : Display this help.
# * version : Display version information.
# ```
def self.defaultHelpPrinter(parser : CommandParser) : Nil
names : Array(String) = parser.names
raise "No help items!" if names.empty?
longestLong : Int32 = names.max_by(&.size).size
finalStr : String = String.build do |str|
if parser.usageLine.nil?
str << "Usage: #{parser.progBinName} <command> [options]"
else
str << parser.usageLine
end
unless parser.preHelpText.empty?
str << '\n' << parser.preHelpText
end
str << "\n\nCommands:\n"
parser.names.each do |key|
str << " * #{key}"
str << (" " * (longestLong - key.size))
str << " : "
parser.help(key).try { |help| RemiLib.buildWrapped(help, str, indent: longestLong + 4 + 3) }
str << '\n'
end
unless parser.postHelpText.empty?
str << '\n' << parser.postHelpText << '\n'
end
end
STDOUT << finalStr
end
# The default printer for the version command The default output looks
# something like this (depending on the values of `#preVerText` and
# `#postVerText`:
#
# ```
# My Program v0.1.0
# ```
def self.defaultVerPrinter(parser : CommandParser) : 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
end