rubocop-hq/rubocop

View on GitHub
lib/rubocop/cop/lint/format_parameter_mismatch.rb

Summary

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

module RuboCop
  module Cop
    module Lint
      # This lint sees if there is a mismatch between the number of
      # expected fields for format/sprintf/#% and what is actually
      # passed as arguments.
      #
      # In addition it checks whether different formats are used in the same
      # format string. Do not mix numbered, unnumbered, and named formats in
      # the same format string.
      #
      # @example
      #
      #   # bad
      #
      #   format('A value: %s and another: %i', a_value)
      #
      # @example
      #
      #   # good
      #
      #   format('A value: %s and another: %i', a_value, another)
      #
      # @example
      #
      #   # bad
      #
      #   format('Unnumbered format: %s and numbered: %2$s', a_value, another)
      #
      # @example
      #
      #   # good
      #
      #   format('Numbered format: %1$s and numbered %2$s', a_value, another)
      class FormatParameterMismatch < Base
        # http://rubular.com/r/CvpbxkcTzy
        MSG = "Number of arguments (%<arg_num>i) to `%<method>s` doesn't " \
              'match the number of fields (%<field_num>i).'
        MSG_INVALID = 'Format string is invalid because formatting sequence types ' \
                      '(numbered, named or unnumbered) are mixed.'

        KERNEL = 'Kernel'
        SHOVEL = '<<'
        STRING_TYPES = %i[str dstr].freeze
        RESTRICT_ON_SEND = %i[format sprintf %].freeze

        def on_send(node)
          return unless format_string?(node)

          if invalid_format_string?(node)
            add_offense(node.loc.selector, message: MSG_INVALID)
            return
          end

          return unless offending_node?(node)

          add_offense(node.loc.selector, message: message(node))
        end

        private

        def format_string?(node)
          called_on_string?(node) && method_with_format_args?(node)
        end

        def invalid_format_string?(node)
          string = if sprintf?(node) || format?(node)
                     node.first_argument.source
                   else
                     node.receiver.source
                   end
          !RuboCop::Cop::Utils::FormatString.new(string).valid?
        end

        def offending_node?(node)
          return false if splat_args?(node)

          num_of_format_args, num_of_expected_fields = count_matches(node)

          return false if num_of_format_args == :unknown

          first_arg = node.first_argument
          return false if num_of_expected_fields.zero? &&
                          (first_arg.dstr_type? || first_arg.array_type?)

          matched_arguments_count?(num_of_expected_fields, num_of_format_args)
        end

        def matched_arguments_count?(expected, passed)
          if passed.negative?
            expected < passed.abs
          else
            expected != passed
          end
        end

        # @!method called_on_string?(node)
        def_node_matcher :called_on_string?, <<~PATTERN
          {(send {nil? const_type?} _ {str dstr} ...)
           (send {str dstr} ...)}
        PATTERN

        def method_with_format_args?(node)
          sprintf?(node) || format?(node) || percent?(node)
        end

        def splat_args?(node)
          return false if percent?(node)

          node.arguments.drop(1).any?(&:splat_type?)
        end

        def heredoc?(node)
          node.first_argument.source[0, 2] == SHOVEL
        end

        def count_matches(node)
          if countable_format?(node)
            count_format_matches(node)
          elsif countable_percent?(node)
            count_percent_matches(node)
          else
            [:unknown] * 2
          end
        end

        def countable_format?(node)
          (sprintf?(node) || format?(node)) && !heredoc?(node)
        end

        def countable_percent?(node)
          percent?(node) && node.first_argument.array_type?
        end

        def count_format_matches(node)
          [node.arguments.count - 1, expected_fields_count(node.first_argument)]
        end

        def count_percent_matches(node)
          [node.first_argument.child_nodes.count,
           expected_fields_count(node.receiver)]
        end

        def format_method?(name, node)
          return false if node.const_receiver? && !node.receiver.loc.name.is?(KERNEL)
          return false unless node.method?(name)

          node.arguments.size > 1 && string_type?(node.first_argument)
        end

        def expected_fields_count(node)
          return :unknown unless string_type?(node)

          format_string = RuboCop::Cop::Utils::FormatString.new(node.source)
          return 1 if format_string.named_interpolation?

          max_digit_dollar_num = format_string.max_digit_dollar_num
          return max_digit_dollar_num if max_digit_dollar_num&.nonzero?

          format_string
            .format_sequences
            .reject(&:percent?)
            .reduce(0) { |acc, seq| acc + seq.arity }
        end

        def format?(node)
          format_method?(:format, node)
        end

        def sprintf?(node)
          format_method?(:sprintf, node)
        end

        def percent?(node)
          receiver = node.receiver

          percent = node.method?(:%) && (string_type?(receiver) || node.first_argument.array_type?)

          return false if percent && string_type?(receiver) && heredoc?(node)

          percent
        end

        def message(node)
          num_args_for_format, num_expected_fields = count_matches(node)

          method_name = node.method?(:%) ? 'String#%' : node.method_name

          format(MSG, arg_num: num_args_for_format, method: method_name,
                      field_num: num_expected_fields)
        end

        def string_type?(node)
          STRING_TYPES.include?(node.type)
        end
      end
    end
  end
end