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.
253 lines
9.2 KiB
Ruby
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
|