rubocop-hq/rubocop

View on GitHub
lib/rubocop/cop/utils/format_string.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
# frozen_string_literal: true

module RuboCop
  module Cop
    module Utils
      # Parses {Kernel#sprintf} format strings.
      class FormatString
        DIGIT_DOLLAR  = /(\d+)\$/.freeze
        FLAG          = /[ #0+-]|#{DIGIT_DOLLAR}/.freeze
        NUMBER_ARG    = /\*#{DIGIT_DOLLAR}?/.freeze
        NUMBER        = /\d+|#{NUMBER_ARG}/.freeze
        WIDTH         = /(?<width>#{NUMBER})/.freeze
        PRECISION     = /\.(?<precision>#{NUMBER})/.freeze
        TYPE          = /(?<type>[bBdiouxXeEfgGaAcps])/.freeze
        NAME          = /<(?<name>\w+)>/.freeze
        TEMPLATE_NAME = /\{(?<name>\w+)\}/.freeze

        SEQUENCE = /
            % (?<type>%)
          | % (?<flags>#{FLAG}*)
            (?:
              (?: #{WIDTH}? #{PRECISION}? #{NAME}?
                | #{WIDTH}? #{NAME} #{PRECISION}?
                | #{NAME} (?<more_flags>#{FLAG}*) #{WIDTH}? #{PRECISION}?
              ) #{TYPE}
              | #{WIDTH}? #{PRECISION}? #{TEMPLATE_NAME}
            )
        /x.freeze

        # The syntax of a format sequence is as follows.
        #
        # ```
        # %[flags][width][.precision]type
        # ```
        #
        # A format sequence consists of a percent sign, followed by optional
        # flags, width, and precision indicators, then terminated with a field
        # type character.
        #
        # For more complex formatting, Ruby supports a reference by name.
        #
        # @see https://ruby-doc.org/core-2.6.3/Kernel.html#method-i-format
        class FormatSequence
          attr_reader :begin_pos, :end_pos, :flags, :width, :precision, :name, :type

          def initialize(match)
            @source = match[0]
            @begin_pos = match.begin(0)
            @end_pos = match.end(0)
            @flags = match[:flags].to_s + match[:more_flags].to_s
            @width = match[:width]
            @precision = match[:precision]
            @name = match[:name]
            @type = match[:type]
          end

          def percent?
            type == '%'
          end

          def annotated?
            name && @source.include?('<')
          end

          def template?
            name && @source.include?('{')
          end

          # Number of arguments required for the format sequence
          def arity
            @source.scan('*').count + 1
          end

          def max_digit_dollar_num
            @source.scan(DIGIT_DOLLAR).map { |(digit_dollar_num)| digit_dollar_num.to_i }.max
          end

          def style
            if annotated?
              :annotated
            elsif template?
              :template
            else
              :unannotated
            end
          end
        end

        def initialize(string)
          @source = string
        end

        def format_sequences
          @format_sequences ||= parse
        end

        def valid?
          !mixed_formats?
        end

        def named_interpolation?
          format_sequences.any?(&:name)
        end

        def max_digit_dollar_num
          format_sequences.map(&:max_digit_dollar_num).max
        end

        private

        def parse
          matches = []
          @source.scan(SEQUENCE) { matches << FormatSequence.new(Regexp.last_match) }
          matches
        end

        def mixed_formats?
          formats = format_sequences.reject(&:percent?).map do |seq|
            if seq.name
              :named
            elsif seq.max_digit_dollar_num
              :numbered
            else
              :unnumbered
            end
          end

          formats.uniq.size > 1
        end
      end
    end
  end
end