rubocop-dev_doc 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/config/default.yml +300 -60
- data/config/obsoletion.yml +7 -3
- data/lib/dev_doc/i18n/tasks.rb +68 -0
- data/lib/dev_doc/railtie.rb +19 -0
- data/lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb +2 -0
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +2 -0
- data/lib/dev_doc/test/pseudo_i18n_crawler.rb +5 -0
- data/lib/rubocop/cop/dev_doc/i18n/hardcoded_text.rb +243 -0
- data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +69 -19
- data/lib/rubocop/cop/dev_doc/i18n/require_translation.rb +13 -17
- data/lib/rubocop/cop/dev_doc/i18n/text_shape.rb +94 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +81 -26
- data/lib/rubocop/cop/dev_doc/rails/no_errors_add_in_controller.rb +177 -0
- data/lib/rubocop/cop/dev_doc/rails/no_form_param_lookup_in_controller.rb +236 -0
- data/lib/rubocop/cop/dev_doc/style/in_memory_association_find.rb +162 -0
- data/lib/rubocop/cop/dev_doc/style/no_silent_rescue.rb +269 -0
- data/lib/rubocop/cop/dev_doc/style/prefer_allowlist_symbol_param.rb +193 -0
- data/lib/rubocop/cop/dev_doc/style/use_g_log.rb +133 -0
- data/lib/rubocop/cop/dev_doc/test/prefer_find_sole_by.rb +146 -0
- data/lib/rubocop/cop/dev_doc/test/test_name_special_characters.rb +108 -0
- data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +11 -1
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +14 -3
- data/lib/rubocop/cop/dev_doc/i18n/report_text.rb +0 -76
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
require_relative 'text_shape'
|
|
2
|
+
require_relative 'localizable_props'
|
|
3
|
+
|
|
4
|
+
module RuboCop
|
|
5
|
+
module Cop
|
|
6
|
+
module DevDoc
|
|
7
|
+
module I18n
|
|
8
|
+
# Warn on any string literal that reads like user-facing copy, wherever
|
|
9
|
+
# it sits — not only in glib text props.
|
|
10
|
+
#
|
|
11
|
+
# ## Rationale
|
|
12
|
+
# `DevDoc/I18n/RequireTranslation` checks the value of a known text prop
|
|
13
|
+
# on a known component. Copy escapes it whenever it reaches the screen
|
|
14
|
+
# by any other route: a ternary or `||` fallback inside the prop
|
|
15
|
+
# (`text: signed_in ? 'Sign out' : 'Sign in'`), a hash built in a local
|
|
16
|
+
# and passed in later (`required = { message: 'Required' }`), a model
|
|
17
|
+
# method that builds select options, a controller error string, a mailer
|
|
18
|
+
# subject, a constant. Each of those ships English-only text that can
|
|
19
|
+
# never be translated.
|
|
20
|
+
#
|
|
21
|
+
# This cop inspects every `str`/`dstr` node and reports the ones whose
|
|
22
|
+
# *shape* is prose (two or more words, a capitalised word, or non-ASCII
|
|
23
|
+
# letters) and whose *position* is not a known non-text one. It skips:
|
|
24
|
+
#
|
|
25
|
+
# * identifier-shaped strings — keys, CSS class lists, paths, URLs,
|
|
26
|
+
# MIME types, emails, constants, inline CSS, SQL, `strftime` formats;
|
|
27
|
+
# * `t(...)`/`translate`/`l`/`localize` keys (a literal `default:` is
|
|
28
|
+
# still checked — it is the English the user sees when the key is
|
|
29
|
+
# missing);
|
|
30
|
+
# * hash keys, and values under `ExemptKeys` (`styleClass:`,
|
|
31
|
+
# `class_name:`, `url:` ...);
|
|
32
|
+
# * receivers and direct arguments of `ExemptMethods` (SQL builders,
|
|
33
|
+
# comparisons, string matching, hash lookups, `require` ...);
|
|
34
|
+
# * anything inside `DeveloperFacingMethods` (`raise`, `warn`, ...), a
|
|
35
|
+
# `logger.*` call, or a call on a `DeveloperFacingReceivers` constant
|
|
36
|
+
# (error trackers such as `Rollbar.error`) — exception, log and
|
|
37
|
+
# error-report text is for developers;
|
|
38
|
+
# * `when` conditions, and the parts of regexps and backticks;
|
|
39
|
+
# * literals `RequireTranslation` already checks (see below).
|
|
40
|
+
#
|
|
41
|
+
# An interpolated string is judged by its literal parts, with each
|
|
42
|
+
# interpolation counted as a word: `"#{count} items"` is prose,
|
|
43
|
+
# `"#{a}. #{b}"` and `"badge--#{state}"` are not.
|
|
44
|
+
#
|
|
45
|
+
# It cannot tell copy a user sees from prose that never leaves the
|
|
46
|
+
# server, so it is a **review aid**: disabled by default, `warning`
|
|
47
|
+
# severity. Run it during a localization pass and either localize each
|
|
48
|
+
# hit or disable it inline with the reason it is not user-facing.
|
|
49
|
+
#
|
|
50
|
+
# ## Relationship with RequireTranslation
|
|
51
|
+
# The two split the work so each literal is reported by exactly one of
|
|
52
|
+
# them. `RequireTranslation` owns a literal that is the direct value of
|
|
53
|
+
# a text prop on a watched glib component (`view.p text: 'Welcome'`,
|
|
54
|
+
# `json.title 'Orders'`) and flags it whatever its shape; this cop
|
|
55
|
+
# skips those and covers every other position, using that cop's
|
|
56
|
+
# `WatchedMethods` and `LocalizableKeys` to tell them apart. Two cases
|
|
57
|
+
# inside a prop stay here because `RequireTranslation` lets them
|
|
58
|
+
# through: a literal wrapped in a ternary or `||`, and an interpolated
|
|
59
|
+
# string that also calls `t(...)`
|
|
60
|
+
# (`text: "#{t('orders.title')} this month"`). Run both during a
|
|
61
|
+
# localization pass — on its own, this cop does not see literal glib
|
|
62
|
+
# props.
|
|
63
|
+
#
|
|
64
|
+
# NOTE: ERB templates are not parsed by RuboCop, so literal text in
|
|
65
|
+
# `.html.erb`/`.text.erb` (mailers, layouts) is not checked. Text built
|
|
66
|
+
# at runtime (`titleize`, `humanize`, ActiveRecord attribute names in
|
|
67
|
+
# `errors.full_messages`) is not a literal either — see
|
|
68
|
+
# `DevDoc/I18n/AvoidTitleizeHumanize` for the former.
|
|
69
|
+
#
|
|
70
|
+
# ⚠️ English-only — can't be translated
|
|
71
|
+
# view.button text: signed_in ? 'Sign out' : 'Sign in'
|
|
72
|
+
# required = { required: { message: 'Required' } }
|
|
73
|
+
# render_error 'Could not save the record'
|
|
74
|
+
# mail to: user.email, subject: "Welcome, #{user.name}"
|
|
75
|
+
#
|
|
76
|
+
# ✔️ Resolved through I18n
|
|
77
|
+
# view.button text: t(signed_in ? 'auth.sign_out' : 'auth.sign_in')
|
|
78
|
+
# render_error t('records.save_failed')
|
|
79
|
+
#
|
|
80
|
+
# @example
|
|
81
|
+
# # warning (fallback branch of a text prop)
|
|
82
|
+
# view.button text: signed_in ? 'Sign out' : 'Sign in'
|
|
83
|
+
#
|
|
84
|
+
# # warning (hash built outside the component call)
|
|
85
|
+
# required = { required: { message: 'Required' } }
|
|
86
|
+
#
|
|
87
|
+
# # warning (literal words around a translation)
|
|
88
|
+
# view.label text: "#{t('orders.title')} this month"
|
|
89
|
+
#
|
|
90
|
+
# # good
|
|
91
|
+
# view.button text: t(signed_in ? 'auth.sign_out' : 'auth.sign_in')
|
|
92
|
+
#
|
|
93
|
+
# # good (identifier-shaped)
|
|
94
|
+
# view.panels_vertical styleClass: 'card card--active'
|
|
95
|
+
#
|
|
96
|
+
# # good (developer-facing)
|
|
97
|
+
# raise ArgumentError, "Unknown step: #{step}"
|
|
98
|
+
class HardcodedText < Base
|
|
99
|
+
MSG ='Possible hardcoded text: this string reads like user-facing copy. ' \
|
|
100
|
+
'Use `t(...)`, or disable inline with the reason it is never shown to a user.'.freeze
|
|
101
|
+
|
|
102
|
+
TRANSLATION_METHODS = %i[t translate l localize].freeze
|
|
103
|
+
LOGGER_METHODS = %i[debug info warn error fatal unknown add log].freeze
|
|
104
|
+
# Nodes a value passes through on its way to a key or call
|
|
105
|
+
# (`[a, b]`, `x ? a : b`, `x || a`, `(a)`), so `styleClass: x ? 'a' : 'b'`
|
|
106
|
+
# is judged by its key just like `styleClass: 'a'`.
|
|
107
|
+
VALUE_WRAPPERS = %i[array splat if or and begin].freeze
|
|
108
|
+
|
|
109
|
+
def on_str(node)
|
|
110
|
+
return if part_of_enclosing_literal?(node)
|
|
111
|
+
return if node.source == '__FILE__'
|
|
112
|
+
|
|
113
|
+
check(node)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def on_dstr(node)
|
|
117
|
+
return if part_of_enclosing_literal?(node)
|
|
118
|
+
|
|
119
|
+
check(node)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def check(node)
|
|
125
|
+
return unless TextShape.prose?(literal_text(node))
|
|
126
|
+
return if exempt_position?(node)
|
|
127
|
+
return if checked_by_require_translation?(node)
|
|
128
|
+
|
|
129
|
+
add_offense(node)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Adjacent/continued literals and interpolated strings are judged as
|
|
133
|
+
# a whole at their outermost `dstr`; regexp and backtick contents are
|
|
134
|
+
# patterns and commands, not copy.
|
|
135
|
+
def part_of_enclosing_literal?(node)
|
|
136
|
+
return true if %i[dstr dsym].include?(node.parent&.type)
|
|
137
|
+
|
|
138
|
+
node.each_ancestor(:regexp, :xstr).any?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def literal_text(node)
|
|
142
|
+
return node.value.to_s if node.str_type?
|
|
143
|
+
return TextShape::PLACEHOLDER unless node.dstr_type?
|
|
144
|
+
|
|
145
|
+
node.children.map { |child| literal_text(child) }.join
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def exempt_position?(node)
|
|
149
|
+
value = outermost_value(node)
|
|
150
|
+
parent = value.parent
|
|
151
|
+
|
|
152
|
+
return true if exempt_pair?(value, parent)
|
|
153
|
+
return true if exempt_call_position?(value, parent)
|
|
154
|
+
return true if parent&.when_type? && parent.conditions.include?(value)
|
|
155
|
+
return true if node.each_ancestor(:in_pattern).any?
|
|
156
|
+
|
|
157
|
+
developer_facing?(node)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def checked_by_require_translation?(node)
|
|
161
|
+
# RequireTranslation accepts a string that interpolates `t(...)`,
|
|
162
|
+
# so the literal words around it are left for this cop.
|
|
163
|
+
return false if node.dstr_type? && LocalizableProps.translated_interpolation?(node)
|
|
164
|
+
|
|
165
|
+
require_translation = config.for_cop('DevDoc/I18n/RequireTranslation')
|
|
166
|
+
LocalizableProps.inspected_value?(
|
|
167
|
+
node,
|
|
168
|
+
require_translation.fetch('WatchedMethods', LocalizableProps::DEFAULT_WATCHED_METHODS),
|
|
169
|
+
require_translation.fetch('LocalizableKeys', LocalizableProps::DEFAULT_LOCALIZABLE_KEYS)
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def outermost_value(node)
|
|
174
|
+
current = node
|
|
175
|
+
while (parent = current.parent) && VALUE_WRAPPERS.include?(parent.type)
|
|
176
|
+
break if parent.if_type? && parent.condition.equal?(current)
|
|
177
|
+
|
|
178
|
+
current = parent
|
|
179
|
+
end
|
|
180
|
+
current
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def exempt_pair?(value, parent)
|
|
184
|
+
return false unless parent&.pair_type?
|
|
185
|
+
return true if parent.key.equal?(value)
|
|
186
|
+
|
|
187
|
+
key = parent.key
|
|
188
|
+
(key.sym_type? || key.str_type?) && exempt_keys.include?(key.value.to_s)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def exempt_call_position?(value, parent)
|
|
192
|
+
return false unless parent&.call_type?
|
|
193
|
+
return true if translation_key?(value, parent)
|
|
194
|
+
return false unless exempt_methods.include?(parent.method_name.to_s)
|
|
195
|
+
|
|
196
|
+
parent.receiver.equal?(value) || parent.arguments.any? { |arg| arg.equal?(value) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def translation_key?(value, call)
|
|
200
|
+
return false unless TRANSLATION_METHODS.include?(call.method_name)
|
|
201
|
+
|
|
202
|
+
receiver = call.receiver
|
|
203
|
+
(receiver.nil? || (receiver.const_type? && receiver.const_name == 'I18n')) &&
|
|
204
|
+
call.first_argument.equal?(value)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def developer_facing?(node)
|
|
208
|
+
node.each_ancestor(:send, :csend).any? { |call| developer_facing_call?(call) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def developer_facing_call?(call)
|
|
212
|
+
receiver = call.receiver
|
|
213
|
+
return developer_facing_methods.include?(call.method_name.to_s) if receiver.nil?
|
|
214
|
+
return true if receiver.const_type? && developer_facing_receivers.include?(receiver.const_name)
|
|
215
|
+
|
|
216
|
+
LOGGER_METHODS.include?(call.method_name) && logger_receiver?(receiver)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def logger_receiver?(receiver)
|
|
220
|
+
(receiver.send_type? || receiver.lvar_type? || receiver.ivar_type?) &&
|
|
221
|
+
receiver.source.match?(/logger\z/i)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def exempt_keys
|
|
225
|
+
cop_config.fetch('ExemptKeys', [])
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def exempt_methods
|
|
229
|
+
cop_config.fetch('ExemptMethods', [])
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def developer_facing_methods
|
|
233
|
+
cop_config.fetch('DeveloperFacingMethods', [])
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def developer_facing_receivers
|
|
237
|
+
cop_config.fetch('DeveloperFacingReceivers', [])
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -3,7 +3,7 @@ module RuboCop
|
|
|
3
3
|
module DevDoc
|
|
4
4
|
module I18n
|
|
5
5
|
# Shared traversal for the glib JSON-UI localization cops
|
|
6
|
-
# (`RequireTranslation
|
|
6
|
+
# (`RequireTranslation`).
|
|
7
7
|
#
|
|
8
8
|
# It locates user-facing text values on watched glib component calls and
|
|
9
9
|
# hands each `(key, value_node)` to the including cop's
|
|
@@ -23,22 +23,84 @@ module RuboCop
|
|
|
23
23
|
DEFAULT_WATCHED_METHODS = %w[
|
|
24
24
|
h1 h2 h3 h4 h5 h6 p label markdown html
|
|
25
25
|
button switch chip progressCircle shareButton
|
|
26
|
-
fields_text fields_number
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
fields_text fields_number fields_email fields_url fields_password
|
|
27
|
+
fields_timer fields_submit fields_textarea fields_richText
|
|
28
|
+
fields_select fields_dynamicSelect fields_dynamicGroup
|
|
29
|
+
fields_check fields_checkGroup fields_chipGroup fields_radio
|
|
30
|
+
fields_radioGroup fields_timeZone fields_date fields_datetime
|
|
31
|
+
fields_file fields_upload fields_sign fields_location fields_phone
|
|
32
|
+
fields_otp fields_captcha fields_rating fields_creditCard
|
|
33
|
+
fields_stripeToken fields_stripeExternalAccount
|
|
30
34
|
dialogs_alert dialogs_notification
|
|
31
35
|
snackbars_alert snackbars_select
|
|
32
36
|
banners_alert banners_select
|
|
33
37
|
].freeze
|
|
34
38
|
|
|
35
39
|
DEFAULT_LOCALIZABLE_KEYS = %w[
|
|
36
|
-
title subtitle subsubtitle label placeholder text
|
|
40
|
+
title subtitle subsubtitle label placeholder hint text
|
|
37
41
|
message description uploadText leftText rightText onLabel
|
|
38
42
|
].freeze
|
|
39
43
|
|
|
40
44
|
TRANSLATION_METHODS = %i[t translate].freeze
|
|
41
45
|
|
|
46
|
+
# Whether `node` is a value this traversal hands to the cop: a
|
|
47
|
+
# localizable key's value on a watched call, at any hash/array depth,
|
|
48
|
+
# or a jbuilder positional argument. HardcodedText skips these so one
|
|
49
|
+
# literal isn't reported by both cops.
|
|
50
|
+
def self.inspected_value?(node, watched_methods, localizable_keys)
|
|
51
|
+
parent = node.parent
|
|
52
|
+
return false unless parent
|
|
53
|
+
|
|
54
|
+
if parent.pair_type?
|
|
55
|
+
localizable_pair_value?(parent, node, localizable_keys) &&
|
|
56
|
+
watched_call_argument?(parent.parent, watched_methods)
|
|
57
|
+
elsif parent.call_type?
|
|
58
|
+
jbuilder_positional_value?(parent, node, localizable_keys)
|
|
59
|
+
else
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.localizable_pair_value?(pair, node, localizable_keys)
|
|
65
|
+
pair.value.equal?(node) && pair.key.sym_type? && localizable_keys.include?(pair.key.value.to_s)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.jbuilder_positional_value?(call, node, localizable_keys)
|
|
69
|
+
call.first_argument.equal?(node) && localizable_keys.include?(call.method_name.to_s) &&
|
|
70
|
+
json_receiver?(call.receiver)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Climbs out of the nested hashes and arrays `each_localizable` walks
|
|
74
|
+
# into, then checks the call they are an argument of.
|
|
75
|
+
def self.watched_call_argument?(container, watched_methods)
|
|
76
|
+
current = container
|
|
77
|
+
current = current.parent.pair_type? ? current.parent.parent : current.parent while nested_value?(current)
|
|
78
|
+
call = current.parent
|
|
79
|
+
return false unless call&.call_type? && watched_methods.include?(call.method_name.to_s)
|
|
80
|
+
|
|
81
|
+
call.arguments.any? { |arg| arg.equal?(current) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.nested_value?(node)
|
|
85
|
+
parent = node.parent
|
|
86
|
+
return false unless parent
|
|
87
|
+
|
|
88
|
+
parent.array_type? || (parent.pair_type? && parent.value.equal?(node))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.translated_interpolation?(node)
|
|
92
|
+
node.each_descendant(:send, :csend).any? do |call|
|
|
93
|
+
TRANSLATION_METHODS.include?(call.method_name)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.json_receiver?(node)
|
|
98
|
+
return false unless node
|
|
99
|
+
|
|
100
|
+
(node.send_type? && node.method_name == :json && node.receiver.nil?) ||
|
|
101
|
+
(node.lvar_type? && node.children.first == :json)
|
|
102
|
+
end
|
|
103
|
+
|
|
42
104
|
def on_send(node)
|
|
43
105
|
node.arguments.each { |arg| each_localizable(arg) } if watched_methods.include?(node.method_name.to_s)
|
|
44
106
|
|
|
@@ -76,7 +138,7 @@ module RuboCop
|
|
|
76
138
|
# mistaken for localizable props.
|
|
77
139
|
def check_jbuilder_positional(node)
|
|
78
140
|
return unless localizable_keys.include?(node.method_name.to_s)
|
|
79
|
-
return unless json_receiver?(node.receiver)
|
|
141
|
+
return unless LocalizableProps.json_receiver?(node.receiver)
|
|
80
142
|
|
|
81
143
|
value = node.first_argument
|
|
82
144
|
return unless value
|
|
@@ -84,18 +146,6 @@ module RuboCop
|
|
|
84
146
|
inspect_localizable(node.method_name, value)
|
|
85
147
|
end
|
|
86
148
|
|
|
87
|
-
def json_receiver?(node)
|
|
88
|
-
return false unless node
|
|
89
|
-
|
|
90
|
-
(node.send_type? && node.method_name == :json && node.receiver.nil?) ||
|
|
91
|
-
(node.lvar_type? && node.children.first == :json)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def translation_call?(node)
|
|
95
|
-
(node.send_type? || node.csend_type?) &&
|
|
96
|
-
TRANSLATION_METHODS.include?(node.method_name)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
149
|
def watched_methods
|
|
100
150
|
cop_config.fetch('WatchedMethods', DEFAULT_WATCHED_METHODS)
|
|
101
151
|
end
|
|
@@ -14,12 +14,13 @@ module RuboCop
|
|
|
14
14
|
#
|
|
15
15
|
# Hash props (`view.p text: '...'`), nested hash props
|
|
16
16
|
# (`view.icon tooltip: { text: '...' }`) and the jbuilder positional
|
|
17
|
-
# form (`json.title '...'`) are all checked.
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
17
|
+
# form (`json.title '...'`) are all checked. Plain strings with no
|
|
18
|
+
# letters (blank, `'0'`, `'2025'`, `'%'`, `'-'`) are ignored — there is
|
|
19
|
+
# nothing in them to translate. An interpolated string is flagged even
|
|
20
|
+
# then: its literal portions are hardcoded copy (`"Hi #{name}"`) or a
|
|
21
|
+
# format that can differ by locale (`"#{n}%"`, `"#{a}, #{b}"`) —
|
|
22
|
+
# unless it interpolates a `t(...)` (or `translate`/`I18n.t`) call,
|
|
23
|
+
# which is treated as positive localization and left alone.
|
|
23
24
|
#
|
|
24
25
|
# The watched method names and localizable keys are configurable via
|
|
25
26
|
# `WatchedMethods:` and `LocalizableKeys:`.
|
|
@@ -73,20 +74,15 @@ module RuboCop
|
|
|
73
74
|
add_offense(value, message: format(MSG, key: key))
|
|
74
75
|
end
|
|
75
76
|
|
|
76
|
-
# A plain string literal is hardcoded copy
|
|
77
|
-
# (interpolated string) is hardcoded copy too — unless it
|
|
78
|
-
# a translation call, which counts as positive
|
|
77
|
+
# A plain string literal is hardcoded copy when it has a letter. A
|
|
78
|
+
# `dstr` (interpolated string) is hardcoded copy too — unless it
|
|
79
|
+
# interpolates a translation call, which counts as positive
|
|
80
|
+
# localization.
|
|
79
81
|
def hardcoded_string?(node)
|
|
80
|
-
return !
|
|
82
|
+
return !LocalizableProps.translated_interpolation?(node) if node.dstr_type?
|
|
81
83
|
return false unless node.str_type?
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def localized_interpolation?(node)
|
|
87
|
-
node.each_descendant(:send, :csend).any? do |call|
|
|
88
|
-
TRANSLATION_METHODS.include?(call.method_name)
|
|
89
|
-
end
|
|
85
|
+
node.value.match?(/\p{L}/)
|
|
90
86
|
end
|
|
91
87
|
end
|
|
92
88
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module I18n
|
|
5
|
+
# Decides whether a string literal's text reads like prose (copy a user
|
|
6
|
+
# could see) or like an identifier (a key, CSS class list, path, URL,
|
|
7
|
+
# constant, inline CSS, SQL or format string). Used by
|
|
8
|
+
# `DevDoc/I18n/HardcodedText`.
|
|
9
|
+
#
|
|
10
|
+
# Interpolations (`#{...}`) and format directives (`%<x>s`, `%{x}`,
|
|
11
|
+
# `%Y`) are replaced by PLACEHOLDER before judging: each counts as one
|
|
12
|
+
# word toward "two or more words" but contributes no letters, so
|
|
13
|
+
# `"#{n} items"` is prose while `"#{a}. #{b}"` and `"badge--#{x}"` are
|
|
14
|
+
# not.
|
|
15
|
+
module TextShape
|
|
16
|
+
PLACEHOLDER = "␀".freeze
|
|
17
|
+
|
|
18
|
+
FORMAT_DIRECTIVE = /%(?:<\w+>[a-z]|\{\w+\}|[-_0^#]?[a-zA-Z])/
|
|
19
|
+
HTML_TAG = %r{</?[a-zA-Z][^<>]*>}
|
|
20
|
+
HTML_ENTITY = /&#?\w+;/
|
|
21
|
+
|
|
22
|
+
LITERAL_WORD = /\p{L}{2,}/
|
|
23
|
+
NON_ASCII_LETTER = /(?=\p{L})[^\p{ASCII}]/
|
|
24
|
+
WORDISH = /\p{L}|␀/
|
|
25
|
+
CAPITALISED_WORD = /\A\p{Lu}\p{Ll}[\p{Ll}'’]*[.!?:…]?\z/
|
|
26
|
+
|
|
27
|
+
URL = %r{\A(?:[a-z][a-z0-9+.-]*://|mailto:|tel:|www\.)\S*\z}i
|
|
28
|
+
PATH = %r{\A[^\s/]*/\S*\z}
|
|
29
|
+
EMAIL = /\A[^\s@]+@[^\s@]+\z/
|
|
30
|
+
# Tailwind arbitrary values (`bg-[#0A2A9E]`) may hold uppercase hex,
|
|
31
|
+
# and `!` marks an important modifier at either end (`!px-4`, `px-4!`).
|
|
32
|
+
IDENTIFIER_TOKEN = %r{\A!?(?:[a-z0-9_.:\-\[\]#/?=&*␀]|-\[[^\]\s]*\])+!?\z}
|
|
33
|
+
IDENTIFIER_SEPARATOR = %r{[-_./:#]}
|
|
34
|
+
CONSTANT_PATH = /\A[A-Z]\w*(?:::[A-Z]\w*)+\z/
|
|
35
|
+
SCREAMING_TOKEN = /\A[A-Z0-9_\-␀]+\z/
|
|
36
|
+
CAMEL_CASE_TOKEN = /\A(?:[a-z]+|[A-Z][a-z0-9]+)(?:[A-Z][a-z0-9]*)+\z/
|
|
37
|
+
SINGLE_TOKEN_NON_TEXT = [
|
|
38
|
+
URL, PATH, EMAIL, IDENTIFIER_TOKEN, CONSTANT_PATH, SCREAMING_TOKEN, CAMEL_CASE_TOKEN
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
CSS_DECLARATIONS = /\A(?:\s*[a-z-]+\s*:\s*[^;:]+;)+\s*(?:[a-z-]+\s*:\s*[^;:]+)?\s*\z/
|
|
42
|
+
SQL = /
|
|
43
|
+
\b(?:SELECT|INSERT\s+INTO|DELETE\s+FROM|WHERE|JOIN|COALESCE|ILIKE|ORDER\s+BY|GROUP\s+BY|
|
|
44
|
+
IS\s+(?:NOT\s+)?NULL|CASE\s+WHEN|ASC|DESC)\b
|
|
45
|
+
| (?:=|<>|!=|>=|<=|\bLIKE|\bIN)\s*\(?\s*(?:\?|:\w)
|
|
46
|
+
| ->>?
|
|
47
|
+
| \b(?:COUNT|SUM|AVG|MIN|MAX|CAST|DATE|DATE_DIFF|DATE_TRUNC|FORMAT_DATE|EXTRACT|NULLIF)\(
|
|
48
|
+
| (?:\)|\b[a-z0-9_]\w*)\s+AS\s+(?:[a-z_]\w*|␀)
|
|
49
|
+
| \b(?:AND|OR)\s+\(?\s*[a-z_][\w.]*\s*(?:[=<>!]|\b(?:IS|IN|LIKE|ILIKE|BETWEEN)\b)
|
|
50
|
+
/x
|
|
51
|
+
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
def prose?(raw)
|
|
55
|
+
text = normalize(raw)
|
|
56
|
+
return false unless text.match?(LITERAL_WORD) || text.match?(NON_ASCII_LETTER)
|
|
57
|
+
return false if non_text?(text)
|
|
58
|
+
|
|
59
|
+
tokens = text.split
|
|
60
|
+
return tokens.count { |token| token.match?(WORDISH) } >= 2 if tokens.size >= 2
|
|
61
|
+
|
|
62
|
+
text.match?(CAPITALISED_WORD) || text.match?(NON_ASCII_LETTER)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize(raw)
|
|
66
|
+
raw.gsub(FORMAT_DIRECTIVE, PLACEHOLDER)
|
|
67
|
+
.gsub(HTML_TAG, ' ')
|
|
68
|
+
.gsub(HTML_ENTITY, ' ')
|
|
69
|
+
.strip
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def non_text?(text)
|
|
73
|
+
return true if text.match?(SQL)
|
|
74
|
+
return true if text.match?(CSS_DECLARATIONS) && (text.include?(';') || text.match?(/\A[a-z]+-[a-z]/))
|
|
75
|
+
|
|
76
|
+
tokens = text.split
|
|
77
|
+
return SINGLE_TOKEN_NON_TEXT.any? { |pattern| text.match?(pattern) } if tokens.size == 1
|
|
78
|
+
|
|
79
|
+
identifier_list?(tokens)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A CSS class list or similar: every token lowercase and
|
|
83
|
+
# identifier-shaped, at least one with a separator (`card card--active`).
|
|
84
|
+
# A plain lowercase phrase (`not yet submitted`) has no separator and
|
|
85
|
+
# stays prose.
|
|
86
|
+
def identifier_list?(tokens)
|
|
87
|
+
tokens.all? { |token| token.match?(IDENTIFIER_TOKEN) } &&
|
|
88
|
+
tokens.any? { |token| token.match?(IDENTIFIER_SEPARATOR) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|