Skip to content

Instantly share code, notes, and snippets.

@Benabik
Last active August 27, 2023 02:36
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Benabik/e28be4a9a416e51c7d7779ecfad9d61c to your computer and use it in GitHub Desktop.
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.
# 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 !@subcommands.include? 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
@Benabik
Copy link
Author

Benabik commented Jul 19, 2019

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 to OptionParse#on gets assigned to the CLI#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 the into: option for OptionParser#order!, which makes other things far less tedious, so I'm willing to deal with it.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment