suboptparse 0.1.24

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: 24db536b0fc85771658568b756c7989ee2c8d3ff8e09bca9d5276e3a03c38967
4
+ data.tar.gz: e4350502b4931b3a8b353adfda993fdfb56c18c85889b924d9d8e6ef7d443d8f
5
+ SHA512:
6
+ metadata.gz: a79c1a8d07d00ad44cfae635e26c65709b0676be125e5437ce1c1d1a31c949d9cf882a439644d6d1005274b5fc41231935eee8772745268e7490aa5c09eb4056
7
+ data.tar.gz: 9ce6eec459229b9f3d56f42790523c55d66021769187dbca616ae62315ddc75019571a765940ca334c68f270c7e46fd13ef257628c7c8e88a30438328838e48f
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,14 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+
4
+ Style/StringLiterals:
5
+ EnforcedStyle: double_quotes
6
+
7
+ Style/StringLiteralsInInterpolation:
8
+ EnforcedStyle: double_quotes
9
+
10
+ Lint/ShadowingOuterLocalVariable:
11
+ Enabled: false
12
+
13
+ Metrics/BlockLength:
14
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,86 @@
1
+ ## [0.1.24]
2
+
3
+ - RDoc updates.
4
+ - GitHub build updates.
5
+
6
+ ## [0.1.14]
7
+
8
+ - Support post_parse and after_parse.
9
+ - Alias on_parse to pre_parse.
10
+
11
+ ## [0.1.13] - 2025-02-04
12
+
13
+ - Throw the LoadError what prevents an command from being auto-required
14
+ if that command is ever executed.
15
+
16
+ ## [0.1.12] - 2025-02-03
17
+
18
+ - Adjust SubOptParse::AutoRequire.require to automatically add the sub-command
19
+ and pass the created SubOptParse object to the callback for further
20
+ configuration.
21
+ - Improve help output to remove duplicate commands and sort the commands by
22
+ command name. Duplicates could appear when a command was registered by
23
+ cmdadd() and cmddocsadd().
24
+
25
+ ## [0.1.11] - 2025-02-03
26
+
27
+ - Extend cmddocadd() to also support dynamic autloading of commands
28
+ defined in this way. This allows cmd documentation to exist when
29
+ autoloading is defined.
30
+
31
+ ## [0.1.10] - 2025-02-03
32
+
33
+ - Add recurisve help. Help is printed from the root command down to the
34
+ last called child command.
35
+
36
+ ## [0.1.9] - 2025-02-02
37
+
38
+ - Add sub-command documentation when there is no loaded command.
39
+ - Allow auto-loading of files if an autorequire_root path is defined.
40
+ - Migrate cmdpath to a list of command names rather than a single string.
41
+
42
+ ## [0.1.8] - 2025-01-31
43
+
44
+ - Update changelog and readme to match new release process.
45
+
46
+ ## [0.1.7] - 2025-01-31
47
+
48
+ - *No Change* - Configuring GitHub actions to release code.
49
+
50
+ ## [0.1.6] - 2025-01-31
51
+
52
+ - *No Change* - Configuring GitHub actions to release code.
53
+
54
+ ## [0.1.5] - 2025-01-30
55
+
56
+ - Update project meta data.
57
+ - Update project rdoc documentation.
58
+ - Add RDoc::Task to Rakefile. Output to ./rdoc.
59
+
60
+ ## [0.1.4] - 2025-01-30
61
+
62
+ - Set @cmdpath in the command ussage. This is the path in the command tree
63
+ that leads to the executing command.
64
+ - Add SubOptParse::Util.merge_recursive() to assist in merging
65
+ configurations loaded into Ruby Hash objects.
66
+ - Add SubOptParse::SharedState to help use shared_state correctly
67
+ in instances of SubOptParsers.
68
+
69
+ ## [0.1.3] - 2025-01-28
70
+
71
+ - Set banner correctly on sub-commands.
72
+
73
+ ## [0.1.2] - 2025-01-28
74
+
75
+ - Update usage on sub-commands.
76
+ - Allow an optional description to be included when calling .cmdadd().
77
+
78
+ ## [0.1.1] - 2025-01-28
79
+
80
+ - Add default "help" sub-command to all commands.
81
+ - Allow for custom command lookups by overriding SubOptParser#[].
82
+ - Allow for adding commands are parse time with .on_parse { |so| ... }
83
+
84
+ ## [0.1.0] - 2025-01-21
85
+
86
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Sam Baskinger <basking2@yahoo.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # SubOptParse
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/suboptparse.svg)](https://badge.fury.io/rb/suboptparse)
4
+ [![Known Vulnerabilities](https://snyk.io/test/github/basking2/suboptparse/badge.svg)](https://snyk.io/test/github/basking2/suboptparse)
5
+
6
+
7
+ [SubOptParse](https://basking2.github.io/suboptparse) is a collection of classes and utilities to extend Ruby's
8
+ [OptionParser](https://ruby-doc.org/current/optparse/tutorial_rdoc.html) with some understanding of sub-commands.
9
+
10
+ Sub-commands maybe thought of as a traditional CLI command but with
11
+ a single parent command as the entry poiont. As an example, consider
12
+ a CLI application that buys things from a store. We'll call the command
13
+ `./buy`. You can have a sub-command `./buy apples --count=3` that
14
+ knows how to buy apples. Now, purchasing from the deli is different
15
+ and so we isolate that into a different sub-command, `deli`.
16
+ You could run `./buy deli potato_salad`.
17
+
18
+ ## Installation
19
+
20
+ Install the gem and add to the application's Gemfile by executing:
21
+
22
+ $ bundle add suboptparse
23
+
24
+ If bundler is not being used to manage dependencies, install the gem by executing:
25
+
26
+ $ gem install suboptparse
27
+
28
+ ## Usage
29
+
30
+ ### Looks Like OptionParser
31
+
32
+ ```ruby
33
+ require "suboptparse"
34
+
35
+ # Looks like OptionParser.
36
+ parser = SubOptParser.new do |sop|
37
+ # Normal OptionParser calls.
38
+ sop.on("--my-option=foo", "-m", "Sets a value") do |v|
39
+ # Record value somewhere.
40
+ end
41
+ end
42
+
43
+ # Still looks like OptionParser, but a command to execute is returned.
44
+ cmd = parser.parse!
45
+
46
+ # If you don't specify a command, don't call this! It throws an exception.
47
+ cmd.call()
48
+ ```
49
+
50
+ ### Define A Root Command
51
+
52
+ This is like the previous example, but we define a command to call.
53
+
54
+ ```ruby
55
+ require "suboptparse"
56
+
57
+ # Looks like OptionParser.
58
+ parser = SubOptParser.new do |sop|
59
+ # Normal OptionParser calls.
60
+ sop.on("--my-option=foo", "-m", "Sets a value") do |v|
61
+ # Record value somewhere.
62
+ end
63
+
64
+ sop.cmd do |args|
65
+ puts "Root command run."
66
+ end
67
+ end
68
+
69
+ # Still looks like OptionParser, but a command to execute is returned.
70
+ cmd = parser.parse!
71
+
72
+ # Now this prints, "Root command run."
73
+ cmd.call()
74
+ ```
75
+
76
+ ### Sub Command Example
77
+
78
+ This example shows a sub-command and a few features.
79
+
80
+ You can raise an exception if arguments are not consumed during parsing
81
+ using `raise_unknown=true`.
82
+
83
+ You can share state between commands so they can parse into a common location.
84
+ This makes parent commands sharing common values with child commands
85
+ easier.
86
+
87
+ Finally, if you set `raise_unknown=false` (the default value), then
88
+ unparsed command line options are passed to the called command.
89
+
90
+ ```ruby
91
+ require "suboptparse"
92
+
93
+ # Looks like OptionParser.
94
+ parser = SubOptParser.new do |sop|
95
+
96
+ # Set shared state. All sub-commands may add values to this.
97
+ # This is set at command creation time and should not be changed after.
98
+ sop.shared_state = SubOptParse::SharedState.new
99
+
100
+ # This command should raise an error if it executes with unconsumed options.
101
+ # Default is false.
102
+ sop.raise_unknown = true
103
+
104
+ # Normal OptionParser calls.
105
+ sop.on("--my-option=foo", "-m", "Sets a value") do |v|
106
+ # Record value somewhere.
107
+ end
108
+
109
+ sop.cmd do |args|
110
+ puts "Root command run."
111
+ end
112
+
113
+ sop.cmdadd("subcommand", "This is a sub-command.") do |sop|
114
+ sop.on("--sub-command-option=value", "A sub-command option.") do |v|
115
+ sop.shared_state["sub-command-option"] = v
116
+ end
117
+
118
+ sop.cmd do |unconsumed_arguments|
119
+ sop.shared_state["sub-command-option"]
120
+ end
121
+ end
122
+ end
123
+
124
+ # Parse and call the command in 1 call.
125
+ ret = parser.call("subcommand", "--sub-command-option=foo", "--my-option=bar")
126
+
127
+ # The returns "foo".
128
+ puts "Calling subcommand returned #{ret}."
129
+ ```
130
+
131
+ ## How Tos
132
+
133
+ ### Intercept -h
134
+
135
+ Calling `-h` normally has the effect of terminating parsing and printing the
136
+ help of the parent command. You can register an `on_parse` handler to
137
+ remove `-h` and append `help` to the end of the command line arguemnts.
138
+ This will cause the default help function of the child command to be called
139
+ when `-h` is on the command line.
140
+
141
+ ```ruby
142
+ require "suboptparse"
143
+
144
+ # Looks like OptionParser.
145
+ parser = SubOptParser.new do |sop|
146
+
147
+ sop.on_parse do |op, argv|
148
+ if argv.include? "-h" or argv.include? "--help"
149
+ argv.delete("-h")
150
+ argv.delete("--help")
151
+ argv.push('help')
152
+ end
153
+ argv
154
+ end
155
+
156
+ sop.shared_state = SubOptParse::SharedState.new
157
+
158
+ # Normal OptionParser calls.
159
+ sop.on("--my-option=foo", "-m", "Sets a value") do |v|
160
+ # Record value somewhere.
161
+ end
162
+
163
+ sop.cmd do |args|
164
+ puts "Root command run."
165
+ end
166
+
167
+ sop.cmdadd("subcommand", "This is a sub-command.") do |sop|
168
+ sop.on("--sub-command-option=value", "A sub-command option.") do |v|
169
+ sop.shared_state["sub-command-option"] = v
170
+ end
171
+
172
+ sop.cmd do
173
+ sop.shared_state["sub-command-option"]
174
+ end
175
+ end
176
+ end
177
+
178
+ # Parse and call the command in 1 call.
179
+ ret = parser.call("-h", "subcommand")
180
+
181
+ puts "Calling subcommand returned #{ret}."
182
+ ```
183
+
184
+ ### Auto Requiring Commands
185
+
186
+ You can configure the SubOptParser to call `require` on files in your
187
+ `$LOAD_PATH` to load and define sub-commands dynamically. There
188
+ are two approaches, implicit and explicit. Both methods require that the
189
+ loading class define itself when loading.
190
+
191
+ This may be done by using class variables or class methods that capture
192
+ the current command name being loaded and the current SubOptParse instance
193
+ doing the loading.
194
+
195
+ A dynamically loaded command might look like this:
196
+
197
+ ```ruby
198
+ # frozen_string_literal: true
199
+
200
+ require "suboptparse/auto_require"
201
+
202
+ SubOptParser::AutoRequire.register do |so, name|
203
+ so.addcmd(name, "A command.") do |so|
204
+ # Define the command...
205
+ so.cmd { puts so.help }
206
+ end
207
+ end
208
+ ```
209
+
210
+ The `register` method is just a conveneint way to access the class
211
+ variables
212
+ `SubOptParse::AutoRequire::auto_require_command_parent`
213
+ and
214
+ `SubOptParse::AutoRequire::auto_require_command_name`.
215
+ You may access them directly, though, that is a lot of typing.
216
+
217
+ ### Implicit Auto Requiring
218
+
219
+ First, to enable the feature, you must define an `autorequire_root`.
220
+ This path will be prefixed to any file attempted to be loaded with a call
221
+ to `require`.
222
+
223
+ ```ruby
224
+ so = SubOptParser.new do |opt|
225
+ opt.autorequire_root = "suboptparse/autoreqtest"
226
+ opt.autorequire_suffix = "_command"
227
+ end
228
+
229
+ # This calls require "suboptparse/autoreqtest/a_command"
230
+ so.get_subcommand("a").help
231
+
232
+ # This calls require "suboptparse/autoreqtest/a/b/c_command"
233
+ so.get_subcommand("a", "b", "c").help
234
+ ```
235
+
236
+ There is a problem with this approache. Because the commands are not
237
+ loaded, calling `help` on the parent command will not show the child
238
+ commands. Calling `help` on the child commands will recursively
239
+ print the help from the root command.
240
+
241
+ You can define documentation for a child command by calling
242
+ `cmddocadd()` as shown here.
243
+
244
+ ```ruby
245
+ so = SubOptParser.new do |opt|
246
+ opt.autorequire_root = "suboptparse/autoreqtest"
247
+ opt.autorequire_suffix = "_command"
248
+ opt.cmddocadd("a", "This is the A command")
249
+ end
250
+ ```
251
+
252
+ Now the help text for the root command will include a listing for the
253
+ sub-command, "a".
254
+
255
+ ### Explicit Auto Requiring
256
+
257
+ Like with implicit loading, to enable the feature, you must define
258
+ an `autorequire_root`. This path will be prefixed to any file attempted to
259
+ be loaded with a call to `require`.
260
+
261
+ To define a sub-command to be loaded, again, `cmddocadd()` is used, but
262
+ included is a 3rd argument which is the `require` path
263
+ *relative to the `autorequire_root`.*
264
+
265
+ ```ruby
266
+ so = SubOptParser.new do |opt|
267
+ opt.shared_state = {}
268
+ opt.autorequire_root = "suboptparse/autoreqtest2"
269
+ opt.cmddocadd("a", "A", "a_command")
270
+ opt.cmddocadd("b", "B", "a/b_command")
271
+ opt.cmddocadd("c", "C", "a/b/c_command")
272
+ end
273
+
274
+ # Calls `require "suboptparse/autoreqtest2/a_command"`.
275
+ so.get_subcommand("a")
276
+
277
+ # Calls `require "suboptparse/autoreqtest2/a/b/c_command"`.
278
+ so.call("a", "b", "c")
279
+ ```
280
+
281
+ ## Development
282
+
283
+ After checking out the repo, run `bin/setup` to install dependencies.
284
+ Then, run `rake spec` to run the tests.
285
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
286
+
287
+ To install this gem onto your local machine, run `bundle exec rake install`.
288
+ To release a new version, update the version number in `version.rb`, and then run
289
+ `bundle exec rake release`, which will create a git tag for the version,
290
+ push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
291
+
292
+ ## Releasing
293
+
294
+ ```shell
295
+ # Tag with a 3 digit tag prefixed with a "v".
296
+ git tag v0.1.2
297
+
298
+ # Push the tag. GitHub actions will do the rest.
299
+ git push origin tag v0.1.2
300
+ ```
301
+
302
+ ## Contributing
303
+
304
+ Bug reports and pull requests are welcome on GitHub at https://github.com/basking2/suboptparse.
305
+
306
+ ## License
307
+
308
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rdoc/task"
6
+ require "English"
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+
10
+ require "rubocop/rake_task"
11
+
12
+ RuboCop::RakeTask.new
13
+
14
+ RDoc::Task.new do |rdoc|
15
+ rdoc.main = "README.md"
16
+ rdoc.rdoc_files.include("README.md", "lib/**/*.rb")
17
+ rdoc.options << "--format=darkfish"
18
+ rdoc.title = "SubOptParse"
19
+ rdoc.rdoc_dir = "rdoc"
20
+ end
21
+
22
+ task :version, [:val] do |_t, args|
23
+ raise StandardError.new, "Version must be formatted as v[digit].[digit].[digit]." \
24
+ unless args[:val] =~ /^v([0-9]+\.[0-9]+\.[0-9]+)$/
25
+
26
+ ver = $LAST_MATCH_INFO[1]
27
+ puts ver
28
+ vfile = [
29
+ "# frozen_string_literal: true",
30
+ "",
31
+ "module SubOptParse",
32
+ " VERSION = \"#{ver}\"",
33
+ "end",
34
+ ""
35
+ ].join("\n")
36
+ File.open("./lib/suboptparse/version.rb", "wt") do |io|
37
+ io.write(vfile)
38
+ end
39
+ end
40
+
41
+ task default: %i[spec rubocop]
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SubOptParser
4
+ # Methods and logic to support auto-requiring commands that are not found in a sub-command.
5
+ #
6
+ # This is done by loading files named after the command, as shown in the example.
7
+ # When the `.rb` file is loaded, the values # AutoRequire.auto_require_command_parent and
8
+ # AutoRequire.auto_require_command_name are set to the current parent command and the name
9
+ # of the command trying to be loaded.
10
+ #
11
+ # so = SubOptParser.new do |opt|
12
+ # opt.autorequire_root = "my_proj/my_commands"
13
+ # opt.autorequire_suffix = "_command"
14
+ # end
15
+ #
16
+ # # This will require...
17
+ # # require "my_proj/my_commands/a_command"
18
+ # # require "my_proj/my_commands/a/b_command"
19
+ # # require "my_proj/my_commands/a/b/c_command"
20
+ # so.call("a", "b", "c")
21
+ #
22
+ # The contents of `c_command.rb` might look like this...
23
+ #
24
+ # require "suboptparse/auto_require"
25
+ #
26
+ # SubOptParser::AutoRequire.register do |so, name|
27
+ # so.addcmd(name, "A command.") do |so|
28
+ # so.cmd do
29
+ # so.shared_state["x"] = 3
30
+ # end
31
+ # end
32
+ # end
33
+ module AutoRequire
34
+ # rubocop:disable Style/ClassVars
35
+ @@auto_require_command_parent = nil
36
+ @@auto_require_command_name = nil
37
+ @@auto_require_command_description = nil
38
+ # rubocop:enable Style/ClassVars
39
+
40
+ class << self
41
+ def auto_require_command_parent
42
+ @@auto_require_command_parent
43
+ end
44
+
45
+ def auto_require_command_name
46
+ @@auto_require_command_name
47
+ end
48
+
49
+ def auto_require_command_description
50
+ @@auto_require_command_description
51
+ end
52
+
53
+ # This registers a command with a description with the current values of
54
+ # @@auto_require_command_name and @@auto_require_command_description and passes
55
+ # the resulting SubOptParser object to the given block.
56
+ #
57
+ # The calling module should them setup the particulars of the newly registered command.
58
+ def register(&blk) # :yields: sub_command
59
+ @@auto_require_command_parent.cmdadd(@@auto_require_command_name, @@auto_require_command_description, &blk)
60
+ end
61
+ end
62
+
63
+ # Appended to the command name to load by a call to `require "path/to/cmd_command"`
64
+ attr_accessor :autorequire_suffix
65
+
66
+ # When non-nil the +cmdpath+ of this command and a sub-command will be used to
67
+ # automatically `require` the Ruby file to register the command.
68
+ #
69
+ attr_accessor :autorequire_root
70
+
71
+ # Auto-require the command.
72
+ #
73
+ # The root app name is ignored as it can change by program invocation.
74
+ def autorequire(name)
75
+ path = generate_require_path(name)
76
+
77
+ do_in_autorequire(name) do
78
+ require path
79
+ self[name]
80
+ rescue LoadError => e
81
+ generate_default_command(name, e)
82
+ end
83
+ end
84
+
85
+ # Build a path for the given command name to pass to require.
86
+ #
87
+ # p = generate_require_path(foo)
88
+ # require p
89
+ #
90
+ def generate_require_path(name)
91
+ if @cmddocs.member?(name) && @cmddocs[name]["require"]
92
+ "#{@autorequire_root}/#{@cmddocs[name]["require"]}"
93
+ else
94
+ # Don't consider the root app name.
95
+ cmdpath = @cmdpath[1..] || []
96
+
97
+ [@autorequire_root, *cmdpath, "#{name}#{autorequire_suffix}"].join("/")
98
+ end
99
+ end
100
+
101
+ # Add the name and description of a command to be documented in help text.
102
+ # This is for use with autoloaded commands which may not fully document themselves unless called.
103
+ #
104
+ # name:: Name of the command.
105
+ # description:: The description of the command.
106
+ # req:: String you can pass to "require" to load the command.
107
+ def cmddocadd(name, description, req = nil)
108
+ @cmddocs[name] = { "name" => name, "description" => description, "require" => req }
109
+ @op.banner = @banner + cmdhelp
110
+ end
111
+
112
+ private
113
+
114
+ # Generate a command which, if executed, throws the exception that prevented it from being created.
115
+ def generate_default_command(name, err)
116
+ cmdadd(name, "Autogenerated command.") do |so|
117
+ so.cmd do
118
+ raise err
119
+ end
120
+ end
121
+ end
122
+
123
+ # rubocop:disable Style/ClassVars, Metrics/MethodLength
124
+ def do_in_autorequire(name)
125
+ prev_auto_require_command_parent = @@auto_require_command_parent
126
+ prev_auto_require_command_name = @@auto_require_command_name
127
+ prev_auto_require_command_description = @@auto_require_command_description
128
+ @@auto_require_command_parent = self
129
+ @@auto_require_command_name = name
130
+ @@auto_require_command_description = get_sub_command_description(name)
131
+ yield if block_given?
132
+ ensure
133
+ @@auto_require_command_parent = prev_auto_require_command_parent
134
+ @@auto_require_command_name = prev_auto_require_command_name
135
+ @@auto_require_command_description = prev_auto_require_command_description
136
+ end
137
+ # rubocop:enable Style/ClassVars, Metrics/MethodLength:
138
+
139
+ def get_sub_command_description(name)
140
+ if (cmddoc = @cmddocs[name])
141
+ cmddoc["description"]
142
+ else
143
+ ""
144
+ end
145
+ end
146
+
147
+ protected
148
+
149
+ def autorequire_init
150
+ @autorequire_root = nil
151
+ @autorequire_suffix = "_command"
152
+
153
+ # Documentation for unloaded commands.
154
+ @cmddocs = {}
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./util"
4
+
5
+ module SubOptParse
6
+ # A helper class that may be used in SubOptParse.shared_state.
7
+ #
8
+ # This class wraps a Hash and allows other hash-like objects to be
9
+ # merged into this hash without creating a new container object.
10
+ class SharedState
11
+ # The current state.
12
+ attr_accessor :curr
13
+
14
+ def initialize(initial_state = {})
15
+ @curr = initial_state
16
+ end
17
+
18
+ def merge!(other)
19
+ @curr = SubOptParse::Util.recursive_merge(@curr, other)
20
+ end
21
+
22
+ # Convenience function equivalent to shared_state.curr[name] = value.
23
+ def []=(name, value)
24
+ @curr[name] = value
25
+ end
26
+
27
+ # Convenience function equivalent to shared_state.curr[name].
28
+ def [](name)
29
+ @curr[name]
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SubOptParse
4
+ # Utility methods that may help in writing commands.
5
+ module Util
6
+ # Merge obj2 into obj1, if possible.
7
+ # Only hashes and lists are mergable.
8
+ # :stopdoc:
9
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
10
+ # :startdoc:
11
+ def self.recursive_merge(obj1, obj2)
12
+ if obj1.nil?
13
+ obj2
14
+ elsif obj2.nil?
15
+ obj1
16
+ elsif obj1.instance_of?(Hash) && obj2.instance_of?(Hash)
17
+ obj2.each { |k, v| obj1[k] = recursive_merge(obj1[k], v) }
18
+ obj1
19
+ elsif obj1.instance_of?(Array) && obj2.instance_of?(Array)
20
+ h = {}
21
+ obj1.each { |v| h[v] = recursive_merge(h[v], v) }
22
+ obj2.each { |v| h[v] = recursive_merge(h[v], v) }
23
+ h.values
24
+ else
25
+ # Can't merge. Return object 2.
26
+ obj2
27
+ end
28
+ end
29
+ # :stopdoc:
30
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
31
+ # :startdoc:
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SubOptParse
4
+ VERSION = "0.1.24"
5
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "suboptparse/version"
4
+ require_relative "suboptparse/shared_state"
5
+ require_relative "suboptparse/util"
6
+ require_relative "suboptparse/auto_require"
7
+
8
+ require "optparse"
9
+
10
+ module SubOptParse
11
+ class Error < StandardError; end
12
+ end
13
+
14
+ # An adaptation of Ruby's default OptionParser to support sub-commands.
15
+ # :stopdoc:
16
+ # rubocop:disable Metrics/ClassLength
17
+ # :startdoc:
18
+ class SubOptParser
19
+ include SubOptParser::AutoRequire
20
+
21
+ # The description of this command.
22
+ attr_accessor :description
23
+
24
+ # The path of parent commands to this command.
25
+ # This is automatically set by #cmdadd().
26
+ attr_accessor :cmdpath
27
+
28
+ # The parent command, or nil? if this is the root command.
29
+ attr_accessor :cmdparent
30
+
31
+ # Arbitrary user data that is shared with all child objects.
32
+ # If the user does not change this, all child commands get the same
33
+ # state assigned when created with #cmdadd().
34
+ #
35
+ # NOTE: This must be set before calling #cmdadd().
36
+ #
37
+ # This may be any object, but the SubOptParse::ShareState class is a
38
+ # useful helper.
39
+ attr_accessor :shared_state
40
+
41
+ # When non-nil the +cmdpath+ of this command and a sub-command will be used to
42
+ # automatically `require` the Ruby file to register the command.
43
+ #
44
+ attr_accessor :autorequire_root
45
+
46
+ # Initialize a new SubOptParser.
47
+ #
48
+ # If block is given, this object is passed to allow for further initialization.
49
+ #
50
+ # banner:: Passed to OptionParser.new.
51
+ # width:: Passed to OptionParser.new.
52
+ # indent:: Passed to OptionParser.new.
53
+ # :parent => parent:: Defines the parent command to this one.
54
+ #
55
+ # :stopdoc:
56
+ # rubocop:disable Metrics/MethodLength
57
+ # :startdoc:
58
+ def initialize(banner = nil, width = 32, indent = " " * 4, **args) # :yields: self
59
+ autorequire_init
60
+ @op = OptionParser.new(banner, width, indent)
61
+ self.raise_unknown = false
62
+ @banner = @op.banner
63
+ @pre_parse_blk = nil
64
+ @post_parse_blk = nil
65
+ @cmdpath = [File.basename($PROGRAM_NAME)]
66
+
67
+ # This command's body.
68
+ @cmd = proc { raise StandardError, "No command defined." }
69
+
70
+ # Sub-command which are SubOptParser objects.
71
+ @cmds = {}
72
+
73
+ @cmdparent = args.delete(:parent)
74
+
75
+ yield(self) if block_given?
76
+ end
77
+ # :stopdoc:
78
+ # rubocop:enable Metrics/MethodLength
79
+ # :startdoc:
80
+
81
+ def method_missing(name, *args, &block)
82
+ @op.__send__(name, *args, &block)
83
+ end
84
+
85
+ def respond_to_missing?(_name, _include_private = false)
86
+ true
87
+ end
88
+
89
+ # If true, an exception will be thrown when an unknown argument is given.
90
+ def raise_unknown
91
+ @op.raise_unknown
92
+ end
93
+
94
+ # If true, an exception will be thrown when an unknown argument is given.
95
+ def raise_unknown=(value)
96
+ @op.raise_unknown = value
97
+ end
98
+
99
+ # Add a sub command as the given name.
100
+ # No automatic initializtion is done. Prefer using #cmdadd().
101
+ #
102
+ # parser["sub_parser"] = SubOptParser.new do { |sub| ... }
103
+ def []=(name, subcmd)
104
+ @cmds[name] = subcmd
105
+ @op.banner = @banner + cmdhelp
106
+ end
107
+
108
+ # Users may override how to lookup a command or return "nil" if none is found.
109
+ def [](name)
110
+ @cmds[name]
111
+ end
112
+
113
+ # A callable that is invoked when this SubOptParser starts parsing arguments.
114
+ # This is primarily here to allow for lazy-populating of commands
115
+ # instead of requiring them to be defined at program invokation *or*
116
+ # to allow filtering or manipulating the command line arguments before
117
+ # parsing.
118
+ #
119
+ # The proc takes 2 arguments, this SubOptParse object and the current
120
+ # command line options array. Whatever is returned by this call
121
+ # is assigned to the command line options array value and is parsed. Eg:
122
+ #
123
+ # parser = SubOptParser.new
124
+ # parser.on_parse { |p,args| p["subcmd"] = SubOptParser.new ; args}
125
+ #
126
+ # Be careful to not create infinite recursion by adding
127
+ # commands that call themselves and then add themselves.
128
+ def on_parse(&blk) # :yields: sub_opt_parser, arguments
129
+ @pre_parse_blk = blk
130
+ end
131
+
132
+ # Similar to #on_parse but happens after parsing.
133
+ def after_parse(&blk)
134
+ @post_parse_blk = blk
135
+ end
136
+
137
+ alias pre_parse on_parse
138
+ alias post_parse after_parse
139
+
140
+ # Add a command (and return the resulting command).
141
+ def cmdadd(name, description = nil, *args) # :yields: self
142
+ o = _create_sub_command(name, description, *args)
143
+
144
+ # Add default "help" sub-job (unless we are the help job).
145
+ _add_default_help_cmd(o) if name != "help"
146
+
147
+ @cmds[name] = o
148
+ yield(o) if block_given?
149
+ @op.banner = @banner + cmdhelp
150
+ o
151
+ end
152
+
153
+ def cmd(prc = nil, &blk) # :yields: unconsumed_arguments
154
+ @cmd = prc unless prc.nil?
155
+ @cmd = blk unless blk.nil?
156
+ end
157
+
158
+ # Put the parent help text at the start of this command's help.
159
+ # This allows for building recursive help.
160
+ def help
161
+ if @cmdparent.nil?
162
+ @op.help
163
+ else
164
+ "#{@cmdparent.help}\n#{@op.help}"
165
+ end
166
+ end
167
+
168
+ def cmdhelp
169
+ cmds = _build_cmd_doc_map
170
+ cmds = _build_cmddocs_doc_map(cmds)
171
+ h = _build_help_body(cmds)
172
+
173
+ # Append extra line.
174
+ "#{h}\n"
175
+ end
176
+
177
+ alias addcmd cmdadd
178
+
179
+ def parse!(argv, into: nil)
180
+ _parse!(argv, into: into)
181
+ end
182
+
183
+ # Calls parse!.
184
+ def parse(*argv, into: nil)
185
+ _parse!(argv, into: into)
186
+ end
187
+
188
+ # Parse the arguments in *argv and execute #call() on the returned command.
189
+ # Any unparsed values are passed to the invocation of #call().
190
+ #
191
+ # This is equivalent to
192
+ #
193
+ # cmd = parser.parse!(args)
194
+ # cmd.call(args)
195
+ def call(*argv, into: nil)
196
+ cmd, rest = _parse!(argv, into: into)
197
+
198
+ # Explode if we have arguments left but should not.
199
+ raise StandardError, "Unconsumed arguments: #{argv.join(",")}" if raise_unknown && !rest.empty?
200
+
201
+ cmd.call(rest)
202
+ end
203
+
204
+ # How sub-commands are loaded.
205
+ # If no sub-command can be loaded for the name, +nil+ is returned.
206
+ def get_subcommand(name)
207
+ if (cmd = self[name])
208
+ cmd
209
+ elsif autorequire_root && (cmd = autorequire(name))
210
+ cmd
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ def _parse!(argv, into: nil)
217
+ argv = @pre_parse_blk.call(self, argv) if @pre_parse_blk
218
+
219
+ # Parse, removing all matching arguments.
220
+ @op.parse!(argv, into: into)
221
+
222
+ argv = @post_parse_blk.call(self, argv) if @post_parse_blk
223
+
224
+ if !argv.empty? && (cmd = get_subcommand(argv[0]))
225
+ argv.shift
226
+ cmd.parse!(argv, into: into)
227
+ else
228
+ [@cmd, argv]
229
+ end
230
+ end
231
+
232
+ def _add_default_help_cmd(opt_parser)
233
+ opt_parser.cmdadd("help") do |o2|
234
+ o2.cmd do
235
+ puts opt_parser.help
236
+ exit 0
237
+ end
238
+ o2.description = "Print help."
239
+ end
240
+ end
241
+
242
+ def _create_sub_command(name, description, *args)
243
+ cmdpath = @cmdpath.dup.append(name)
244
+ o = SubOptParser.new("Usage: #{cmdpath.join(" ")} [options]", *args, parent: self)
245
+ o.cmdpath = cmdpath
246
+ o.description ||= description
247
+ o.shared_state = @shared_state
248
+ o.raise_unknown = raise_unknown
249
+ o.autorequire_root = autorequire_root
250
+ o
251
+ end
252
+
253
+ # Build a map of command names to documentaton strings from @cmds.
254
+ def _build_cmd_doc_map
255
+ # Map of command names to descriptions.
256
+ @cmds.each_with_object({}) do |arr, h|
257
+ h[arr[0]] = arr[1].description
258
+ end
259
+ end
260
+
261
+ # Add to the given +cmds+ map of commands to documentation those
262
+ # commands in the @cmddocs map.
263
+ #
264
+ # If a command was already added by _build_cmd_doc_map, it is not
265
+ # overwritten.
266
+ #
267
+ # The final map is returned of commands to documentation.
268
+ def _build_cmddocs_doc_map(cmds)
269
+ # Map of documented command names to descriptions.
270
+ @cmddocs.each_with_object(cmds) do |arr, h|
271
+ h[arr[0]] = arr[1]["description"] unless @cmds.member?(arr[0])
272
+ end
273
+ end
274
+
275
+ # Given a map of command names to documentation strings,
276
+ # return a string that lists them in sorted order suitable for use in
277
+ # -h, --help or help output.
278
+ def _build_help_body(cmds)
279
+ cmds.keys.sort.inject("\n\n") do |h, key|
280
+ "#{h}#{key} - #{cmds[key]}\n"
281
+ end
282
+ end
283
+ end
284
+ # :stopdoc:
285
+ # rubocop:enable Metrics/ClassLength
286
+ # :startdoc:
@@ -0,0 +1,4 @@
1
+ module Suboptparse
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: suboptparse
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.24
5
+ platform: ruby
6
+ authors:
7
+ - Sam Baskinger
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-02-12 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Augment the default optparse OptionParser with some support for sub commands.
14
+ email:
15
+ - basking2@yahoo.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".rspec"
21
+ - ".rubocop.yml"
22
+ - CHANGELOG.md
23
+ - LICENSE.txt
24
+ - README.md
25
+ - Rakefile
26
+ - lib/suboptparse.rb
27
+ - lib/suboptparse/auto_require.rb
28
+ - lib/suboptparse/shared_state.rb
29
+ - lib/suboptparse/util.rb
30
+ - lib/suboptparse/version.rb
31
+ - sig/suboptparse.rbs
32
+ homepage: https://basking2.github.io/suboptparse/
33
+ licenses:
34
+ - MIT
35
+ metadata:
36
+ allowed_push_host: https://rubygems.org
37
+ homepage_uri: https://basking2.github.io/suboptparse/
38
+ source_code_uri: https://github.com/basking2/suboptparse
39
+ changelog_uri: https://github.com/basking2/suboptparse/blob/main/CHANGELOG.md
40
+ post_install_message:
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 3.0.0
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 3.5.9
56
+ signing_key:
57
+ specification_version: 4
58
+ summary: Add subcommands to OptionParser.
59
+ test_files: []