claide 0.5.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,83 @@
1
+ module CLAide
2
+ class ARGV
3
+ module Parser
4
+ # @return [Array<Array<Symbol, String, Array>>] A list of tuples for each
5
+ # parameter, where the first entry is the `type` and the second
6
+ # entry the actual parsed parameter.
7
+ #
8
+ # @example
9
+ #
10
+ # list = parse(['tea', '--no-milk', '--sweetner=honey'])
11
+ # list # => [[:arg, "tea"],
12
+ # [:flag, ["milk", false]],
13
+ # [:option, ["sweetner", "honey"]]]
14
+ #
15
+ def self.parse(argv)
16
+ entries = []
17
+ copy = argv.map(&:to_s)
18
+ while argument = copy.shift
19
+ type = argument_type(argument)
20
+ parameter = argument_parameter(type, argument)
21
+ entries << [type, parameter]
22
+ end
23
+ entries
24
+ end
25
+
26
+ # @return [Symbol] Returns the type of an argument. The types can be
27
+ # either: `:arg`, `:flag`, `:option`.
28
+ #
29
+ # @param [String] argument
30
+ # The argument to check.
31
+ #
32
+ def self.argument_type(argument)
33
+ if argument.start_with?('--')
34
+ if argument.include?('=')
35
+ :option
36
+ else
37
+ :flag
38
+ end
39
+ else
40
+ :arg
41
+ end
42
+ end
43
+
44
+ # @return [String, Array<String, String>] Returns argument itself for
45
+ # normal arguments (like commands) and a tuple with they key and
46
+ # the value for options and flags.
47
+ #
48
+ # @param [Symbol] type
49
+ # The type of the argument.
50
+ #
51
+ # @param [String] argument
52
+ # The argument to check.
53
+ #
54
+ def self.argument_parameter(type, argument)
55
+ case type
56
+ when :arg
57
+ return argument
58
+ when :flag
59
+ return flag_paramenter(argument)
60
+ when :option
61
+ return argument[2..-1].split('=', 2)
62
+ end
63
+ end
64
+
65
+ # @return [String, Array<String, String>] Returns the parameter
66
+ # describing a flag arguments.
67
+ #
68
+ # @param [String] argument
69
+ # The flag argument to check.
70
+ #
71
+ def self.flag_paramenter(argument)
72
+ if argument.start_with?('--no-')
73
+ key = argument[5..-1]
74
+ value = false
75
+ else
76
+ key = argument[2..-1]
77
+ value = true
78
+ end
79
+ [key, value]
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,9 +1,13 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  require 'claide/command/banner'
4
+ require 'claide/command/parser'
5
+ require 'claide/command/plugins_helper'
6
+ require 'claide/command/options'
7
+ require 'claide/command/shell_completion_helper'
8
+ require 'claide/command/validation_helper'
4
9
 
5
10
  module CLAide
6
-
7
11
  # This class is used to build a command-line interface
8
12
  #
9
13
  # Each command is represented by a subclass of this class, which may be
@@ -44,11 +48,7 @@ module CLAide
44
48
  # defaults to {Command.summary}.
45
49
  #
46
50
  class Command
47
-
48
- #-------------------------------------------------------------------------#
49
-
50
51
  class << self
51
-
52
52
  # @return [Boolean] Indicates whether or not this command can actually
53
53
  # perform work of itself, or that it only contains subcommands.
54
54
  #
@@ -60,6 +60,7 @@ module CLAide
60
60
  # help banner or to show its subcommands instead.
61
61
  #
62
62
  # Setting this to `true` implies it’s an abstract command.
63
+ #
63
64
  attr_reader :ignore_in_command_lookup
64
65
  alias_method :ignore_in_command_lookup?, :ignore_in_command_lookup
65
66
  def ignore_in_command_lookup=(flag)
@@ -88,10 +89,43 @@ module CLAide
88
89
  #
89
90
  attr_accessor :plugin_prefix
90
91
 
91
- # @return [String] A list of arguments the command handles. This is shown
92
- # in the usage section of the command’s help banner.
92
+ # @return [Array<Tuple>] A list of arguments the command handles. This
93
+ # is shown in the usage section of the command’s help banner.
94
+ # Each tuple in the array represents an argument using
95
+ # [name, type] where:
96
+ # - name is a String containing the argument
97
+ # - type is either :optional or :required
93
98
  #
