claide 0.5.0 → 0.6.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.
@@ -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