Files
ubicloud/spec/lib/rodish_spec.rb
Jeremy Evans d2ba87091d Introduce Rodish
Rodish is a generic argv parsing library. So named as the DSL it uses
is Roda-ish, and command line programs that would generally use it
would be running in something similar to "sh" (Bourne shell).

Rodish's Features:

* Designed to handle the needs complex CLI programs with multiple
  levels of subcommands, with option parsing at each level.

* Option parsing at each level uses optparse (included in ruby's
  stdlib or as a bundled gem).

* Unsupported options and invalid numbers of arguments result in
  errors.

* Supports before hooks at every level. Before hooks are only
  executed if the command is well-formed (passes optional/argument
  handling at every level).

* DSL uses on/is, similar to Roda.

* Supports splitting up the subcommand handling into separate files,
  similar to how the Roda hash_branches plugin works.

* Supports autoloading of subcommand files, similar to the Roda
  autoload_hash_branches plugin works.

* Designed to be frozen in production, just like Roda.

* Significantly different from Roda internally, as it doesn't use the
  same type of routing tree.  Command blocks are executed inside a
  provided context, but that is all.  Reasons for this:

  * CLI program arguments are generally not structured like the
    URLs that Roda is designed to work best with.  In general,
    CLI programs wouldn't do:

      ubi vm $vm_name ssh

    They would do:

      ubi vm ssh $vm_name

    When the information that would allow you to take advantage of
    a routing tree (being able to take actions during parsing
    instead of after parsing) comes at the end of parsing, there
    isn't a significant benefit to the routing tree.

  * Option parsers are not cheap to setup, and creating one for
    each argv parse for each subcommand level would be bad for
    performance. This wouldn't matter for traditional CLI usage,
    where the option parsing is only done once per process
    execution, but we'll be using this in a Clover's api route
    to parse argv submitted by clients.

  * This allows introspection of the command tree.  One way this
    is exposed is to get the option parser usage text for all levels
    in a single call. This will be useful to produce documentation
    on each subcommand.

This could be open sourced at some point in the future, since it is
independent of Ubicloud.
2025-02-05 11:01:58 -08:00

253 lines
9.2 KiB
Ruby