99
+ # @TODO Remove deprecation
100
+ #
101
+ # rubocop:disable all
94
102
  attr_accessor :arguments
103
+ def arguments
104
+ @arguments ||= []
105
+ end
106
+
107
+ # @param [Array<Tuple>] arguments
108
+ # An array containing arguments, each described by a tuple of
109
+ # the form [name, type], where:
110
+ # - name is a String containing the argument
111
+ # - type is either :optional or :required
112
+ #
113
+ def arguments=(arguments)
114
+ if arguments.is_a?(Array)
115
+ @arguments = arguments
116
+ else
117
+ warn '[!] The specification of arguments as a string has been' \
118
+ " deprecated #{self}: `#{arguments}`".ansi.yellow
119
+ @arguments = arguments.split(' ').map do |argument|
120
+ if argument.start_with?('[')
121
+ [argument.sub(/\[(.*)\]/, '\1'), :optional]
122
+ else
123
+ [argument, :required]
124
+ end
125
+ end
126
+ end
127
+ end
128
+ # rubocop:enable all
95
129
 
96
130
  # @return [Boolean] The default value for {Command#ansi_output}. This
97
131
  # defaults to `true` if `STDOUT` is connected to a TTY and
@@ -101,31 +135,13 @@ module CLAide
101
135
  #
102
136
  def ansi_output
103
137
  if @ansi_output.nil?
104
- @ansi_output = STDOUT.tty? &&
105
- String.method_defined?(:red) &&
106
- String.method_defined?(:green) &&
107
- String.method_defined?(:yellow)
138
+ @ansi_output = STDOUT.tty?
108
139
  end
109
140
  @ansi_output
110
141
  end
111
142
  attr_writer :ansi_output
112
143
  alias_method :ansi_output?, :ansi_output
113
144
 
114
- def colorize_output
115
- warn "[!] The use of `CLAide::Command.colorize_output` has been " \
116
- "deprecated. Use `CLAide::Command.ansi_output` instead. " \
117
- "(Called from: #{caller.first})"
118
- ansi_output
119
- end
120
- alias_method :colorize_output?, :colorize_output
121
-
122
- def colorize_output=(flag)
123
- warn "[!] The use of `CLAide::Command.colorize_output=` has been " \
124
- "deprecated. Use `CLAide::Command.ansi_output=` instead. " \
125
- "(Called from: #{caller.first})"
126
- self.ansi_output = flag
127
- end
128
-
129
145
  # @return [String] The name of the command. Defaults to a snake-cased
130
146
  # version of the class’ name.
131
147
  #
@@ -136,265 +152,215 @@ module CLAide
136
152
  end
137
153
  attr_writer :command
138
154
 
139
- # @return [String] The full command up-to this command, as it would be
140
- # looked up during parsing.
141
- #
142
- # @note (see #ignore_in_command_lookup)
143
- #
144
- # @example
155
+ # @return [String] The version of the command. This value will be printed
156
+ # by the `--version` flag if used for the root command.
145
157
  #
146
- # BevarageMaker::Tea.full_command # => "beverage-maker tea"
147
- #
148
- def full_command
149
- if superclass == Command
150
- ignore_in_command_lookup? ? '' : command
158
+ attr_accessor :version
159
+ end
160
+
161
+ #-------------------------------------------------------------------------#
162
+
163
+ # @return [String] The full command up-to this command, as it would be
164
+ # looked up during parsing.
165
+ #
166
+ # @note (see #ignore_in_command_lookup)
167
+ #
168
+ # @example
169
+ #
170
+ # BevarageMaker::Tea.full_command # => "beverage-maker tea"
171
+ #
172
+ def self.full_command
173
+ if superclass == Command
174
+ ignore_in_command_lookup? ? '' : command
175
+ else
176
+ if ignore_in_command_lookup?
177
+ superclass.full_command
151
178
  else
152
- if ignore_in_command_lookup?
153
- superclass.full_command
154
- else
155
- "#{superclass.full_command} #{command}"
156
- end
179
+ "#{superclass.full_command} #{command}"
157
180
  end
