
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:
-
We didn’t supply the
--echo
option so there is no output because there was nothing to parse. -
We provided a required argument, in this case: "hi".
-
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:
-
Alias (short).
-
Alias (long) plus argument (required in this case).
-
Type.
-
Allowed values.
-
Description.
-
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

Output

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!