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
@@ -10,16 +10,16 @@ module I18n
10
10
  # @param calling_method [#call, Symbol, String, false, nil]
11
11
  # @return [String] absolute version of the key
12
12
  def absolute_key(key, path, roots: config[:relative_roots],
13
- exclude_method_name_paths: config[:relative_exclude_method_name_paths],
14
- calling_method: nil)
13
+ exclude_method_name_paths: config[:relative_exclude_method_name_paths],
14
+ calling_method: nil)
15
15
  return key unless key.start_with?(DOT)
16
- fail 'roots argument is required' unless roots.present?
16
+ fail "roots argument is required" unless roots.present?
17
17
 
18
18
  normalized_path = File.expand_path(path)
19
19
  (root = path_root(normalized_path, roots)) ||
20
20
  fail(CommandError, "Cannot resolve relative key \"#{key}\".\n" \
21
21
  "Set search.relative_roots in config/i18n-tasks.yml (currently #{roots.inspect})")
22
- normalized_path.sub!(root, '')
22
+ normalized_path.sub!(root, "")
23
23
 
24
24
  if (exclude_method_name_paths || []).map { |p| expand_path(p) }.include?(root)
25
25
  "#{prefix(normalized_path)}#{key}"
@@ -30,7 +30,7 @@ module I18n
30
30
 
31
31
  private
32
32
 
33
- DOT = '.'
33
+ DOT = "."
34
34
 
35
35
  # Detect the appropriate relative path root
36
36
  # @param [String] path /full/path
@@ -54,14 +54,14 @@ module I18n
54
54
  # @param normalized_path [String] path/relative/to/a/root
55
55
  # @param calling_method [#call, Symbol, String, false, nil]
56
56
  def prefix(normalized_path, calling_method: nil)
57
- file_key = normalized_path.gsub(%r{(\.[^/]+)*$}, '').tr(File::SEPARATOR, DOT)
57
+ file_key = normalized_path.gsub(%r{(\.[^/]+)*$}, "").tr(File::SEPARATOR, DOT)
58
58
  calling_method = calling_method.call if calling_method.respond_to?(:call)
59
59
  if calling_method&.present?
60
60
  # Relative keys in mailers have a `_mailer` infix, but relative keys in controllers do not have one:
61
- "#{file_key.sub(/_controller$/, '')}.#{calling_method}"
61
+ "#{file_key.sub(/_controller$/, "")}.#{calling_method}"
62
62
  else
63
63
  # Remove _ prefix from partials
64
- file_key.gsub('._', DOT)
64
+ file_key.gsub("._", DOT)
65
65
  end
66
66
  end
67
67
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/scanners/results/occurrence'
3
+ require "i18n/tasks/scanners/results/occurrence"
4
4
 
5
5
  module I18n::Tasks::Scanners::Results
6
6
  # A scanned key and all its occurrences.
@@ -14,7 +14,7 @@ module I18n::Tasks::Scanners::Results
14
14
  attr_reader :occurrences
15
15
 
16
16
  def initialize(key:, occurrences:)
17
- @key = key
17
+ @key = key
18
18
  @occurrences = occurrences
19
19
  end
20
20
 
@@ -31,7 +31,7 @@ module I18n::Tasks::Scanners::Results
31
31
  end
32
32
 
33
33
  def inspect
34
- "KeyOccurrences(#{key.inspect}, [#{occurrences.map(&:inspect).join(', ')}])"
34
+ "KeyOccurrences(#{key.inspect}, [#{occurrences.map(&:inspect).join(", ")}])"
35
35
  end
36
36
 
37
37
  # Merge {KeyOccurrences} in an {Enumerable<KeyOccurrences>} so that in the resulting {Array<KeyOccurrences>}:
@@ -28,6 +28,9 @@ module I18n::Tasks
28
28
  # @return [String, nil] the raw key (for relative keys and references)
29
29
  attr_accessor :raw_key
30
30
 
31
+ # @return [Array<String>, nil] candidate keys that may be used at runtime
32
+ attr_reader :candidate_keys
33
+
31
34
  # @param path [String]