158
181
  end
182
+ end
159
183
 
160
- # @return [Array<Class>] A list of all command classes that are nested
161
- # under this command.
162
- #
163
- def subcommands
164
- @subcommands ||= []
165
- end
184
+ # @return [Bool] Whether this is the root command class
185
+ #
186
+ def self.root_command?
187
+ superclass == CLAide::Command
188
+ end
166
189
 
167
- # @return [Array<Class>] A list of command classes that are nested under
168
- # this command _or_ the subcommands of those command classes in
169
- # case the command class should be ignored in command lookup.
170
- #
171
- def subcommands_for_command_lookup
172
- subcommands.map do |subcommand|
173
- if subcommand.ignore_in_command_lookup?
174
- subcommand.subcommands_for_command_lookup
175
- else
176
- subcommand
177
- end
178
- end.flatten
179
- end
190
+ # @return [Array<Class>] A list of all command classes that are nested
191
+ # under this command.
192
+ #
193
+ def self.subcommands
194
+ @subcommands ||= []
195
+ end
180
196
 
181
- # Searches the list of subcommands that should not be ignored for command
182
- # lookup for a subcommand with the given `name`.
183
- #
184
- # @param [String] name
185
- # The name of the subcommand to be found.
186
- #
187
- # @return [CLAide::Command, nil] The subcommand, if found.
188
- #
189
- def find_subcommand(name)
190
- subcommands_for_command_lookup.find { |sc| sc.command == name }
191
- end
197
+ # @return [Array<Class>] A list of command classes that are nested under
198
+ # this command _or_ the subcommands of those command classes in
199
+ # case the command class should be ignored in command lookup.
200
+ #
201
+ def self.subcommands_for_command_lookup
202
+ subcommands.map do |subcommand|
203
+ if subcommand.ignore_in_command_lookup?
204
+ subcommand.subcommands_for_command_lookup
205
+ else
206
+ subcommand
207
+ end
208
+ end.flatten
209
+ end
192
210
 
193
- # @visibility private
194
- #
195
- # Automatically registers a subclass as a subcommand.
196
- #
197
- def inherited(subcommand)
198
- subcommands << subcommand
199
- end
211
+ # Searches the list of subcommands that should not be ignored for command
212
+ # lookup for a subcommand with the given `name`.
213
+ #
214
+ # @param [String] name
215
+ # The name of the subcommand to be found.
216
+ #
217
+ # @return [CLAide::Command, nil] The subcommand, if found.
218
+ #
219
+ def self.find_subcommand(name)
220
+ subcommands_for_command_lookup.find { |sc| sc.command == name }
221
+ end
200
222
 
201
- # Should be overridden by a subclass if it handles any options.
202
- #
203
- # The subclass has to combine the result of calling `super` and its own
204
- # list of options. The recommended way of doing this is by concatenating
205
- # concatenating to this classes’ own options.
206
- #
207
- # @return [Array<Array>]
208
- #
209
- # A list of option name and description tuples.
210
- #
211
- # @example
212
- #
213
- # def self.options
214
- # [
215
- # ['--verbose', 'Print more info'],
216
- # ['--help', 'Print help banner'],
217
- # ].concat(super)
218
- # end
219
- #
220
- def options
221
- options = [
222
- ['--verbose', 'Show more debugging information'],
223
- ['--help', 'Show help banner of specified command'],
224
- ]
225
- if Command.ansi_output?
226
- options.unshift(['--no-ansi', 'Show output without ANSI codes'])
227
- end
228
- options
229
- end
223
+ # @visibility private
224
+ #
225
+ # Automatically registers a subclass as a subcommand.
226
+ #
227
+ def self.inherited(subcommand)
228
+ subcommands << subcommand
229
+ end
230
230
 
