The letter A styled as Alchemists logo. lchemists
Published July 1, 2023 Updated July 29, 2023
Cover
Ruby Option Parser

Ruby's native Option Parser gem provides a quick solution for implementing Command Line Interfaces (CLIs) with minimal effort. This is great for simple scripts, one-off tasks, and so forth but becomes untenable when building more complex and robust interfaces. So, today, we’re going to take a look at the Option Parser gem, see how it works, learn what the pros and cons are, and understand where you can go once you outgrow it.

Basics

Before jumping into the syntax of the Option Parser gem, let’s quickly set the stage by talking about CLIs in general. Here’s an example of the ls command — common to UNIX based systems — for listing files and directories:

ls -l --color=always .

The above lists all files and directories in long format with color always enabled for the current directory. The terminology breaks down as follows:

  • ls: The command.

  • -l: The long format flag.

  • --color WHEN: The color option which takes a required argument (i.e. WHEN) to indicate when the color should be applied. In this case, always.

  • .: The directory path argument which is positional, in that it must be supplied at the end, and is optional to indicate which directory should be listed. In this case, the . is for the current working directory and is the default. I’m only being explicit for illustration purposes.

Keep in mind, the above is a general overview. There is a lot to talk about in terms of boolean flags, required and optional arguments, positional arguments, default values, and much more which we’ll tackle shortly.

Setup

The Option Parser gem is part of the Standard Gems and comes installed by default when you install Ruby. To get started — and for the majority of examples in this article — we can use a simple Ruby script for experimentation purposes:

#! /usr/bin/env ruby
# frozen_string_literal: true

# Save as `demo`, then `chmod 755 demo`, and run as `./demo`.

require "optparse"

OptionParser.new { |act| act.on("--echo TEXT") { |value| puts value } }
            .parse!

Notice, once you’ve required Option Parser, you only need to initialize, implement behavior via the #on method block, and parse the input which defaults to ARGV. Once you copy and save the above script locally, you can run it like this:

./demo
./demo --echo hi
./demo --echo

…​you’ll then get this output:

(nil)
hi
missing argument: --echo (OptionParser::MissingArgument)

In the order of output listed above, here’s what’s happening:

  1. We didn’t supply the --echo option so there is no output because there was nothing to parse.

  2. We provided a required argument, in this case: "hi".

  3. We forgot to supply the required argument and ended up with a MissingArgument error.

We’ll dive into these details shortly but we need to talk about initialization first.

Initialization

Earlier, we used OptionParser.new to create an instance but .new can take a few optional positional arguments which are listed in the order to be used:

  • banner: Provides an overall description of your CLI. Default: nil.

  • width: Defines the width used when displaying option summaries. Default: 32.

  • indent: Defines how far in your summaries should be indented. Default: ' ' * 4.

Using the same script, as implemented earlier, here’s the default rendering as seen by using the -h (short) or --help (long) flags:

./demo --help

# Usage: demo [options]
#         --echo TEXT

Not the most elegant but does the job. If we modify our original implementation, we can make this look better:

parser = OptionParser.new("Demo 0.0.0: A demonstration CLI.", 40, "  ") do |act|
  act.separator "\nUSAGE"
  act.on("--echo TEXT", "Echoes input as output.") { |value| puts value }
end

parser.parse!

Now, if we run ./demo --help, we see a slightly improved interface:

Demo 0.0.0: A demonstration CLI.

USAGE
      --echo TEXT                          Echoes input as output.

You’ll notice I used the #separator message to categorize usage. Order matters which is why I messaged #separator before #on to get the layout I wanted. You can also see the summary width and indentation modifications via the spacing used between the --echo option and it’s corresponding description.

Input

By default, Option Parser reads from and parses ARGV when you send the #parse or #parse! message. This means each time we run ./demo --echo hi, for example, this translates into ["--echo", "hi"] which means you can modify the script to swap ARGV with your own array. Example:

