Ruby makes advanced CLI options easy
If you're not a "UNIX person", the thought of writing a command line application can be scary and off-putting. People find the command line so intimidating that Ruby—which was initially populated by a swarm of Windows-to-Mac migrants—now boasts a crowded field of gems that purport to make CLI development easier, often at the cost of fine-grained control over basic process management, limited option parsing, and increased dependency risk (many Rails upgrades have been stalled by a project's inclusion of another CLI gem that was built with an old version of thor
).
Good news, you probably don't need any of those gems!
See, every UNIX-flavored language ships with its own built-in tools for building command line interfaces (after all, ruby
itself is a CLI!), but I just wanted to take a minute to draw your attention to how capable Ruby's built-in OptionParser is in its own right. So long as you're writing a straightforward, conventional command line tool, it's hard to imagine needing much else.
This morning, I was working on me and Aaron's tldr test runner, and I was working on making the timeout configurable. What I wanted was a single flag that could do three things: (1) enable the timeout, (2) disable the timeout, and (3) set the timeout to some number of seconds. At first, I started implementing this as three separate options, but then I remembered that OptionParser
is surprisingly adept at reading the arcane string format you might have seen in a CLI's man
page or help output and "do the right thing" for you.
Here's a script to demo what I'm talking about:
#!/usr/bin/env ruby
require "optparse"
config = {}
OptionParser.new do |opts|
opts.banner = "Usage: timer_outer [options]"
opts.on "-t", "--[no-]timeout [TIMEOUT]", Numeric, "Timeout (in seconds) before timer aborts the run (Default: 1.8)" do |timeout|
config[:timeout] = if timeout.is_a?(Numeric)
# --timeout 42.3 / -t 42.3
timeout
elsif timeout.nil?
# --timeout / -t
1.8
elsif timeout == false
# --no-timeout / --no-t
-1
end
end
end.parse!(ARGV)
puts "Timeout: #{config[:timeout].inspect}"
And here's what you get when you run the script from the command line. What you'll find is that this single option packs in SEVEN permutations of flags users can specify (including not setting providing the option at all):
$ ruby timer_outer.rb --timeout 5.5
Timeout: 5.5
$ ruby timer_outer.rb --timeout
Timeout: 1.8
$ ruby timer_outer.rb --no-timeout
Timeout: -1
$ ruby timer_outer.rb -t 2
Timeout: 2
$ ruby timer_outer.rb -t
Timeout: 1.8
$ ruby timer_outer.rb --no-t
Timeout: -1
$ ruby timer_outer.rb
Timeout: nil
Moreover, using OptionParser
will define a --help
(and -h
) option for you:
$ ruby timer_outer.rb --help
Usage: timer_outer [options]
-t, --[no-]timeout [TIMEOUT] Timeout (in seconds) before timer aborts the run (Default: 1.8)
What's going on here
The script above is hopefully readable, but this line is so dense it may be hard for you to (ugh) parse:
opts.on "-t", "--[no-]timeout [TIMEOUT]", Numeric, "Timeout …" do |timeout|
Let's break down each argument passed to OptionParser#on
above:
"-t"
is the short option name, which includes a single a single hyphen and a single character"--[no-]timeout [TIMEOUT]"
does four things at once:- Specifies
--timeout
as the long option name, indicated by two hyphens and at least two characters - Adds an optional negation with
--[no-]timeout
, which, when passed by the user, will pass a value offalse
to the block - The dummy word
TIMEOUT
signals that users can pass a value after the flag (conventionally upper-cased to visually distinguish them from option names) - Indicates the
TIMEOUT
value is optional by wrapping it in brackets as[TIMEOUT]
- Specifies
Numeric
is an ancestor ofFloat
andInteger
and indicates thatOptionParser
should cast the user's input from a string to a number for you"Timeout…"
is the description that will be printed by--help
do |timeout|
is a block that will be invoked every time this option is detected by the parser. The block'stimeout
argument will either be set to a numeric option value,nil
when no option value is provided, orfalse
when the flag is negated
If a user specifies multiple timeout flags above, OptionParser
will parse it and invoke your block each time. That means in a conventional implementation like the one above, "last in wins":
$ ruby timer_outer.rb --timeout 3 --no-timeout
Timeout: -1
$ ruby timer_outer.rb --no-timeout --timeout 3
Timeout: 3
And this is just scratching the surface! There are an incredible number of CLI features available in modern Ruby, but the above has covered the vast majority of my use case for CLIs like standard and tldr.
For more on using OptionParser
, check out the official docs and tutorial.