postmodern/command_kit.rb

View on GitHub
lib/command_kit/commands.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

require_relative 'commands/subcommand'
require_relative 'commands/parent_command'
require_relative 'commands/help'
require_relative 'command_name'
require_relative 'usage'
require_relative 'options'
require_relative 'stdio'
require_relative 'env'

module CommandKit
  #
  # Adds sub-commands to a command.
  #
  # ## Examples
  #
  #     class CLI
  #     
  #       include CommandKit::Commands
  #     
  #       command_name :foo
  #     
  #       class Foo < CommandKit::Command
  #         # ...
  #       end
  #     
  #       class FooBar < CommandKit::Command
  #         # ...
  #       end
  #     
  #       command Foo
  #       command 'foo-bar', FooBar
  #     
  #     end
  #
  module Commands
    include CommandName
    include Usage
    include Options
    include Stdio
    include Env

    #
    # @api private
    #
    module ModuleMethods
      #
      # Extends {ClassMethods} or {ModuleMethods}, depending on whether
      # {Commands} is being included into a class or a module.
      #
      # @param [Class, Module] context
      #   The class or module which is including {Commands}.
      #
      def included(context)
        super(context)

        if context.class == Module
          context.extend ModuleMethods
        else
          context.usage "[options] [COMMAND [ARGS...]]"
          context.argument :command, required: false,
                                     desc: 'The command name to run'

          context.argument :args, required: false,
                                  repeats: true,
                                  desc: 'Additional arguments for the command'

          context.extend ClassMethods
          context.command Help
        end
      end
    end

    extend ModuleMethods

    #
    # Class-level methods.
    #
    module ClassMethods
      #
      # The registered sub-commands.
      #
      # @return [Hash{String => Subcommand}]
      #   The Hash of sub-command names and command classes.
      #
      # @api semipublic
      # 
      def commands
        @commands ||= if superclass.kind_of?(ClassMethods)
                        superclass.commands.dup
                      else
                        {}
                      end
      end

      #
      # The registered command aliases.
      #
      # @return [Hash{String => String}]
      #   The Hash of command aliases to primary command names.
      # 
      # @api semipublic
      #
      def command_aliases
        @command_aliases ||= if superclass.kind_of?(ClassMethods)
                               superclass.command_aliases.dup
                             else
                               {}
                             end
      end

      #
      # Mounts a command as a sub-command.
      #
      # @param [#to_s] name
      #   The optional name to mount the command as. Defaults to the command's
      #   {CommandName::ClassMethods#command_name command_name}.
      #
      # @param [Class#main] command_class
      #   The sub-command class.
      #
      # @param [Hash{Symbol => Object}] kwargs
      #   Keyword arguments.
      #
      # @option kwargs [String, nil] summary
      #   A short summary for the subcommand. Defaults to the first sentence
      #   of the command.
      #
      # @option kwags [Array<String>] aliases
      #   Optional alias names for the subcommand.
      #
      # @return [Subcommand]
      #   The registered sub-command class.
      #
      # @example
      #   command Foo
      #
      # @example
      #   command 'foo-bar', FooBar
      #
      # @api public
      #
      def command(name=nil, command_class, **kwargs)
        name = if name then name.to_s
               else         command_class.command_name
               end

        subcommand = Subcommand.new(command_class,**kwargs)
        commands[name] = subcommand

        subcommand.aliases.each do |alias_name|
          command_aliases[alias_name] = name
        end

        return subcommand
      end

      #
      # Gets the command.
      #
      # @param [String] name
      #   The command name.
      #
      # @return [Class#main, nil]
      #   The command class or `nil` if no command could be found.
      #
      # @api private
      #
      def get_command(name)
        name = name.to_s
        name = command_aliases.fetch(name,name)

        if (subcommand = commands[name])
          subcommand.command
        end
      end
    end

    #
    # Initializes the command.
    #
    # @note Adds a special rule to the {Options#option_parser #option_parser} to
    # stop parsing options when the first non-option is encountered.
    #
    # @api public
    #
    def initialize(**kwargs)
      super(**kwargs)

      @option_parser.on(/^[^-].*$/) do |command|
        OptionParser.terminate(command)
      end
    end

    #
    # Looks up the given command name and initializes a subcommand.
    #
    # @param [#to_s] name
    #   The given command name.
    #
    # @return [Object#main, nil]
    #   The initialized subcommand.
    #
    # @api private
    #
    def command(name)
      unless (command_class = self.class.get_command(name))
        return
      end

      kwargs = {}

      if command_class.include?(ParentCommand)
        kwargs[:parent_command] = self
      end

      if command_class.include?(CommandName)
        kwargs[:command_name] = "#{command_name} #{command_class.command_name}"
      end

      if command_class.include?(Stdio)
        kwargs[:stdin]  = stdin
        kwargs[:stdout] = stdout
        kwargs[:stderr] = stderr
      end

      if command_class.include?(Env)
        kwargs[:env] = if env.eql?(ENV) then env.to_h
                       else                  env.dup
                       end
      end

      if command_class.include?(Options)
        kwargs[:options] = options.dup
      end

      return command_class.new(**kwargs)
    end

    #
    # Invokes the command with the given argv.
    #
    # @param [String] name
    #   The name of the command to invoke.
    #
    # @param [Array<String>] argv
    #   The additional arguments to pass to the command.
    #
    # @return [Integer]
    #   The exit status of the command.
    #
    # @api semipublic
    #
    def invoke(name,*argv)
      if (command = command(name))
        command.main(argv)
      else
        on_unknown_command(name,argv)
      end
    end

    #
    # Prints an error about an unknown command and exits with an error code.
    #
    # @param [String] name
    #   The command name.
    #
    # @api semipublic
    #
    def command_not_found(name)
      print_error "'#{name}' is not a #{command_name} command. See `#{command_name} help`"
      exit(1)
    end

    #
    # Place-holder method that is called when the subcommand is not known.
    #
    # @param [String] name
    #   The given sub-command name.
    #
    # @param [Array<String>] argv
    #   Additional argv.
    #
    # @abstract
    #
    # @see command_not_found
    #
    # @api semipublic
    #
    def on_unknown_command(name,argv=[])
      command_not_found(name)
    end

    #
    # Runs the command or specified subcommand.
    #
    # @note If no subcommand is given, {#help} will be called.
    #
    # @api public
    #
    def run(command=nil,*argv)
      if command
        exit invoke(command,*argv)
      else
        help
        exit(1)
      end
    end

    #
    # Prints the available commands and their summaries.
    #
    # @api semipublic
    #
    def help_commands
      unless self.class.commands.empty?
        puts
        puts "Commands:"

        command_aliases = Hash.new { |hash,key| hash[key] = [] }

        self.class.command_aliases.each do |alias_name,name|
          command_aliases[name] << alias_name
        end

        self.class.commands.sort.each do |name,subcommand|
          names = [name, *command_aliases[name]].join(', ')

          if subcommand.summary
            puts "    #{names}\t#{subcommand.summary}"
          else
            puts "    #{names}"
          end
        end
      end
    end

    #
    # Prints help information and available commands.
    #
    # @api public
    #
    def help
      super

      help_commands
    end
  end
end