parse-argv 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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