32
35
  # @param pos [Integer]
33
36
  # @param line_num [Integer]
@@ -36,24 +39,25 @@ module I18n::Tasks
36
39
  # @param raw_key [String, nil]
37
40
  # @param default_arg [String, nil]
38
41
  # rubocop:disable Metrics/ParameterLists
39
- def initialize(path:, pos:, line_num:, line_pos:, line:, raw_key: nil, default_arg: nil)
40
- @path = path
41
- @pos = pos
42
- @line_num = line_num
43
- @line_pos = line_pos
44
- @line = line
45
- @raw_key = raw_key
42
+ def initialize(path:, pos:, line_num:, line_pos:, line:, raw_key: nil, default_arg: nil, candidate_keys: nil)
43
+ @path = path
44
+ @pos = pos
45
+ @line_num = line_num
46
+ @line_pos = line_pos
47
+ @line = line
48
+ @raw_key = raw_key
46
49
  @default_arg = default_arg
50
+ @candidate_keys = candidate_keys
47
51
  end
48
52
  # rubocop:enable Metrics/ParameterLists
49
53
 
50
54
  def inspect
51
- "Occurrence(#{@path}:#{@line_num}, line_pos: #{@line_pos}, pos: #{@pos}, raw_key: #{@raw_key}, default_arg: #{@default_arg}, line: #{@line})" # rubocop:disable Layout/LineLength
55
+ "Occurrence(#{@path}:#{@line_num}, line_pos: #{@line_pos}, pos: #{@pos}, raw_key: #{@raw_key}, candidate_keys: #{@candidate_keys}, default_arg: #{@default_arg}, line: #{@line})" # rubocop:disable Layout/LineLength
52
56
  end
53
57
 
54
58
  def ==(other)
55
59
  other.path == @path && other.pos == @pos && other.line_num == @line_num && other.line == @line &&
56
- other.raw_key == @raw_key && other.default_arg == @default_arg
60
+ other.raw_key == @raw_key && other.default_arg == @default_arg && other.candidate_keys == @candidate_keys
57
61
  end
58
62
 
59
63
  def eql?(other)
@@ -61,7 +65,7 @@ module I18n::Tasks
61
65
  end
62
66
 
63
67
  def hash
64
- [@path, @pos, @line_num, @line_pos, @line, @default_arg].hash
68
+ [@path, @pos, @line_num, @line_pos, @line, @default_arg, @candidate_keys].hash
65
69
  end
66
70
 
67
71
  # @param raw_key [String]
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ast'
3
+ require "ast"
4
4
 
5
5
  module I18n::Tasks::Scanners
6
6
  class RubyAstCallFinder
@@ -2,7 +2,7 @@
2
2
 
3
3
  module I18n::Tasks::Scanners
4
4
  module RubyKeyLiterals
5
- LITERAL_RE = /:?".+?"|:?'.+?'|:\w+/.freeze
5
+ LITERAL_RE = /:?".+?"|:?'.+?'|:\w+/
6
6
 
7
7
  # Match literals:
8
8
  # * String: '', "#{}"
@@ -15,16 +15,16 @@ module I18n::Tasks::Scanners
15
15
  # @param literal [String] e.g: "key", 'key', or :key.
16
16
  # @return [String] key
17
17
  def strip_literal(literal)
18
- literal = literal[1..] if literal[0] == ':'
19
- literal = literal[1..-2] if literal[0] == "'" || literal[0] == '"'
18
+ literal = literal[1..] if literal[0] == ":"
19
+ literal = literal[1..-2] if ["'", '"'].include?(literal[0])
20
20
  literal
21
21
  end
22
22
 
23
- VALID_KEY_CHARS = %r{(?:[[:word:]]|[-.?!:;À-ž/])}.freeze
24
- VALID_KEY_RE = /^#{VALID_KEY_CHARS}+$/.freeze
23
+ VALID_KEY_CHARS = %r{(?:[[:word:]]|[-.?!:;À-ž\\/]|(?<=[\p{L}\d])\s(?=[\p{L}\d]))}
24
+ VALID_KEY_RE = /^#{VALID_KEY_CHARS}+$/
25
25
 
