File: command.rb

package info (click to toggle)
ruby-cri 2.15.12-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 384 kB
  • sloc: ruby: 2,776; makefile: 11
file content (461 lines) | stat: -rw-r--r-- 14,693 bytes parent folder | download | duplicates (3)
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
# frozen_string_literal: true

module Cri
  # Cri::Command represents a command that can be executed on the command line.
  # It is also used for the command-line tool itself.
  class Command
    # Delegate used for partitioning the list of arguments and options. This
    # delegate will stop the parser as soon as the first argument, i.e. the
    # command, is found.
    #
    # @api private
    class ParserPartitioningDelegate
      # Returns the last parsed argument, which, in this case, will be the
      # first argument, which will be either nil or the command name.
      #
      # @return [String] The last parsed argument.
      attr_reader :last_argument

      # Called when an option is parsed.
      #
      # @param [Symbol] _key The option key (derived from the long format)
      #
      # @param _value The option value
      #
      # @param [Cri::Parser] _parser The option parser
      #
      # @return [void]
      def option_added(_key, _value, _parser); end

      # Called when an argument is parsed.
      #
      # @param [String] argument The argument
      #
      # @param [Cri::Parser] parser The option parser
      #
      # @return [void]
      def argument_added(argument, parser)
        @last_argument = argument
        parser.stop
      end
    end

    # Signals that Cri should abort execution. Unless otherwise specified using the `hard_exit`
    # param, this exception will cause Cri to exit the running process.
    #
    # @api private
    class CriExitException < StandardError
      def initialize(is_error:)
        super('exit requested')

        @is_error = is_error
      end

      def error?
        @is_error
      end
    end

    # @return [Cri::Command, nil] This command’s supercommand, or nil if the
    #   command has no supercommand
    attr_accessor :supercommand

    # @return [Set<Cri::Command>] This command’s subcommands
    attr_accessor :commands
    alias subcommands commands

    # @return [Symbol] The name of the default subcommand
    attr_accessor :default_subcommand_name

    # @return [String] The name
    attr_accessor :name

    # @return [Array<String>] A list of aliases for this command that can be
    #   used to invoke this command
    attr_accessor :aliases

    # @return [String] The short description (“summary”)
    attr_accessor :summary

    # @return [String] The long description (“description”)
    attr_accessor :description

    # @return [String] The usage, without the “usage:” prefix and without the
    #   supercommands’ names.
    attr_accessor :usage

    # @return [Boolean] true if the command is hidden (e.g. because it is
    #   deprecated), false otherwise
    attr_accessor :hidden
    alias hidden? hidden

    # @return [Array<Cri::OptionDefinition>] The list of option definitions
    attr_accessor :option_definitions

    # @return [Array<Hash>] The list of parameter definitions
    attr_accessor :parameter_definitions

    # @return [Boolean] Whether or not this command has parameters
    attr_accessor :explicitly_no_params
    alias explicitly_no_params? explicitly_no_params

    # @return [Proc] The block that should be executed when invoking this
    #   command (ignored for commands with subcommands)
    attr_accessor :block

    # @return [Boolean] true if the command should skip option parsing and
    # treat all options as arguments.
    attr_accessor :all_opts_as_args
    alias all_opts_as_args? all_opts_as_args

    # Creates a new command using the DSL. If a string is given, the command
    # will be defined using the string; if a block is given, the block will be
    # used instead.
    #
    # If the block has one parameter, the block will be executed in the same
    # context with the command DSL as its parameter. If the block has no
    # parameters, the block will be executed in the context of the DSL.
    #
    # @param [String, nil] string The command definition as a string
    #
    # @param [String, nil] filename The filename corresponding to the string parameter (only useful if a string is given)
    #
    # @return [Cri::Command] The newly defined command
    def self.define(string = nil, filename = nil, &block)
      dsl = Cri::CommandDSL.new
      if string
        args = filename ? [string, filename] : [string]
        dsl.instance_eval(*args)
      elsif [-1, 0].include? block.arity
        dsl.instance_eval(&block)
      else
        block.call(dsl)
      end
      dsl.command
    end

    # Creates a new command using a DSL, from code defined in the given filename.
    #
    # @param [String] filename The filename that contains the command definition
    #   as a string
    #
    # @return [Cri::Command] The newly defined command
    def self.load_file(filename, infer_name: false)
      code = File.read(filename, encoding: 'UTF-8')
      define(code, filename).tap do |cmd|
        if infer_name
          command_name = File.basename(filename, '.rb')
          cmd.modify { name command_name }
        end
      end
    end

    # Returns a new command that has support for the `-h`/`--help` option and
    # also has a `help` subcommand. It is intended to be modified (adding
    # name, summary, description, other subcommands, …)
    #
    # @return [Cri::Command] A basic root command
    def self.new_basic_root
      filename = File.dirname(__FILE__) + '/commands/basic_root.rb'
      define(File.read(filename))
    end

    # Returns a new command that implements showing help.
    #
    # @return [Cri::Command] A basic help command
    def self.new_basic_help
      filename = File.dirname(__FILE__) + '/commands/basic_help.rb'
      define(File.read(filename))
    end

    def initialize
      @aliases            = Set.new
      @commands           = Set.new
      @option_definitions = Set.new
      @parameter_definitions = []
      @explicitly_no_params = false
      @default_subcommand_name = nil
    end

    # Modifies the command using the DSL.
    #
    # If the block has one parameter, the block will be executed in the same
    # context with the command DSL as its parameter. If the block has no
    # parameters, the block will be executed in the context of the DSL.
    #
    # @return [Cri::Command] The command itself
    def modify(&block)
      dsl = Cri::CommandDSL.new(self)
      if [-1, 0].include? block.arity
        dsl.instance_eval(&block)
      else
        yield(dsl)
      end
      self
    end

    # @return [Enumerable<Cri::OptionDefinition>] The option definitions for the
    #   command itself and all its ancestors
    def global_option_definitions
      res = Set.new
      res.merge(option_definitions)
      res.merge(supercommand.global_option_definitions) if supercommand
      res
    end

    # Adds the given command as a subcommand to the current command.
    #
    # @param [Cri::Command] command The command to add as a subcommand
    #
    # @return [void]
    def add_command(command)
      @commands << command
      command.supercommand = self
    end

    # Defines a new subcommand for the current command using the DSL.
    #
    # @param [String, nil] name The name of the subcommand, or nil if no name
    #   should be set (yet)
    #
    # @return [Cri::Command] The subcommand
    def define_command(name = nil, &block)
      # Execute DSL
      dsl = Cri::CommandDSL.new
      dsl.name name unless name.nil?
      if [-1, 0].include? block.arity
        dsl.instance_eval(&block)
      else
        yield(dsl)
      end

      # Create command
      cmd = dsl.command
      add_command(cmd)
      cmd
    end

    # Returns the commands that could be referred to with the given name. If
    # the result contains more than one command, the name is ambiguous.
    #
    # @param [String] name The full, partial or aliases name of the command
    #
    # @return [Array<Cri::Command>] A list of commands matching the given name
    def commands_named(name)
      # Find by exact name or alias
      @commands.each do |cmd|
        found = cmd.name == name || cmd.aliases.include?(name)
        return [cmd] if found
      end

      # Find by approximation
      @commands.select do |cmd|
        cmd.name[0, name.length] == name
      end
    end

    # Returns the command with the given name. This method will display error
    # messages and exit in case of an error (unknown or ambiguous command).
    #
    # The name can be a full command name, a partial command name (e.g. “com”
    # for “commit”) or an aliased command name (e.g. “ci” for “commit”).
    #
    # @param [String] name The full, partial or aliases name of the command
    #
    # @return [Cri::Command] The command with the given name
    def command_named(name, hard_exit: true)
      commands = commands_named(name)

      if commands.empty?
        warn "#{self.name}: unknown command '#{name}'\n"
        raise CriExitException.new(is_error: true)
      elsif commands.size > 1
        warn "#{self.name}: '#{name}' is ambiguous:"
        warn "  #{commands.map(&:name).sort.join(' ')}"
        raise CriExitException.new(is_error: true)
      else
        commands[0]
      end
    rescue CriExitException => e
      exit(e.error? ? 1 : 0) if hard_exit
    end

    # Runs the command with the given command-line arguments, possibly invoking
    # subcommands and passing on the options and arguments.
    #
    # @param [Array<String>] opts_and_args A list of unparsed arguments
    #
    # @param [Hash] parent_opts A hash of options already handled by the
    #   supercommand
    #
    # @return [void]
    def run(opts_and_args, parent_opts = {}, hard_exit: true)
      # Parse up to command name
      stuff = partition(opts_and_args)
      opts_before_subcmd, subcmd_name, opts_and_args_after_subcmd = *stuff

      if subcommands.empty? || (subcmd_name.nil? && !block.nil?)
        run_this(opts_and_args, parent_opts)
      else
        # Handle options
        handle_options(opts_before_subcmd)

        # Get command
        if subcmd_name.nil?
          if default_subcommand_name
            subcmd_name = default_subcommand_name
          else
            warn "#{name}: no command given"
            raise CriExitException.new(is_error: true)
          end
        end
        subcommand = command_named(subcmd_name, hard_exit: hard_exit)
        return if subcommand.nil?

        # Run
        subcommand.run(opts_and_args_after_subcmd, parent_opts.merge(opts_before_subcmd), hard_exit: hard_exit)
      end
    rescue CriExitException => e
      exit(e.error? ? 1 : 0) if hard_exit
    end

    # Runs the actual command with the given command-line arguments, not
    # invoking any subcommands. If the command does not have an execution
    # block, an error ir raised.
    #
    # @param [Array<String>] opts_and_args A list of unparsed arguments
    #
    # @param [Hash] parent_opts A hash of options already handled by the
    #   supercommand
    #
    # @raise [NotImplementedError] if the command does not have an execution
    #   block
    #
    # @return [void]
    def run_this(opts_and_args, parent_opts = {})
      if all_opts_as_args?
        args = opts_and_args
        global_opts = parent_opts
      else
        # Parse
        parser = Cri::Parser.new(
          opts_and_args,
          global_option_definitions,
          parameter_definitions,
          explicitly_no_params?,
        )
        handle_errors_while { parser.run }
        local_opts = parser.options
        global_opts = parent_opts.merge(parser.options)
        global_opts = add_defaults(global_opts)

        # Handle options
        handle_options(local_opts)
        args = handle_errors_while { parser.gen_argument_list }
      end

      # Execute
      if block.nil?
        raise NotImplementedError,
              "No implementation available for '#{name}'"
      end
      block.call(global_opts, args, self)
    end

    def all_opt_defns
      if supercommand
        supercommand.all_opt_defns | option_definitions
      else
        option_definitions
      end
    end

    # @return [String] The help text for this command
    #
    # @option params [Boolean] :verbose true if the help output should be
    #   verbose, false otherwise.
    #
    # @option params [IO] :io ($stdout) the IO the help text is intended for.
    #   This influences the decision to enable/disable colored output.
    def help(**params)
      HelpRenderer.new(self, **params).render
    end

    # Compares this command's name to the other given command's name.
    #
    # @param [Cri::Command] other The command to compare with
    #
    # @return [-1, 0, 1] The result of the comparison between names
    #
    # @see Object<=>
    def <=>(other)
      name <=> other.name
    end

    private

    def handle_options(opts)
      opts.each_pair do |key, value|
        opt_defn = global_option_definitions.find { |o| (o.long || o.short) == key.to_s }
        block = opt_defn.block
        block&.call(value, self)
      end
    end

    def partition(opts_and_args)
      return [{}, opts_and_args.first, opts_and_args] if all_opts_as_args?

      # Parse
      delegate = Cri::Command::ParserPartitioningDelegate.new
      parser = Cri::Parser.new(
        opts_and_args,
        global_option_definitions,
        parameter_definitions,
        explicitly_no_params?,
      )
      parser.delegate = delegate
      handle_errors_while { parser.run }

      # Extract
      [
        parser.options,
        delegate.last_argument,
        parser.unprocessed_arguments_and_options,
      ]
    end

    def handle_errors_while
      yield
    rescue Cri::Parser::IllegalOptionError => e
      warn "#{name}: unrecognised option -- #{e}"
      raise CriExitException.new(is_error: true)
    rescue Cri::Parser::OptionRequiresAnArgumentError => e
      warn "#{name}: option requires an argument -- #{e}"
      raise CriExitException.new(is_error: true)
    rescue Cri::Parser::IllegalOptionValueError => e
      warn "#{name}: #{e.message}"
      raise CriExitException.new(is_error: true)
    rescue Cri::ArgumentList::ArgumentCountMismatchError => e
      warn "#{name}: #{e.message}"
      raise CriExitException.new(is_error: true)
    end

    def add_defaults(options)
      all_opt_defns_by_key =
        all_opt_defns.each_with_object({}) do |opt_defn, hash|
          key = (opt_defn.long || opt_defn.short).to_sym
          hash[key] = opt_defn
        end

      new_options = Hash.new do |hash, key|
        hash.fetch(key) { all_opt_defns_by_key[key]&.default }
      end

      options.each do |key, value|
        new_options[key] = value
      end

      new_options
    end
  end
end