OptionParser.new { |parser| parser.on("--echo TEXT") { |value| puts value } }
            .parse! %w[--echo hi]
# "hi"

Note that #parse! mutates ARGV by deleting each argument from the array once parsed while #parse does not. I tend to use #parse! — despite not being a fan of mutation — because it cleans up ARGV as options are parsed. This is can be handy when used in combination with Kernel#gets when prompting users for input because you need to ensure Kernel#gets isn’t reading previously parsed and/or non-parsed options.

For the rest of this article, I’ll toggle between examples which use the command line or pure Ruby.

Actions

Now that setup, initialization, and input are out of the way, we can dive into implementing #on messages. I tend to lump all of this into what I call: actions. Actions is my umbrella terminology for CLI options and/or flags and there are several categories to discuss. At a high level, here is what is possible:

parser = OptionParser.new do |act|
  act.on(
    "-c",
    "--config ACTION",
    Array,
    %w[edit view],
    "Manage configuration.",
    %(Actions: #{%w[edit view].join(", ")})
  ) do |value|
    puts value
  end
end

parser.parse! ["--help"]

# Usage: snippet [options]
#     -c, --config ACTION              Manage configuration.
#                                      Actions: edit, view

The above can be explained by position:

  1. Alias (short).

  2. Alias (long) plus argument (required in this case).

  3. Type.

  4. Allowed values.

  5. Description.

  6. Ancillary (a.k.a. sub-description).

The next couple sections will explain each in detail.

Aliases

Aliases are denoted by the single (i.e. -) and double (i.e. --) dashes to inform Option Parser what actions to parse. You’ve seen some of this already but here’s a closer look:

parser = OptionParser.new do |act|
  act.on("-m") { puts "Monitoring..." }
  act.on("--build") { puts "Building..." }
  act.on("-g", "--generate") { puts "Generating..." }
end

parser.parse! %w[-m --build --generate]

# Monitoring...
# Building...
# Generating...

Here we see a combination of short and long aliases used. As each is parsed, in the order listed, you see the corresponding output. Here are a few guidelines to help you use these correctly when architecting your own implementation:

  • Avoid using short aliases without long aliases. Short aliases should be added for usability convenience to avoid constantly typing long aliases.

  • Prefer long aliases for documentation purposes since short aliases are less intuitive to read and understand.

  • Prefer long aliases without short aliases if there is a naming conflict with existing short aliases or want to make the long alias stand out because the equivalent short alias isn’t helpful for some reason.

  • Prefer using capitalized short aliases (i.e. -X instead of -x) to avoid name conflicts with identical short aliases but only if the capitalized version is intuitive.

Booleans

Boolean flags are long alases only, take no arguments, and use [no-] syntax after the double dashes. Example:

OptionParser.new { |act| act.on("--[no-]build") { |value| puts value } }
            .parse!

Output:

./demo --build     # true
./demo --no-build  # false

Option Parser ensures the value is always a boolean so you never have to worry about getting a different type.

Arguments

Arguments can be optional or required and use brackets (i.e. []) to denote the difference. Example:

parser = OptionParser.new do |act|
  act.on("--name TEXT") { |text| puts "Name: #{text}" }
  act.on("--label [TEXT]") { |text| puts %(Label: #{text || "Unknown"}) }
end

parser.parse! %w[--name demo --label Demo]
# Name: demo
# Label: Demo

parser.parse! %w[--name demo --label]
# Name: demo
# Label: Unknown

parser.parse! %w[--name]
# missing argument: --name (OptionParser::MissingArgument)

Notice --name has a required argument because TEXT isn’t wrapped in brackets while --label is optional because [TEXT] is wrapped in brackets. This is also why, in the last example, we get a MissingArgument exception because the required argument wasn’t supplied.

Additionally, --label was taught to have a fallback of "Unknown" when the optional argument isn’t present. Sadly, this introduces a Control Parameter smell due to the way Option Parser is implemented. The correct way to avoid this code smell is to use an optional parameter in the block:

act.on("--label [TEXT]") { |text = "Unknown"| puts "Label: #{text}" }

Unfortunately, Option Parser ignores optional parameters so while the above code is ideal, Option Parser will ignore it completely.

Lastly, you aren’t limited to single arguments but can use lists which can be optional or required as well. Example:

parser = OptionParser.new do |act|
  act.on("--one a,b,c") { |list| puts String(list).split(",").inspect }
  act.on("--two [a,b,c]") { |list| puts String(list).split(",").inspect }
  act.on("--three a,b,c", Array) { |list| puts list.inspect }
end

parser.parse! %w[--one 1,2,3 --two --three 1,2,3]
# ["1", "2", "3"]
# []
# ["1", "2", "3"]

A list is always delimited by a comma and, by default, the value you receive will be a string of that same format. However, in the third example, you can circumvent this issue by specifying an Array type which is explained next.

Types

Types are optional but worth having when you need to cast or provide safety checks. Example:

parser = OptionParser.new do |act|
  act.on("--cast NUMBER", Float) { |number| puts number }
end

parser.parse! %w[--cast 123]     # 123.0
parser.parse! %w[--cast 1.5]     # 1.5
parser.parse! %w[--cast Danger]  # invalid argument: --cast Danger (OptionParser::InvalidArgument)

Notice the type is a Float where only the first two examples work but the last one ends in an InvalidArgument exception because Option Parser can’t cast the number to a float.

You are not limited to Ruby’s native primitives but can use existing types from other gems or craft your own custom type. Here are a few examples:

Sod: Provides a pathname type.

require "sod"
require "sod/types/pathname"

parser = OptionParser.new do |act|
  act.on("--cast PATH", Sod::Types::Pathname) { |path| puts path }
end

Versionaire: Provides a version type.

require "versionaire"
require "versionaire/extensions/option_parser"

parser = OptionParser.new do |act|
  act.on("--cast VERSION", Versionaire::Version) { |version| puts version }
end

Custom: Can be implemented in only a couple of files.

# lib/my_type.rb

MyType = -> value { # Implementation details go here. }
# lib/extensions/option_parser.rb
require "optparse"

OptionParser.accept(MyType) { |value| MyType.call value }

Allows

Allows give you the ability to define what is acceptable as valid values. What you define as allowable values must match the type of argument used even if the type isn’t explicitly defined. Example:

parser = OptionParser.new do |act|
  act.on("--greet TEXT", %w[hi hello] ) { |text| puts text }
end

parser.parse! %w[--greet hi]     # hi
parser.parse! %w[--greet hello]  # hello
parser.parse! %w[--greet hey]    # invalid argument: --greet hey (OptionParser::InvalidArgument)

Notice the first two examples are fine while the last one fails because "hey" isn’t an allowed value. The allowed array can also be used in conjunction with a hash to provide value aliasing. Example:

parser = OptionParser.new do |act|
  act.on("--greet TEXT", %w[hi hello], {"h" => "hi", "H" => "hello"} ) { |text| puts text }
end

parser.parse! %w[--greet hi]     # hi
parser.parse! %w[--greet hello]  # hello
parser.parse! %w[--greet h]      # hi
parser.parse! %w[--greet H]      # hello

The above works as long as the shortest form of the text is unambiguous.

Descriptions

You’ve seen descriptions before and are the second-to-last positional argument you can provide. Example:

parser = OptionParser.new do |act|
  act.on("--greet TEXT", "Print greeting.") { |text| puts text }
end

parser.parse! ["--help"]

# Usage: snippet [options]
#         --greet TEXT                 Print a greeting.

Descriptions are strongly recommended because they provide useful documentation that can help others understand your CLI without having to look up further documentation.

Ancillaries

Ancillaries — or supplemental text to your description — are the very last arguments you can provide and can range from one to many arguments. Here’s an example with both a description and a single ancillary.

parser = OptionParser.new do |act|
  act.on("--greet TEXT", "Print greeting.", "Prints directly to the console.") { |text| puts text }
end

parser.parse! ["--help"]

# Usage: snippet [options]
#         --greet TEXT                 Print greeting.
#                                      Prints directly to the console.

Notice how the ancillary text horizontally aligns with your description. You can also supply multiple arguments. Example:

parser = OptionParser.new do |act|
  act.on("--greet TEXT", "Print greeting.", "One.", "Two.", "Three.") { |text| puts text }
end

# Usage: snippet [options]
#         --greet TEXT                 Print greeting.
#                                      One.
#                                      Two.
#                                      Three.

Use ancillary text with caution because, as you can see, this can get out of hand quickly and become too verbose for your users to appreciate.

Commands

Since a single instance of an Option Parser can only parse one to many unique actions you can, sometimes, find yourself in contention when needing the same name with different behavior. This is where commands — also known as namespaces or subcommands — can help.

When implementing commands, there should always be one command that is the main command. This main command is responsible for basic functionality such as help and version information. Additional commands can be added afterwards as deemed necessary. The following demonstrates use of subcommands via a hash lookup:

main = OptionParser.new :main do |act|
  act.on("--name TEXT", "Set project name.") { |name| puts "Project: #{name.inspect}." }
end

config = OptionParser.new :config do |act|
  act.on("--edit", "Edit configuration.") { puts "Editing configuration..." }
  act.on("--view", "View configuration.") { puts "Viewing configuration..." }
end

build = OptionParser.new :build do |act|
  act.on("--name TEXT", "Set build name.") { |name| puts "Building #{name.inspect}..." }
end

commands = {"config" => config, "build" => build}
argument = ARGV.first

if %w[-h --help].include? argument
  puts main
  commands.each { |_key, command| puts command }
elsif argument.start_with?("-")
  main.parse ARGV
else
  commands[argument].parse ARGV
end

Notice that three parsers are initialized: main, config, and build. Additionally, both the main and build parsers have the same action: --name. Because they are different instances of Option Parser, they can have different behavior which isn’t possible with a single parser. The critical piece of this implementation is messaging #order! on the main parser so that commands (and associated actions) are delmited appropriately. Everything else falls back to the main parser (including help). Here’s the resulting output:

./demo
# Does nothing by default.

./demo --help
# main
#         --name TEXT                  Set project name.
# config
#         --edit                       Edit configuration.
#         --view                       View configuration.
# build
#         --name TEXT                  Set build name.

./demo --name demo
# Project: "demo".

./demo config --edit
# Editing configuration...

./demo config --view
# Viewing configuration...

./demo build --name demo
# Building "demo"...

The use of #order! also takes a block which means you can have nested commands multiple levels deep by walking the hierarchy. Delving into that is outside the scope of this article but is exactly how the Sod gem implements this for you by recursively walking the hierarchy so you don’t have to deal with it. We’ll revisit Sod a bit more towards the end of this article.

State

A downside to using Option Parser is dealing with shared state. By default, there isn’t a nice way to pass information between your commands and actions unless you mutate state. Example:

input = {}

parser = OptionParser.new do |act|
  act.on("-l", "--name TEXT") { |name| input[:name] = name }
  act.on("-l", "--label [TEXT]") { |label| input[:label] = label || "Undefined" }
end

parser.parse! []

puts input

The corresponding output is:

./demo                      # {}
./demo --name demo          # {:name=>"demo"}
./demo --name demo --label  # {:name=>"demo", :label=>"Demo"}

Notice how you have to switch from acting upon the action immediately to storing the input for processing later via the input hash which must be mutated for each change. If using Dry Container, this means you’d need to register your input with memoization enabled. Otherwise, you’d end up with a new input instance for each modification made. Example:

register(:input, memoize: true) { Hash.new }

Again, mutation isn’t ideal nor is use of a primitive Hash. Using a Data object won’t work either because it’s immutable by default so that leaves us with Structs. Here’s the same implementation as shown earlier but refactored to use a Struct:

input = Struct.new(:name, :label).new

parser = OptionParser.new do |act|
  act.on("-l", "--name TEXT") { |name| input.name = name }
  act.on("-l", "--label [TEXT]") { |label| input.label = label || "Undefined" }
end

parser.parse!
puts input

The output is as follows:

./demo                      # #<struct name=nil, label=nil>
./demo --name demo          # #<struct name="demo", label=nil>
./demo --name demo --label  # #<struct name="demo", label="Demo">

Using a Struct is definitely better than using a Hash but you still have to initialize input in a global or semi-global manner in order to capture changes across all of your actions and/or commands. This also introduces additional complexity when testing because you’ll need to make sure state is reset for each test otherwise you could have intermittent failures or false positives. Dry Container makes this easier to deal with but, even so, you’ll want to proceed with caution.

Errors

Multiple types of errors are possible when using Option Parser. The following implementation will be used to demonstrate:

require "optparse"
require "optparse/time"

parser = OptionParser.new do |act|
  act.on("-l", "--label [TEXT]", %w[Demo Demi]) { |label| puts "Label: #{label}." }
  act.on("-t", "--time TIME", Time) { |time| puts "Using: #{time}." }
  act.on("--[no-]verbose") { |value| puts "Verbosity: #{value}." }
  act.on("-v", "--version") { |value| puts "Version 0.0.0." }
end

parser.parse!

OptionParser::ParseError

The superclass of all errors so if you don’t care about a specific error and want to capture all Option Parser related errors then this is all you need. Otherwise, each specific error is detailed below.

OptionParser::AmbiguousArgument

parser.parse ["--label De"]
# ambiguous argument: --label De (OptionParser::AmbiguousArgument)

Using only "De" as the argument is ambiguous because the optional label argument can only be "Demo" or "Demi".

OptionParser::AmbiguousOption

parser.parse ["--ve"]
# ambiguous option: --ve (OptionParser::AmbiguousOption)

Using only --ve is ambiguous since --verbose and --version both start with --ve.

OptionParser::InvalidArgument

parser.parse ["--time", "danger"]
# invalid argument: --time danger (OptionParser::InvalidArgument)

Using "danger" as an argument is invalid because --time requires a value that can be cast into a time object.

OptionParser::InvalidOption

parser.parse ["--bogus"]
# invalid option: --bogus (OptionParser::InvalidOption)

Using --bogus as an option is invalid because no action by that name is implemented.

OptionParser::MissingArgument

parser.parse ["--time"]
# missing argument: --time (OptionParser::MissingArgument)

Using --time without an argument is invalid because the argument is required and can’t be missing.

OptionParser::NeedlessArgument

parser.parse ["--verbose=extra"]
# needless argument: --verbose=extra (OptionParser::NeedlessArgument)

Using --verbose with extra arguments is invalid since --verbose is a boolean action which ignores arguments.

Sod

As useful as Option Parser is, it definitely can’t handle all situations. This is where Sod comes into play because it’s built atop Option Parser. All of what has been described thus far, and more, is supported by the Sod gem. The nicest of which is the DSL and colorized output. Here are a few screenshots:

DSL

A screenshot of the DSL syntax

Output

A screenshot of the generated help documentation

If you find yourself outgrowing Option Parser, then Sod might be a nice upgrade.

Conclusion

Hopefully you’ve enjoyed this look into Option Parser and CLIs in general. There are plenty of alternatives out there but if you need something that is built atop Option Parser with a simple DSL and clean implementation then give Sod a look too.

Enjoy and may your CLIs be a fun to implement, easy to test, and a joy to use!