231
- # @param [Array, ARGV] argv
232
- # A list of (remaining) parameters.
233
- #
234
- # @return [Command] An instance of the command class that was matched by
235
- # going through the arguments in the parameters and drilling down
236
- # command classes.
237
- #
238
- def parse(argv)
239
- argv = ARGV.new(argv) unless argv.is_a?(ARGV)
240
- cmd = argv.arguments.first
241
- if cmd && subcommand = find_subcommand(cmd)
242
- argv.shift_argument
243
- subcommand.parse(argv)
244
- elsif abstract_command? && default_subcommand
245
- subcommand = find_subcommand(default_subcommand)
246
- unless subcommand
247
- raise "Unable to find the default subcommand " \
248
- "`#{default_subcommand}` for command `#{self}`."
249
- end
250
- result = subcommand.parse(argv)
251
- result.invoked_as_default = true
252
- result
253
- else
254
- new(argv)
255
- end
256
- end
231
+ # Should be overridden by a subclass if it handles any options.
232
+ #
233
+ # The subclass has to combine the result of calling `super` and its own
234
+ # list of options. The recommended way of doing this is by concatenating
235
+ # concatenating to this classes’ own options.
236
+ #
237
+ # @return [Array<Array>]
238
+ #
239
+ # A list of option name and description tuples.
240
+ #
241
+ # @example
242
+ #
243
+ # def self.options
244
+ # [
245
+ # ['--verbose', 'Print more info'],
246
+ # ['--help', 'Print help banner'],
247
+ # ].concat(super)
248
+ # end
249
+ #
250
+ def self.options
251
+ Options.default_options(self)
252
+ end
257
253
 
258
- # Instantiates the command class matching the parameters through
259
- # {Command.parse}, validates it through {Command#validate!}, and runs it
260
- # through {Command#run}.
261
- #
262
- # @note
263
- #
264
- # You should normally call this on
265
- #
266
- # @param [Array, ARGV] argv
267
- #
268
- # A list of parameters. For instance, the standard `ARGV` constant,
269
- # which contains the parameters passed to the program.
270
- #
271
- # @return [void]
272
- #
273
- def run(argv)
274
- load_plugins
275
- command = parse(argv)
254
+ # Instantiates the command class matching the parameters through
255
+ # {Command.parse}, validates it through {Command#validate!}, and runs it
256
+ # through {Command#run}.
257
+ #
258
+ # @note You should normally call this on
259
+ #
260
+ # @note The ANSI support is configured before running a command to allow
261
+ # the same process to run multiple commands with different
262
+ # settings. For example a process with ANSI output enabled might
263
+ # want to programmatically invoke another command with the output
264
+ # enabled.
265
+ #
266
+ # @param [Array, ARGV] argv
267
+ # A list of parameters. For instance, the standard `ARGV` constant,
268
+ # which contains the parameters passed to the program.
269
+ #
270
+ # @return [void]
271
+ #
272
+ def self.run(argv = [])
273
+ argv = ARGV.coherce(argv)
274
+ PluginsHelper.load_plugins(plugin_prefix)
275
+ command = parse(argv)
276
+
277
+ unless Options.handle_root_option(command, argv)
278
+ ANSI.disabled = !ansi_output?
276
279
  command.validate!
277
280
  command.run
278
- rescue Exception => exception
279
- if exception.is_a?(InformativeError)
280
- puts exception.message
281
- if command.verbose?
282
- puts
283
- puts(*exception.backtrace)
284
- end
285
- exit exception.exit_status
286
- else
287
- report_error(exception)
288
- end
289
- end
290
-
291
- # Allows the application to perform custom error reporting, by overriding
292
- # this method.
293
- #
294
- # @param [Exception] exception
295
- #
296
- # An exception that occurred while running a command through
297
- # {Command.run}.
298
- #
299
- # @raise
300
- #
301
- # By default re-raises the specified exception.
302
- #
303
- # @return [void]
304
- #
305
- def report_error(exception)
306
- raise exception
307
281
  end
282
+ rescue Object => exception
283
+ handle_exception(command, exception)
284
+ end
308
285
 
309
- # @visibility private
310
- #
311
- # @param [String] error_message
312
- # The error message to show to the user.
313
- #
314
- # @param [Boolean] ansi_output
315
- # Whether or not to use ANSI codes to prettify output.
316
- #
317
- # @param [Class] help_class
318
- # The class to use to raise a ‘help’ error.
319
- #
320
- # @raise [Help]
321
- #
322
- # Signals CLAide that a help banner for this command should be shown,
323
- # with an optional error message.
324
- #
325
- # @return [void]
326
- #
327
- def help!(error_message = nil, ansi_output = false, help_class = Help)
328
- raise help_class.new(banner(ansi_output), error_message, ansi_output)
329
- end
286
+ def self.parse(argv)
287
+ Parser.parse(self, argv)
288
+ end
330
289
 
