# frozen_string_literal: true module RuboCop module Cop module Style # Use a consistent style for named format string tokens. # # NOTE: `unannotated` style cop only works for strings # which are passed as arguments to those methods: # `printf`, `sprintf`, `format`, `%`. # The reason is that _unannotated_ format is very similar # to encoded URLs or Date/Time formatting strings. # # This cop can be customized allowed methods with `AllowedMethods`. # By default, there are no methods to allowed. # # @example EnforcedStyle: annotated (default) # # # bad # format('%{greeting}', greeting: 'Hello') # format('%s', 'Hello') # # # good # format('%s', greeting: 'Hello') # # @example EnforcedStyle: template # # # bad # format('%s', greeting: 'Hello') # format('%s', 'Hello') # # # good # format('%{greeting}', greeting: 'Hello') # # @example EnforcedStyle: unannotated # # # bad # format('%s', greeting: 'Hello') # format('%{greeting}', greeting: 'Hello') # # # good # format('%s', 'Hello') # # It is allowed to contain unannotated token # if the number of them is less than or equals to # `MaxUnannotatedPlaceholdersAllowed`. # # @example MaxUnannotatedPlaceholdersAllowed: 0 # # # bad # format('%06d', 10) # format('%s %s.', 'Hello', 'world') # # # good # format('%06d', number: 10) # # @example MaxUnannotatedPlaceholdersAllowed: 1 (default) # # # bad # format('%s %s.', 'Hello', 'world') # # # good # format('%06d', 10) # # @example AllowedMethods: [] (default) # # # bad # redirect('foo/%{bar_id}') # # @example AllowedMethods: [redirect] # # # good # redirect('foo/%{bar_id}') # # @example AllowedPatterns: [] (default) # # # bad # redirect('foo/%{bar_id}') # # @example AllowedPatterns: ['redirect'] # # # good # redirect('foo/%{bar_id}') # class FormatStringToken < Base include ConfigurableEnforcedStyle include AllowedMethods include AllowedPattern extend AutoCorrector def on_str(node) return if format_string_token?(node) || use_allowed_method?(node) detections = collect_detections(node) return if detections.empty? return if allowed_unannotated?(detections) detections.each do |detected_sequence, token_range| check_sequence(detected_sequence, token_range) end end private # @!method format_string_in_typical_context?(node) def_node_matcher :format_string_in_typical_context?, <<~PATTERN { ^(send _ {:format :sprintf :printf} %0 ...) ^(send %0 :% _) } PATTERN def format_string_token?(node) !node.value.include?('%') || node.each_ancestor(:xstr, :regexp).any? end def use_allowed_method?(node) send_parent = node.each_ancestor(:send).first send_parent && (allowed_method?(send_parent.method_name) || matches_allowed_pattern?(send_parent.method_name)) end def check_sequence(detected_sequence, token_range) if detected_sequence.style == style correct_style_detected elsif correctable_sequence?(detected_sequence.type) style_detected(detected_sequence.style) add_offense(token_range, message: message(detected_sequence.style)) do |corrector| autocorrect_sequence(corrector, detected_sequence, token_range) end end end def correctable_sequence?(detected_type) detected_type == 's' || style == :annotated || style == :unannotated end def autocorrect_sequence(corrector, detected_sequence, token_range) return if style == :unannotated name = detected_sequence.name return if name.nil? flags = detected_sequence.flags width = detected_sequence.width precision = detected_sequence.precision type = detected_sequence.style == :template ? 's' : detected_sequence.type correction = case style when :annotated then "%<#{name}>#{flags}#{width}#{precision}#{type}" when :template then "%#{flags}#{width}#{precision}{#{name}}" end corrector.replace(token_range, correction) end def unannotated_format?(node, detected_style) detected_style == :unannotated && !format_string_in_typical_context?(node) end def message(detected_style) "Prefer #{message_text(style)} over #{message_text(detected_style)}." end # rubocop:disable Style/FormatStringToken def message_text(style) { annotated: 'annotated tokens (like `%s`)', template: 'template tokens (like `%{foo}`)', unannotated: 'unannotated tokens (like `%s`)' }[style] end # rubocop:enable Style/FormatStringToken def tokens(str_node, &block) return if str_node.source == '__FILE__' token_ranges(str_contents(str_node.loc), &block) end def str_contents(source_map) if source_map.is_a?(Parser::Source::Map::Heredoc) source_map.heredoc_body elsif source_map.begin source_map.expression.adjust(begin_pos: +1, end_pos: -1) else source_map.expression end end def token_ranges(contents) format_string = RuboCop::Cop::Utils::FormatString.new(contents.source) format_string.format_sequences.each do |detected_sequence| next if detected_sequence.percent? token = contents.begin.adjust(begin_pos: detected_sequence.begin_pos, end_pos: detected_sequence.end_pos) yield(detected_sequence, token) end end def collect_detections(node) detections = [] tokens(node) do |detected_sequence, token_range| unless unannotated_format?(node, detected_sequence.style) detections << [detected_sequence, token_range] end end detections end def allowed_unannotated?(detections) return false unless detections.all? do |detected_sequence,| detected_sequence.style == :unannotated end return true if detections.size <= max_unannotated_placeholders_allowed detections.any? { |detected_sequence,| !correctable_sequence?(detected_sequence.type) } end def max_unannotated_placeholders_allowed cop_config['MaxUnannotatedPlaceholdersAllowed'] end end end end end