# 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 = /(?#{NUMBER})/.freeze PRECISION = /\.(?#{NUMBER})/.freeze TYPE = /(?[bBdiouxXeEfgGaAcps])/.freeze NAME = /<(?\w+)>/.freeze TEMPLATE_NAME = /\{(?\w+)\}/.freeze SEQUENCE = / % (?%) | % (?#{FLAG}*) (?: (?: #{WIDTH}? #{PRECISION}? #{NAME}? | #{WIDTH}? #{NAME} #{PRECISION}? | #{NAME} (?#{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