CocoaPods/CLAide

View on GitHub
lib/claide/command/banner.rb

Summary

Maintainability
A
2 hrs
Test Coverage
A
98%
# encoding: utf-8

module CLAide
  class Command
    # Creates the formatted banner to present as help of the provided command
    # class.
    #
    class Banner
      # @return [Class] The command for which the banner should be created.
      #
      attr_accessor :command

      # @param [Class] command @see command
      #
      def initialize(command)
        @command = command
      end

      # @return [String] The banner for the command.
      #
      def formatted_banner
        sections = [
          ['Usage',    formatted_usage_description],
          ['Commands', formatted_subcommand_summaries],
          ['Options',  formatted_options_description],
        ]
        banner = sections.map do |(title, body)|
          [prettify_title("#{title}:"), body] unless body.empty?
        end.compact.join("\n\n")
        banner
      end

      private

      # @!group Banner sections
      #-----------------------------------------------------------------------#

      # @return [String] The indentation of the text.
      #
      TEXT_INDENT = 6

      # @return [Fixnum] The maximum width of the text.
      #
      MAX_WIDTH = TEXT_INDENT + 80

      # @return [Fixnum] The minimum between a name and its description.
      #
      DESCRIPTION_SPACES = 3

      # @return [Fixnum] The minimum between a name and its description.
      #
      SUBCOMMAND_BULLET_SIZE = 2

      # @return [String] The section describing the usage of the command.
      #
      def formatted_usage_description
        message = command.description || command.summary || ''
        message = TextWrapper.wrap_formatted_text(message,
                                                  TEXT_INDENT,
                                                  MAX_WIDTH)
        message = prettify_message(command, message)
        "#{signature}\n\n#{message}"
      end

      # @return [String] The signature of the command.
      #
      def signature
        full_command = command.full_command
        sub_command = signature_sub_command
        arguments = signature_arguments
        result = prettify_signature(full_command, sub_command, arguments)
        result.insert(0, '$ ')
        result.insert(0, ' ' * (TEXT_INDENT - '$ '.size))
      end

      # @return [String] The subcommand indicator of the signature.
      #
      def signature_sub_command
        return '[COMMAND]' if command.default_subcommand
        return 'COMMAND' if command.subcommands.any?
      end

      # @return [String] The arguments of the signature.
      #
      def signature_arguments
        command.arguments.map do |arg|
          names = arg.names.join('|')
          names.concat(' ' + Argument::ELLIPSIS) if arg.repeatable?
          arg.required? ? names : "[#{names}]"
        end.join(' ')
      end

      # @return [String] The section describing the subcommands of the command.
      #
      # @note   The plus sign emphasizes the that the subcommands are added to
      #         the command. The square brackets conveys a sense of direction
      #         and indicates the gravitational force towards the default
      #         command.
      #
      def formatted_subcommand_summaries
        subcommands = subcommands_for_banner
        subcommands.map do |subcommand|
          name = subcommand.command
          bullet = (name == command.default_subcommand) ? '>' : '+'
          name = "#{bullet} #{name}"
          pretty_name = prettify_subcommand(name)
          entry_description(pretty_name, subcommand.summary, name.size)
        end.join("\n")
      end

      # @return [String] The section describing the options of the command.
      #
      def formatted_options_description
        options = command.options
        options.map do |name, description|
          pretty_name = prettify_option_name(name)
          entry_description(pretty_name, description, name.size)
        end.join("\n")
      end

      # @return [String] The line describing a single entry (subcommand or
      #         option).
      #
      def entry_description(name, description, name_width)
        max_name_width = compute_max_name_width
        desc_start = max_name_width + (TEXT_INDENT - 2) + DESCRIPTION_SPACES
        result = ' ' * (TEXT_INDENT - 2)
        result << name
        result << ' ' * DESCRIPTION_SPACES
        result << ' ' * (max_name_width - name_width)
        result << TextWrapper.wrap_with_indent(description,
                                               desc_start,
                                               MAX_WIDTH)
      end

      # @!group Overrides
      #-----------------------------------------------------------------------#

      # @return [String] A decorated title.
      #
      def prettify_title(title)
        title.ansi.underline
      end

      # @return [String] A decorated textual representation of the subcommand
      #         name.
      #
      def prettify_subcommand(name)
        name.chomp.ansi.green
      end

      # @return [String] A decorated textual representation of the option name.
      #
      #
      def prettify_option_name(name)
        name.chomp.ansi.blue
      end

      # @return [String] A decorated textual representation of the command.
      #
      def prettify_signature(command, subcommand, argument)
        components = [
          [command, :green],
          [subcommand, :green],
          [argument, :magenta],
        ]
        components.reduce('') do |memo, (string, ansi_key)|
          next memo if !string || string.empty?
          memo << ' ' << string.ansi.apply(ansi_key)
        end.lstrip
      end

      # @return [String] A decorated command description.
      #
      def prettify_message(command, message)
        message = message.dup
        command.arguments.each do |arg|
          arg.names.each do |name|
            message.gsub!("`#{name.gsub(/\.{3}$/, '')}`", '\0'.ansi.magenta)
          end
        end
        command.options.each do |(name, _description)|
          message.gsub!("`#{name}`", '\0'.ansi.blue)
        end
        message
      end

      # @!group Private helpers
      #-----------------------------------------------------------------------#

      # @return [Array<String>] The list of the subcommands to use in the
      #         banner.
      #
      def subcommands_for_banner
        command.subcommands_for_command_lookup.reject do |subcommand|
          subcommand.summary.nil?
        end.sort_by(&:command)
      end

      # @return [Fixnum] The width of the largest command name or of the
      #         largest option name. Used to align all the descriptions.
      #
      def compute_max_name_width
        widths = []
        widths << command.options.map { |option| option.first.size }
        widths << subcommands_for_banner.map do |cmd|
          cmd.command.size + SUBCOMMAND_BULLET_SIZE
        end.max
        widths.flatten.compact.max || 1
      end

      module TextWrapper
        # @return [String] Wraps a formatted string (e.g. markdown) by stripping
        #         heredoc indentation and wrapping by word to the terminal width
        #         taking into account a maximum one, and indenting the string.
        #         Code lines (i.e. indented by four spaces) are not wrapped.
        #
        # @param  [String] string
        #         The string to format.
        #
        # @param  [Fixnum] indent
        #         The number of spaces to insert before the string.
        #
        # @param  [Fixnum] max_width
        #         The maximum width to use to format the string if the terminal
        #         is too wide.
        #
        def self.wrap_formatted_text(string, indent = 0, max_width = 80)
          paragraphs = strip_heredoc(string).split("\n\n")
          paragraphs = paragraphs.map do |paragraph|
            if paragraph.start_with?(' ' * 4)
              paragraph.gsub!(/\n/, "\n#{' ' * indent}")
            else
              paragraph = wrap_with_indent(paragraph, indent, max_width)
            end
            paragraph.insert(0, ' ' * indent).rstrip
          end
          paragraphs.join("\n\n")
        end

        # @return [String] Wraps a string to the terminal width taking into
        #         account the given indentation.
        #
        # @param  [String] string
        #         The string to indent.
        #
        # @param  [Fixnum] indent
        #         The number of spaces to insert before the string.
        #
        # @param  [Fixnum] max_width
        #         The maximum width to use to format the string if the terminal
        #         is too wide.
        #
        def self.wrap_with_indent(string, indent = 0, max_width = 80)
          if terminal_width == 0
            width = max_width
          else
            width = [terminal_width, max_width].min
          end

          full_line = string.gsub("\n", ' ')
          available_width = width - indent
          space = ' ' * indent
          word_wrap(full_line, available_width).split("\n").join("\n#{space}")
        end

        # @return [String] Lifted straight from ActionView. Thanks guys!
        #
        def self.word_wrap(line, line_width)
          line.gsub(/(.{1,#{line_width}})(\s+|$)/, "\\1\n").strip
        end

        # @return [String] Lifted straight from ActiveSupport. Thanks guys!
        #
        def self.strip_heredoc(string)
          if min = string.scan(/^[ \t]*(?=\S)/).min
            string.gsub(/^[ \t]{#{min.size}}/, '')
          else
            string
          end
        end

        # @!group Private helpers
        #---------------------------------------------------------------------#

        # @return [Fixnum] The width of the current terminal unless being piped.
        #
        def self.terminal_width
          @terminal_width ||=
            (!ENV['CLAIDE_DISABLE_AUTO_WRAP'] &&
             STDOUT.tty? &&
             calculate_terminal_width) || 0
        end

        def self.calculate_terminal_width
          require 'io/console'
          STDOUT.winsize.last
        rescue LoadError
          (system('which tput > /dev/null 2>&1') && `tput cols`.to_i) || 0
        rescue
          0
        end
        private_class_method :calculate_terminal_width
      end
    end
  end
end