331
- # @visibility private
332
- #
333
- # Returns the banner for the command.
334
- #
335
- # @param [Boolean] ansi
336
- # Whether the banner should use ANSI codes to prettify output.
337
- #
338
- # @param [Class] banner_class
339
- # The class to use to format help banners.
340
- #
341
- # @return [String] The banner for the command.
342
- #
343
- def banner(ansi_output = false, banner_class = Banner)
344
- banner_class.new(self, ansi_output).formatted_banner
290
+ # Presents an exception to the user according to class of the .
291
+ #
292
+ # @param [Command] command
293
+ # The command which originated the exception.
294
+ #
295
+ # @param [Object] exception
296
+ # The exception to present.
297
+ #
298
+ # @return [void]
299
+ #
300
+ def self.handle_exception(command, exception)
301
+ if exception.is_a?(InformativeError)
302
+ puts exception.message
303
+ if command.verbose?
304
+ puts
305
+ puts(*exception.backtrace)
306
+ end
307
+ exit exception.exit_status
308
+ else
309
+ report_error(exception)
345
310
  end
311
+ end
346
312
 
347
- # Load additional plugins via rubygems looking for:
348
- #
349
- # <command-path>/plugin.rb
350
- #
351
- # where <command-path> is the namespace of the Command converted to a
352
- # path, for example:
353
- #
354
- # Pod::Command
355
- #
356
- # maps to
357
- #
358
- # pod/command
359
- #
360
- def load_plugins
361
- return unless plugin_prefix
362
- files_to_require = if Gem.respond_to? :find_latest_files
363
- Gem.find_latest_files("#{plugin_prefix}_plugin")
364
- else
365
- Gem.find_files("#{plugin_prefix}_plugin")
366
- end
367
- files_to_require.each { |path| require_plugin_path(path) }
368
- end
313
+ # Allows the application to perform custom error reporting, by overriding
314
+ # this method.
315
+ #
316
+ # @param [Exception] exception
317
+ #
318
+ # An exception that occurred while running a command through
319
+ # {Command.run}.
320
+ #
321
+ # @raise
322
+ #
323
+ # By default re-raises the specified exception.
324
+ #
325
+ # @return [void]
326
+ #
327
+ def self.report_error(exception)
328
+ raise exception
329
+ end
369
330
 
370
- # Loads the plugin file at the given path, catching any failure.
371
- #
372
- # @param [String] path
373
- # The path to load.
374
- #
375
- def require_plugin_path(path)
376
- require path
377
- rescue Exception => exception
378
- message = "\n---------------------------------------------"
379
- message << "\nError loading the plugin with path `#{path}`.\n"
380
- message << "\n#{exception.class} - #{exception.message}"
381
- message << "\n#{exception.backtrace.join("\n")}"
382
- message << "\n---------------------------------------------\n"
383
- puts prettify_plugin_load_error(message)
384
- end
331
+ # @visibility private
332
+ #
333
+ # @param [String] error_message
334
+ # The error message to show to the user.
335
+ #
336
+ # @param [Class] help_class
337
+ # The class to use to raise a ‘help’ error.
338
+ #
339
+ # @raise [Help]
340
+ #
341
+ # Signals CLAide that a help banner for this command should be shown,
342
+ # with an optional error message.
343
+ #
344
+ # @return [void]
345
+ #
346
+ def self.help!(error_message = nil, help_class = Help)
347
+ raise help_class.new(banner, error_message)
348
+ end
385
349
 
