parse-argv 0.1.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 +7 -0
- data/.yardopts +5 -0
- data/LICENSE +28 -0
- data/ReadMe.md +101 -0
- data/examples/ReadMe.md +29 -0
- data/examples/check.rb +109 -0
- data/examples/conversion.rb +47 -0
- data/examples/multi.rb +79 -0
- data/examples/simple.rb +37 -0
- data/lib/parse-argv/conversion.rb +398 -0
- data/lib/parse-argv/version.rb +8 -0
- data/lib/parse-argv.rb +900 -0
- data/lib/parse_argv.rb +3 -0
- data/syntax.md +158 -0
- metadata +70 -0
data/lib/parse-argv.rb
ADDED
@@ -0,0 +1,900 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# ParseArgv uses a help text of a command line interface (CLI) to find out how
|
5
|
+
# to parse a command line. It takes care that required command line arguments
|
6
|
+
# are given and optional arguments are consumed.
|
7
|
+
#
|
8
|
+
# With a given help text and a command line it produces a {Result} which
|
9
|
+
# contains all values and meta data ({ParseArgv.from}). The {Result::Value}s
|
10
|
+
# support type {Conversion} and contextual error handling ({Result::Value#as}).
|
11
|
+
#
|
12
|
+
# For some debugging and test purpose it can also serialize a given help text
|
13
|
+
# to a informational format ({ParseArgv.parse}).
|
14
|
+
#
|
15
|
+
# For details about the help text syntax see {file:syntax.md}.
|
16
|
+
#
|
17
|
+
# @example
|
18
|
+
# require 'parse-argv'
|
19
|
+
#
|
20
|
+
# args = ParseArgv.from <<~HELP
|
21
|
+
# usage: test [options] <infile> [<outfile>]
|
22
|
+
#
|
23
|
+
# This is just a demonstration.
|
24
|
+
#
|
25
|
+
# options:
|
26
|
+
# -f, --format <format> specify the format
|
27
|
+
# --verbose enable verbose mode
|
28
|
+
# -h, --help print this help text
|
29
|
+
# HELP
|
30
|
+
#
|
31
|
+
# args.verbose?
|
32
|
+
# #=> true, when "--verbose" argument was specified
|
33
|
+
# #=> false, when "--verbose" argument was not specified
|
34
|
+
#
|
35
|
+
# args[:infile].as(File, :readable)
|
36
|
+
# #=> file name
|
37
|
+
#
|
38
|
+
# args.outfile?
|
39
|
+
# #=> true, when second argument was specified
|
40
|
+
# args.outfile
|
41
|
+
# #=> second argument or nil when not specified
|
42
|
+
#
|
43
|
+
module ParseArgv
|
44
|
+
#
|
45
|
+
# Parses the given +help_text+ and command line +argv+ to create an {Result}.
|
46
|
+
#
|
47
|
+
# @param help_text [String] help text describing all sub-commands, parameters
|
48
|
+
# and options in human readable format
|
49
|
+
# @param argv [Array<String>] command line arguments
|
50
|
+
# @return [Result] the arguments parsed from +argv+ related to the given
|
51
|
+
# +help_text+.
|
52
|
+
#
|
53
|
+
def self.from(help_text, argv = ARGV)
|
54
|
+
result = Result.new(*Assemble[help_text, argv])
|
55
|
+
block_given? ? yield(result) : result
|
56
|
+
rescue Error => e
|
57
|
+
@on_error&.call(e) or raise
|
58
|
+
end
|
59
|
+
|
60
|
+
#
|
61
|
+
# Parses the given +help_text+ and returns descriptive information about the
|
62
|
+
# commands found.
|
63
|
+
#
|
64
|
+
# This method can be used to test a +help_text+.
|
65
|
+
#
|
66
|
+
# @param help_text [String] help text describing all sub-commands, parameters
|
67
|
+
# and options in human readable format
|
68
|
+
# @return [Array<Hash>] descriptive information
|
69
|
+
# @raise [ArgumentError] when the help text contains invalid information
|
70
|
+
#
|
71
|
+
def self.parse(help_text)
|
72
|
+
Assemble.commands(help_text).map!(&:to_h)
|
73
|
+
end
|
74
|
+
|
75
|
+
#
|
76
|
+
# Defines custom error handler which will be called with the detected
|
77
|
+
# {Error}.
|
78
|
+
#
|
79
|
+
# By default the error handler writes the {Error#message} prefixed with
|
80
|
+
# related {Error#command} name to *$std_err* and terminates the application
|
81
|
+
# with the suggested {Error#code}.
|
82
|
+
#
|
83
|
+
# @overload on_error(function)
|
84
|
+
# Uses the given call back function to handle all parsing errors.
|
85
|
+
# @param func [Proc] call back function which receives the {Error}
|
86
|
+
# @example
|
87
|
+
# ParseArgv.on_error ->(e) { $stderr.puts e or exit e.code }
|
88
|
+
#
|
89
|
+
# @overload on_error(&block)
|
90
|
+
# Uses the given +block+ to handle all parsing errors.
|
91
|
+
# @example
|
92
|
+
# ParseArgv.on_error do |e|
|
93
|
+
# $stderr.puts(e)
|
94
|
+
# exit(e.code)
|
95
|
+
# end
|
96
|
+
#
|
97
|
+
# @return [ParseArgv] itself
|
98
|
+
#
|
99
|
+
def self.on_error(function = nil, &block)
|
100
|
+
function ||= block
|
101
|
+
return @on_error if function.nil?
|
102
|
+
@on_error = function == :raise ? nil : function
|
103
|
+
self
|
104
|
+
end
|
105
|
+
|
106
|
+
#
|
107
|
+
# Raised when the command line is parsed and an error was found.
|
108
|
+
#
|
109
|
+
# @see .on_error
|
110
|
+
#
|
111
|
+
class Error < StandardError
|
112
|
+
#
|
113
|
+
# @return [Integer] error code
|
114
|
+
#
|
115
|
+
attr_reader :code
|
116
|
+
#
|
117
|
+
# @return [Result::Command] related command
|
118
|
+
#
|
119
|
+
attr_reader :command
|
120
|
+
|
121
|
+
#
|
122
|
+
# @!attribute [r] message
|
123
|
+
# @return [String] message to be reported
|
124
|
+
#
|
125
|
+
|
126
|
+
#
|
127
|
+
# @param command [Result::Command] related command
|
128
|
+
# @param message [String] message to be reported
|
129
|
+
# @param code [Integer] error code
|
130
|
+
#
|
131
|
+
def initialize(command, message, code = 1)
|
132
|
+
@command = command
|
133
|
+
@code = code
|
134
|
+
super("#{command.full_name}: #{message}")
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
#
|
139
|
+
# The result of a complete parsing process made with {ParseArgv.from}. It
|
140
|
+
# contains all arguments parsed from the command line and the defined
|
141
|
+
# commands.
|
142
|
+
#
|
143
|
+
class Result
|
144
|
+
#
|
145
|
+
# Represents a command with a name and related help text
|
146
|
+
#
|
147
|
+
class Command
|
148
|
+
#
|
149
|
+
# @return [String] complete command name
|
150
|
+
#
|
151
|
+
attr_reader :full_name
|
152
|
+
#
|
153
|
+
# @return [String] subcommand name
|
154
|
+
#
|
155
|
+
attr_reader :name
|
156
|
+
|
157
|
+
# @!visibility private
|
158
|
+
def initialize(full_name, help, name = nil)
|
159
|
+
@full_name = full_name.freeze
|
160
|
+
@help = help
|
161
|
+
@name = name || +@full_name
|
162
|
+
end
|
163
|
+
|
164
|
+
# @!parse attr_reader :help
|
165
|
+
# @return [String] help text of the command
|
166
|
+
def help
|
167
|
+
return @help if @help.is_a?(String)
|
168
|
+
@help.shift while @help.first&.empty?
|
169
|
+
@help.pop while @help.last&.empty?
|
170
|
+
@help = @help.join("\n").freeze
|
171
|
+
end
|
172
|
+
|
173
|
+
# @!visibility private
|
174
|
+
def inspect
|
175
|
+
"#{__to_s[..-2]} #{@full_name}>"
|
176
|
+
end
|
177
|
+
|
178
|
+
alias __to_s to_s
|
179
|
+
private :__to_s
|
180
|
+
alias to_s name
|
181
|
+
end
|
182
|
+
|
183
|
+
#
|
184
|
+
# This is a helper class to get parsed arguments from {Result} to be
|
185
|
+
# converted.
|
186
|
+
#
|
187
|
+
# @see Result#[]
|
188
|
+
#
|
189
|
+
class Value
|
190
|
+
include Comparable
|
191
|
+
|
192
|
+
#
|
193
|
+
# @return [Object] the argument value
|
194
|
+
#
|
195
|
+
attr_reader :value
|
196
|
+
|
197
|
+
# @!visibility private
|
198
|
+
def initialize(value, error_proc)
|
199
|
+
@value = value
|
200
|
+
@error_proc = error_proc
|
201
|
+
end
|
202
|
+
|
203
|
+
#
|
204
|
+
# @attribute [r] value?
|
205
|
+
# @return [Boolean] whenever the value is nil and not false
|
206
|
+
#
|
207
|
+
def value?
|
208
|
+
@value != nil && @value != false
|
209
|
+
end
|
210
|
+
|
211
|
+
#
|
212
|
+
# Requests the value to be converted to a specified +type+. It uses
|
213
|
+
# {Conversion.[]} to obtain the conversion procedure.
|
214
|
+
#
|
215
|
+
# Some conversion procedures allow additional parameters which will be
|
216
|
+
# forwarded.
|
217
|
+
#
|
218
|
+
# @example convert to a positive number (or fallback to 10)
|
219
|
+
# sample.as(:integer, :positive, default: 10)
|
220
|
+
#
|
221
|
+
# @example convert to a file name of an existing, readable file
|
222
|
+
# sample.as(File, :readable)
|
223
|
+
#
|
224
|
+
# @example convert to Time and use the +reference+ to complete the the date parts (when not given)
|
225
|
+
# sample.as(Time, reference: Date.new(2022, 1, 2))
|
226
|
+
#
|
227
|
+
# @param type [Symbol, Class, Enumerable<String>, Array(type), Regexp]
|
228
|
+
# conversion type, see {Conversion.[]}
|
229
|
+
# @param args [Array<Object>] optional arguments to be forwarded to the
|
230
|
+
# conversion procedure
|
231
|
+
# @param default [Object] returned, when an argument was not specified
|
232
|
+
# @param kwargs [Symbol => Object] optional named arguments forwarded to the conversion procedure
|
233
|
+
# @return [Object] converted argument or +default+
|
234
|
+
#
|
235
|
+
# @see Conversion
|
236
|
+
#
|
237
|
+
def as(type, *args, default: nil, **kwargs)
|
238
|
+
return default if @value.nil?
|
239
|
+
conv = Conversion[type]
|
240
|
+
if value.is_a?(Array)
|
241
|
+
value.map { |v| conv.call(v, *args, **kwargs, &@error_proc) }
|
242
|
+
else
|
243
|
+
conv.call(@value, *args, **kwargs, &@error_proc)
|
244
|
+
end
|
245
|
+
rescue Error => e
|
246
|
+
ParseArgv.on_error&.call(e) or raise
|
247
|
+
end
|
248
|
+
|
249
|
+
# @!visibility private
|
250
|
+
def eql?(other)
|
251
|
+
other.is_a?(self.class) ? @value == other.value : @value == other
|
252
|
+
end
|
253
|
+
alias == eql?
|
254
|
+
|
255
|
+
# @!visibility private
|
256
|
+
def equal?(other)
|
257
|
+
(self.class == other.class) && (@value == other.value)
|
258
|
+
end
|
259
|
+
|
260
|
+
# @!visibility private
|
261
|
+
def <=>(other)
|
262
|
+
other.is_a?(self.class) ? @value <=> other.value : @value <=> other
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
#
|
267
|
+
# @return [Array<Command>] all defined commands
|
268
|
+
#
|
269
|
+
attr_reader :all_commands
|
270
|
+
|
271
|
+
#
|
272
|
+
# @return [Command] command used for this result
|
273
|
+
#
|
274
|
+
attr_reader :current_command
|
275
|
+
|
276
|
+
#
|
277
|
+
# @return [Command] main command if subcommands are used
|
278
|
+
#
|
279
|
+
attr_reader :main_command
|
280
|
+
|
281
|
+
# @!visibility private
|
282
|
+
def initialize(all_commands, current_command, main_command, args)
|
283
|
+
@all_commands = all_commands
|
284
|
+
@current_command = current_command
|
285
|
+
@main_command = main_command
|
286
|
+
@rgs = args
|
287
|
+
end
|
288
|
+
|
289
|
+
#
|
290
|
+
# Get an argument as {Value} which can be converted.
|
291
|
+
#
|
292
|
+
# @example get argument _count_ as a positive integer (or fallback to 10)
|
293
|
+
# result[:count].as(:integer, :positive, default: 10)
|
294
|
+
#
|
295
|
+
# @param name [String, Symbol] name of the requested argument
|
296
|
+
# @return [Value] argument value
|
297
|
+
# @return [nil] when argument is not defined
|
298
|
+
#
|
299
|
+
def [](name)
|
300
|
+
name = name.to_sym
|
301
|
+
@rgs.key?(name) ? Value.new(@rgs[name], argument_error(name)) : nil
|
302
|
+
end
|
303
|
+
|
304
|
+
#
|
305
|
+
# Calls the error handler defined by {ParseArgv.on_error}.
|
306
|
+
#
|
307
|
+
# By default the error handler writes the {Error#message} prefixed with
|
308
|
+
# related {Error#command} name to *$std_err* and terminates the application
|
309
|
+
# with {Error#code}.
|
310
|
+
#
|
311
|
+
# If no error handler was defined an {Error} will be raised.
|
312
|
+
#
|
313
|
+
# This method is useful whenever your application needs signal an critical
|
314
|
+
# error case (and should be terminated).
|
315
|
+
#
|
316
|
+
# @param message [String] error message to present
|
317
|
+
# @param code [Integer] termination code
|
318
|
+
# @raise {Error} when no error handler was defined
|
319
|
+
#
|
320
|
+
# @see ParseArgv.on_error
|
321
|
+
#
|
322
|
+
def error!(message, code = 1)
|
323
|
+
error = Error.new(current_command, message, code)
|
324
|
+
ParseArgv.on_error&.call(error) || raise(error)
|
325
|
+
end
|
326
|
+
|
327
|
+
#
|
328
|
+
# Try to fetch the value for the given argument +name+.
|
329
|
+
#
|
330
|
+
# @overload fetch(name)
|
331
|
+
# Will raise an ArgumentError when the requested attribute does not
|
332
|
+
# exist.
|
333
|
+
# @param name [String, Symbol] attribute name
|
334
|
+
# @return [Object] attribute value
|
335
|
+
# @raise [ArgumentError] when attribute was not defined
|
336
|
+
#
|
337
|
+
# @overload fetch(name, default_value)
|
338
|
+
# Returns the +default_value+ when the requested attribute does not
|
339
|
+
# exist.
|
340
|
+
# @param name [String, Symbol] attribute name
|
341
|
+
# @param default_value [Object] default value to return when attribute
|
342
|
+
# not exists
|
343
|
+
# @return [Object] attribute value; maybe the +default_value+
|
344
|
+
#
|
345
|
+
# @overload fetch(name, &block)
|
346
|
+
# Returns the +block+ result when the requested attribute does not
|
347
|
+
# exist.
|
348
|
+
# @yieldparam name [Symbol] attribute name
|
349
|
+
# @yieldreturn [Object] return value
|
350
|
+
# @param name [String, Symbol] attribute name
|
351
|
+
# @return [Object] attribute value or result of the +block+ if attribute
|
352
|
+
# not found
|
353
|
+
#
|
354
|
+
def fetch(name, *args, &block)
|
355
|
+
name = name.to_sym
|
356
|
+
value = @args[name]
|
357
|
+
return value unless value.nil?
|
358
|
+
args.empty? ? (block || ATTRIBUTE_ERROR).call(name) : args.first
|
359
|
+
end
|
360
|
+
|
361
|
+
#
|
362
|
+
# Find the command with given +name+.
|
363
|
+
#
|
364
|
+
# @param name [String]
|
365
|
+
# @return [Command] found command
|
366
|
+
# @return [nil] when no command was found
|
367
|
+
#
|
368
|
+
def find_command(name)
|
369
|
+
return if name.nil?
|
370
|
+
name = name.is_a?(Array) ? name.join(' ') : name.to_s
|
371
|
+
@all_commands.find { |cmd| cmd.name == name }
|
372
|
+
end
|
373
|
+
|
374
|
+
# @!visibility private
|
375
|
+
def respond_to_missing?(sym, _)
|
376
|
+
@rgs.key?(sym.end_with?('?') ? sym[..-2].to_sym : sym) || super
|
377
|
+
end
|
378
|
+
|
379
|
+
#
|
380
|
+
# @overload to_h
|
381
|
+
# Transform itself into a Hash containing all arguments.
|
382
|
+
# @return [{Symbol => String, Boolean}] Hash of all argument name/value
|
383
|
+
# pairs
|
384
|
+
#
|
385
|
+
# @overload to_h(&block)
|
386
|
+
# @yieldparam name [Symbol] attribute name
|
387
|
+
# @yieldparam value [String,Boolean] attribute value
|
388
|
+
# @yieldreturn [Array(key, value)] key/value pair to include
|
389
|
+
# @return [Hash] Hash of all argument key/value pairs
|
390
|
+
#
|
391
|
+
def to_h(&block)
|
392
|
+
# ensure to return a not frozen copy
|
393
|
+
block ? @rgs.to_h(&block) : Hash[@rgs.to_a]
|
394
|
+
end
|
395
|
+
|
396
|
+
# @!visibility private
|
397
|
+
def inspect
|
398
|
+
"#{__to_s[..-2]}:#{@current_command.full_name} #{
|
399
|
+
@rgs.map { |k, v| "#{k}: #{v}" }.join(', ')
|
400
|
+
}>"
|
401
|
+
end
|
402
|
+
|
403
|
+
alias __to_s to_s
|
404
|
+
private :__to_s
|
405
|
+
|
406
|
+
#
|
407
|
+
# Returns the help text of the {#current_command}
|
408
|
+
# @return [String] the help text
|
409
|
+
#
|
410
|
+
def to_s
|
411
|
+
current_command.help
|
412
|
+
end
|
413
|
+
|
414
|
+
private
|
415
|
+
|
416
|
+
#
|
417
|
+
# All command line attributes are read-only attributes for this instance.
|
418
|
+
#
|
419
|
+
# @example
|
420
|
+
# # given there was <format> option defined
|
421
|
+
# result.format?
|
422
|
+
# #=> whether the option was specified
|
423
|
+
# result.format
|
424
|
+
# # String of format option or nil, when not specified
|
425
|
+
#
|
426
|
+
def method_missing(sym, *args)
|
427
|
+
args.size.zero? or
|
428
|
+
raise(
|
429
|
+
ArgumentError,
|
430
|
+
"wrong number of arguments (given #{args.size}, expected 0)"
|
431
|
+
)
|
432
|
+
return @rgs.key?(sym) ? @rgs[sym] : super unless sym.end_with?('?')
|
433
|
+
sym = sym[..-2].to_sym
|
434
|
+
@rgs.key?(sym) or return super
|
435
|
+
value = @rgs[sym]
|
436
|
+
value != nil && value != false
|
437
|
+
end
|
438
|
+
|
439
|
+
def argument_error(name)
|
440
|
+
proc do |message|
|
441
|
+
raise(InvalidArgumentTypeError.new(current_command, message, name))
|
442
|
+
end
|
443
|
+
end
|
444
|
+
|
445
|
+
ATTRIBUTE_ERROR = ->(name) { raise(UnknownAttributeError, name) }
|
446
|
+
private_constant(:ATTRIBUTE_ERROR)
|
447
|
+
end
|
448
|
+
|
449
|
+
module Assemble
|
450
|
+
def self.[](help_text, argv)
|
451
|
+
Prepare.new(Commands.new.parse(help_text)).from(argv)
|
452
|
+
end
|
453
|
+
|
454
|
+
def self.commands(help_text)
|
455
|
+
Prepare.new(Commands.new.parse(help_text)).all
|
456
|
+
end
|
457
|
+
|
458
|
+
class Prepare
|
459
|
+
attr_reader :all
|
460
|
+
|
461
|
+
def initialize(all_commands)
|
462
|
+
raise(NoCommandDefinedError) if all_commands.empty?
|
463
|
+
@all = all_commands
|
464
|
+
@main = find_main or raise(NoDefaultCommandDefinedError)
|
465
|
+
prepare_subcommands
|
466
|
+
end
|
467
|
+
|
468
|
+
def from(argv)
|
469
|
+
@argv = Array.new(argv)
|
470
|
+
found = @current = find_current
|
471
|
+
(@current == @main ? all_to_cmd_main : all_to_cmd)
|
472
|
+
@all.sort_by!(&:name).freeze
|
473
|
+
[@all, @current, @main, found.parse(@argv)]
|
474
|
+
end
|
475
|
+
|
476
|
+
private
|
477
|
+
|
478
|
+
def find_main
|
479
|
+
@all.find { |cmd| cmd.name.index(' ').nil? }
|
480
|
+
end
|
481
|
+
|
482
|
+
def find_current
|
483
|
+
@argv.empty? || @all.size == 1 ? @main : find_sub_command
|
484
|
+
end
|
485
|
+
|
486
|
+
def prepare_subcommands
|
487
|
+
prefix = "#{@main.full_name} "
|
488
|
+
@all.each do |cmd|
|
489
|
+
next if cmd == @main
|
490
|
+
next cmd.name.delete_prefix!(prefix) if cmd.name.start_with?(prefix)
|
491
|
+
raise(InvalidSubcommandNameError.new(@main.name, cmd.name))
|
492
|
+
end
|
493
|
+
end
|
494
|
+
|
495
|
+
def find_sub_command
|
496
|
+
args = @argv.take_while { |arg| arg[0] != '-' }
|
497
|
+
return @main if args.empty?
|
498
|
+
found = find_command(args)
|
499
|
+
found.nil? ? raise(InvalidCommandError.new(@main, args.first)) : found
|
500
|
+
end
|
501
|
+
|
502
|
+
def find_command(args)
|
503
|
+
args
|
504
|
+
.size
|
505
|
+
.downto(1) do |i|
|
506
|
+
name = args.take(i).join(' ')
|
507
|
+
cmd = @all.find { |c| c != @main && c.name == name } or next
|
508
|
+
@argv.shift(i)
|
509
|
+
return cmd
|
510
|
+
end
|
511
|
+
nil
|
512
|
+
end
|
513
|
+
|
514
|
+
def all_to_cmd_main
|
515
|
+
@all.map! do |command|
|
516
|
+
next @main = @current = command.to_cmd if command == @main
|
517
|
+
command.to_cmd
|
518
|
+
end
|
519
|
+
end
|
520
|
+
|
521
|
+
def all_to_cmd
|
522
|
+
@all.map! do |command|
|
523
|
+
next @main = command.to_cmd if command == @main
|
524
|
+
next @current = command.to_cmd if command == @current
|
525
|
+
command.to_cmd
|
526
|
+
end
|
527
|
+
end
|
528
|
+
end
|
529
|
+
|
530
|
+
class Commands
|
531
|
+
def initialize
|
532
|
+
@commands = []
|
533
|
+
@help = []
|
534
|
+
@header_text = true
|
535
|
+
@line_number = 0
|
536
|
+
end
|
537
|
+
|
538
|
+
def parse(help_text)
|
539
|
+
help_text.each_line(chomp: true) do |line|
|
540
|
+
@line_number += 1
|
541
|
+
case line
|
542
|
+
when /\A\s*#/
|
543
|
+
@help = ['']
|
544
|
+
@header_text = true
|
545
|
+
next
|
546
|
+
when /usage: (\w+([ \w]+)?)/i
|
547
|
+
new_command(Regexp.last_match)
|
548
|
+
end
|
549
|
+
@help << line
|
550
|
+
end
|
551
|
+
@commands
|
552
|
+
end
|
553
|
+
|
554
|
+
private
|
555
|
+
|
556
|
+
def new_command(match)
|
557
|
+
name = match[1].rstrip
|
558
|
+
@help = [] unless @header_text
|
559
|
+
@header_text = false
|
560
|
+
@commands.find do |cmd|
|
561
|
+
next if cmd.name != name
|
562
|
+
raise(DoublicateCommandDefinitionError.new(name, @line_number))
|
563
|
+
end
|
564
|
+
@command = CommandParser.new(name, @help)
|
565
|
+
define_arguments(@command, match.post_match)
|
566
|
+
@commands << @command
|
567
|
+
end
|
568
|
+
|
569
|
+
def define_arguments(parser, str)
|
570
|
+
return if str.empty?
|
571
|
+
str.scan(/(\[?<([[:alnum:]]+)>(\.{3})?\]?)/) do |(all, name, cons)|
|
572
|
+
parser.argument(name.to_sym, ARGTYPE[all[0] == '['][cons.nil?])
|
573
|
+
end
|
574
|
+
end
|
575
|
+
|
576
|
+
ARGTYPE = {
|
577
|
+
true => {
|
578
|
+
true => :optional,
|
579
|
+
false => :optional_rest
|
580
|
+
}.compare_by_identity,
|
581
|
+
false => {
|
582
|
+
true => :required,
|
583
|
+
false => :required_rest
|
584
|
+
}.compare_by_identity
|
585
|
+
}.compare_by_identity
|
586
|
+
end
|
587
|
+
|
588
|
+
class CommandParser < Result::Command
|
589
|
+
def initialize(name, help)
|
590
|
+
super
|
591
|
+
@options = {}
|
592
|
+
@arguments = {}
|
593
|
+
@prepared = false
|
594
|
+
end
|
595
|
+
|
596
|
+
def argument(name, type)
|
597
|
+
@arguments[checked(name, DoublicateArgumentDefinitionError)] = type
|
598
|
+
end
|
599
|
+
|
600
|
+
def parse(argv)
|
601
|
+
prepare!
|
602
|
+
@result = {}.compare_by_identity
|
603
|
+
arguments = parse_argv(argv)
|
604
|
+
process_switches
|
605
|
+
process(arguments) unless help?
|
606
|
+
@result.freeze
|
607
|
+
end
|
608
|
+
|
609
|
+
def to_cmd
|
610
|
+
Result::Command.new(@full_name, @help, @name)
|
611
|
+
end
|
612
|
+
|
613
|
+
def to_h
|
614
|
+
prepare!
|
615
|
+
{
|
616
|
+
full_name: @full_name,
|
617
|
+
name: @name.freeze,
|
618
|
+
arguments: arguments_as_hash,
|
619
|
+
help: help
|
620
|
+
}
|
621
|
+
end
|
622
|
+
|
623
|
+
private
|
624
|
+
|
625
|
+
def argument_type(type)
|
626
|
+
case type
|
627
|
+
when :optional
|
628
|
+
{ type: :argument, required: false }
|
629
|
+
when :optional_rest
|
630
|
+
{ type: :argument_array, required: false }
|
631
|
+
when :required
|
632
|
+
{ type: :argument, required: true }
|
633
|
+
when :required_rest
|
634
|
+
{ type: :argument_array, required: true }
|
635
|
+
end
|
636
|
+
end
|
637
|
+
|
638
|
+
def arguments_as_hash
|
639
|
+
ret = @arguments.to_h { |n, type| [n.to_sym, argument_type(type)] }
|
640
|
+
@options.each_pair do |name, var_name|
|
641
|
+
tname = var_name.delete_prefix('!')
|
642
|
+
type = var_name == tname ? :option : :switch
|
643
|
+
tname = tname.to_sym
|
644
|
+
next ret.dig(tname, :names) << name if ret.key?(tname)
|
645
|
+
ret[tname] = { type: type, names: [name] }
|
646
|
+
end
|
647
|
+
ret.each_value { |v| v[:names]&.sort! }
|
648
|
+
end
|
649
|
+
|
650
|
+
def prepare!
|
651
|
+
return if @prepared
|
652
|
+
@help.each do |line|
|
653
|
+
case line
|
654
|
+
when /\A\s+-([[:alnum:]]), --([[[:alnum:]]-]+)[ :]<([[:lower:]]+)>\s+\S+/
|
655
|
+
option(Regexp.last_match)
|
656
|
+
when /\A\s+-{1,2}([[[:alnum:]]-]+)[ :]<([[:lower:]]+)>\s+\S+/
|
657
|
+
simple_option(Regexp.last_match)
|
658
|
+
when /\A\s+-([[:alnum:]]), --([[[:alnum:]]-]+)\s+\S+/
|
659
|
+
switch(Regexp.last_match)
|
660
|
+
when /\A\s+-{1,2}([[[:alnum:]]-]+)\s+\S+/
|
661
|
+
simple_switch(Regexp.last_match(1))
|
662
|
+
end
|
663
|
+
end
|
664
|
+
@prepared = true
|
665
|
+
self
|
666
|
+
end
|
667
|
+
|
668
|
+
def help?
|
669
|
+
name.index(' ').nil? &&
|
670
|
+
(@result[:help] == true || @result[:version] == true)
|
671
|
+
end
|
672
|
+
|
673
|
+
def parse_argv(argv)
|
674
|
+
arguments = []
|
675
|
+
while (arg = argv.shift)
|
676
|
+
case arg
|
677
|
+
when '--'
|
678
|
+
return arguments + argv
|
679
|
+
when /\A--([[[:alnum:]]-]+)\z/
|
680
|
+
process_option(Regexp.last_match(1), argv)
|
681
|
+
when /\A-{1,2}([[[:alnum:]]-]+):(.+)\z/
|
682
|
+
process_option_arg(Regexp.last_match)
|
683
|
+
when /\A-([[:alnum:]]+)\z/
|
684
|
+
process_opts(Regexp.last_match(1), argv)
|
685
|
+
else
|
686
|
+
arguments << arg
|
687
|
+
end
|
688
|
+
end
|
689
|
+
arguments
|
690
|
+
end
|
691
|
+
|
692
|
+
def process(argv)
|
693
|
+
reduce(argv)
|
694
|
+
@arguments.each_pair do |key, type|
|
695
|
+
@result[key] = case type
|
696
|
+
when :optional
|
697
|
+
argv.empty? ? nil : argv.shift
|
698
|
+
when :optional_rest
|
699
|
+
argv.empty? ? nil : argv.shift(argv.size)
|
700
|
+
when :required
|
701
|
+
argv.shift or raise(ArgumentMissingError.new(self, key))
|
702
|
+
when :required_rest
|
703
|
+
raise(ArgumentMissingError.new(self, key)) if argv.empty?
|
704
|
+
argv.shift(argv.size)
|
705
|
+
end
|
706
|
+
end
|
707
|
+
raise(TooManyArgumentsError, self) unless argv.empty?
|
708
|
+
end
|
709
|
+
|
710
|
+
def argument_results(args)
|
711
|
+
@arguments.each_key { |name| @result[name.to_sym] = args.shift }
|
712
|
+
end
|
713
|
+
|
714
|
+
def reduce(argv)
|
715
|
+
keys = @arguments.keys.reverse!
|
716
|
+
while argv.size < @arguments.size
|
717
|
+
nonreq = keys.find { |key| @arguments[key][0] == 'o' }
|
718
|
+
nonreq or raise(ArgumentMissingError.new(self, @arguments.keys.last))
|
719
|
+
@arguments.delete(keys.delete(nonreq))
|
720
|
+
@result[nonreq] = nil
|
721
|
+
end
|
722
|
+
end
|
723
|
+
|
724
|
+
def process_option(name, argv, pref = '-')
|
725
|
+
key = @options[name]
|
726
|
+
raise(UnknonwOptionError.new(self, "#{pref}-#{name}")) if key.nil?
|
727
|
+
return @result[key[1..].to_sym] = true if key[0] == '!'
|
728
|
+
@result[key.to_sym] = value = argv.shift
|
729
|
+
return unless value.nil? || value[0] == '-'
|
730
|
+
raise(OptionArgumentMissingError.new(self, key, "#{pref}-#{name}"))
|
731
|
+
end
|
732
|
+
|
733
|
+
def process_option_arg(match)
|
734
|
+
key = @options[match[1]] or
|
735
|
+
raise(UnknonwOptionError.new(self, "#{match.pre_match}#{match[1]}"))
|
736
|
+
return @result[key[1..].to_sym] = as_boolean(match[2]) if key[0] == '!'
|
737
|
+
@result[key.to_sym] = match[2]
|
738
|
+
end
|
739
|
+
|
740
|
+
def process_opts(name, argv)
|
741
|
+
name.each_char { |n| process_option(n, argv, nil) }
|
742
|
+
end
|
743
|
+
|
744
|
+
def process_switches
|
745
|
+
@options.each_value do |name|
|
746
|
+
if name[0] == '!'
|
747
|
+
name = name[1..].to_sym
|
748
|
+
@result[name] = false unless @result.key?(name)
|
749
|
+
else
|
750
|
+
name = name.to_sym
|
751
|
+
@result[name] = nil unless @result.key?(name)
|
752
|
+
end
|
753
|
+
end
|
754
|
+
end
|
755
|
+
|
756
|
+
def as_boolean(str)
|
757
|
+
%w[y yes t true on].include?(str)
|
758
|
+
end
|
759
|
+
|
760
|
+
def checked(name, err)
|
761
|
+
raise(err, name) if @options.key?(name) || @arguments.key?(name)
|
762
|
+
name
|
763
|
+
end
|
764
|
+
|
765
|
+
def checked_opt(name)
|
766
|
+
checked(name, DoublicateOptionDefinitionError)
|
767
|
+
end
|
768
|
+
|
769
|
+
def option(match)
|
770
|
+
@options[checked_opt(match[1])] = @options[
|
771
|
+
checked_opt(match[2])
|
772
|
+
] = match[3]
|
773
|
+
end
|
774
|
+
|
775
|
+
def simple_option(match)
|
776
|
+
@options[checked_opt(match[1])] = match[2]
|
777
|
+
end
|
778
|
+
|
779
|
+
def switch(match)
|
780
|
+
name = checked_opt(match[2])
|
781
|
+
@options[name] = @options[checked_opt(match[1])] = "!#{name}"
|
782
|
+
end
|
783
|
+
|
784
|
+
def simple_switch(name)
|
785
|
+
@options[checked_opt(name)] = "!#{name}"
|
786
|
+
end
|
787
|
+
end
|
788
|
+
|
789
|
+
private_constant(:Prepare, :Commands, :CommandParser)
|
790
|
+
end
|
791
|
+
|
792
|
+
class InvalidCommandError < Error
|
793
|
+
def initialize(command, name)
|
794
|
+
super(command, "invalid command - #{name}")
|
795
|
+
end
|
796
|
+
end
|
797
|
+
|
798
|
+
class ArgumentMissingError < Error
|
799
|
+
def initialize(command, name)
|
800
|
+
super(command, "argument missing - <#{name}>")
|
801
|
+
end
|
802
|
+
end
|
803
|
+
|
804
|
+
class InvalidArgumentTypeError < Error
|
805
|
+
def initialize(command, message, name)
|
806
|
+
super(command, "#{message} - <#{name}>")
|
807
|
+
end
|
808
|
+
end
|
809
|
+
|
810
|
+
class TooManyArgumentsError < Error
|
811
|
+
def initialize(command)
|
812
|
+
super(command, 'too many arguments')
|
813
|
+
end
|
814
|
+
end
|
815
|
+
|
816
|
+
class UnknonwOptionError < Error
|
817
|
+
def initialize(command, name)
|
818
|
+
super(command, "unknown option - '#{name}'")
|
819
|
+
end
|
820
|
+
end
|
821
|
+
|
822
|
+
class OptionArgumentMissingError < Error
|
823
|
+
def initialize(command, name, option)
|
824
|
+
super(command, "argument <#{name}> missing - '#{option}'")
|
825
|
+
end
|
826
|
+
end
|
827
|
+
|
828
|
+
class DoublicateOptionDefinitionError < ArgumentError
|
829
|
+
def initialize(name)
|
830
|
+
super("option already defined - #{name}")
|
831
|
+
end
|
832
|
+
end
|
833
|
+
|
834
|
+
class DoublicateArgumentDefinitionError < ArgumentError
|
835
|
+
def initialize(name)
|
836
|
+
super("argument already defined - #{name}")
|
837
|
+
end
|
838
|
+
end
|
839
|
+
|
840
|
+
class NoCommandDefinedError < ArgumentError
|
841
|
+
def initialize
|
842
|
+
super('help text does not define a valid command')
|
843
|
+
end
|
844
|
+
end
|
845
|
+
|
846
|
+
class DoublicateCommandDefinitionError < ArgumentError
|
847
|
+
def initialize(name, line_number)
|
848
|
+
super("command '#{name}' already defined - line #{line_number}")
|
849
|
+
end
|
850
|
+
end
|
851
|
+
|
852
|
+
class NoDefaultCommandDefinedError < ArgumentError
|
853
|
+
def initialize
|
854
|
+
super('no default command defined')
|
855
|
+
end
|
856
|
+
end
|
857
|
+
|
858
|
+
class InvalidSubcommandNameError < ArgumentError
|
859
|
+
def initialize(default_name, bad_name)
|
860
|
+
super("invalid sub-command name for #{default_name} - #{bad_name}")
|
861
|
+
end
|
862
|
+
end
|
863
|
+
|
864
|
+
class UnknownAttributeError < ArgumentError
|
865
|
+
def initialize(name)
|
866
|
+
super("unknown attribute - #{name}")
|
867
|
+
end
|
868
|
+
end
|
869
|
+
|
870
|
+
class UnknownAttributeConverterError < ArgumentError
|
871
|
+
def initialize(name)
|
872
|
+
super("unknown attribute converter - #{name}")
|
873
|
+
end
|
874
|
+
end
|
875
|
+
|
876
|
+
@on_error = ->(e) { $stderr.puts e or exit e.code }
|
877
|
+
|
878
|
+
lib_dir = __FILE__[..-4]
|
879
|
+
|
880
|
+
autoload(:Conversion, "#{lib_dir}/conversion")
|
881
|
+
autoload(:VERSION, "#{lib_dir}/version")
|
882
|
+
|
883
|
+
private_constant(
|
884
|
+
:Assemble,
|
885
|
+
:ArgumentMissingError,
|
886
|
+
:DoublicateArgumentDefinitionError,
|
887
|
+
:DoublicateCommandDefinitionError,
|
888
|
+
:DoublicateOptionDefinitionError,
|
889
|
+
:InvalidArgumentTypeError,
|
890
|
+
:InvalidCommandError,
|
891
|
+
:InvalidSubcommandNameError,
|
892
|
+
:NoCommandDefinedError,
|
893
|
+
:NoDefaultCommandDefinedError,
|
894
|
+
:OptionArgumentMissingError,
|
895
|
+
:TooManyArgumentsError,
|
896
|
+
:UnknonwOptionError,
|
897
|
+
:UnknownAttributeError,
|
898
|
+
:UnknownAttributeConverterError
|
899
|
+
)
|
900
|
+
end
|