26
26
  def valid_key?(key)
27
- key =~ VALID_KEY_RE && !key.end_with?('.')
27
+ key =~ VALID_KEY_RE && !key.end_with?(".")
28
28
  end
29
29
  end
30
30
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module provides a factory class for creating a Ruby parser instance.
4
+ # It temporarily disables verbose mode to suppress compatibility warnings
5
+ # when loading the "parser/current" library.
6
+ #
7
+ # Example warning for the release of Ruby 3.4.1:
8
+ # warning: parser/current is loading parser/ruby34, which recognizes
9
+ # 3.4.0-compliant syntax, but you are running 3.4.1.
10
+ # Please see https://github.com/whitequark/parser#compatibility-with-ruby-mri.
11
+ #
12
+ # By disabling verbose mode, these warnings are suppressed to provide a cleaner
13
+ # output and avoid confusion. The verbose mode is restored after the parser
14
+ # instance is created to maintain the original behavior.
15
+
16
+ module I18n::Tasks::Scanners
17
+ class RubyParserFactory
18
+ def self.create_parser
19
+ prev = $VERBOSE
20
+ $VERBOSE = nil
21
+ require "parser/current"
22
+ ::Parser::CurrentRuby.new
23
+ ensure
24
+ $VERBOSE = prev
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n/tasks/logging"
4
+ require "i18n/tasks/scanners/file_scanner"
5
+ require "i18n/tasks/scanners/relative_keys"
6
+ require "i18n/tasks/scanners/ruby_ast_call_finder"
7
+ require "i18n/tasks/scanners/ruby_parser_factory"
8
+ require "i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher"
9
+ require "i18n/tasks/scanners/ast_matchers/message_receivers_matcher"
10
+ require "i18n/tasks/scanners/ast_matchers/rails_model_matcher"
11
+ require "i18n/tasks/scanners/prism_scanners/visitor"
12
+
13
+ module I18n::Tasks::Scanners
14
+ # Scan for I18n.translate calls using whitequark/parser primarily and Prism if configured.
15
+ class RubyScanner < FileScanner
16
+ MAGIC_COMMENT_SKIP_PRISM = "i18n-tasks-skip-prism"
17
+ include RelativeKeys
18
+ include AST::Sexp
19
+ include ::I18n::Tasks::Logging
20
+
21
+ MAGIC_COMMENT_PREFIX = /\A.\s*i18n-tasks-use\s+/
22
+
23
+ protected
24
+
25
+ # Extract all occurrences of translate calls from the file at the given path.
26
+ #
27
+ # @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
28
+ def scan_file(path)
29
+ if config[:prism]
30
+ prism_parse_file(path)
31
+ else
32
+ ast_parser_parse_file(path)
33
+ end
34
+ rescue => e
35
+ raise ::I18n::Tasks::CommandError.new(e, "Error scanning #{path}: #{e.message}")
36
+ end
37
+
38
+ def ast_parser_parse_file(path)
39
+ setup_ast_parser
40
+ ast, comments = path_to_ast_and_comments(path)
41
+
42
+ ast_to_occurences(ast) + comments_to_occurences(path, ast, comments)
43
+ end
44
+
45
+ # Parse file on path and returns AST and comments.
46
+ #
47
+ # @param path Path to file to parse
48
+ # @return [{Parser::AST::Node}, [Parser::Source::Comment]]
49
+ def path_to_ast_and_comments(path)
50
+ @parser.reset
51
+ @parser.parse_with_comments(make_buffer(path))
52
+ end
53
+
54
+ # Create an {Parser::Source::Buffer} with the given contents.
55
+ # The contents are assigned a {Parser::Source::Buffer#raw_source}.
56
+ #
57
+ # @param path [String] Path to assign as the buffer name.
58
+ # @param contents [String]
59
+ # @return [Parser::Source::Buffer] file contents
60
+ def make_buffer(path, contents = read_file(path))
61
+ Parser::Source::Buffer.new(path).tap do |buffer|
62
+ buffer.raw_source = contents
63
+ end
64
+ end
65
+
66
+ # Convert an array of {Parser::Source::Comment} to occurrences.
67
+ #
68
+ # @param path Path to file
69
+ # @param ast Parser::AST::Node
70
+ # @param comments [Parser::Source::Comment]
71
+ # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
72
+ def comments_to_occurences(path, ast, comments)
73
+ magic_comments = comments.select { |comment| comment.text =~ MAGIC_COMMENT_PREFIX }
74
+ comment_to_node = Parser::Source::Comment.associate_locations(ast, magic_comments).tap do |h|
75
+ h.transform_values!(&:first)
76
+ end.invert
77
+
78
+ magic_comments.flat_map do |comment|
79
+ @parser.reset
80
+ associated_node = comment_to_node[comment]
81
+ ast = @parser.parse(make_buffer(path, comment.text.sub(MAGIC_COMMENT_PREFIX, "").split(/\s+(?=t)/).join("; ")))
82
+ calls = RubyAstCallFinder.new.collect_calls(ast)
83
+ results = []
84
+
85
+ # method_name is not available at this stage
86
+ calls.each do |(send_node, _method_name)|
87
+ @matchers.each do |matcher|
88
+ result = matcher.convert_to_key_occurrences(
89
+ send_node,
90
+ nil,
91
+ location: associated_node || comment.location
92
+ )
93
+ next unless result
94
+
95
+ if result.is_a?(Array) && result.first.is_a?(Array)
96
+ results.concat(result)
97
+ else
98
+ results << result
99
+ end
100
+ end
101
+ end
102
+
103
+ results
104
+ end
105
+ end
106
+
107
+ # Convert {Parser::AST::Node} to occurrences.
108
+ #
109
+ # @param ast {Parser::Source::Comment}
110
+ # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
111
+ def ast_to_occurences(ast)
112
+ calls = RubyAstCallFinder.new.collect_calls(ast)
113
+ results = []
114
+ calls.each do |send_node, method_name|
115
+ @matchers.each do |matcher|
116
+ result = matcher.convert_to_key_occurrences(send_node, method_name)
117
+ next unless result
118
+
119
+ if result.is_a?(Array) && result.first.is_a?(Array)
120
+ results.concat(result)
121
+ else
122
+ results << result
123
+ end
124
+ end
125
+ end
126
+
127
+ results
128
+ end
129
+
130
+ def setup_ast_parser
131
+ @parser ||= RubyParserFactory.create_parser
132
+ @magic_comment_parser ||= RubyParserFactory.create_parser
133
+ setup_ast_matchers
134
+ end
135
+
136
+ def setup_ast_matchers
137
+ return if defined?(@matchers)
138
+
139
+ if config[:receiver_messages]
140
+ @matchers = config[:receiver_messages].map do |receiver, message|
141
+ AstMatchers::MessageReceiversMatcher.new(
142
+ receivers: [receiver],
143
+ message: message,
144
+ scanner: self
145
+ )
146
+ end
147
+ else
148
+ @matchers = %i[t t! translate translate!].map do |message|
149
+ AstMatchers::MessageReceiversMatcher.new(
150
+ receivers: [
151
+ AST::Node.new(:const, [nil, :I18n]),
152
+ nil
153
+ ],
154
+ message: message,
155
+ scanner: self
156
+ )
157
+ end
158
+
159
+ Array(config[:ast_matchers]).each do |class_name|
160
+ @matchers << ActiveSupport::Inflector.constantize(class_name).new(scanner: self)
161
+ end
162
+ end
163
+ end
164
+
165
+ # ---------- Prism parser below ----------
166
+
167
+ # Extract all occurrences of translate calls from the file at the given path.
168
+ # @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
169
+ def prism_parse_file(path)
170
+ process_prism_results(path, Prism.parse_file(path))
171
+ end
172
+
173
+ # This method handles only parsing to be able to test it properly.
174
+ # Therefore it cannot handle the parsing itself.
175
+ def process_prism_results(path, parse_results)
176
+ comments = parse_results.attach_comments!
177
+ parsed = parse_results.value
178
+
179
+ # Check for magic comment to skip prism parsing, fallback to Parser AST
180
+ return ast_parser_parse_file(path) if skip_prism_comment?(comments)
181
+
182
+ visitor = I18n::Tasks::Scanners::PrismScanners::Visitor.new(
183
+ rails: config[:prism] != "ruby",
184
+ file_path: path
185
+ )
186
+ parsed.accept(visitor)
187
+
188
+ occurrences = []
189
+ visitor.process.each do |translation_call|
190
+ result = translation_call.occurrences(path)
191
+ next unless result
192
+
193
+ if result.is_a?(Array) && result.first.is_a?(Array)
194
+ occurrences.concat(result)
195
+ else
196
+ occurrences << result
197
+ end
198
+ end
199
+
200
+ occurrences
201
+ end
202
+
203
+ def skip_prism_comment?(comments)
204
+ comments.any? do |comment|
205
+ content =
206
+ comment.respond_to?(:slice) ? comment.slice : comment.location.slice
207
+ content.include?(MAGIC_COMMENT_SKIP_PRISM)
208
+ end
209
+ end
210
+ end
211
+
212
+ class RubyAstScanner < RubyScanner
213
+ def initialize(**args)
214
+ warn_deprecated("RubyAstScanner is deprecated, use RubyScanner instead")
215
+ super
216
+ end
217
+ end
218
+
219
+ class PrismScanner < RubyScanner
220
+ def initialize(**args)
221
+ warn_deprecated('PrismScanner is deprecated, use RubyScanner with prism: "rails" or prism: "ruby" instead')
222
+ super
223
+ end
224
+ end
225
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/scanners/results/key_occurrences'
3
+ require "i18n/tasks/scanners/results/key_occurrences"
4
4
 
