i18n-tasks 1.0.15 → 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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +85 -13
  3. data/Rakefile +4 -4
  4. data/bin/i18n-tasks +3 -3
  5. data/config/locales/en.yml +6 -0
  6. data/config/locales/ru.yml +7 -0
  7. data/i18n-tasks.gemspec +28 -41
  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 +25 -25
  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 +5 -5
  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 +32 -32
  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 +22 -21
  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 +1 -1
  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 +8 -8
  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 +8 -1
  73. data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +101 -61
  74. data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +169 -105
  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 +1 -1
  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 +5 -5
  87. data/lib/i18n/tasks/translators/base_translator.rb +40 -14
  88. data/lib/i18n/tasks/translators/deepl_translator.rb +17 -14
  89. data/lib/i18n/tasks/translators/google_translator.rb +169 -25
  90. data/lib/i18n/tasks/translators/openai_translator.rb +34 -23
  91. data/lib/i18n/tasks/translators/watsonx_translator.rb +16 -16
  92. data/lib/i18n/tasks/translators/yandex_translator.rb +8 -8
  93. data/lib/i18n/tasks/unused_keys.rb +1 -1
  94. data/lib/i18n/tasks/used_keys.rb +32 -33
  95. data/lib/i18n/tasks/version.rb +1 -1
  96. data/lib/i18n/tasks.rb +17 -17
  97. data/templates/config/i18n-tasks.yml +12 -0
  98. data/templates/minitest/i18n_test.rb +3 -3
  99. data/templates/rspec/i18n_spec.rb +7 -7
  100. metadata +25 -185
  101. data/lib/i18n/tasks/scanners/prism_scanner.rb +0 -83
  102. data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +0 -145
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/scanners/file_scanner'
4
- require 'i18n/tasks/scanners/relative_keys'
5
- require 'i18n/tasks/scanners/occurrence_from_position'
6
- require 'i18n/tasks/scanners/ruby_key_literals'
3
+ require "i18n/tasks/scanners/file_scanner"
4
+ require "i18n/tasks/scanners/relative_keys"
5
+ require "i18n/tasks/scanners/occurrence_from_position"
6
+ require "i18n/tasks/scanners/ruby_key_literals"
7
7
 
8
8
  module I18n::Tasks::Scanners
9
9
  # Maps the provided patterns to keys.
@@ -34,14 +34,14 @@ module I18n::Tasks::Scanners
34
34
  @patterns.flat_map do |pattern, key|
35
35
  result = []
36
36
  text.scan(pattern) do |_|
37
- match = Regexp.last_match
38
- matches = match.names.map(&:to_sym).zip(match.captures).to_h
37
+ match = Regexp.last_match
38
+ matches = match.names.map(&:to_sym).zip(match.captures).to_h
39
39
  if matches.key?(:key)
40
40
  matches[:key] = strip_literal(matches[:key])
41
41
  next unless valid_key?(matches[:key])
42
42
  end
43
43
  result << [absolute_key(format(key, matches), path),
44
- occurrence_from_position(path, text, match.offset(0).first)]
44
+ occurrence_from_position(path, text, match.offset(0).first)]
45
45
  end
46
46
  result
47
47
  end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/scanners/file_scanner'
4
- require 'i18n/tasks/scanners/relative_keys'
5
- require 'i18n/tasks/scanners/occurrence_from_position'
6
- require 'i18n/tasks/scanners/ruby_key_literals'
3
+ require "i18n/tasks/scanners/file_scanner"
4
+ require "i18n/tasks/scanners/relative_keys"
5
+ require "i18n/tasks/scanners/occurrence_from_position"
6
+ require "i18n/tasks/scanners/ruby_key_literals"
7
7
 
8
8
  module I18n::Tasks::Scanners
9
9
  # Scan for I18n.t usages using a simple regular expression.
@@ -12,15 +12,15 @@ module I18n::Tasks::Scanners
12
12
  include OccurrenceFromPosition
13
13
  include RubyKeyLiterals
14
14
 
