justin․searls․co

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 of false 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]
  • Numeric is an ancestor of Float and Integer and indicates that OptionParser 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's timeout argument will either be set to a numeric option value, nil when no option value is provided, or false 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.


Got a taste for hot, fresh takes?

Then you're in luck, because you can subscribe to this site via RSS or Mastodon! And if that ain't enough, then sign up for my newsletter and I'll send you a usually-pretty-good essay once a month. I also have a solo podcast, because of course I do.