5
5
  module I18n::Tasks::Scanners
6
6
  # Describes the API of a scanner.
@@ -11,7 +11,7 @@ module I18n::Tasks::Scanners
11
11
  # @abstract
12
12
  # @return [Array<Results::KeyOccurrences>] the keys found by this scanner and their occurrences.
13
13
  def keys
14
- fail 'Unimplemented'
14
+ fail "Unimplemented"
15
15
  end
16
16
  end
17
17
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/scanners/scanner'
3
+ require "i18n/tasks/scanners/scanner"
4
4
 
5
5
  module I18n::Tasks::Scanners
6
6
  # Run multiple {Scanner Scanners} and merge their results.
@@ -21,7 +21,7 @@ module I18n
21
21
 
22
22
  parts = []
23
23
  current_parenthesis_end_char = nil
24
- part = ''
24
+ part = ""
25
25
  key.each_char.with_index do |char, index|
26
26
  if current_parenthesis_end_char
27
27
  part += char
@@ -29,14 +29,14 @@ module I18n
29
29
  elsif START_KEYS.include?(char)
30
30
  part += char
31
31
  current_parenthesis_end_char = END_KEYS[char]
32
- elsif char == '.'
32
+ elsif char == "."
33
33
  parts << part
34
34
  if parts.size + 1 == max
