# 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. # # @example # # # bad # # format('A value: %s and another: %i', a_value) # # @example # # # good # # format('A value: %s and another: %i', a_value, another) class FormatParameterMismatch < Cop # http://rubular.com/r/CvpbxkcTzy MSG = "Number of arguments (%i) to `%s` doesn't match the number of " \ 'fields (%i).'.freeze FIELD_REGEX = /(%(([\s#+-0\*]*)(\d*)?(.\d+)?[bBdiouxXeEfgGaAcps]|%))/ NAMED_FIELD_REGEX = /%\{[_a-zA-Z][_a-zA-Z]+\}/ KERNEL = 'Kernel'.freeze SHOVEL = '<<'.freeze PERCENT = '%'.freeze PERCENT_PERCENT = '%%'.freeze STRING_TYPES = %i[str dstr].freeze NAMED_INTERPOLATION = /%(?:<\w+>|\{\w+\})/ def on_send(node) return unless offending_node?(node) add_offense(node, :selector) end private def offending_node?(node) return false unless called_on_string?(node) return false unless method_with_format_args?(node) return false if named_mode?(node) || splat_args?(node) num_of_format_args, num_of_expected_fields = count_matches(node) return false if num_of_format_args == :unknown matched_arguments_count?(num_of_expected_fields, num_of_format_args) end def matched_arguments_count?(expected, passed) if passed < 0 expected < passed.abs else expected != passed end end def called_on_string?(node) receiver_node, _method, format_string, = *node if receiver_node.nil? || receiver_node.const_type? format_string && format_string.str_type? else receiver_node.str_type? end end def method_with_format_args?(node) sprintf?(node) || format?(node) || percent?(node) end def named_mode?(node) receiver_node, _method_name, *args = *node relevant_node = if sprintf?(node) || format?(node) args.first elsif percent?(node) receiver_node end !relevant_node.source.scan(NAMED_FIELD_REGEX).empty? end def splat_args?(node) return false if percent?(node) node.arguments.butfirst.any?(&:splat_type?) end def heredoc?(node) node.first_argument.source[0, 2] == SHOVEL end def count_matches(node) receiver_node, _method_name, *args = *node if (sprintf?(node) || format?(node)) && !heredoc?(node) number_of_args_for_format = arguments_count(args) - 1 number_of_expected_fields = expected_fields_count(args.first) elsif percent?(node) && args.first.array_type? number_of_expected_fields = expected_fields_count(receiver_node) number_of_args_for_format = arguments_count(args.first.child_nodes) else number_of_args_for_format = number_of_expected_fields = :unknown end [number_of_args_for_format, number_of_expected_fields] end def format_method?(name, node) receiver, method_name, *args = *node if receiver && receiver.const_type? return false unless receiver.loc.name.is?(KERNEL) end return false unless method_name == name args.size > 1 && args.first.str_type? end def expected_fields_count(node) return :unknown unless node.str_type? return 1 if node.source =~ NAMED_INTERPOLATION node .source .scan(FIELD_REGEX) .reject { |x| x.first == PERCENT_PERCENT } .reduce(0) { |acc, elem| acc + (elem[2] =~ /\*/ ? 2 : 1) } end def arguments_count(args) if args.empty? 0 elsif args.last.type == :splat -(args.count - 1) else args.count end 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_name == :% && (STRING_TYPES.include?(receiver.type) || node.first_argument.array_type?) if percent && STRING_TYPES.include?(receiver.type) return false if heredoc?(node) end percent end def message(node) num_args_for_format, num_expected_fields = count_matches(node) method_name = if node.method_name.to_s == PERCENT 'String#%' else node.method_name end format(MSG, num_args_for_format, method_name, num_expected_fields) end end end end end