386
- # Override to control how to print the warning that’s shown when an
387
- # exception occurs during plugin loading.
388
- #
389
- # By default this will be displayed in yellow if `#ansi_output?` returns
390
- # `true`.
391
- #
392
- # @param [String] message
393
- # The plugin load error message.
394
- #
395
- def prettify_plugin_load_error(message)
396
- ansi_output? ? message.yellow : message
397
- end
350
+ # @visibility private
351
+ #
352
+ # Returns the banner for the command.
353
+ #
354
+ # @param [Boolean] ansi
355
+ # Whether the banner should use ANSI codes to prettify output.
356
+ #
357
+ # @param [Class] banner_class
358
+ # The class to use to format help banners.
359
+ #
360
+ # @return [String] The banner for the command.
361
+ #
362
+ def self.banner(banner_class = Banner)
363
+ banner_class.new(self).formatted_banner
398
364
  end
399
365
 
400
366
  #-------------------------------------------------------------------------#
@@ -429,27 +395,6 @@ module CLAide
429
395
  attr_accessor :ansi_output
430
396
  alias_method :ansi_output?, :ansi_output
431
397
 
432
- def colorize_output
433
- warn "[!] The use of `CLAide::Command#colorize_output` has been " \
434
- "deprecated. Use `CLAide::Command#ansi_output` instead. " \
435
- "(Called from: #{caller.first})"
436
- ansi_output
437
- end
438
- alias_method :colorize_output?, :colorize_output
439
-
440
- def colorize_output=(flag)
441
- warn "[!] The use of `CLAide::Command#colorize_output=` has been " \
442
- "deprecated. Use `CLAide::Command#ansi_output=` instead. " \
443
- "(Called from: #{caller.first})"
444
- self.ansi_output = flag
445
- end
446
-
447
- # @return [Bool] Whether the command was invoked by an abstract command by
448
- # default.
449
- #
450
- attr_accessor :invoked_as_default
451
- alias_method :invoked_as_default?, :invoked_as_default
452
-
453
398
  # Subclasses should override this method to remove the arguments/options
454
399
  # they support from `argv` _before_ calling `super`.
455
400
  #
@@ -466,17 +411,15 @@ module CLAide
466
411
  argv = ARGV.new(argv) unless argv.is_a?(ARGV)
467
412
  @verbose = argv.flag?('verbose')
468
413
  @ansi_output = argv.flag?('ansi', Command.ansi_output?)
469
-
470
- color = argv.flag?('color')
471
- unless color.nil?
472
- warn "[!] The use of the `--color`/`--no-color` flag has been " \
473
- "deprecated. Use `--ansi`/`--no-ansi` instead."
474
- @ansi_output = color
475
- end
476
-
477
414
  @argv = argv
478
415
  end
479
416
 
417
+ # @return [Bool] Whether the command was invoked by an abstract command by
418
+ # default.
419
+ #
420
+ attr_accessor :invoked_as_default
421
+ alias_method :invoked_as_default?, :invoked_as_default
422
+
480
423
  # Raises a Help exception if the `--help` option is specified, if `argv`
481
424
  # still contains remaining arguments/options by the time it reaches this
482
425
  # implementation, or when called on an ‘abstract command’.
@@ -491,17 +434,20 @@ module CLAide
491
434
  #
492
435
  def validate!
493
436
  help! if @argv.flag?('help')
494
- help! "Unknown arguments: #{@argv.remainder.join(' ')}" if !@argv.empty?
437
+ unless @argv.empty?
438
+ help! ValidationHelper.argument_suggestion(@argv.remainder, self.class)
439
+ end
495
440
  help! if self.class.abstract_command?
496
441
  end
497
442
 
498
- # This method should be overridden by the command class to perform its work.
443
+ # This method should be overridden by the command class to perform its
444
+ # work.
499
445
  #
500
446
  # @return [void
501
447
  #
502
448
  def run
503
- raise "A subclass should override the `CLAide::Command#run` method to " \
504
- "actually perform some work."
449
+ raise 'A subclass should override the `CLAide::Command#run` method to ' \
450
+ 'actually perform some work.'
505
451
  end
506
452
 
507
453
  protected
@@ -519,10 +465,9 @@ module CLAide
519
465
  else
520
466
  command = self.class
521
467
  end
522
- command = command.help!(error_message, ansi_output?)
468
+ command.help!(error_message)
523
469
  end
524
470
 
525
471
  #-------------------------------------------------------------------------#
526
-
527
472
  end
528
473
  end