35
35
  remaining = key[(index + 1)..]
36
36
  parts << remaining unless remaining.empty?
37
37
  return parts
38
38
  end
39
- part = ''
39
+ part = ""
40
40
  else
41
41
  part += char
42
42
  end
@@ -44,7 +44,7 @@ module I18n
44
44
 
45
45
  return parts if part.empty?
46
46
 
47
- current_parenthesis_end_char ? parts.concat(part.split('.')) : parts << part
47
+ current_parenthesis_end_char ? parts.concat(part.split(".")) : parts << part
48
48
  end
49
49
 
50
50
  def last_key_part(key)
@@ -6,15 +6,15 @@ module I18n::Tasks
6
6
  key_count = forest.leaves.count
7
7
  locale_count = forest.count
8
8
  if key_count.zero?
9
- { key_count: 0 }
9
+ {key_count: 0}
10
10
  else
11
11
  {
12
- locales: forest.map(&:key).join(', '),
12
+ locales: forest.map(&:key).join(", "),
13
13
  key_count: key_count,
14
14
  locale_count: locale_count,
15
15
  per_locale_avg: forest.inject(0) { |sum, f| sum + f.leaves.count } / locale_count,
16
16
  key_segments_avg: format(
17
- '%.1f', forest.leaves.inject(0) { |sum, node| sum + node.walk_to_root.count - 1 } / key_count.to_f
17
+ "%.1f", forest.leaves.inject(0) { |sum, node| sum + node.walk_to_root.count - 1 } / key_count.to_f
18
18
  ),
19
19
  value_chars_avg: forest.leaves.inject(0) { |sum, node| sum + node.value.to_s.length } / key_count
20
20
  }
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n/tasks/translators/deepl_translator'
4
- require 'i18n/tasks/translators/google_translator'
5
- require 'i18n/tasks/translators/openai_translator'
6
- require 'i18n/tasks/translators/yandex_translator'
3
+ require "i18n/tasks/translators/deepl_translator"
4
+ require "i18n/tasks/translators/google_translator"
5
+ require "i18n/tasks/translators/openai_translator"
6
+ require "i18n/tasks/translators/watsonx_translator"
7
+ require "i18n/tasks/translators/yandex_translator"
7
8
 
