rodish 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: be6ead8f4e3fb70c94b061d779c3f552842d78acb16e3828e37b64d02804772b
4
+ data.tar.gz: ec3ad13d30478c9048ea2ff74d07da4cd7c8e1d2efe99e3a569bd4fba09274f4
5
+ SHA512:
6
+ metadata.gz: 93555b428ac603ad16d90fde03a0ff26ff2d8ce7097f8b9d32a8d57d1685b7042c1e8cee4842aeba3e2396ba990eb34b47e7d5df0ef3f90135d3892bb53e97f7
7
+ data.tar.gz: f306a8549e5b70abac451dc0d75add3a4266c2d66d3aa79c2f05b8efde26379505a91cfc5bbc99cfbcd72931942f8f536e91568e18df7f2e970b03dda537a028
data/CHANGELOG ADDED
@@ -0,0 +1,3 @@
1
+ === 1.0.0 (2025-02-27)
2
+
3
+ * Initial Public Release
data/MIT-LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2025 Jeremy Evans
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to
5
+ deal in the Software without restriction, including without limitation the
6
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
7
+ sell copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16
+ THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,257 @@
1
+ = Rodish
2
+
3
+ Rodish parses an argv array using a routing tree approach. It is
4
+ designed to make it easy to implement command line applications
5
+ that support multiple levels of subcommands, with options at each
6
+ level.
7
+
8
+ = Installation
9
+
10
+ gem install rodish
11
+
12
+ = Source Code
13
+
14
+ Source code is available on GitHub at https://github.com/jeremyevans/rodish
15
+
16
+ = Simple Example
17
+
18
+ Here's a simple commented example with a single subcommand:
19
+
20
+ require "rodish"
21
+
22
+ # This just creates a normal Ruby class. For each argv parse, Rodish will
23
+ # create an instance of this class
24
+ class CliExample
25
+
26
+ # This allows instances of the class to be instantiated with or without
27
+ # a default person.
28
+ def initialize(default_person=nil)
29
+ @default_person = default_person
30
+ end
31
+
32
+ # This installs the Rodish processor into CliExample. It also extends the
33
+ # CliExample class with the Rodish::Processor module). The block provided
34
+ # is evaluated in the context of a Rodish::DSL instance.
35
+ Rodish.processor(self) do
36
+
37
+ # This method call creates a hello subcommand of the current/root command.
38
+ # If the given argv is for the hello subcommand, the block will be
39
+ # executed in the context of the CliExample instance.
40
+ on "hello" do
41
+
42
+ # This adds a usage string and a -p options for the hello subcommand.
43
+ # The block passed is used to set the options via the optparse library.
44
+ options("cli-example hello [options]") do
45
+ on("-p", "--person=name", "say hello to specific person")
46
+ end
47
+
48
+ run do |opts|
49
+ "Hello #{opts[:person] || @default_person || 'World'}"
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ # This requests Rodish to process the provided argv. Rodish will determine
56
+ # the related command block to execute and execute it. The return value of
57
+ # that block will be returned to the caller.
58
+ CliExample.process(["hello"])
59
+ # => "Hello World"
60
+
61
+ # Additional arguments passed to .process are passed to .new. In this
62
+ # example, this sets the default person.
63
+ CliExample.process(["hello"], "Adol")
64
+ # => "Hello Adol"
65
+
66
+ # This shows an example of passing an option to a subcommand, and using
67
+ # the option value when returning a response.
68
+ CliExample.process(["hello", "-p", "Feena"], "Adol")
69
+ # => "Hello Feena"
70
+
71
+ = Rodish DSL
72
+
73
+ Inside the <tt>Rodish.processor</tt> block, you are in the context of the root
74
+ command. The following methods are available for configuring the processing of
75
+ the command.
76
+
77
+ == +on+
78
+
79
+ The +on+ method adds a subcommand of the current command, and yields to the
80
+ block to configure the subcommand. All of the methods described in the Rodish
81
+ DSL section can be executed inside the +on+ block, and arbitrary levels of
82
+ subcommands are supported.
83
+
84
+ == +options+
85
+
86
+ The +options+ method sets up an options parser for the current command. The
87
+ default options parser disallows any options. Options are parsed into a hash,
88
+ which is yielded to commands (as in the above example).
89
+
90
+ This method requires a String argument for the usage for the current command.
91
+ You can also provide a +key+ keyword argument, to put parsed options into
92
+ a subhash of the main options hash, which can be useful when options are
93
+ parsed at multiple levels.
94
+
95
+ If a block is provided, it is executed in the context of a Rodish::OptionParser
96
+ instance. Rodish::OptionParser is a subclass of Ruby's standard OptionParser
97
+ (from +optparse+), with a few additional methods.
98
+
99
+ == +args+
100
+
101
+ The +args+ method sets the number of arguments accepted when running the command.
102
+ The default for +args+ is +0+. You can provide either an Integer to accept a
103
+ fixed number of arguments, or a Range to allow any number of arguments in that
104
+ range.
105
+
106
+ The method also accepts an +invalid_args_message+ keyword argument for the
107
+ message, to set the message to display if an invalid number of arguments is
108
+ provided.
109
+
110
+ == +run+
111
+
112
+ The +run+ method sets the block to run for the current command. If the
113
+ command accepts a fixed number of arguments, those arguments are yielded
114
+ as the first arguments to the command. If the command accepts a range of
115
+ argument numbers, then the remaining argv array will be passed as the
116
+ first argument.
117
+
118
+ The block will be passed two additional arguments, the options already
119
+ parsed, and the current Rodish::Command object.
120
+
121
+ == +is+
122
+
123
+ The +is+ method is a shortcut for calling the +on+ method and +run+ method.
124
+ For example:
125
+
126
+ is "hello" do
127
+ :world
128
+ end
129
+
130
+ is equivalent to:
131
+
132
+ on "hello" do
133
+ run do
134
+ :world
135
+ end
136
+ end
137
+
138
+ The +is+ method also takes +args+ and +invalid_args_message+ keyword arguments.
139
+
140
+ == +before+
141
+
142
+ The +before+ method takes a block, and this block is executed before command
143
+ or subcommand execution, in the same context that the +run+ block would be
144
+ executed in. It is passed the remaining argv array and the already parsed
145
+ options.
146
+
147
+ == +skip_option_parsing+
148
+
149
+ The +skip_option_parsing+ method makes the command do no option parsing,
150
+ treating all elements of the remaining argv as options. It requires a
151
+ usage string for the command, similar to +options+.
152
+
153
+ == +run_on+
154
+
155
+ The +run_on+ method is similar to +on+, except it creates a post subcommand
156
+ instead of a normal subcommand. Post subcommands allow the +run+ block
157
+ to parse part of the remaining argv array, and then call a subcommand with
158
+ the modified (or a new) argv array. You dispatch to post subcommands
159
+ inside the +run+ block by calling +run+ on the command argument:
160
+
161
+ on "hello" do
162
+ args(2...)
163
+
164
+ run do |argv, opts, command|
165
+ @name = argv.shift
166
+ command.run(self, opts, argv)
167
+ end
168
+
169
+ run_on "world" do
170
+ run do
171
+ "Hello #{@name.upcase} World!"
172
+ end
173
+ end
174
+ end
175
+
176
+ # process(%w[hello foo world])
177
+ # => "Hello FOO World!"
178
+
179
+ == +run_is+
180
+
181
+ The +run_is+ method operates similarly to +is+, but adds a post subcommand
182
+ instead of a normal subcommand.
183
+
184
+ == +post_options+
185
+
186
+ The +post_options+ method sets an option parser that is used for post
187
+ subcommands. This parses options from the argv array that passed to
188
+ +command.run+, before calling the related subcommand. Example:
189
+
190
+ on "hello" do
191
+ args(2...)
192
+
193
+ post_options("Usage: hello name [options] subcommand ...") do
194
+ on("-c", "--cap", "capitalize instead of uppercase")
195
+ end
196
+
197
+ run do |argv, opts, command|
198
+ @name = argv.shift
199
+ command.run(self, opts, argv)
200
+ end
201
+
202
+ run_is "world" do |opts|
203
+ name = opts[:cap] ? @name.capitalize : @name.upcase
204
+ "Hello #{name} World!"
205
+ end
206
+ end
207
+
208
+ # process(%w[hello foo world])
209
+ # => "Hello FOO World!"
210
+ # process(%w[hello foo -c world])
211
+ # => "Hello Foo World!"
212
+
213
+ == +autoload_subcommand_dir+
214
+
215
+ The +autoload_subcommand_dir+ takes a directory, and will autoload
216
+ subcommands from the given directory. Filenames ending in +.rb+ in
217
+ this directory will be treated as subcommands, and requiring the
218
+ file should add the appropriate subcommand.
219
+
220
+ This allows you to design complex command line programs where only
221
+ the parts of the program needed to handle the given argv are loaded.
222
+
223
+ == +autoload_post_subcommand_dir+
224
+
225
+ The +autoload_post_subcommand_dir+ operates the same as
226
+ +autoload_subcommand_dir+, but it handles post subcommands instead of
227
+ normal subcommands.
228
+
229
+ = Examples
230
+
231
+ The tests that ship with Rodish fully cover all of Rodish's functionality.
232
+
233
+ If you would like to view a production example using Rodish, which
234
+ uses most of Rodish's features, please see UbiCli, which is part of
235
+ Ubicloud:
236
+
237
+ * Main class: https://github.com/ubicloud/ubicloud/blob/main/lib/ubi_cli.rb
238
+ * Commands (separate command per file): https://github.com/ubicloud/ubicloud/tree/main/cli-commands
239
+
240
+ = History
241
+
242
+ Rodish was extracted from Ubicloud (https://github.com/ubicloud/ubicloud),
243
+ and is the argv processor used in Ubicloud's command line interface.
244
+
245
+ = Naming
246
+
247
+ The name Rodish was chosen because Rodish uses an API similar (-ish) to the Roda
248
+ web framework (http://roda.jeremyevans.net), and the library is designed for
249
+ use in applications executed from a shell (sh).
250
+
251
+ = License
252
+
253
+ MIT
254
+
255
+ = Author
256
+
257
+ Jeremy Evans <code@jeremyevans.net>
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "option_parser"
4
+ require_relative "skip_option_parser"
5
+ require_relative "errors"
6
+
7
+ module Rodish
8
+ # Rodish::Command is the main object in Rodish's processing.
9
+ # It handles a single command, and may have one or more
10
+ # subcommands and/or post subcommands, forming a tree.
11
+ #
12
+ # Rodish's argv processing starts with the root command,
13
+ # processing options and deleting appropriately to subcommands,
14
+ # until the requested command or subcommand is located,
15
+ # which is then executed.
16
+ class Command
17
+ option_parser = OptionParser.new
18
+ option_parser.set_banner("")
19
+ option_parser.freeze
20
+
21
+ # The default option parser if no options are given for
22
+ # the command.
23
+ DEFAULT_OPTION_PARSER = option_parser
24
+
25
+ # A hash of subcommands for the command. Keys are
26
+ # subcommand name strings.
27
+ attr_reader :subcommands
28
+
29
+ # A hash of post subcommands for the command. Keys are
30
+ # post subcommand name strings.
31
+ attr_reader :post_subcommands
32
+
33
+ # The block to execute if this command is the requested
34
+ # subcommand. May be nil if this subcommand cannot be
35
+ # executed, and can only dispatch to subcommands.
36
+ attr_accessor :run_block
37
+
38
+ # An array of command names that represent a path to the
39
+ # current command. Empty for the root command.
40
+ attr_accessor :command_path
41
+
42
+ # The option parser for the current command. May be nil,
43
+ # in which case the default option parser is used.
44
+ attr_accessor :option_parser
45
+
46
+ # If set, places parsed options in a subhash of the options
47
+ # hash, keyed by the given value. If nil, parsed options
48
+ # are placed directly in the options hash.
49
+ attr_accessor :option_key
50
+
51
+ # The post option parser for the current command. Called
52
+ # only before dispatching to post subcommands.
53
+ attr_accessor :post_option_parser
54
+
55
+ # Similar to +option_key+, but for post options instead
56
+ # of normal subcommands.
57
+ attr_accessor :post_option_key
58
+
59
+ # A before hook to execute before executing the current
60
+ # command or dispatching to subcommands.
61
+ attr_accessor :before
62
+
63
+ # The number of arguments the run block will accept.
64
+ # Should be either an integer or a range of integers.
65
+ attr_accessor :num_args
66
+
67
+ # The error message to use if an invalid number of
68
+ # arguments is provided.
69
+ attr_accessor :invalid_args_message
70
+
71
+ def initialize(command_path)
72
+ @command_path = command_path
73
+ @command_name = command_path.join(" ").freeze
74
+ @subcommands = {}
75
+ @post_subcommands = {}
76
+ @num_args = 0
77
+ end
78
+
79
+ # Freeze all subcommands and option parsers in
80
+ # addition to the command itself.
81
+ def freeze
82
+ @subcommands.each_value(&:freeze)
83
+ @subcommands.freeze
84
+ @post_subcommands.each_value(&:freeze)
85
+ @post_subcommands.freeze
86
+ @option_parser.freeze
87
+ @post_option_parser.freeze
88
+ super
89
+ end
90
+
91
+ # Run a post subcommand using the given context (generally self),
92
+ # options, and argv. Usually called inside a run block, after
93
+ # shifting one or more values off the given argv:
94
+ #
95
+ # run do |argv, opts, command|
96
+ # @name = argv.shift
97
+ # command.run(self, opts, argv)
98
+ # end
99
+ def run(context, options, argv)
100
+ begin
101
+ process_options(argv, options, @post_option_key, @post_option_parser)
102
+ rescue ::OptionParser::InvalidOption => e
103
+ raise CommandFailure.new(e.message, @post_option_parser)
104
+ end
105
+
106
+ arg = argv[0]
107
+ if arg && @post_subcommands[arg]
108
+ process_subcommand(@post_subcommands, context, options, argv)
109
+ else
110
+ process_command_failure(arg, @post_subcommands, @post_option_parser, "post ")
111
+ end
112
+ end
113
+ alias run_post_subcommand run
114
+
115
+ # Process the current command. This first processes the options.
116
+ # After processing the options, it checks if the first argument
117
+ # in the remaining argv is a subcommand. If so, it dispatches to
118
+ # that subcommand. If not, it dispatches to the run block.
119
+ def process(context, options, argv)
120
+ process_options(argv, options, @option_key, @option_parser)
121
+
122
+ arg = argv[0]
123
+ if argv && @subcommands[arg]
124
+ process_subcommand(@subcommands, context, options, argv)
125
+ elsif run_block
126
+ if valid_args?(argv)
127
+ context.instance_exec(argv, options, &before) if before
128
+
129
+ if @num_args.is_a?(Integer)
130
+ context.instance_exec(*argv, options, self, &run_block)
131
+ else
132
+ context.instance_exec(argv, options, self, &run_block)
133
+ end
134
+ elsif @invalid_args_message
135
+ raise_failure("invalid arguments#{subcommand_name} (#{@invalid_args_message})")
136
+ else
137
+ raise_failure("invalid number of arguments#{subcommand_name} (accepts: #{@num_args}, given: #{argv.length})")
138
+ end
139
+ else
140
+ process_command_failure(arg, @subcommands, @option_parser, "")
141
+ end
142
+ rescue ::OptionParser::InvalidOption => e
143
+ if @option_parser || @post_option_parser
144
+ raise_failure(e.message)
145
+ else
146
+ raise
147
+ end
148
+ end
149
+
150
+ # This yields the current command and all subcommands and
151
+ # post subcommands, recursively.
152
+ def each_subcommand(names = [].freeze, &block)
153
+ yield names, self
154
+ _each_subcommand(names, @subcommands, &block)
155
+ _each_subcommand(names, @post_subcommands, &block)
156
+ end
157
+
158
+ # Raise a CommandFailure with the given error and the given
159
+ # option parsers.
160
+ def raise_failure(message, option_parsers = self.option_parsers)
161
+ raise CommandFailure.new(message, option_parsers)
162
+ end
163
+
164
+ # Returns a string of options text for the command's option parsers.
165
+ def options_text
166
+ option_parsers = self.option_parsers
167
+ unless option_parsers.empty?
168
+ _options_text(option_parsers)
169
+ end
170
+ end
171
+
172
+ # Returns a Command instance for the named subcommand.
173
+ # This will autoload the subcommand if not already loaded.
174
+ def subcommand(name)
175
+ _subcommand(@subcommands, name)
176
+ end
177
+
178
+ # Returns a Command instance for the named post subcommand.
179
+ # This will autoload the post subcommand if not already loaded.
180
+ def post_subcommand(name)
181
+ _subcommand(@post_subcommands, name)
182
+ end
183
+
184
+ # An array of option parsers for the command. May be empty
185
+ # if the command has no option parsers.
186
+ def option_parsers
187
+ [@option_parser, @post_option_parser].compact
188
+ end
189
+
190
+ private
191
+
192
+ # Yield to the block for each subcommand in the given
193
+ # subcommands. Internals of #each_subcommand.
194
+ def _each_subcommand(names, subcommands, &block)
195
+ subcommands.each_key do |name|
196
+ sc_names = names + [name]
197
+ _subcommand(subcommands, name).each_subcommand(sc_names.freeze, &block)
198
+ end
199
+ end
200
+
201
+ # Return the named subcommand from the given subcommands hash,
202
+ # autoloading it if it is not already loaded.
203
+ def _subcommand(subcommands, name)
204
+ subcommand = subcommands[name]
205
+
206
+ if subcommand.is_a?(String)
207
+ require subcommand
208
+ subcommand = subcommands[name]
209
+ unless subcommand.is_a?(Command)
210
+ raise ProgramBug, "program bug, autoload of subcommand #{name} failed"
211
+ end
212
+ end
213
+
214
+ subcommand
215
+ end
216
+
217
+ # Return a string containing all option parser text.
218
+ def _options_text(option_parsers)
219
+ option_parsers.join("\n\n")
220
+ end
221
+
222
+ # Handle command failures for both subcommands and post subcommands.
223
+ def process_command_failure(arg, subcommands, option_parser, prefix)
224
+ if subcommands.empty?
225
+ raise ProgramBug, "program bug, no run block or #{prefix}subcommands defined#{subcommand_name}"
226
+ elsif arg
227
+ raise_failure("invalid #{prefix}subcommand: #{arg}", option_parser)
228
+ else
229
+ raise_failure("no #{prefix}subcommand provided", option_parser)
230
+ end
231
+ end
232
+
233
+ # Process options for the given command. If option_key is set,
234
+ # parsed options are added as a options subhash under the given key.
235
+ # Otherwise, parsed options placed directly into options.
236
+ def process_options(argv, options, option_key, option_parser)
237
+ case option_parser
238
+ when SkipOptionParser
239
+ # do nothing
240
+ when nil
241
+ DEFAULT_OPTION_PARSER.order!(argv)
242
+ else
243
+ command_options = option_key ? {} : options
244
+
245
+ option_parser.order!(argv, into: command_options)
246
+
247
+ if option_key
248
+ options[option_key] = command_options
249
+ end
250
+ end
251
+ end
252
+
253
+ # Dispatch to the appropriate subcommand using the first entry in
254
+ # the provided argv.
255
+ def process_subcommand(subcommands, context, options, argv)
256
+ subcommand = _subcommand(subcommands, argv[0])
257
+ argv.shift
258
+ context.instance_exec(argv, options, &before) if before
259
+ subcommand.process(context, options, argv)
260
+ end
261
+
262
+ # Helper used for constructing error messages.
263
+ def subcommand_name
264
+ if @command_name.empty?
265
+ " for command"
266
+ else
267
+ " for #{@command_name} subcommand"
268
+ end
269
+ end
270
+
271
+ # Return whether the given argv has a valid number of arguments.
272
+ def valid_args?(argv)
273
+ if @num_args.is_a?(Integer)
274
+ argv.length == @num_args
275
+ else
276
+ @num_args.include?(argv.length)
277
+ end
278
+ end
279
+ end
280
+ end
data/lib/rodish/dsl.rb ADDED
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+ require_relative "option_parser"
5
+ require_relative "skip_option_parser"
6
+
7
+ module Rodish
8
+ # The Rodish::DSL class implements Rodish's DSL. Blocks
9
+ # passed to Rodish.processor and on/run_on blocks inside
10
+ # those blocks evaluated in the context of an instance of
11
+ # Rodish::DSL.
12
+ #
13
+ # Each Rodish::DSL instance is bound to a single
14
+ # Rodish::Command and allows the DSL to modify the state
15
+ # of the command.
16
+ class DSL
17
+ # Create a new command with the given path and evaluate
18
+ # the given block in the context of a new instance using
19
+ # that command.
20
+ def self.command(command_path, &block)
21
+ command = Command.new(command_path)
22
+ new(command).instance_exec(&block)
23
+ command
24
+ end
25
+
26
+ def initialize(command)
27
+ @command = command
28
+ end
29
+
30
+ # Skip option parsing for the command. This is different
31
+ # then the default option parsing, which will error if any
32
+ # options are given. A banner must be provided, setting
33
+ # the usage for the command.
34
+ #
35
+ # The main reason to use this is if you are going to pass
36
+ # the entire remaining argv as the argv to another
37
+ # program.
38
+ def skip_option_parsing(banner)
39
+ @command.option_parser = SkipOptionParser.new(banner)
40
+ end
41
+
42
+ # Set the option parser for the command to based on the
43
+ # provided block, which is executed in the context of a new
44
+ # instance of Rodish::OptionParser. These options are parsed
45
+ # for execuction of both subcommands and the current command.
46
+ #
47
+ # The banner argument is required and sets the usage string
48
+ # for the command.
49
+ #
50
+ # If +key+ is given, parsed options
51
+ # will be placed in a subhash using that key.
52
+ #
53
+ # The block is optional, allowing you to set a usage banner for
54
+ # commands without allowing any options.
55
+ def options(banner, key: nil, &block)
56
+ @command.option_key = key
57
+ @command.option_parser = create_option_parser(banner, @command.subcommands, &block)
58
+ end
59
+
60
+ # Similar to +options+, but sets the option parser for post
61
+ # subcommands. This option parser is only used when the
62
+ # command is executed and chooses to run a post subcommand.
63
+ def post_options(banner, key: nil, &block)
64
+ @command.post_option_key = key
65
+ @command.post_option_parser = create_option_parser(banner, @command.post_subcommands, &block)
66
+ end
67
+
68
+ # Sets the before block. This block is executed in the same
69
+ # context as the run block would be executed, before either
70
+ # subcommand execution or execution of the current command.
71
+ def before(&block)
72
+ @command.before = block
73
+ end
74
+
75
+ # Set the number of arguments supported by this command.
76
+ # The default is 0. To support a fixed number of arguments,
77
+ # pass an Integer. To support a variable number of arguments,
78
+ # pass a Range. The +invalid_args_message+ argument sets the
79
+ # error message to use if an invalid number of arguments is
80
+ # passed.
81
+ def args(args, invalid_args_message: nil)
82
+ @command.num_args = args
83
+ @command.invalid_args_message = invalid_args_message
84
+ end
85
+
86
+ # Autoload subcommands from the given directory. Filenames
87
+ # ending in .rb in this directory should be valid subcommands,
88
+ # and requiring the related file should load the subcommand.
89
+ #
90
+ # You can use this so that your argv parser does not need to
91
+ # load code not needed to support processing the command.
92
+ def autoload_subcommand_dir(dir)
93
+ _autoload_subcommand_dir(@command.subcommands, dir)
94
+ end
95
+
96
+ # Similar to +autoload_subcommand_dir+, but for post
97
+ # subcommands instead of normal subcommands.
98
+ def autoload_post_subcommand_dir(dir)
99
+ _autoload_subcommand_dir(@command.post_subcommands, dir)
100
+ end
101
+
102
+ # Create a new subcommand with the given name and yield to
103
+ # the block to configure the subcommand.
104
+ def on(command_name, &block)
105
+ _on(@command.subcommands, command_name, &block)
106
+ end
107
+
108
+ # Same as +on+, but for post subcommands instead of normal
109
+ # subcommands.
110
+ def run_on(command_name, &block)
111
+ _on(@command.post_subcommands, command_name, &block)
112
+ end
113
+
114
+ # Set the block to run for subcommand execution. Commands
115
+ # should have subcommands and/or a run block, otherwise it
116
+ # is not possible to use the command successfully.
117
+ def run(&block)
118
+ @command.run_block = block
119
+ end
120
+
121
+ # A shortcut for calling +on+ and +run+.
122
+ #
123
+ # is "hello" do
124
+ # :world
125
+ # end
126
+ #
127
+ # is equivalent to:
128
+ #
129
+ # on "hello" do
130
+ # run do
131
+ # :world
132
+ # end
133
+ # end
134
+ #
135
+ # The +args+ argument sets the number of arguments supported by
136
+ # the command.
137
+ #
138
+ # The +invalid_args_message+ arguments set the error message to
139
+ # use if an invalid number of arguments is provided.
140
+ def is(command_name, args: 0, invalid_args_message: nil, &block)
141
+ _is(:on, command_name, args:, invalid_args_message:, &block)
142
+ end
143
+
144
+ # Similar to +is+, but for post subcommands instead of normal
145
+ # subcommands.
146
+ def run_is(command_name, args: 0, invalid_args_message: nil, &block)
147
+ _is(:run_on, command_name, args:, invalid_args_message:, &block)
148
+ end
149
+
150
+ private
151
+
152
+ # Internals of autoloading of normal and post subcommands.
153
+ # This sets the value of the subcommand as a string instead of a
154
+ # Command instance, and the Command#_subcommand method recognizes
155
+ # this and handles the autoloading.
156
+ def _autoload_subcommand_dir(hash, base)
157
+ Dir.glob("*.rb", base:).each do |filename|
158
+ hash[filename.chomp(".rb")] = File.expand_path(File.join(base, filename))
159
+ end
160
+ end
161
+
162
+ # Internals of +is+ and +run_is+.
163
+ def _is(meth, command_name, args:, invalid_args_message: nil, &block)
164
+ public_send(meth, command_name) do
165
+ args(args, invalid_args_message:)
166
+ run(&block)
167
+ end
168
+ end
169
+
170
+ # Internals of +on+ and +run_on+.
171
+ def _on(hash, command_name, &block)
172
+ command_path = @command.command_path + [command_name]
173
+ hash[command_name] = DSL.command(command_path.freeze, &block)
174
+ end
175
+
176
+ # Internals of +options+ and +post_options+.
177
+ def create_option_parser(banner, subcommands, &block)
178
+ option_parser = OptionParser.new
179
+ option_parser.set_banner("Usage: #{banner}")
180
+ if block
181
+ option_parser.separator ""
182
+ option_parser.separator "Options:"
183
+ option_parser.instance_exec(&block)
184
+ end
185
+ option_parser.subcommands = subcommands
186
+ option_parser
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodish
4
+ # Rodish::CommandExit is the base error class for Rodish, signaling
5
+ # that a command execution finished. Callers of
6
+ # Rodish::Processor#process should rescue CommandExit to handle
7
+ # both failures as well as early exits.
8
+ #
9
+ # Direct instances represent successful execution. This is
10
+ # raised when calling halt inside an options parser block (if an
11
+ # option should result in an early exit). It can also be raised
12
+ # manually inside command run blocks to exit early.
13
+ class CommandExit < StandardError
14
+ # Whether or not the command failed. For CommandExit, this always
15
+ # returns false, since CommandExit represents successful execution
16
+ # exits.
17
+ def failure?
18
+ false
19
+ end
20
+ end
21
+
22
+ # Rodish::CommandFailure is used for failures of commands, such as:
23
+ #
24
+ # * Invalid options
25
+ # * Invalid number of arguments for a command
26
+ # * Invalid subcommands
27
+ # * No subcommand given for a command that only supports subcommands
28
+ class CommandFailure < CommandExit
29
+ def initialize(message, option_parsers = [])
30
+ option_parsers = [option_parsers] unless option_parsers.is_a?(Array)
31
+ @option_parsers = option_parsers.compact
32
+ super(message)
33
+ end
34
+
35
+ # Always returns false, since CommandFailure represents failures.
36
+ def failure?
37
+ true
38
+ end
39
+
40
+ # Return the message along with the content of any related option
41
+ # parsers. This can be used to show usage an options along with
42
+ # error messages for failing commands.
43
+ def message_with_usage
44
+ if @option_parsers.empty?
45
+ message
46
+ else
47
+ "#{message}\n\n#{@option_parsers.join("\n\n")}"
48
+ end
49
+ end
50
+ end
51
+
52
+ # Rodish::ProgramBug is a subclass of Rodish::CommandFailure only used
53
+ # in cases where there is a bug in the program, such as a command with
54
+ # no subcommands or run block, or when subcommand autoloads do not
55
+ # result in the subcommand being defined.
56
+ class ProgramBug < CommandFailure
57
+ end
58
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "errors"
5
+
6
+ module Rodish
7
+ # Rodish::OptionPaser is a subclass of Ruby's standard OptionParser
8
+ # (from the optparse library).
9
+ class OptionParser < ::OptionParser
10
+ # A hash of subcommands for the option parser. If not empty,
11
+ # shows available subcommands when showing options.
12
+ attr_accessor :subcommands
13
+
14
+ # Don't add officious, which includes options that call exit.
15
+ # With Rodish, there are no secret options, only options you define.
16
+ def add_officious
17
+ end
18
+
19
+ # Add the available subcommands to the returned string if there are
20
+ # any subcommands.
21
+ def to_s
22
+ string = super
23
+
24
+ if subcommands.length > 6
25
+ string += "\nSubcommands:\n #{subcommands.keys.sort.join("\n ")}\n"
26
+ elsif !subcommands.empty?
27
+ string += "\nSubcommands: #{subcommands.keys.sort.join(" ")}\n"
28
+ end
29
+
30
+ string
31
+ end
32
+
33
+ # Helper method that takes an array of values, wraps them to the given
34
+ # limit, and adds each line as a separator. This is useful when you
35
+ # have a large amount of information you want to display and you want
36
+ # to wrap if for display to the user when showing options.
37
+ def wrap(prefix, values, separator: " ", limit: 80)
38
+ line = [prefix]
39
+ lines = [line]
40
+ prefix_length = length = prefix.length
41
+ sep_length = separator.length
42
+ indent = " " * prefix_length
43
+
44
+ values.each do |value|
45
+ value_length = value.length
46
+ new_length = sep_length + length + value_length
47
+ if new_length > limit
48
+ line = [indent, separator, value]
49
+ lines << line
50
+ length = prefix_length
51
+ else
52
+ line << separator << value
53
+ end
54
+ length += sep_length + value_length
55
+ end
56
+
57
+ lines.each do |line|
58
+ separator line.join
59
+ end
60
+ end
61
+
62
+ # Halt processing with a CommandExit using the given string.
63
+ # This can be used to implement early exits, by calling this
64
+ # method in a block:
65
+ #
66
+ # on("--version", "show program version") { halt VERSION }
67
+ def halt(string)
68
+ raise CommandExit, string
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dsl"
4
+
5
+ module Rodish
6
+ module Processor
7
+ attr_reader :command
8
+
9
+ # Process an argv array using a new instance of the class that is
10
+ # extended with Rodish::Processor. Additional arguments are passed to
11
+ # new when creating the instance.
12
+ #
13
+ # Callers of this method are encouraged to rescue Rodish::CommandExit,
14
+ # to handle both early exits and command failures.
15
+ def process(argv, *a, **kw)
16
+ # Deliberately do not pass a block here, to reserve
17
+ # block handling for future use.
18
+ @command.process(new(*a, **kw), {}, argv)
19
+ rescue ::OptionParser::InvalidOption => e
20
+ @command.raise_failure(e.message)
21
+ end
22
+
23
+ # Without a block, returns the Command instance for related subcommand
24
+ # (a nested subcommand if multiple command names are given).
25
+ #
26
+ # With a block, uses the last command name to create a subcommand under
27
+ # the other named commands, configuring the created subcommand using the
28
+ # block.
29
+ def on(*command_names, &block)
30
+ if block
31
+ command_name = command_names.pop
32
+ dsl(command_names).on(command_name, &block)
33
+ else
34
+ dsl(command_names)
35
+ end
36
+ end
37
+
38
+ # Uses the last command name to create a subcommand under the other
39
+ # named commands, with the block being the commands
40
+ def is(*command_names, command_name, args: 0, invalid_args_message: nil, &block)
41
+ dsl(command_names).is(command_name, args:, invalid_args_message:, &block)
42
+ end
43
+
44
+ # Freeze the command when freezing the object.
45
+ def freeze
46
+ command.freeze
47
+ super
48
+ end
49
+
50
+ # Return a hash of usage strings for the root command and all subcommands,
51
+ # recursively. The hash has string keys for the command name, and
52
+ # string values for the option parser(s) for the command.
53
+ def usages
54
+ usages = {}
55
+
56
+ command.each_subcommand do |names, command|
57
+ option_parsers = command.option_parsers
58
+ unless option_parsers.empty?
59
+ usages[names.join(" ")] = command.option_parsers.join("\n\n")
60
+ end
61
+ end
62
+
63
+ usages
64
+ end
65
+
66
+ private
67
+
68
+ # Use the array of command names to find the appropriate subcommand
69
+ # (which may be empty to use the root command), and return a DSL instance
70
+ # for it.
71
+ def dsl(command_names)
72
+ command = self.command
73
+ command_names.each do |name|
74
+ command = command.subcommands.fetch(name)
75
+ end
76
+ DSL.new(command)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodish
4
+ # Rodish::SkipOptionParser is used when option parsing should be
5
+ # skipped, treating all entries in argv as arguments.
6
+ class SkipOptionParser
7
+ # A usage banner to use for the related command.
8
+ attr_reader :banner
9
+
10
+ # The same as banner, but ending in a newline, similarly
11
+ # to how OptionParser#to_s works.
12
+ attr_reader :to_s
13
+
14
+ def initialize(banner)
15
+ @banner = "Usage: #{banner}".freeze
16
+ @to_s = (@banner + "\n").freeze
17
+ end
18
+ end
19
+ end
data/lib/rodish.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rodish/processor"
4
+ require_relative "rodish/dsl"
5
+
6
+ module Rodish
7
+ # Install a Rodish processor in the given class. This extends the class
8
+ # with Rodish::Processor, and uses the block to configure the processor
9
+ # using Rodish::DSL.
10
+ def self.processor(klass, &block)
11
+ klass.extend(Processor)
12
+ klass.instance_variable_set(:@command, DSL.command([].freeze, &block))
13
+ klass
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rodish
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeremy Evans
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-02-27 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: optparse
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest-global_expectations
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: |
55
+ Rodish parses an argv array using a routing tree approach. It is
56
+ designed to make it easy to implement command line applications
57
+ that support multiple levels of subcommands, with options at each
58
+ level.
59
+ email: code@jeremyevans.net
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files:
63
+ - README.rdoc
64
+ - CHANGELOG
65
+ - MIT-LICENSE
66
+ files:
67
+ - CHANGELOG
68
+ - MIT-LICENSE
69
+ - README.rdoc
70
+ - lib/rodish.rb
71
+ - lib/rodish/command.rb
72
+ - lib/rodish/dsl.rb
73
+ - lib/rodish/errors.rb
74
+ - lib/rodish/option_parser.rb
75
+ - lib/rodish/processor.rb
76
+ - lib/rodish/skip_option_parser.rb
77
+ homepage: http://github.com/jeremyevans/rodish
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ bug_tracker_uri: https://github.com/jeremyevans/rodish/issues
82
+ changelog_uri: https://github.com/jeremyevans/rodish/blob/master/CHANGELOG
83
+ source_code_uri: https://github.com/jeremyevans/rodish
84
+ rdoc_options:
85
+ - "--quiet"
86
+ - "--line-numbers"
87
+ - "--inline-source"
88
+ - "--title"
89
+ - 'Rodish: Routing tree argv parser'
90
+ - "--main"
91
+ - README.rdoc
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '3.1'
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.6.2
106
+ specification_version: 4
107
+ summary: Routing tree argv parser
108
+ test_files: []