i18n-tasks 1.0.14 → 1.1.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/README.md +138 -39
- data/Rakefile +4 -4
- data/bin/i18n-tasks +3 -3
- data/config/locales/en.yml +17 -1
- data/config/locales/ru.yml +18 -1
- data/i18n-tasks.gemspec +28 -38
- data/lib/i18n/tasks/base_task.rb +19 -19
- data/lib/i18n/tasks/cli.rb +37 -30
- data/lib/i18n/tasks/command/collection.rb +4 -4
- data/lib/i18n/tasks/command/commander.rb +5 -5
- data/lib/i18n/tasks/command/commands/check_prism.rb +126 -0
- data/lib/i18n/tasks/command/commands/data.rb +33 -33
- data/lib/i18n/tasks/command/commands/eq_base.rb +3 -3
- data/lib/i18n/tasks/command/commands/health.rb +6 -5
- data/lib/i18n/tasks/command/commands/interpolations.rb +14 -3
- data/lib/i18n/tasks/command/commands/meta.rb +6 -6
- data/lib/i18n/tasks/command/commands/missing.rb +28 -26
- data/lib/i18n/tasks/command/commands/tree.rb +33 -33
- data/lib/i18n/tasks/command/commands/usages.rb +24 -24
- data/lib/i18n/tasks/command/dsl.rb +1 -1
- data/lib/i18n/tasks/command/option_parsers/enum.rb +8 -7
- data/lib/i18n/tasks/command/option_parsers/locale.rb +4 -4
- data/lib/i18n/tasks/command/options/common.rb +16 -16
- data/lib/i18n/tasks/command/options/data.rb +18 -18
- data/lib/i18n/tasks/command/options/locales.rb +33 -24
- data/lib/i18n/tasks/commands.rb +14 -12
- data/lib/i18n/tasks/concurrent/cache.rb +1 -1
- data/lib/i18n/tasks/concurrent/cached_value.rb +1 -1
- data/lib/i18n/tasks/configuration.rb +26 -20
- data/lib/i18n/tasks/console_context.rb +11 -11
- data/lib/i18n/tasks/data/adapter/json_adapter.rb +1 -1
- data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +5 -5
- data/lib/i18n/tasks/data/file_formats.rb +3 -3
- data/lib/i18n/tasks/data/file_system.rb +5 -5
- data/lib/i18n/tasks/data/file_system_base.rb +26 -26
- data/lib/i18n/tasks/data/language_names.rb +202 -0
- data/lib/i18n/tasks/data/router/conservative_router.rb +3 -3
- data/lib/i18n/tasks/data/router/isolating_router.rb +19 -19
- data/lib/i18n/tasks/data/router/pattern_router.rb +5 -5
- data/lib/i18n/tasks/data/tree/node.rb +27 -27
- data/lib/i18n/tasks/data/tree/nodes.rb +10 -10
- data/lib/i18n/tasks/data/tree/siblings.rb +20 -20
- data/lib/i18n/tasks/data/tree/traversal.rb +5 -5
- data/lib/i18n/tasks/data.rb +4 -4
- data/lib/i18n/tasks/html_keys.rb +2 -2
- data/lib/i18n/tasks/ignore_keys.rb +9 -9
- data/lib/i18n/tasks/interpolations.rb +21 -1
- data/lib/i18n/tasks/key_pattern_matching.rb +8 -8
- data/lib/i18n/tasks/logging.rb +2 -1
- data/lib/i18n/tasks/missing_keys.rb +24 -8
- data/lib/i18n/tasks/plural_keys.rb +6 -4
- data/lib/i18n/tasks/references.rb +4 -4
- data/lib/i18n/tasks/reports/base.rb +18 -14
- data/lib/i18n/tasks/reports/terminal.rb +64 -47
- data/lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb +3 -3
- data/lib/i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher.rb +3 -3
- data/lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb +10 -10
- data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +2 -2
- data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +69 -10
- data/lib/i18n/tasks/scanners/file_scanner.rb +5 -5
- data/lib/i18n/tasks/scanners/files/caching_file_finder.rb +3 -3
- data/lib/i18n/tasks/scanners/files/caching_file_finder_provider.rb +3 -3
- data/lib/i18n/tasks/scanners/files/caching_file_reader.rb +2 -2
- data/lib/i18n/tasks/scanners/files/file_finder.rb +8 -8
- data/lib/i18n/tasks/scanners/files/file_reader.rb +1 -1
- data/lib/i18n/tasks/scanners/local_ruby_parser.rb +9 -9
- data/lib/i18n/tasks/scanners/occurrence_from_position.rb +1 -1
- data/lib/i18n/tasks/scanners/pattern_mapper.rb +7 -7
- data/lib/i18n/tasks/scanners/pattern_scanner.rb +20 -20
- data/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +8 -8
- data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +48 -0
- data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +374 -0
- data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +337 -0
- data/lib/i18n/tasks/scanners/relative_keys.rb +8 -8
- data/lib/i18n/tasks/scanners/results/key_occurrences.rb +3 -3
- data/lib/i18n/tasks/scanners/results/occurrence.rb +14 -10
- data/lib/i18n/tasks/scanners/ruby_ast_call_finder.rb +1 -1
- data/lib/i18n/tasks/scanners/ruby_key_literals.rb +6 -6
- data/lib/i18n/tasks/scanners/ruby_parser_factory.rb +27 -0
- data/lib/i18n/tasks/scanners/ruby_scanner.rb +225 -0
- data/lib/i18n/tasks/scanners/scanner.rb +2 -2
- data/lib/i18n/tasks/scanners/scanner_multiplexer.rb +1 -1
- data/lib/i18n/tasks/split_key.rb +4 -4
- data/lib/i18n/tasks/stats.rb +3 -3
- data/lib/i18n/tasks/translation.rb +8 -5
- data/lib/i18n/tasks/translators/base_translator.rb +43 -13
- data/lib/i18n/tasks/translators/deepl_translator.rb +22 -14
- data/lib/i18n/tasks/translators/google_translator.rb +178 -26
- data/lib/i18n/tasks/translators/openai_translator.rb +56 -31
- data/lib/i18n/tasks/translators/watsonx_translator.rb +155 -0
- data/lib/i18n/tasks/translators/yandex_translator.rb +13 -9
- data/lib/i18n/tasks/unused_keys.rb +1 -1
- data/lib/i18n/tasks/used_keys.rb +32 -32
- data/lib/i18n/tasks/version.rb +1 -1
- data/lib/i18n/tasks.rb +17 -16
- data/templates/config/i18n-tasks.yml +14 -2
- data/templates/minitest/i18n_test.rb +3 -3
- data/templates/rspec/i18n_spec.rb +7 -7
- metadata +38 -172
- data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +0 -145
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# These classes are used in the PrismScanners::Visitor class to store the translations found in the parsed code
|
|
4
|
+
# Used in the PrismScanners::Visitor class.
|
|
5
|
+
module I18n::Tasks::Scanners::PrismScanners
|
|
6
|
+
class Root
|
|
7
|
+
attr_reader(:calls, :translation_calls, :children, :node, :parent, :rails, :file_path)
|
|
8
|
+
|
|
9
|
+
def initialize(node: nil, parent: nil, file_path: nil, rails: false)
|
|
10
|
+
@calls = []
|
|
11
|
+
@translation_calls = []
|
|
12
|
+
@children = []
|
|
13
|
+
@node = node
|
|
14
|
+
@parent = parent
|
|
15
|
+
@rails = rails
|
|
16
|
+
@file_path = file_path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add_child(node)
|
|
20
|
+
@children << node
|
|
21
|
+
node
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def add_call(node)
|
|
25
|
+
@calls << node
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def add_translation_call(translation_call)
|
|
29
|
+
@translation_calls += Array(translation_call)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def rails_view?
|
|
33
|
+
rails && file_path.present? && file_path.include?("app/views/")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def partial_view?
|
|
37
|
+
file_path.present? && File.basename(file_path).start_with?("_")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def support_relative_keys?
|
|
41
|
+
rails_view? && !partial_view?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def path
|
|
45
|
+
if rails_view?
|
|
46
|
+
folder_path = file_path.sub(%r{app/views/}, "").split("/")
|
|
47
|
+
name = folder_path.pop.split(".").first
|
|
48
|
+
|
|
49
|
+
[*folder_path, name]
|
|
50
|
+
else
|
|
51
|
+
[]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def process
|
|
56
|
+
(@translation_calls + @children.flat_map(&:process)).flatten
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Only supported for Rails controllers currently
|
|
60
|
+
def private_method
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class TranslationCall
|
|
66
|
+
class ScopeError < StandardError; end
|
|
67
|
+
attr_reader(:node, :key, :receiver, :options, :parent)
|
|
68
|
+
|
|
69
|
+
def initialize(node:, key:, receiver:, options:, parent:)
|
|
70
|
+
@node = node
|
|
71
|
+
@key = key
|
|
72
|
+
@receiver = receiver
|
|
73
|
+
@options = options
|
|
74
|
+
@parent = parent
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def relative_key?
|
|
78
|
+
@key&.start_with?(".") && @receiver.nil?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def with_parent(parent)
|
|
82
|
+
self.class.new(
|
|
83
|
+
node: @node,
|
|
84
|
+
key: @key,
|
|
85
|
+
receiver: @receiver,
|
|
86
|
+
options: @options,
|
|
87
|
+
parent: parent
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def with_node(node)
|
|
92
|
+
self.class.new(
|
|
93
|
+
node: node,
|
|
94
|
+
key: @key,
|
|
95
|
+
receiver: @receiver,
|
|
96
|
+
options: @options,
|
|
97
|
+
parent: @parent
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def occurrences(file_path)
|
|
102
|
+
occurrence(file_path)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns either a single key string or an array of candidate key strings for this call.
|
|
106
|
+
def full_key
|
|
107
|
+
return nil if key.nil?
|
|
108
|
+
return nil unless key.is_a?(String)
|
|
109
|
+
return nil if relative_key? && !support_relative_keys?
|
|
110
|
+
|
|
111
|
+
base_parts = [scope].compact
|
|
112
|
+
|
|
113
|
+
if relative_key?
|
|
114
|
+
# For relative keys in controllers/methods, generate candidate keys by
|
|
115
|
+
# progressively stripping trailing path segments from the parent path.
|
|
116
|
+
# Example: parent.path = ["events", "create"], key = ".success"
|
|
117
|
+
# yields: ["events.create.success", "events.success"]
|
|
118
|
+
parent_path = parent&.path || []
|
|
119
|
+
rel_key = key[1..] # strip leading dot # rubocop:disable Performance/ArraySemiInfiniteRangeSlice
|
|
120
|
+
|
|
121
|
+
candidates = []
|
|
122
|
+
parent_path_length = parent_path.length
|
|
123
|
+
# Do not generate an unscoped bare key (keep_count = 0). Start from full parent path
|
|
124
|
+
parent_path_length.downto(1) do |keep_count|
|
|
125
|
+
parts = base_parts + parent_path.first(keep_count) + [rel_key]
|
|
126
|
+
candidates << parts.compact.join(".")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
candidates.map { |c| c.gsub("..", ".") }
|
|
130
|
+
elsif key.start_with?(".")
|
|
131
|
+
[base_parts + [key[1..]]].flatten.compact.join(".").gsub("..", ".") # rubocop:disable Performance/ArraySemiInfiniteRangeSlice,Performance/ChainArrayAllocation
|
|
132
|
+
else
|
|
133
|
+
[base_parts + [key]].flatten.compact.join(".").gsub("..", ".") # rubocop:disable Performance/ChainArrayAllocation
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def scope
|
|
140
|
+
return nil if @options.nil?
|
|
141
|
+
return nil unless @options["scope"]
|
|
142
|
+
|
|
143
|
+
fail(ScopeError, "Could not process scope") if @options.key?("scope") && (Array(@options["scope"]).empty? || !Array(@options["scope"]).all? { |s| s.is_a?(String) || s.is_a?(Symbol) })
|
|
144
|
+
|
|
145
|
+
Array(@options["scope"]).join(".")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def occurrence(file_path)
|
|
149
|
+
local_node = @node
|
|
150
|
+
|
|
151
|
+
location = local_node.location
|
|
152
|
+
|
|
153
|
+
final = full_key
|
|
154
|
+
return nil if final.nil?
|
|
155
|
+
|
|
156
|
+
occurrence = ::I18n::Tasks::Scanners::Results::Occurrence.new(
|
|
157
|
+
path: file_path,
|
|
158
|
+
line: local_node.respond_to?(:slice) ? local_node.slice : local_node.location.slice,
|
|
159
|
+
pos: location.start_offset,
|
|
160
|
+
line_pos: location.start_column,
|
|
161
|
+
line_num: location.start_line,
|
|
162
|
+
raw_key: key
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# full_key may be a single String or an Array of candidate strings
|
|
166
|
+
if final.is_a?(Array)
|
|
167
|
+
# record candidate keys on the occurrence (first candidate is the primary)
|
|
168
|
+
occurrence.instance_variable_set(:@candidate_keys, final)
|
|
169
|
+
[final.first, occurrence]
|
|
170
|
+
else
|
|
171
|
+
occurrence.instance_variable_set(:@candidate_keys, [final])
|
|
172
|
+
[final, occurrence]
|
|
173
|
+
end
|
|
174
|
+
rescue ScopeError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Only public methods are added to the context path
|
|
179
|
+
# Only some classes supports relative keys
|
|
180
|
+
def support_relative_keys?
|
|
181
|
+
(parent.is_a?(ParsedMethod) || parent.is_a?(Root)) && parent.support_relative_keys?
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
class ParsedModule < Root
|
|
186
|
+
def support_relative_keys?
|
|
187
|
+
false
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def path
|
|
191
|
+
(@parent&.path || []) + [path_name]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def path_name
|
|
195
|
+
@node.name.to_s.underscore
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
class ParsedClass < Root
|
|
200
|
+
attr_reader(:private_method)
|
|
201
|
+
|
|
202
|
+
def initialize(node:, parent:, rails:)
|
|
203
|
+
@private_method = false
|
|
204
|
+
@methods = []
|
|
205
|
+
@private_methods = []
|
|
206
|
+
@before_actions = []
|
|
207
|
+
|
|
208
|
+
super
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def add_child(node)
|
|
212
|
+
case node
|
|
213
|
+
when ParsedMethod
|
|
214
|
+
if @private_method
|
|
215
|
+
@private_methods << node
|
|
216
|
+
else
|
|
217
|
+
@methods << node
|
|
218
|
+
end
|
|
219
|
+
when ParsedBeforeAction
|
|
220
|
+
@before_actions << node
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
super
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def process # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
227
|
+
if controller?
|
|
228
|
+
process_controller
|
|
229
|
+
else
|
|
230
|
+
super
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def process_controller
|
|
235
|
+
methods_by_name = @methods.group_by(&:name)
|
|
236
|
+
private_methods_by_name = @private_methods.group_by(&:name)
|
|
237
|
+
|
|
238
|
+
# For each before_action we need to
|
|
239
|
+
# - Find which method it calls
|
|
240
|
+
# - Find out which methods it applies to
|
|
241
|
+
# - Calculate translation calls (and see if they are relative)
|
|
242
|
+
# - Add the translation calls to the methods it applies to
|
|
243
|
+
|
|
244
|
+
@before_actions.each do |before_action|
|
|
245
|
+
before_action_name = before_action.name&.to_sym
|
|
246
|
+
method_call = methods_by_name[before_action_name]&.first || private_methods_by_name[before_action_name]&.first
|
|
247
|
+
translation_calls = (method_call&.translation_calls || []) + before_action.translation_calls
|
|
248
|
+
|
|
249
|
+
# We need to handle the parent here, should not be the before_action when it is called in the method.
|
|
250
|
+
@methods.each do |method|
|
|
251
|
+
next unless before_action.applies_to?(method.name)
|
|
252
|
+
|
|
253
|
+
method.add_translation_call(
|
|
254
|
+
translation_calls.map do |call|
|
|
255
|
+
call.with_parent(method)
|
|
256
|
+
end
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
nested_calls = {}
|
|
262
|
+
new_translation_calls = []
|
|
263
|
+
|
|
264
|
+
@methods.each do |method|
|
|
265
|
+
method.calls.each do |call|
|
|
266
|
+
next if call.receiver.present?
|
|
267
|
+
|
|
268
|
+
other_method = methods_by_name[call.name]&.first || private_methods_by_name[call.name]&.first
|
|
269
|
+
next unless other_method
|
|
270
|
+
|
|
271
|
+
nested_calls[method.name] ||= []
|
|
272
|
+
nested_calls[method.name] << other_method.name
|
|
273
|
+
|
|
274
|
+
if nested_calls[call.name]&.include?(method.name)
|
|
275
|
+
next
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
other_method.translation_calls.each do |translation_call|
|
|
279
|
+
new_translation_calls.push(translation_call.with_parent(method))
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
@translation_calls + @children.flat_map(&:process) + new_translation_calls
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def private_methods!
|
|
288
|
+
@private_method = true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def support_relative_keys?
|
|
292
|
+
controller? || mailer?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def path
|
|
296
|
+
(@parent&.path || []) + [path_name]
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def controller?
|
|
300
|
+
@rails && @node.name.to_s.end_with?("Controller")
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def mailer?
|
|
304
|
+
@rails && @node.name.to_s.end_with?("Mailer")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def path_name
|
|
308
|
+
path = @node.constant_path.full_name_parts.map { |s| s.to_s.underscore }
|
|
309
|
+
path.last.delete_suffix!("_controller") if controller?
|
|
310
|
+
|
|
311
|
+
path
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
class ParsedMethod < Root
|
|
316
|
+
def initialize(node:, parent:, private_method: false)
|
|
317
|
+
@private_method = private_method
|
|
318
|
+
|
|
319
|
+
super(node: node, parent: parent)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def support_relative_keys?
|
|
323
|
+
!@private_method && @parent&.support_relative_keys?
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def path
|
|
327
|
+
(@parent&.path || []) + [@node.name]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def name
|
|
331
|
+
@node.name
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def process
|
|
335
|
+
@translation_calls
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
class ParsedBeforeAction < Root
|
|
340
|
+
attr_accessor(:name, :only, :except)
|
|
341
|
+
|
|
342
|
+
def initialize(node:, parent:, name: nil, only: nil, except: nil)
|
|
343
|
+
@name = name
|
|
344
|
+
@only = only.present? ? Array(only).map(&:to_s) : nil
|
|
345
|
+
@except = except.present? ? Array(except).map(&:to_s) : nil
|
|
346
|
+
|
|
347
|
+
super(node: node, parent: parent)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def support_relative_keys?
|
|
351
|
+
false
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def applies_to?(method_name)
|
|
355
|
+
if @only.nil? && @except.nil?
|
|
356
|
+
true
|
|
357
|
+
elsif @only.nil?
|
|
358
|
+
!@except.include?(method_name.to_s)
|
|
359
|
+
elsif @except.nil?
|
|
360
|
+
@only.include?(method_name.to_s)
|
|
361
|
+
else
|
|
362
|
+
false
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def path
|
|
367
|
+
@parent&.path || []
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def process
|
|
371
|
+
@translation_calls.filter { |call| !call.relative_key? }
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism/visitor"
|
|
4
|
+
require_relative "nodes"
|
|
5
|
+
require_relative "arguments_visitor"
|
|
6
|
+
|
|
7
|
+
# Implementation of Prism::Visitor (https://ruby.github.io/prism/rb/Prism/Visitor.html)
|
|
8
|
+
# It processes the parsed AST from Prism and creates a new AST with the nodes defined in prism_scanners/nodes.rb
|
|
9
|
+
# It defines processing of arguments in a way that is needed for the translation calls.
|
|
10
|
+
# The argument `rails` is used to determine if the scanner should handle Rails specific calls, such as
|
|
11
|
+
# `before_action`, `human_attribute_name`, and `model_name.human`.
|
|
12
|
+
|
|
13
|
+
module I18n::Tasks::Scanners::PrismScanners
|
|
14
|
+
class Visitor < Prism::Visitor # rubocop:disable Metrics/ClassLength
|
|
15
|
+
MAGIC_COMMENT_PREFIX = /\A.\s*i18n-tasks-use\s+/
|
|
16
|
+
|
|
17
|
+
attr_reader(:calls, :current_module, :current_class, :current_method, :root)
|
|
18
|
+
|
|
19
|
+
def initialize(rails: false, file_path: nil)
|
|
20
|
+
@calls = []
|
|
21
|
+
|
|
22
|
+
@current_module = nil
|
|
23
|
+
@current_class = nil
|
|
24
|
+
@current_method = nil
|
|
25
|
+
@root = Root.new(file_path:, rails: rails)
|
|
26
|
+
|
|
27
|
+
@rails = rails
|
|
28
|
+
|
|
29
|
+
# Needs to have () because the Prism::Visitor has no arguments
|
|
30
|
+
super()
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parent
|
|
34
|
+
@current_before_action || @current_method || @current_class || @current_module || @root
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def visit_module_node(node)
|
|
38
|
+
previous_module = @current_module
|
|
39
|
+
@current_module = parent.add_child(
|
|
40
|
+
ParsedModule.new(node: node, parent: parent)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
handle_comments(node)
|
|
44
|
+
|
|
45
|
+
super
|
|
46
|
+
ensure
|
|
47
|
+
@current_module = previous_module
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def visit_program_node(node)
|
|
51
|
+
handle_comments(node)
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def visit_class_node(node)
|
|
56
|
+
previous_class = @current_class
|
|
57
|
+
|
|
58
|
+
@current_class = parent.add_child(
|
|
59
|
+
ParsedClass.new(
|
|
60
|
+
node: node,
|
|
61
|
+
parent: parent,
|
|
62
|
+
rails: @rails
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
handle_comments(node)
|
|
67
|
+
|
|
68
|
+
super
|
|
69
|
+
ensure
|
|
70
|
+
@current_class = previous_class
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def visit_def_node(node)
|
|
74
|
+
handle_comments(node)
|
|
75
|
+
previous_method = @current_method
|
|
76
|
+
parent = @current_class || @current_module || @root
|
|
77
|
+
@current_method = parent.add_child(
|
|
78
|
+
ParsedMethod.new(
|
|
79
|
+
node: node,
|
|
80
|
+
parent: parent,
|
|
81
|
+
private_method: parent.private_method
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
super
|
|
86
|
+
ensure
|
|
87
|
+
@current_method = previous_method
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def visit_call_node(node)
|
|
91
|
+
handle_comments(node)
|
|
92
|
+
|
|
93
|
+
case node.name
|
|
94
|
+
when :private
|
|
95
|
+
@current_class&.private_methods!
|
|
96
|
+
when :t, :t!, :translate, :translate!
|
|
97
|
+
args, kwargs = process_arguments(node)
|
|
98
|
+
parent.add_translation_call(
|
|
99
|
+
TranslationCall.new(
|
|
100
|
+
node: node,
|
|
101
|
+
key: args[0],
|
|
102
|
+
receiver: node.receiver,
|
|
103
|
+
options: kwargs,
|
|
104
|
+
parent: parent
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
else
|
|
108
|
+
handled_call = handle_rails_call_node(node) { super } if @rails
|
|
109
|
+
|
|
110
|
+
return if handled_call
|
|
111
|
+
|
|
112
|
+
parent.add_call(node)
|
|
113
|
+
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
super
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def process
|
|
120
|
+
@root.process
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def process_arguments(node)
|
|
126
|
+
return [], {} if node.nil?
|
|
127
|
+
return [], {} unless node.respond_to?(:arguments)
|
|
128
|
+
return [], {} if node.arguments.nil?
|
|
129
|
+
|
|
130
|
+
arguments_visitor = ArgumentsVisitor.new
|
|
131
|
+
arguments = node.arguments.accept(arguments_visitor)
|
|
132
|
+
keywords, args = arguments.partition { |arg| arg.is_a?(Hash) }
|
|
133
|
+
|
|
134
|
+
[args.compact, keywords.first || {}]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def handle_comments(node)
|
|
138
|
+
return if node.nil?
|
|
139
|
+
return if node.comments.empty?
|
|
140
|
+
|
|
141
|
+
node.comments.each do |comment|
|
|
142
|
+
content =
|
|
143
|
+
comment.respond_to?(:slice) ? comment.slice : comment.location.slice
|
|
144
|
+
match = content.match(MAGIC_COMMENT_PREFIX)
|
|
145
|
+
|
|
146
|
+
next if match.nil?
|
|
147
|
+
|
|
148
|
+
string =
|
|
149
|
+
content.gsub(MAGIC_COMMENT_PREFIX, "").delete("#").strip
|
|
150
|
+
visitor = Visitor.new
|
|
151
|
+
Prism
|
|
152
|
+
.parse(string)
|
|
153
|
+
.value
|
|
154
|
+
.accept(visitor)
|
|
155
|
+
|
|
156
|
+
# Process and remap the found translation calls to be for the found comment
|
|
157
|
+
visitor.process.each do |comment_node|
|
|
158
|
+
parent.add_translation_call(comment_node.with_node(node))
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ---- Rails specific methods ----
|
|
164
|
+
# Returns true if the node was handled
|
|
165
|
+
def handle_rails_call_node(node, &)
|
|
166
|
+
# First, handle calls that should be matched everywhere
|
|
167
|
+
case node.name
|
|
168
|
+
when :human_attribute_name
|
|
169
|
+
rails_handle_human_attribute_name(node)
|
|
170
|
+
return true
|
|
171
|
+
when :human
|
|
172
|
+
return false if node.receiver.nil? || node.receiver.name != :model_name
|
|
173
|
+
rails_handle_model_name(node)
|
|
174
|
+
return true
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if @current_class&.controller?
|
|
178
|
+
rails_handle_controller_call_node(node, &)
|
|
179
|
+
elsif @current_class&.mailer?
|
|
180
|
+
rails_handle_mailer_call_node(node, &)
|
|
181
|
+
else
|
|
182
|
+
false
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def rails_handle_controller_call_node(node, &)
|
|
187
|
+
case node.name
|
|
188
|
+
when :before_action
|
|
189
|
+
rails_handle_before_action(node, &)
|
|
190
|
+
true
|
|
191
|
+
else
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def rails_handle_mailer_call_node(node, &)
|
|
197
|
+
case node.name
|
|
198
|
+
when :mail
|
|
199
|
+
_array_args, keywords = process_arguments(node)
|
|
200
|
+
if keywords.key?("subject")
|
|
201
|
+
# Let traversal continue so nested calls (e.g., t('.subject') or default_i18n_subject)
|
|
202
|
+
# get processed by their own handlers.
|
|
203
|
+
false
|
|
204
|
+
else
|
|
205
|
+
parent.add_translation_call(
|
|
206
|
+
TranslationCall.new(
|
|
207
|
+
node: node,
|
|
208
|
+
receiver: nil,
|
|
209
|
+
key: ".subject",
|
|
210
|
+
parent: parent,
|
|
211
|
+
options: {}
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
true
|
|
215
|
+
end
|
|
216
|
+
when :default_i18n_subject
|
|
217
|
+
parent.add_translation_call(
|
|
218
|
+
TranslationCall.new(
|
|
219
|
+
node: node,
|
|
220
|
+
receiver: nil,
|
|
221
|
+
key: ".subject",
|
|
222
|
+
parent: parent,
|
|
223
|
+
options: {}
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
true
|
|
227
|
+
else
|
|
228
|
+
false
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def rails_handle_before_action(node) # rubocop:disable Metrics/MethodLength
|
|
233
|
+
before_action = ParsedBeforeAction.new(
|
|
234
|
+
node: node,
|
|
235
|
+
parent: @current_class
|
|
236
|
+
)
|
|
237
|
+
@current_class&.add_child(before_action)
|
|
238
|
+
# Need to set current_before_action before processing arguments
|
|
239
|
+
# since they can be lambdas that need to be processed in the context of the before_action
|
|
240
|
+
@current_before_action = before_action
|
|
241
|
+
array_arguments, keywords = process_arguments(node)
|
|
242
|
+
first_argument = array_arguments.first
|
|
243
|
+
|
|
244
|
+
if array_arguments.empty? && node.block.present?
|
|
245
|
+
# No need to add data
|
|
246
|
+
elsif first_argument.is_a?(String)
|
|
247
|
+
before_action.name = first_argument
|
|
248
|
+
before_action.only = keywords["only"]
|
|
249
|
+
before_action.except = keywords["except"]
|
|
250
|
+
elsif first_argument.try(:type) == :lambda_node
|
|
251
|
+
before_action.only = keywords["only"]
|
|
252
|
+
before_action.except = keywords["except"]
|
|
253
|
+
else
|
|
254
|
+
fail(
|
|
255
|
+
ArgumentError,
|
|
256
|
+
"Cannot handle before_action with this argument #{first_argument.class}"
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
yield
|
|
261
|
+
ensure
|
|
262
|
+
@current_before_action = nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def rails_handle_model_name(node)
|
|
266
|
+
_args, kwargs = process_arguments(node)
|
|
267
|
+
|
|
268
|
+
# We need to check for `node.receiver` since node is the `human` call
|
|
269
|
+
model_name = if current_class.present? && rails_model_method_called_on_current_class?(node.receiver)
|
|
270
|
+
current_class.path.flatten.map!(&:underscore).join(".")
|
|
271
|
+
elsif node.receiver.present?
|
|
272
|
+
node.receiver&.receiver&.name&.to_s&.underscore
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
return if model_name.nil?
|
|
276
|
+
|
|
277
|
+
# Handle count being a symbol, e.g. count: :other
|
|
278
|
+
count_key = case kwargs["count"]
|
|
279
|
+
when Integer
|
|
280
|
+
(kwargs["count"] > 1) ? "other" : "one"
|
|
281
|
+
when "one", :one, nil
|
|
282
|
+
"one"
|
|
283
|
+
else
|
|
284
|
+
"other"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
parent.add_translation_call(
|
|
288
|
+
TranslationCall.new(
|
|
289
|
+
node: node,
|
|
290
|
+
receiver: nil,
|
|
291
|
+
key: [:activerecord, :models, model_name, count_key].join("."),
|
|
292
|
+
parent: parent,
|
|
293
|
+
options: kwargs
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def rails_model_method_called_on_current_class?(node)
|
|
299
|
+
node.receiver.nil? || (node.receiver&.name&.to_s == "class" && node.receiver.receiver&.is_a?(Prism::SelfNode))
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def rails_handle_human_attribute_name(node)
|
|
303
|
+
array_args, keywords = process_arguments(node)
|
|
304
|
+
# Arguments empty or cannot be processed, e.g. if it is a call
|
|
305
|
+
return unless array_args.size == 1 && keywords.empty?
|
|
306
|
+
|
|
307
|
+
# Handle if called on `self.class` or if the current_class has `model_name.i18n_key`
|
|
308
|
+
key = if current_class.present? && rails_model_method_called_on_current_class?(node)
|
|
309
|
+
[
|
|
310
|
+
:activerecord,
|
|
311
|
+
:attributes,
|
|
312
|
+
current_class.path.flatten.map!(&:underscore).join("."),
|
|
313
|
+
array_args.first
|
|
314
|
+
].join(".")
|
|
315
|
+
elsif node.receiver&.name.present?
|
|
316
|
+
[
|
|
317
|
+
:activerecord,
|
|
318
|
+
:attributes,
|
|
319
|
+
node.receiver.name.to_s.underscore,
|
|
320
|
+
array_args.first
|
|
321
|
+
].join(".")
|
|
322
|
+
else
|
|
323
|
+
return
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
parent.add_translation_call(
|
|
327
|
+
TranslationCall.new(
|
|
328
|
+
node: node,
|
|
329
|
+
key: key,
|
|
330
|
+
receiver: nil,
|
|
331
|
+
parent: parent,
|
|
332
|
+
options: {}
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|