8
9
  module I18n::Tasks
9
10
  module Translation
@@ -11,7 +12,7 @@ module I18n::Tasks
11
12
  # @param [String] from locale
12
13
  # @param [:deepl, :openai, :google, :yandex] backend
13
14
  # @return [I18n::Tasks::Tree::Siblings] translated forest
14
- def translate_forest(forest, from:, backend: :google)
15
+ def translate_forest(forest, from:, backend:)
15
16
  case backend
16
17
  when :deepl
17
18
  Translators::DeeplTranslator.new(self).translate_forest(forest, from)
@@ -19,6 +20,8 @@ module I18n::Tasks
19
20
  Translators::GoogleTranslator.new(self).translate_forest(forest, from)
20
21
  when :openai
21
22
  Translators::OpenAiTranslator.new(self).translate_forest(forest, from)
23
+ when :watsonx
24
+ Translators::WatsonxTranslator.new(self).translate_forest(forest, from)
22
25
  when :yandex
23
26
  Translators::YandexTranslator.new(self).translate_forest(forest, from)
24
27
  else
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "i18n/tasks/data/language_names"
4
+
3
5
  module I18n::Tasks
4
6
  module Translators
5
7
  class BaseTranslator
6
8
  include ::I18n::Tasks::Logging
9
+
7
10
  # @param [I18n::Tasks::BaseTask] i18n_tasks
8
11
  def initialize(i18n_tasks)
9
12
  @i18n_tasks = i18n_tasks
@@ -14,7 +17,26 @@ module I18n::Tasks
14
17
  # @return [I18n::Tasks::Tree::Siblings] translated forest
15
18
  def translate_forest(forest, from)
16
19
  forest.inject @i18n_tasks.empty_forest do |result, root|
17
- translated = translate_pairs(root.key_values(root: true), to: root.key, from: from)
20
+ pairs = root.key_values(root: true)
21
+
22
+ @progress_bar = ProgressBar.create(total: pairs.flatten.size, format: "%a <%B> %e %c/%C (%p%%)")
23
+
24
+ begin
25
+ translated = translate_pairs(pairs, to: root.key, from: from)
26
+ rescue => e
27
+ warn "Translation for locale #{root.key} failed: #{e.message}"
28
+ # If translate_pairs raised, try to salvage any partial translations
29
+ # by attempting to translate each slice individually and collecting successes.
30
+ translated = []
31
+ pairs.group_by { |k_v| @i18n_tasks.html_key? k_v[0], from }.each do |_is_html, list_slice|
32
+ translated.concat(fetch_translations(list_slice, to: root.key, from: from))
33
+ rescue => e2
34
+ warn "Partial translation failed for locale #{root.key}: #{e2.message} - leaving keys untranslated"
35
+ # leave the original list_slice untranslated
36
+ translated.concat(list_slice)
37
+ end
38
+ end
39
+
18
40
  result.merge! Data::Tree::Siblings.from_flat_pairs(translated)
