/
format_string.rb
133 lines (114 loc) · 3.55 KB
/
format_string.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# 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