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.
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