# frozen_string_literal: true
RSpec.describe Rodish do
let(:base_app) do
described_class.processor do
options "example [options] [subcommand [subcommand_options] [...]]" do
on("-v", "top verbose output")
on("--version", "show program version") { halt "0.0.0" }
on("--help", "show program help") { halt to_s }
end
# Rubocop incorrectly believes these before blocks are related to rspec examples,
# when they are rodish hooks. Rubocop's auto correction breaks this Rodish processor
# unless this cop is disabled.
# rubocop:disable RSpec/ScatteredSetup
before do
push :top
end
on "a" do
before do
push :before_a
end
options "example a [options] [subcommand [subcommand_options] [...]]", key: :a do
on("-v", "a verbose output")
end
on "b" do
before do
push :before_b
end
# rubocop:enable RSpec/ScatteredSetup
options "example a b [options] arg [...]", key: :b do
on("-v", "b verbose output")
end
args(1...)
run do |args, opts|
push [:b, args, opts]
end
end
args 2
run do |x, y|
push [:a, x, y]
end
end
is "c" do
push :c
end
is "d", args: 1 do |d|
push [:d, d]
end
on "e" do
on "f" do
run do
push :f
end
end
end
run do
push :empty
end
end
end
[true, false].each do |frozen|
context "when #{"not " unless frozen}frozen" do
let(:app) do
app = base_app
app.freeze if frozen
app
end
it "executes expected command code in expected order" do
res = []
app.process([], context: res.clear)
expect(res).to eq [:top, :empty]
app.process(%w[a b 1], context: res.clear)
expect(res).to eq [:top, :before_a, :before_b, [:b, %w[1], {}]]
app.process(%w[a b 1 2], context: res.clear)
expect(res).to eq [:top, :before_a, :before_b, [:b, %w[1 2], {}]]
app.process(%w[a 3 4], context: res.clear)
expect(res).to eq [:top, :before_a, [:a, "3", "4"]]
app.process(%w[c], context: res.clear)
expect(res).to eq [:top, :c]
app.process(%w[d 5], context: res.clear)
expect(res).to eq [:top, [:d, "5"]]
app.process(%w[e f], context: res.clear)
expect(res).to eq [:top, :f]
end
it "handles options at any level they are defined" do
res = []
app.process(%w[-v a b -v 1 2], context: res.clear)
expect(res).to eq [:top, :before_a, :before_b, [:b, %w[1 2], {b: {v: true}, v: true}]]
app.process(%w[a -v b 1 2], context: res.clear)
expect(res).to eq [:top, :before_a, :before_b, [:b, %w[1 2], {a: {v: true}}]]
end
it "raises CommandFailure for unexpected number of arguments without executing code" do
res = []
expect { app.process(%w[6], context: res) }.to raise_error(Rodish::CommandFailure, "invalid number of arguments for command (accepts: 0, given: 1)")
expect(res).to be_empty
expect { app.process(%w[a b], context: res) }.to raise_error(Rodish::CommandFailure, "invalid number of arguments for a b subcommand (accepts: 1..., given: 0)")
expect(res).to be_empty
expect { app.process(%w[a], context: res) }.to raise_error(Rodish::CommandFailure, "invalid number of arguments for a subcommand (accepts: 2, given: 0)")
expect(res).to be_empty
expect { app.process(%w[a 1], context: res) }.to raise_error(Rodish::CommandFailure, "invalid number of arguments for a subcommand (accepts: 2, given: 1)")
expect(res).to be_empty
expect { app.process(%w[a 1 2 3], context: res) }.to raise_error(Rodish::CommandFailure, "invalid number of arguments for a subcommand (accepts: 2, given: 3)")
expect(res).to be_empty
expect { app.process(%w[c 1], context: res) }.to raise_error(Rodish::CommandFailure, "invalid number of arguments for c subcommand (accepts: 0, given: 1)")
expect(res).to be_empty
expect { app.process(%w[d], context: res) }.to raise_error(Rodish::CommandFailure, "invalid number of arguments for d subcommand (accepts: 1, given: 0)")
expect(res).to be_empty
expect { app.process(%w[d 1 2], context: res) }.to raise_error(Rodish::CommandFailure, "invalid number of arguments for d subcommand (accepts: 1, given: 2)")
expect(res).to be_empty
end
it "raises CommandFailure for invalid subcommand" do
res = []
expect { app.process(%w[e g], context: res) }.to raise_error(Rodish::CommandFailure, "invalid subcommand g, valid subcommands for e subcommand are: f")
expect(res).to be_empty
app = described_class.processor do
on("f") {}
end
expect { app.process(%w[g], context: res) }.to raise_error(Rodish::CommandFailure, "invalid subcommand g, valid subcommands for command are: f")
expect(res).to be_empty
end
it "raises CommandFailure when there a command has no command block or subcommands" do
app = described_class.processor {}
expect { app.process([]) }.to raise_error(Rodish::CommandFailure, "program bug, no run block or subcommands defined for command")
app = described_class.processor do
on("f") {}
end
expect { app.process(%w[f]) }.to raise_error(Rodish::CommandFailure, "program bug, no run block or subcommands defined for f subcommand")
end
it "raises CommandFailure for unexpected options" do
res = []
expect { app.process(%w[-d], context: res) }.to raise_error(Rodish::CommandFailure, /top verbose output/)
expect(res).to be_empty
expect { app.process(%w[a -d], context: res) }.to raise_error(Rodish::CommandFailure, /a verbose output/)
expect(res).to be_empty
expect { app.process(%w[a b -d], context: res) }.to raise_error(Rodish::CommandFailure, /b verbose output/)
expect(res).to be_empty
expect { app.process(%w[d -d 1 2], context: res) }.to raise_error(Rodish::CommandFailure, /top verbose output/)
expect(res).to be_empty
end
it "raises CommandExit for blocks that use halt" do
expect { app.process(%w[--version]) }.to raise_error(Rodish::CommandExit, "0.0.0")
expect { app.process(%w[--help]) }.to raise_error(Rodish::CommandExit, <<~USAGE)
Usage: example [options] [subcommand [subcommand_options] [...]]
Options:
-v top verbose output
--version show program version
--help show program help
Subcommands: a c d e
USAGE
end
it "can get usages for all options" do
usages = app.usages
expect(usages.length).to eq 3
expect(usages[""]).to eq <<~USAGE
Usage: example [options] [subcommand [subcommand_options] [...]]
Options:
-v top verbose output
--version show program version
--help show program help
Subcommands: a c d e
USAGE
expect(usages["a"]).to eq <<~USAGE
Usage: example a [options] [subcommand [subcommand_options] [...]]
Options:
-v a verbose output
Subcommands: b
USAGE
expect(usages["a b"]).to eq <<~USAGE
Usage: example a b [options] arg [...]
Options:
-v b verbose output
USAGE
end
unless frozen
it "supports adding subcommands after initialization" do
res = []
expect { app.process(%w[g], context: res) }.to raise_error(Rodish::CommandFailure, "invalid number of arguments for command (accepts: 0, given: 1)")
expect(res).to be_empty
app.on("g") do
args 1
run do |arg|
push [:g, arg]
end
end
app.process(%w[g h], context: res.clear)
expect(res).to eq [:top, [:g, "h"]]
app.on("g", "h") do
run do
push :h
end
end
app.process(%w[g h], context: res.clear)
expect(res).to eq [:top, :h]
app.is("g", "h", "i", args: 1) do |arg|
push [:i, arg]
end
app.process(%w[g h i j], context: res.clear)
expect(res).to eq [:top, [:i, "j"]]
end
it "supports autoloading" do
main = TOPLEVEL_BINDING.receiver
main.instance_variable_set(:@ExampleRodish, app)
app.on("k") do
autoload_subcommand_dir("spec/lib/rodish-example")
end
res = []
app.process(%w[k m], context: res.clear)
expect(res).to eq [:top, :m]
expect { app.process(%w[k n], context: res) }.to raise_error(Rodish::CommandFailure, "program bug, autoload of subcommand n failed")
ensure
main.remove_instance_variable(:@ExampleRodish)
end
end
end
end
end