Last active
August 27, 2023 02:36
-
-
Save Benabik/e28be4a9a416e51c7d7779ecfad9d61c to your computer and use it in GitHub Desktop.
A Ruby class for turning command line options into keyword arguments and subcommands into method calls.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
require 'forwardable' | |
require 'ostruct' | |
require 'optparse' | |
# This class extends OptionParser to handle git style subcommands. Users of | |
# this class create a OptionParser::Subcommand object, set up the options using | |
# OptionParser methods, and then pass a class to OptionParser::Subcommand#run. | |
# Command line options before the first argument are passed as keyword | |
# arguments to the intializer. The first non-option argument is used as a | |
# method name, with further options as keyword arguments and others as | |
# positional. Subcommands can be nested. The nested subcommand calls methods | |
# on the return value of the outer one. | |
# | |
# For example, we will re-implement a git command: | |
# | |
# OptionParser::Subcommand.new do |git| | |
# git.on '-C path' | |
# git.subcommand 'remote' do |remote| | |
# remote.on '--verbose' | |
# remote.subcommand 'show' do |show| | |
# show.on '-n' | |
# end | |
# end | |
# end.run Git | |
# | |
# Running <code>git -C repo remote -v show -n origin</code> is the same | |
# as the following code: | |
# | |
# git = Git.new C: 'repo' | |
# remote = git.remote verbose: true | |
# remote.show 'origin', n: true | |
# | |
# A +--help+ option will be automatically created that uses the OptionParser | |
# summary and adds a list of defined subcommands with their +description+ | |
# attributes. | |
# | |
# If you want more than simple strings from an option, see the documentation | |
# for OptionParser#on. OptionParser::Subcommand#on redirects to this method via | |
# OptionParser::Subcommand#parser. When using a block to parse options, note | |
# that whatever is returned from the block will be stored under the option name | |
# in OptionParser::Subcommand#options, which is the behavior of the +into+ | |
# option on OptionParser's parsing routines. | |
class OptionParser::Subcommand | |
extend Forwardable | |
# Since errors can occur when parsing options for nested | |
# OptionParser::Subcommands, save which one had a problem. This allows | |
# access to options and parser for error output. | |
class Error < RuntimeError | |
# OptionParser::Subcommand object that caused the error | |
attr_accessor :cli | |
def initialize(cli, message = nil) | |
@cli = cli | |
super message || $! | |
end | |
end | |
# Thrown when a subcommand can't be found | |
class InvalidSubcommand < Error | |
# Argument that was attempted to be parsed as a subcommand. May be +nil+. | |
attr_accessor :subcommand | |
def initialize(cli, subcommand) | |
@subcommand = subcommand | |
super cli, subcommand ? "invalid subcommand: #{subcommand}" : 'missing subcommand' | |
end | |
end | |
# Initializes the OptionParser::Subcommand object. Options are added via | |
# method calls. If given a block, yields itself for easy configuration. | |
def initialize | |
@options = OpenStruct.new | |
@parser = OptionParser.new | |
@subcommands = {} | |
@parser.on_tail('-h', '--help', 'Prints this help') { help } | |
yield self if block_given? | |
end | |
# The OptionParser used for option parsing. | |
# | |
# The following methods are delegated to it: +banner+, +banner=+ +on+, +on_tail+, | |
# +program_name+, +program_name=+, +separator, +separator=, +version+, +version=+ | |
attr_reader :parser | |
def_delegators :@parser, :banner, :banner=, :on, :on_tail, | |
:program_name, :program_name=, :separator, :separator=, :version, :version= | |
# A quick description of a command. | |
# | |
# It is used in help to describe the current command and its immediate | |
# subcommands. | |
attr_reader :description | |
# Sets the command description. | |
# | |
# It is added to the `--help` with a blank line afterwards. Setting this | |
# multiple times will result in all descriptions being added to the help | |
# with blank lines between. Only the last value can be accessed. | |
def description=(desc) | |
@description = desc | |
separator desc | |
separator '' | |
desc | |
end | |
# Defines a subcommand. Subcommands are nested OptionParser::Subcommand | |
# objects so that each one can have their own OptionsParser, description, | |
# etc. The subcommand name is used as a method call on the object created by | |
# OptionParser::Subcommand#run. | |
# | |
# If a block is given, the subcommand OptionParser::Subcommand object is | |
# yielded for configuration. | |
# | |
# If the default keyword argument is true, then this command will be run if | |
# no recognized subcommand is found when parsing. | |
# | |
# Subcommands will have their +program_name+ attribute set to that of their | |
# parent with their name appended. | |
# | |
# cli = OptionParser::Subcommand.new do |cli| | |
# cli.program_name = 'example' | |
# cli.subcommand 'sub' | |
# end | |
# puts cli['sub'].program_name # => 'example sub' | |
def subcommand(name, default: false, &block) | |
@default_subcommand = name if default | |
subcommand = OptionParser::Subcommand.new | |
@subcommands[name] = subcommand | |
subcommand.program_name = "#{program_name} #{name}" | |
block.call subcommand if block | |
subcommand | |
end | |
# A hash of subcommand names to OptionParser::Subcommand objects. Can also be | |
# indexed directly via OptionParser::Subcommand#[] | |
attr_reader :subcommands | |
def_delegators :@subcommands, :[] | |
# The name of the subcommand run if no positional arguments are given or the | |
# first argument doesn't match a subcommand. If +nil+ (the default), | |
# InvalidSubcommand will be raised instead. | |
attr_reader :default_subcommand | |
# Note: Attempting to set default_subcommand to a subcommand that hasn't | |
# been defined will immediately raise InvalidSubcommand. | |
def default_subcommand=(name) | |
raise InvalidSubcommand.new self, name if name and [email protected]? name | |
@default_subcommand = name | |
end | |
# An OpenStruct object that command line options will be stored in. It will | |
# be passed as keyword arguments to methods. | |
# | |
# If no block is passed to OptionParser::Subcommand#on, then the value for the | |
# option will be stored here. The attribute can also be used in blocks for | |
# more complex parsing, or during configuration to set default values. | |
attr_reader :options | |
# Used for the +--help+ option, prints OptionParser#to_s and a list of | |
# subcommands to +$stdout+ and exist with the given +status+. | |
# | |
# Note that +--help+ will only display options for the current command, but | |
# it can be called after specifying a subcommand: | |
# | |
# OptionParser::Subcommand.new |cli| | |
# cli.description = 'Example' | |
# cli.on '--foo', 'Option 1' | |
# cli.subcommand 'bar' do |bar| | |
# bar.description = 'Subcommand' | |
# bar.on '--baz', 'Option 2' | |
# end | |
# end.run nil | |
# | |
# Running <code>cli --help</code> gives: | |
# | |
# Usage: cli [options] | |
# Example | |
# | |
# --foo Option 1 | |
# -h, --help Prints this help | |
# | |
# Subcommands: | |
# bar - Subcommand | |
# | |
# Running <code>cli bar --help</code> gives: | |
# | |
# Usage: cli bar [options] | |
# Subcommand | |
# | |
# --baz Option 2 | |
# -h, --help Prints this help | |
# | |
# The usage string can be changed via +banner=+ | |
def help(status = 0) | |
puts @parser | |
unless @subcommands.empty? | |
puts | |
puts "Subcommands:" | |
@subcommands.each do |name, obj| | |
print ' ', name | |
if obj.description | |
print ' - ', obj.description | |
end | |
puts | |
end | |
end | |
exit status | |
end | |
# Parses +args+ via OptionParser and uses them to call methods on +target+. | |
# | |
# Arguments are parsed initially with OptionParser#order! so that options | |
# before the subcommand can be used to initialize a target and options after | |
# passed to the subcommand method. The subcommand will use | |
# OptionParser#permute! so that options can be mixed with arguments. | |
# (If subcommands are nested, then only the deepest one uses +permute!+) | |
# | |
# +target+ is a class object to be initialized with initial options and | |
# methods to be called on for subcommands. If target is +nil+, no methods | |
# will be called (mostly for testing). | |
# | |
# +args+ is the arguments to be parsed. Defaults to +ARGV+ for convenience. | |
# Note that this options will be removed from +args+ as they are parsed. | |
# | |
# +method+ is the method to be called with the initial options. Defaults to | |
# +:new+ so a class can be used. Whatever that method returns will have a | |
# method called on it for the parsed subcommand. | |
# | |
# +exceptions+ specifies if exceptions should escape this method. This | |
# includes exceptions thrown by calling methods on +target+. Other | |
# exceptions include +OptionParser::Subcommand::InvalidSubcommand+ thrown when | |
# +args+ does not contain an expected subcommand, and | |
# +OptionParser::Subcommand::Error+ wrapping +OptionParser::ParseError+ (the | |
# wrapping is so you can discover which | |
# parser had a problem). | |
# | |
# When +exceptions+ is false (the default), exception messages will be | |
# printed to <tt>$stderr</tt> prefixed by the +program_name+. Errors parsing | |
# arguments will have the +banner+ printed afterwards (which is usually a | |
# usage string). | |
def run(target, args = ARGV, method: :new, exceptions: false) | |
begin | |
@parser.order! args, into: @options | |
rescue OptionParser::InvalidOption => e | |
raise unless @default_subcommand | |
# If there is a default subcommand, keep the unknown option | |
args[0,0] = e.args | |
end | |
options = @options.to_h | |
if target | |
if options.empty? | |
target = target.public_send method | |
else | |
target = target.public_send method, **@options.to_h | |
end | |
end | |
subcommand = args.first | |
if @subcommands.include? subcommand | |
args.shift | |
elsif @default_subcommand | |
subcommand = @default_subcommand | |
end | |
cli = @subcommands[subcommand] | |
raise InvalidSubcommand.new self, subcommand unless cli | |
if cli.subcommands.empty? | |
# Use permute for deepest subcommand | |
cli.parser.permute! args, into: cli.options | |
options = cli.options.to_h | |
target.public_send subcommand, *args, **options if target | |
else | |
cli.run target, args, method: subcommand, exceptions: exceptions | |
end | |
rescue OptionParser::ParseError => e | |
cli ||= self | |
raise Error.new cli if exceptions | |
$stderr.puts e | |
abort cli.banner | |
rescue => e | |
raise if exceptions | |
cli ||= self | |
abort "#{cli.program_name}: #{e}" | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Noticed this when I was trying to parse a complex option (parsing
--foo bar=baz --foo qux=quux
into a hash): Anything returned from a block passed toOptionParse#on
gets assigned to theCLI#options
hash. I had to make sure I returned the hash I was setting, not just a single item from it. (This is due to theinto:
option forOptionParser#order!
, which makes other things far less tedious, so I'm willing to deal with it.)