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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +138 -39
  3. data/Rakefile +4 -4
  4. data/bin/i18n-tasks +3 -3
  5. data/config/locales/en.yml +17 -1
  6. data/config/locales/ru.yml +18 -1
  7. data/i18n-tasks.gemspec +28 -38
  8. data/lib/i18n/tasks/base_task.rb +19 -19
  9. data/lib/i18n/tasks/cli.rb +37 -30
  10. data/lib/i18n/tasks/command/collection.rb +4 -4
  11. data/lib/i18n/tasks/command/commander.rb +5 -5
  12. data/lib/i18n/tasks/command/commands/check_prism.rb +126 -0
  13. data/lib/i18n/tasks/command/commands/data.rb +33 -33
  14. data/lib/i18n/tasks/command/commands/eq_base.rb +3 -3
  15. data/lib/i18n/tasks/command/commands/health.rb +6 -5
  16. data/lib/i18n/tasks/command/commands/interpolations.rb +14 -3
  17. data/lib/i18n/tasks/command/commands/meta.rb +6 -6
  18. data/lib/i18n/tasks/command/commands/missing.rb +28 -26
  19. data/lib/i18n/tasks/command/commands/tree.rb +33 -33
  20. data/lib/i18n/tasks/command/commands/usages.rb +24 -24
  21. data/lib/i18n/tasks/command/dsl.rb +1 -1
  22. data/lib/i18n/tasks/command/option_parsers/enum.rb +8 -7
  23. data/lib/i18n/tasks/command/option_parsers/locale.rb +4 -4
  24. data/lib/i18n/tasks/command/options/common.rb +16 -16
  25. data/lib/i18n/tasks/command/options/data.rb +18 -18
  26. data/lib/i18n/tasks/command/options/locales.rb +33 -24
  27. data/lib/i18n/tasks/commands.rb +14 -12
  28. data/lib/i18n/tasks/concurrent/cache.rb +1 -1
  29. data/lib/i18n/tasks/concurrent/cached_value.rb +1 -1
  30. data/lib/i18n/tasks/configuration.rb +26 -20
  31. data/lib/i18n/tasks/console_context.rb +11 -11
  32. data/lib/i18n/tasks/data/adapter/json_adapter.rb +1 -1
  33. data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +5 -5
  34. data/lib/i18n/tasks/data/file_formats.rb +3 -3
  35. data/lib/i18n/tasks/data/file_system.rb +5 -5
  36. data/lib/i18n/tasks/data/file_system_base.rb +26 -26
  37. data/lib/i18n/tasks/data/language_names.rb +202 -0
  38. data/lib/i18n/tasks/data/router/conservative_router.rb +3 -3
  39. data/lib/i18n/tasks/data/router/isolating_router.rb +19 -19
  40. data/lib/i18n/tasks/data/router/pattern_router.rb +5 -5
  41. data/lib/i18n/tasks/data/tree/node.rb +27 -27
  42. data/lib/i18n/tasks/data/tree/nodes.rb +10 -10
  43. data/lib/i18n/tasks/data/tree/siblings.rb +20 -20
  44. data/lib/i18n/tasks/data/tree/traversal.rb +5 -5
  45. data/lib/i18n/tasks/data.rb +4 -4
  46. data/lib/i18n/tasks/html_keys.rb +2 -2
  47. data/lib/i18n/tasks/ignore_keys.rb +9 -9
  48. data/lib/i18n/tasks/interpolations.rb +21 -1
  49. data/lib/i18n/tasks/key_pattern_matching.rb +8 -8
  50. data/lib/i18n/tasks/logging.rb +2 -1
  51. data/lib/i18n/tasks/missing_keys.rb +24 -8
  52. data/lib/i18n/tasks/plural_keys.rb +6 -4
  53. data/lib/i18n/tasks/references.rb +4 -4
  54. data/lib/i18n/tasks/reports/base.rb +18 -14
  55. data/lib/i18n/tasks/reports/terminal.rb +64 -47
  56. data/lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb +3 -3
  57. data/lib/i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher.rb +3 -3
  58. data/lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb +10 -10
  59. data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +2 -2
  60. data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +69 -10
  61. data/lib/i18n/tasks/scanners/file_scanner.rb +5 -5
  62. data/lib/i18n/tasks/scanners/files/caching_file_finder.rb +3 -3
  63. data/lib/i18n/tasks/scanners/files/caching_file_finder_provider.rb +3 -3
  64. data/lib/i18n/tasks/scanners/files/caching_file_reader.rb +2 -2
  65. data/lib/i18n/tasks/scanners/files/file_finder.rb +8 -8
  66. data/lib/i18n/tasks/scanners/files/file_reader.rb +1 -1
  67. data/lib/i18n/tasks/scanners/local_ruby_parser.rb +9 -9
  68. data/lib/i18n/tasks/scanners/occurrence_from_position.rb +1 -1
  69. data/lib/i18n/tasks/scanners/pattern_mapper.rb +7 -7
  70. data/lib/i18n/tasks/scanners/pattern_scanner.rb +20 -20
  71. data/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +8 -8
  72. data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +48 -0
  73. data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +374 -0
  74. data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +337 -0
  75. data/lib/i18n/tasks/scanners/relative_keys.rb +8 -8
  76. data/lib/i18n/tasks/scanners/results/key_occurrences.rb +3 -3
  77. data/lib/i18n/tasks/scanners/results/occurrence.rb +14 -10
  78. data/lib/i18n/tasks/scanners/ruby_ast_call_finder.rb +1 -1
  79. data/lib/i18n/tasks/scanners/ruby_key_literals.rb +6 -6
  80. data/lib/i18n/tasks/scanners/ruby_parser_factory.rb +27 -0
  81. data/lib/i18n/tasks/scanners/ruby_scanner.rb +225 -0
  82. data/lib/i18n/tasks/scanners/scanner.rb +2 -2
  83. data/lib/i18n/tasks/scanners/scanner_multiplexer.rb +1 -1
  84. data/lib/i18n/tasks/split_key.rb +4 -4
  85. data/lib/i18n/tasks/stats.rb +3 -3
  86. data/lib/i18n/tasks/translation.rb +8 -5
  87. data/lib/i18n/tasks/translators/base_translator.rb +43 -13
  88. data/lib/i18n/tasks/translators/deepl_translator.rb +22 -14
  89. data/lib/i18n/tasks/translators/google_translator.rb +178 -26
  90. data/lib/i18n/tasks/translators/openai_translator.rb +56 -31
  91. data/lib/i18n/tasks/translators/watsonx_translator.rb +155 -0
  92. data/lib/i18n/tasks/translators/yandex_translator.rb +13 -9
  93. data/lib/i18n/tasks/unused_keys.rb +1 -1
  94. data/lib/i18n/tasks/used_keys.rb +32 -32
  95. data/lib/i18n/tasks/version.rb +1 -1
  96. data/lib/i18n/tasks.rb +17 -16
  97. data/templates/config/i18n-tasks.yml +14 -2
  98. data/templates/minitest/i18n_test.rb +3 -3
  99. data/templates/rspec/i18n_spec.rb +7 -7
  100. metadata +38 -172
  101. 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