19
41
  end
20
42
  end
@@ -33,6 +55,10 @@ module I18n::Tasks
33
55
  list -= reference_key_vals
34
56
  result = list.group_by { |k_v| @i18n_tasks.html_key? k_v[0], opts[:from] }.map do |is_html, list_slice|
35
57
  fetch_translations(list_slice, opts.merge(is_html ? options_for_html : options_for_plain))
58
+ rescue => e
59
+ warn "Translation slice failed: #{e.message} - leaving slice untranslated"
60
+ # Return the original untranslated slice so already completed translations are preserved
61
+ list_slice
36
62
  end.reduce(:+) || []
37
63
  result.concat(reference_key_vals)
38
64
  result.sort! { |a, b| key_pos[a[0]] <=> key_pos[b[0]] }
@@ -104,8 +130,7 @@ module I18n::Tasks
104
130
  end
105
131
  end
106
132
 
107
- INTERPOLATION_KEY_RE = /%\{[^}]+}/.freeze
108
- UNTRANSLATABLE_STRING = 'X__'
133
+ INTERPOLATION_KEY_RE = /%\{[^}]+}/
109
134
 
110
135
  # @param [String] value
111
136
  # @return [String] 'hello, %{name}' => 'hello, <round-trippable string>'
@@ -113,7 +138,7 @@ module I18n::Tasks
113
138
  i = -1
114
139
  value.gsub INTERPOLATION_KEY_RE do
115
140
  i += 1
116
- "#{UNTRANSLATABLE_STRING}#{i}"
141
+ "X__#{i}"
117
142
  end
118
143
  end
119
144
 
@@ -121,13 +146,13 @@ module I18n::Tasks
121
146
  # @param [String] translated
122
147
  # @return [String] 'hello, <round-trippable string>' => 'hello, %{name}'
123
148
  def restore_interpolations(untranslated, translated)
124
- return translated if untranslated !~ INTERPOLATION_KEY_RE
149
+ return translated if !INTERPOLATION_KEY_RE.match?(untranslated)
125
150
 
126
151
  values = untranslated.scan(INTERPOLATION_KEY_RE)
127
- translated.gsub(/#{Regexp.escape(UNTRANSLATABLE_STRING)}\d+/i) do |m|
128
- values[m[UNTRANSLATABLE_STRING.length..].to_i]
152
+ translated.gsub(/X__(\d+)/) do |m|
153
+ values[$1.to_i]
129
154
  end
130
- rescue StandardError => e
155
+ rescue => e
131
156
  raise_interpolation_error(untranslated, translated, e)
132
157
  end
133
158
 
@@ -144,24 +169,29 @@ module I18n::Tasks
144
169
  # @param [Hash] options
145
170
  # @return [Array<String>]
146
171
  # @abstract
147
- def translate_values(list, **options); end
172
+ def translate_values(list, **options)
173
+ end
148
174
 
149
175
  # @param [Hash] options
150
176
  # @return [Hash]
151
177
  # @abstract
152
- def options_for_translate_values(options); end
178
+ def options_for_translate_values(options)
179
+ end
153
180
 
154
181
  # @return [Hash]
155
182
  # @abstract
156
- def options_for_html; end
183
+ def options_for_html
184
+ end
157
185
 
158
186
  # @return [Hash]
159
187
  # @abstract
160
- def options_for_plain; end
188
+ def options_for_plain
189
+ end
161
190
 
162
191
  # @return [String]
163
192
  # @abstract
164
- def no_results_error_message; end
193
+ def no_results_error_message
194
+ end
165
195
  end
166
196
  end
167
197
  end