File: runner.rb

package info (click to toggle)
ruby-commander 4.6.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 360 kB
  • sloc: ruby: 1,971; makefile: 9
file content (462 lines) | stat: -rw-r--r-- 13,122 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# frozen_string_literal: true

require 'optparse'

module Commander
  class Runner
    #--
    # Exceptions
    #++

    class CommandError < StandardError; end

    class InvalidCommandError < CommandError; end

    attr_reader :commands, :options, :help_formatter_aliases

    ##
    # Initialize a new command runner. Optionally
    # supplying _args_ for mocking, or arbitrary usage.

    def initialize(args = ARGV)
      @args, @commands, @aliases, @options = args, {}, {}, []
      @help_formatter_aliases = help_formatter_alias_defaults
      @program = program_defaults
      @always_trace = false
      @never_trace = false
      create_default_commands
    end

    ##
    # Return singleton Runner instance.

    def self.instance
      @instance ||= new
    end

    ##
    # Run command parsing and execution process.

    def run!
      trace = @always_trace || false
      require_program :version, :description
      trap('INT') { abort program(:int_message) } if program(:int_message)
      trap('INT') { program(:int_block).call } if program(:int_block)
      global_option('-h', '--help', 'Display help documentation') do
        args = @args - %w(-h --help)
        command(:help).run(*args)
        return
      end
      global_option('-v', '--version', 'Display version information') do
        say version
        return
      end
      global_option('-t', '--trace', 'Display backtrace when an error occurs') { trace = true } unless @never_trace || @always_trace
      parse_global_options
      remove_global_options options, @args
      if trace
        run_active_command
      else
        begin
          run_active_command
        rescue InvalidCommandError => e
          abort "#{e}. Use --help for more information"
        rescue \
          OptionParser::InvalidOption,
          OptionParser::InvalidArgument,
          OptionParser::MissingArgument => e
          abort e.to_s
        rescue StandardError => e
          if @never_trace
            abort "error: #{e}."
          else
            abort "error: #{e}. Use --trace to view backtrace"
          end
        end
      end
    end

    ##
    # Return program version.

    def version
      format('%s %s', program(:name), program(:version))
    end

    ##
    # Enable tracing on all executions (bypasses --trace)

    def always_trace!
      @always_trace = true
      @never_trace = false
    end

    ##
    # Hide the trace option from the help menus and don't add it as a global option

    def never_trace!
      @never_trace = true
      @always_trace = false
    end

    ##
    # Assign program information.
    #
    # === Examples
    #
    #   # Set data
    #   program :name, 'Commander'
    #   program :version, Commander::VERSION
    #   program :description, 'Commander utility program.'
    #   program :help, 'Copyright', '2008 TJ Holowaychuk'
    #   program :help, 'Anything', 'You want'
    #   program :int_message 'Bye bye!'
    #   program :help_formatter, :compact
    #   program :help_formatter, Commander::HelpFormatter::TerminalCompact
    #
    #   # Get data
    #   program :name # => 'Commander'
    #
    # === Keys
    #
    #   :version         (required) Program version triple, ex: '0.0.1'
    #   :description     (required) Program description
    #   :name            Program name, defaults to basename of executable
    #   :help_formatter  Defaults to Commander::HelpFormatter::Terminal
    #   :help            Allows addition of arbitrary global help blocks
    #   :help_paging     Flag for toggling help paging
    #   :int_message     Message to display when interrupted (CTRL + C)
    #

    def program(key, *args, &block)
      if key == :help && !args.empty?
        @program[:help] ||= {}
        @program[:help][args.first] = args.at(1)
      elsif key == :help_formatter && !args.empty?
        @program[key] = (@help_formatter_aliases[args.first] || args.first)
      elsif block
        @program[key] = block
      else
        unless args.empty?
          @program[key] = args.count == 1 ? args[0] : args
        end
        @program[key]
      end
    end

    ##
    # Creates and yields a command instance when a block is passed.
    # Otherwise attempts to return the command, raising InvalidCommandError when
    # it does not exist.
    #
    # === Examples
    #
    #   command :my_command do |c|
    #     c.when_called do |args|
    #       # Code
    #     end
    #   end
    #

    def command(name, &block)
      yield add_command(Commander::Command.new(name)) if block
      @commands[name.to_s]
    end

    ##
    # Add a global option; follows the same syntax as Command#option
    # This would be used for switches such as --version, --trace, etc.

    def global_option(*args, &block)
      switches, description = Runner.separate_switches_from_description(*args)
      @options << {
        args: args,
        proc: block,
        switches: switches,
        description: description,
      }
    end

    ##
    # Alias command _name_ with _alias_name_. Optionally _args_ may be passed
    # as if they were being passed straight to the original command via the command-line.

    def alias_command(alias_name, name, *args)
      @commands[alias_name.to_s] = command name
      @aliases[alias_name.to_s] = args
    end

    ##
    # Default command _name_ to be used when no other
    # command is found in the arguments.

    def default_command(name)
      @default_command = name
    end

    ##
    # Add a command object to this runner.

    def add_command(command)
      @commands[command.name] = command
    end

    ##
    # Check if command _name_ is an alias.

    def alias?(name)
      @aliases.include? name.to_s
    end

    ##
    # Check if a command _name_ exists.

    def command_exists?(name)
      @commands[name.to_s]
    end

    #:stopdoc:

    ##
    # Get active command within arguments passed to this runner.

    def active_command
      @active_command ||= command(command_name_from_args)
    end

    ##
    # Attempts to locate a command name from within the arguments.
    # Supports multi-word commands, using the largest possible match.
    # Returns the default command, if no valid commands found in the args.

    def command_name_from_args
      @command_name_from_args ||= (longest_valid_command_name_from(@args) || @default_command)
    end

    ##
    # Returns array of valid command names found within _args_.

    def valid_command_names_from(*args)
      remove_global_options options, args
      arg_string = args.delete_if { |value| value =~ /^-/ }.join ' '
      commands.keys.find_all { |name| name if arg_string =~ /^#{name}\b/ }
    end

    ##
    # Help formatter instance.

    def help_formatter
      @help_formatter ||= program(:help_formatter).new self
    end

    ##
    # Return arguments without the command name.

    def args_without_command_name
      removed = []
      parts = command_name_from_args.split rescue []
      @args.dup.delete_if do |arg|
        removed << arg if parts.include?(arg) && !removed.include?(arg)
      end
    end

    ##
    # Returns hash of help formatter alias defaults.

    def help_formatter_alias_defaults
      {
        compact: HelpFormatter::TerminalCompact,
      }
    end

    ##
    # Returns hash of program defaults.

    def program_defaults
      {
        help_formatter: HelpFormatter::Terminal,
        name: File.basename($PROGRAM_NAME),
        help_paging: true,
      }
    end

    ##
    # Creates default commands such as 'help' which is
    # essentially the same as using the --help switch.

    def create_default_commands
      command :help do |c|
        c.syntax = 'commander help [command]'
        c.description = 'Display global or [command] help documentation'
        c.example 'Display global help', 'command help'
        c.example "Display help for 'foo'", 'command help foo'
        c.when_called do |args, _options|
          UI.enable_paging if program(:help_paging)
          if args.empty?
            say help_formatter.render
          else
            command = command(longest_valid_command_name_from(args))
            begin
              require_valid_command command
            rescue InvalidCommandError => e
              abort "#{e}. Use --help for more information"
            end
            say help_formatter.render_command(command)
          end
        end
      end
    end

    ##
    # Raises InvalidCommandError when a _command_ is not found.

    def require_valid_command(command = active_command)
      fail InvalidCommandError, 'invalid command', caller if command.nil?
    end

    ##
    # Removes global _options_ from _args_. This prevents an invalid
    # option error from occurring when options are parsed
    # again for the command.

    def remove_global_options(options, args)
      options.each do |option|
        switches = option[:switches]
        next if switches.empty?

        option_takes_argument = switches.any? { |s| s =~ /[ =]/ }
        switches = expand_optionally_negative_switches(switches)

        option_argument_needs_removal = false
        args.delete_if do |token|
          break if token == '--'

          # Use just the portion of the token before the = when
          # comparing switches.
          index_of_equals = token.index('=') if option_takes_argument
          token = token[0, index_of_equals] if index_of_equals
          token_contains_option_argument = !index_of_equals.nil?

          if switches.any? { |s| s[0, token.length] == token }
            option_argument_needs_removal =
              option_takes_argument && !token_contains_option_argument
            true
          elsif option_argument_needs_removal && token !~ /^-/
            option_argument_needs_removal = false
            true
          else
            option_argument_needs_removal = false
            false
          end
        end
      end
    end

    # expand switches of the style '--[no-]blah' into both their
    # '--blah' and '--no-blah' variants, so that they can be
    # properly detected and removed
    def expand_optionally_negative_switches(switches)
      switches.reduce([]) do |memo, val|
        if val =~ /\[no-\]/
          memo << val.gsub(/\[no-\]/, '')
          memo << val.gsub(/\[no-\]/, 'no-')
        else
          memo << val
        end
      end
    end

    ##
    # Parse global command options.

    def parse_global_options
      parser = options.inject(OptionParser.new) do |options, option|
        options.on(*option[:args], &global_option_proc(option[:switches], &option[:proc]))
      end

      options = @args.dup
      begin
        parser.parse!(options)
      rescue OptionParser::InvalidOption => e
        # Remove the offending args and retry.
        options = options.reject { |o| e.args.include?(o) }
        retry
      end
    end

    ##
    # Returns a proc allowing for commands to inherit global options.
    # This functionality works whether a block is present for the global
    # option or not, so simple switches such as --verbose can be used
    # without a block, and used throughout all commands.

    def global_option_proc(switches, &block)
      lambda do |value|
        unless active_command.nil?
          active_command.global_options << [Runner.switch_to_sym(switches.last), value]
        end
        yield value if block && !value.nil?
      end
    end

    ##
    # Raises a CommandError when the program any of the _keys_ are not present, or empty.

    def require_program(*keys)
      keys.each do |key|
        fail CommandError, "program #{key} required" if program(key).nil? || program(key).empty?
      end
    end

    ##
    # Return switches and description separated from the _args_ passed.

    def self.separate_switches_from_description(*args)
      switches = args.find_all { |arg| arg.to_s =~ /^-/ }
      description = args.last if args.last.is_a?(String) && !args.last.match(/^-/)
      [switches, description]
    end

    ##
    # Attempts to generate a method name symbol from +switch+.
    # For example:
    #
    #   -h                 # => :h
    #   --trace            # => :trace
    #   --some-switch      # => :some_switch
    #   --[no-]feature     # => :feature
    #   --file FILE        # => :file
    #   --list of,things   # => :list
    #

    def self.switch_to_sym(switch)
      switch.scan(/[\-\]](\w+)/).join('_').to_sym rescue nil
    end

    ##
    # Run the active command.

    def run_active_command
      require_valid_command
      if alias? command_name_from_args
        active_command.run(*(@aliases[command_name_from_args.to_s] + args_without_command_name))
      else
        active_command.run(*args_without_command_name)
      end
    end

    def say(*args) #:nodoc:
      HighLine.default_instance.say(*args)
    end

    private

    ##
    # Attempts to locate a command name from within the provided arguments.
    # Supports multi-word commands, using the largest possible match.

    def longest_valid_command_name_from(args)
      valid_command_names_from(*args.dup).max
    end
  end
end