15
- TRANSLATE_CALL_RE = /(?<=^|[^\p{L}_'\-.]|[^\p{L}'-]I18n\.|I18n\.)t(?:!|ranslate!?)?/.freeze
15
+ TRANSLATE_CALL_RE = /(?<=^|[^\p{L}_'\-.]|[^\p{L}'-]I18n\.|I18n\.)t(?:!|ranslate!?)?/
16
16
  IGNORE_LINES = {
17
- 'coffee' => /^\s*#(?!\si18n-tasks-use)/,
18
- 'erb' => /^\s*<%\s*#(?!\si18n-tasks-use)/,
19
- 'es6' => %r{^\s*//(?!\si18n-tasks-use)},
20
- 'haml' => /^\s*-\s*#(?!\si18n-tasks-use)/,
21
- 'js' => %r{^\s*//(?!\si18n-tasks-use)},
22
- 'opal' => /^\s*#(?!\si18n-tasks-use)/,
23
- 'slim' => %r{^\s*(?:-#|/)(?!\si18n-tasks-use)}
17
+ "coffee" => /^\s*#(?!\si18n-tasks-use)/,
18
+ "erb" => /^\s*<%\s*#(?!\si18n-tasks-use)/,
19
+ "es6" => %r{^\s*//(?!\si18n-tasks-use)},
20
+ "haml" => /^\s*-\s*#(?!\si18n-tasks-use)/,
21
+ "js" => %r{^\s*//(?!\si18n-tasks-use)},
22
+ "opal" => /^\s*#(?!\si18n-tasks-use)/,
23
+ "slim" => %r{^\s*(?:-#|/)(?!\si18n-tasks-use)}
24
24
  }.freeze
25
25
 
26
26
  def initialize(**args)
@@ -47,13 +47,13 @@ module I18n::Tasks::Scanners
47
47
  key = match_to_key(match, path, location)
48
48
  next unless key
49
49
 
50
- key += ':' if key.end_with?('.')
50
+ key += ":" if key.end_with?(".")
51
51
  next unless valid_key?(key)
52
52
 
53
53
  keys << [key, location]
54
54
  end
55
55
  keys
56
- rescue Exception => e # rubocop:disable Lint/RescueException
56
+ rescue => e
57
57
  raise ::I18n::Tasks::CommandError.new(e, "Error scanning #{path}: #{e.message}")
58
58
  end
59
59
 
@@ -62,7 +62,7 @@ module I18n::Tasks::Scanners
62
62
  # @return [String] full absolute key name
63
63
  def match_to_key(match, path, location)
64
64
  absolute_key(strip_literal(match[0]), path,
65
- calling_method: -> { closest_method(location) if key_relative_to_method?(path) })
65
+ calling_method: -> { closest_method(location) if key_relative_to_method?(path) })
66
66
  end
67
67
 
68
68
  def exclude_line?(line, path)
@@ -70,11 +70,11 @@ module I18n::Tasks::Scanners
70
70
  re && re =~ line
71
71
  end
72
72
 
73
- VALID_KEY_RE_DYNAMIC = /^(#{VALID_KEY_CHARS}|[:\#{@}\[\]])+$/.freeze
73
+ VALID_KEY_RE_DYNAMIC = /^(#{VALID_KEY_CHARS}|[:\#{@}\[\]])+$/
74
74
 
75
75
  def valid_key?(key)
76
76
  if @config[:strict]
77
- super(key)
77
+ super
78
78
  else
79
79
  key =~ VALID_KEY_RE_DYNAMIC
80
80
  end
@@ -85,9 +85,9 @@ module I18n::Tasks::Scanners
85
85
  end
86
86
 
87
87
  def closest_method(occurrence)
88
- method = File.readlines(occurrence.path, encoding: 'UTF-8')
89
- .first(occurrence.line_num - 1).reverse_each.find { |x| x =~ /\bdef\b/ }
90
- method && method.strip.sub(/^def\s*/, '').sub(/[(\s;].*$/, '')
88
+ method = File.readlines(occurrence.path, encoding: "UTF-8")
89
+ .first(occurrence.line_num - 1).reverse_each.find { |x| x =~ /\bdef\b/ }
90
+ method && method.strip.sub(/^def\s*/, "").sub(/[(\s;].*$/, "")
91
91
  end
92
92
 
93
93
  # This method only exists for backwards compatibility with monkey-patches and plugins
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/scanners/pattern_scanner'
3
+ require "i18n/tasks/scanners/pattern_scanner"
4
4
 
5
5
  module I18n::Tasks::Scanners
6
6
  # Scans for I18n.t(key, scope: ...) usages
@@ -21,13 +21,13 @@ module I18n::Tasks::Scanners
21
21
  # @param [String] path
22
22
  # @return [String] full absolute key name with scope resolved if any
23
23
  def match_to_key(match, path, location)
24
- key = super
24
+ key = super
25
25
  scope = match[1]
26
26
  if scope
27
27
  scope_parts = extract_literal_or_array_of_literals(scope)
28
28
  return nil if scope_parts.nil? || scope_parts.empty?
29
29
 
30
- "#{scope_parts.join('.')}.#{key}"
30
+ "#{scope_parts.join(".")}.#{key}"
31
31
  else
32
32
  key unless match[0] =~ /\A\w/
33
33
  end
@@ -43,7 +43,7 @@ module I18n::Tasks::Scanners
43
43
  if val =~ /\A[\w@]/
44
44
  "\#{#{val}}"
45
45
  else
46
- super(val)
46
+ super
47
47
  end
48
48
  end
49
49
 
@@ -77,18 +77,18 @@ module I18n::Tasks::Scanners
77
77
  end
78
78
  end
79
79
  s.each_char.with_index do |c, i|
80
- if c == '['
80
+ if c == "["
81
81
  return nil unless braces_stack.empty?
82
82
 
83
83
  braces_stack.push(i)
84
- elsif c == ']'
84
+ elsif c == "]"
85
85
  break
86
- elsif c == ','
86
+ elsif c == ","
87
87
  consume_literal.call
88
88
  break if braces_stack.empty?
89
89
  elsif c =~ VALID_KEY_CHARS || /['":]/ =~ c
90
90
  acc << c
91
- elsif c != ' '
91
+ elsif c != " "
92
92
  return nil
93
93
  end
94
94
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'prism/visitor'
3
+ require "prism/visitor"
4
4
 
5
5
  # This class is used to parse the arguments to e.g. a Prism::CallNode and return the values we need
6
6
  # for turning them into translations and occurrences.
@@ -9,11 +9,18 @@ module I18n::Tasks::Scanners::PrismScanners
9
9
  class ArgumentsVisitor < Prism::Visitor
10
10
  def visit_keyword_hash_node(node)
11
11
  node.child_nodes.each_with_object({}) do |child, hash|
12
+ next if child.type == :assoc_splat_node
13
+
12
14
  hash[visit(child.key)] = visit(child.value)
13
15
  hash
14
16
  end
15
17
  end
16
18
 
19
+ # Cannot handle arguments that are calls
20
+ def visit_call_node(_node)
21
+ nil
22
+ end
23
+
17
24
  def visit_symbol_node(node)
18
25
  node.value
19
26
  end
@@ -4,14 +4,16 @@
4
4
  # Used in the PrismScanners::Visitor class.
5
5
  module I18n::Tasks::Scanners::PrismScanners
6
6
  class Root
7
- attr_reader(:calls, :translation_calls, :children, :node, :parent)
7
+ attr_reader(:calls, :translation_calls, :children, :node, :parent, :rails, :file_path)
8
8
 
9
- def initialize(node: nil, parent: nil)
9
+ def initialize(node: nil, parent: nil, file_path: nil, rails: false)
10
10
  @calls = []
11
11
  @translation_calls = []
12
12
  @children = []
13
13
  @node = node
14
14
  @parent = parent
15
+ @rails = rails
16
+ @file_path = file_path
15
17
  end
16
18
 
17
19
  def add_child(node)
@@ -24,27 +26,44 @@ module I18n::Tasks::Scanners::PrismScanners
24
26
  end
25
27
 
26
28
  def add_translation_call(translation_call)
27
- @translation_calls << translation_call
29
+ @translation_calls += Array(translation_call)
28
30
  end
29
31
 
30
- def support_relative_keys?
31
- false
32
+ def rails_view?
33
+ rails && file_path.present? && file_path.include?("app/views/")
32
34
  end
33
35
 
34
- def private_method
35
- false
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?
36
42
  end
37
43
 
38
44
  def path
39
- []
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
40
53
  end
41
54
 
42
55
  def process
43
56
  (@translation_calls + @children.flat_map(&:process)).flatten
44
57
  end
58
+
59
+ # Only supported for Rails controllers currently
60
+ def private_method
61
+ false
62
+ end
45
63
  end
46
64
 
47
65
  class TranslationCall
66
+ class ScopeError < StandardError; end
48
67
  attr_reader(:node, :key, :receiver, :options, :parent)
49
68
 
50
69
  def initialize(node:, key:, receiver:, options:, parent:)
@@ -56,7 +75,7 @@ module I18n::Tasks::Scanners::PrismScanners
56
75
  end
57
76
 
58
77
  def relative_key?
59
- @key&.start_with?('.') && @receiver.nil?
78
+ @key&.start_with?(".") && @receiver.nil?
60
79
  end
61
80
 
62
81
  def with_parent(parent)
@@ -83,34 +102,47 @@ module I18n::Tasks::Scanners::PrismScanners
83
102
  occurrence(file_path)
84
103
  end
85
104
 
105
+ # Returns either a single key string or an array of candidate key strings for this call.
86
106
  def full_key
87
107
  return nil if key.nil?
88
108
  return nil unless key.is_a?(String)
89
109
  return nil if relative_key? && !support_relative_keys?
90
110
 
91
- parts = [scope]
111
+ base_parts = [scope].compact
92
112
 
93
113
  if relative_key?
94
- parts.concat(parent&.path || [])
95
- parts << 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
96
128
 
97
- # TODO: Fallback to controller without action name
98
- elsif key.start_with?('.')
99
- parts << key[1..]
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
100
132
  else
101
- parts << key
133
+ [base_parts + [key]].flatten.compact.join(".").gsub("..", ".") # rubocop:disable Performance/ChainArrayAllocation
102
134
  end
103
-
104
- parts.compact.join('.').gsub('..', '.')
105
135
  end
106
136
 
107
137
  private
108
138
 
109
139
  def scope
110
140
  return nil if @options.nil?
111
- return nil unless @options['scope']
141
+ return nil unless @options["scope"]
112
142
 
113
- Array(@options['scope']).compact.map(&:to_s).join('.')
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(".")
114
146
  end
115
147
 
116
148
  def occurrence(file_path)
@@ -118,38 +150,35 @@ module I18n::Tasks::Scanners::PrismScanners
118
150
 
119
151
  location = local_node.location
120
152
 
121
- final_key = full_key
122
- return nil if final_key.nil?
123
-
124
- [
125
- final_key,
126
- ::I18n::Tasks::Scanners::Results::Occurrence.new(
127
- path: file_path,
128
- line: local_node.respond_to?(:slice) ? local_node.slice : local_node.location.slice,
129
- pos: location.start_offset,
130
- line_pos: location.start_column,
131
- line_num: location.start_line,
132
- raw_key: key
133
- )
134
- ]
135
- end
136
-
137
- def occurrences_from_comments(file_path)
138
- Array(@comment_translations).flat_map do |child_node|
139
- child_node.with_context(
140
- path: @path,
141
- options: {
142
- **@context_options,
143
- comment_for_node: @node
144
- }
145
- ).occurrences(file_path)
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]
146
173
  end
174
+ rescue ScopeError
175
+ nil
147
176
  end
148
177
 
149
178
  # Only public methods are added to the context path
150
179
  # Only some classes supports relative keys
151
180
  def support_relative_keys?
152
- parent.is_a?(ParsedMethod) && parent.support_relative_keys?
181
+ (parent.is_a?(ParsedMethod) || parent.is_a?(Root)) && parent.support_relative_keys?
153
182
  end
154
183
  end
155
184
 
@@ -158,10 +187,6 @@ module I18n::Tasks::Scanners::PrismScanners
158
187
  false
159
188
  end
160
189
 
161
- def private_method
162
- false
163
- end
164
-
165
190
  def path
166
191
  (@parent&.path || []) + [path_name]
167
192
  end
@@ -179,9 +204,8 @@ module I18n::Tasks::Scanners::PrismScanners
179
204
  @methods = []
180
205
  @private_methods = []
181
206
  @before_actions = []
182
- @rails = rails
183
207
 
184
- super(node: node, parent: parent)
208
+ super
185
209
  end
186
210
 
187
211
  def add_child(node)
@@ -200,8 +224,14 @@ module I18n::Tasks::Scanners::PrismScanners
200
224
  end
201
225
 
202
226
  def process # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
203
- return @children.flat_map(&:process) unless controller?
227
+ if controller?
228
+ process_controller
229
+ else
230
+ super
231
+ end
232
+ end
204
233
 
234
+ def process_controller
205
235
  methods_by_name = @methods.group_by(&:name)
206
236
  private_methods_by_name = @private_methods.group_by(&:name)
207
237
 
@@ -221,7 +251,9 @@ module I18n::Tasks::Scanners::PrismScanners
221
251
  next unless before_action.applies_to?(method.name)
222
252
 
223
253
  method.add_translation_call(
224
- translation_calls.map { |call| call.with_parent(method) }
254
+ translation_calls.map do |call|
255
+ call.with_parent(method)
256
+ end
225
257
  )
226
258
  end
227
259
  end
@@ -240,7 +272,7 @@ module I18n::Tasks::Scanners::PrismScanners
240
272
  nested_calls[method.name] << other_method.name
241
273
 
242
274
  if nested_calls[call.name]&.include?(method.name)
243
- fail(ArgumentError, "Cyclic call detected: #{call.name} -> #{method.name}")
275
+ next
244
276
  end
245
277
 
246
278
  other_method.translation_calls.each do |translation_call|
@@ -249,7 +281,7 @@ module I18n::Tasks::Scanners::PrismScanners
249
281
  end
250
282
  end
251
283
 
252
- @children.flat_map(&:process) + new_translation_calls
284
+ @translation_calls + @children.flat_map(&:process) + new_translation_calls
253
285
  end
254
286
 
255
287
  def private_methods!
@@ -257,7 +289,7 @@ module I18n::Tasks::Scanners::PrismScanners
257
289
  end
258
290
 
259
291
  def support_relative_keys?
260
- @rails && controller?
292
+ controller? || mailer?
261
293
  end
262
294
 
263
295
  def path
@@ -265,12 +297,16 @@ module I18n::Tasks::Scanners::PrismScanners
265
297
  end
266
298
 
267
299
  def controller?
268
- @node.name.to_s.end_with?('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")
269
305
  end
270
306
 
271
307
  def path_name
272
308
  path = @node.constant_path.full_name_parts.map { |s| s.to_s.underscore }
273
- path.last.gsub!(/_controller\z/, '') if controller?
309
+ path.last.delete_suffix!("_controller") if controller?
274
310
 
275
311
  path
276
312
  end
@@ -301,7 +337,7 @@ module I18n::Tasks::Scanners::PrismScanners
301
337
  end
302
338
 
303
339
  class ParsedBeforeAction < Root
304
- attr_reader(:name)
340
+ attr_accessor(:name, :only, :except)
305
341
 
306
342
  def initialize(node:, parent:, name: nil, only: nil, except: nil)
307
343
  @name = name
@@ -312,7 +348,7 @@ module I18n::Tasks::Scanners::PrismScanners
312
348
  end
313
349
 
314
350
  def support_relative_keys?
315
- true
351
+ false
316
352
  end
317
353
 
318
354
  def applies_to?(method_name)
@@ -330,5 +366,9 @@ module I18n::Tasks::Scanners::PrismScanners
330
366
  def path
331
367
  @parent&.path || []
332
368
  end
369
+
370
+ def process
371
+ @translation_calls.filter { |call| !call.relative_key? }
372
+ end
333
373
  end
334
374
  end