i18n-tasks 1.1.2 → 1.2.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +321 -242
  3. data/Rakefile +5 -1
  4. data/benchmarks/bench_helper.rb +156 -0
  5. data/benchmarks/data_bench.rb +173 -0
  6. data/benchmarks/end_to_end_bench.rb +138 -0
  7. data/benchmarks/fixtures/generator.rb +257 -0
  8. data/benchmarks/results/baseline.json +250 -0
  9. data/benchmarks/run_all.rb +60 -0
  10. data/benchmarks/scanning_bench.rb +135 -0
  11. data/benchmarks/tree_bench.rb +205 -0
  12. data/config/locales/en.yml +10 -0
  13. data/config/locales/ru.yml +14 -0
  14. data/i18n-tasks.gemspec +1 -1
  15. data/lib/i18n/tasks/base_task.rb +2 -0
  16. data/lib/i18n/tasks/command/commands/check_prism.rb +67 -15
  17. data/lib/i18n/tasks/command/commands/prune.rb +57 -0
  18. data/lib/i18n/tasks/commands.rb +2 -0
  19. data/lib/i18n/tasks/data/adapter/json_adapter.rb +1 -1
  20. data/lib/i18n/tasks/data/file_formats.rb +4 -1
  21. data/lib/i18n/tasks/data/router/conservative_router.rb +13 -22
  22. data/lib/i18n/tasks/data/router/pattern_router.rb +26 -14
  23. data/lib/i18n/tasks/data/tree/node.rb +3 -3
  24. data/lib/i18n/tasks/data/tree/siblings.rb +11 -3
  25. data/lib/i18n/tasks/data/tree/traversal.rb +20 -14
  26. data/lib/i18n/tasks/interpolations.rb +61 -3
  27. data/lib/i18n/tasks/logging.rb +1 -2
  28. data/lib/i18n/tasks/missing_keys.rb +7 -38
  29. data/lib/i18n/tasks/plural_keys.rb +56 -0
  30. data/lib/i18n/tasks/prune.rb +18 -0
  31. data/lib/i18n/tasks/references.rb +9 -1
  32. data/lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb +11 -2
  33. data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +16 -2
  34. data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +6 -3
  35. data/lib/i18n/tasks/scanners/file_scanner.rb +4 -1
  36. data/lib/i18n/tasks/scanners/files/file_finder.rb +29 -6
  37. data/lib/i18n/tasks/scanners/files/parse_cache.rb +24 -0
  38. data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +33 -0
  39. data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +57 -23
  40. data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +70 -16
  41. data/lib/i18n/tasks/scanners/relative_keys.rb +21 -11
  42. data/lib/i18n/tasks/scanners/ruby_scanner.rb +37 -5
  43. data/lib/i18n/tasks/split_key.rb +16 -6
  44. data/lib/i18n/tasks/translators/base_translator.rb +25 -23
  45. data/lib/i18n/tasks/translators/deepl_translator.rb +18 -15
  46. data/lib/i18n/tasks/translators/google_translator.rb +41 -14
  47. data/lib/i18n/tasks/translators/openai_translator.rb +31 -17
  48. data/lib/i18n/tasks/translators/watsonx_translator.rb +8 -10
  49. data/lib/i18n/tasks/translators/yandex_translator.rb +4 -0
  50. data/lib/i18n/tasks/unused_keys.rb +35 -5
  51. data/lib/i18n/tasks/used_keys.rb +6 -1
  52. data/lib/i18n/tasks/version.rb +1 -1
  53. data/script/report_test_results.rb +58 -0
  54. data/templates/config/i18n-tasks.yml +11 -3
  55. metadata +17 -7
@@ -60,6 +60,36 @@ module I18n::Tasks::Translators
60
60
  {format: "text"}
61
61
  end
62
62
 
63
+ # Batches pairs by the request limits so a failed request only skips its own keys.
64
+ # A single pair can exceed the limits (e.g. a large array value), see #batch_texts.
65
+ def translation_batches(list, options)
66
+ batches = []
67
+ current_batch = []
68
+ current_text_count = 0
69
+ current_chars = 0
70
+
71
+ list.each do |pair|
72
+ pair_values = replace_newlines_with_placeholder(to_values([pair], options))
73
+ pair_text_count = pair_values.size
74
+ pair_chars = pair_values.sum(&:length)
75
+
76
+ if (current_text_count + pair_text_count > MAX_TEXTS_PER_REQUEST ||
77
+ current_chars + pair_chars > MAX_CHARS_PER_REQUEST) && current_batch.any?
78
+ batches << current_batch
79
+ current_batch = []
80
+ current_text_count = 0
81
+ current_chars = 0
82
+ end
83
+
84
+ current_batch << pair
85
+ current_text_count += pair_text_count
86
+ current_chars += pair_chars
87
+ end
88
+
89
+ batches << current_batch if current_batch.any?
90
+ batches
91
+ end
92
+
63
93
  def no_results_error_message
64
94
  I18n.t("i18n_tasks.google_translate.errors.no_results")
65
95
  end
@@ -116,25 +146,23 @@ module I18n::Tasks::Translators
116
146
 
117
147
  raise ArgumentError, "target language (:to) is required" if to.nil? || to.empty?
118
148
 
119
- # Split into batches if needed
120
- batches = batch_texts(texts)
121
-
122
- batches.flat_map do |batch|
149
+ batch_texts(texts).flat_map do |batch|
123
150
  translate_batch(texts: batch, api_key:, to:, from:, html:)
124
151
  end
125
152
  end
126
153
 
154
+ # Splits texts to stay within the request limits. Needed because #translation_batches
155
+ # cannot split a single pair, as its translated values are mapped back to it.
127
156
  def batch_texts(texts)
128
157
  batches = []
129
158
  current_batch = []
130
159
  current_chars = 0
131
160
 
132
161
  texts.each do |text|
133
- text_length = text.to_s.length
162
+ text_length = text.length
134
163
 
135
- # If adding this text would exceed limits, start new batch
136
- if current_batch.size >= MAX_TEXTS_PER_REQUEST ||
137
- (current_chars + text_length > MAX_CHARS_PER_REQUEST && current_batch.any?)
164
+ if (current_batch.size >= MAX_TEXTS_PER_REQUEST ||
165
+ current_chars + text_length > MAX_CHARS_PER_REQUEST) && current_batch.any?
138
166
  batches << current_batch
139
167
  current_batch = []
140
168
  current_chars = 0
@@ -169,18 +197,17 @@ module I18n::Tasks::Translators
169
197
  end
170
198
 
171
199
  def make_request(uri, body)
172
- http = Net::HTTP.new(uri.host, uri.port)
173
- http.use_ssl = true
174
- http.read_timeout = 60
175
- http.open_timeout = 30
176
-
177
200
  request = Net::HTTP::Post.new(uri.request_uri)
178
201
  request["Content-Type"] = "application/json"
179
202
  request["Accept"] = "application/json"
180
203
  request["User-Agent"] = "i18n-tasks/GoogleTranslateApi"
181
204
  request.body = JSON.generate(body)
182
205
 
183
- response = http.request(request)
206
+ # An explicit `start` block is required: on JRuby, implicit connections intermittently fail with
207
+ # "closing inbound before receiving peer's close_notify" when reading Google's response.
208
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60, open_timeout: 30) do |http|
209
+ http.request(request)
210
+ end
184
211
 
185
212
  handle_error_response(response) unless response.is_a?(Net::HTTPSuccess)
186
213
  response
@@ -50,6 +50,10 @@ module I18n::Tasks::Translators
50
50
  {}
51
51
  end
52
52
 
53
+ def translation_batches(list, _options)
54
+ list.each_slice(BATCH_SIZE).to_a
55
+ end
56
+
53
57
  def no_results_error_message
54
58
  I18n.t("i18n_tasks.openai_translate.errors.no_results")
55
59
  end
@@ -77,6 +81,10 @@ module I18n::Tasks::Translators
77
81
  @temperature ||= @i18n_tasks.translation_config[:openai_temperature].presence || 0.0
78
82
  end
79
83
 
84
+ def reasoning_effort
85
+ @i18n_tasks.translation_config[:openai_reasoning_effort].presence
86
+ end
87
+
80
88
  def system_prompt(to_locale)
81
89
  prompt = if locale_prompts[to_locale].present?
82
90
  locale_prompts[to_locale]
@@ -92,27 +100,33 @@ module I18n::Tasks::Translators
92
100
  end
93
101
 
94
102
  def translate_values(list, from:, to:)
95
- results = []
96
-
97
- list.each_slice(BATCH_SIZE) do |batch|
98
- result = translate(batch, from, to)
99
- results << result
100
-
101
- @progress_bar.progress += result.size
102
- end
103
+ return [] if list.empty?
103
104
 
104
- results.flatten
105
+ result = translate(list, from, to)
106
+ @progress_bar.progress += result.size
107
+ result
105
108
  end
106
109
 
107
110
  def translate(values, from, to)
108
- response = translator.chat(
109
- parameters: {
110
- model: model,
111
- messages: build_messages(values, from, to),
112
- temperature: temperature,
113
- response_format: {type: "json_object"}
114
- }
115
- )
111
+ effort = reasoning_effort
112
+ parameters = {
113
+ model: model,
114
+ messages: build_messages(values, from, to),
115
+ response_format: {type: "json_object"}
116
+ }
117
+
118
+ parameters[:reasoning_effort] = effort if effort
119
+
120
+ if effort && effort != "none"
121
+ if @i18n_tasks.translation_config[:openai_temperature].present?
122
+ fail ::I18n::Tasks::CommandError,
123
+ "openai_temperature is not supported when openai_reasoning_effort is #{effort}"
124
+ end
125
+ else
126
+ parameters[:temperature] = temperature
127
+ end
128
+
129
+ response = translator.chat(parameters: parameters)
116
130
 
117
131
  translations = response.dig("choices", 0, "message", "content")
118
132
  error = response["error"]
@@ -39,6 +39,10 @@ module I18n::Tasks::Translators
39
39
  {}
40
40
  end
41
41
 
42
+ def translation_batches(list, _options)
43
+ list.each_slice(BATCH_SIZE).to_a
44
+ end
45
+
42
46
  def no_results_error_message
43
47
  I18n.t("i18n_tasks.watsonx_translate.errors.no_results")
44
48
  end
@@ -79,17 +83,11 @@ module I18n::Tasks::Translators
79
83
  end
80
84
 
81
85
  def translate_values(list, from:, to:)
82
- results = []
83
-
84
- list.each_slice(BATCH_SIZE) do |batch|
85
- translations = translate(batch, from, to)
86
- result = JSON.parse(translations)
87
- results << result
88
-
89
- @progress_bar.progress += results.size
90
- end
86
+ return [] if list.empty?
91
87
 
92
- results.flatten
88
+ result = JSON.parse(translate(list, from, to))
89
+ @progress_bar.progress += result.size
90
+ result
93
91
  end
94
92
 
95
93
  def translate(values, from, to)
@@ -38,6 +38,10 @@ module I18n::Tasks::Translators
38
38
  {format: "plain"}
39
39
  end
40
40
 
41
+ def translation_batches(list, _options)
42
+ list.each_slice(1).to_a
43
+ end
44
+
41
45
  def no_results_error_message
42
46
  I18n.t("i18n_tasks.yandex_translate.errors.no_results")
43
47
  end
@@ -7,19 +7,49 @@ module I18n
7
7
  module UnusedKeys
8
8
  def unused_keys(locales: nil, strict: nil)
9
9
  locales = Array(locales).presence || self.locales
10
- locales.map { |locale| unused_tree(locale: locale, strict: strict) }.compact.reduce(:merge!)
10
+ strict = strict?(strict)
11
+ return unused_forest(locales, strict: strict) if strict
12
+
13
+ # `used_in_expr?` scans the source again, non-strictly
14
+ parse_cache.retain { unused_forest(locales, strict: strict) }
11
15
  end
12
16
 
13
17
  # @param [String] locale
14
- # @param [Boolean] strict if true, do not match dynamic keys
15
- def unused_tree(locale: base_locale, strict: nil)
16
- used_key_names = used_tree(strict: true).key_names
18
+ # @param [Boolean, nil] strict if true, do not match dynamic keys. Defaults to `search.strict`.
19
+ def unused_tree(locale: base_locale, strict: nil, used_key_names: nil)
20
+ strict = strict?(strict)
21
+ used_key_names ||= used_tree(strict: true).key_names.to_set
17
22
  collapse_plural_nodes!(data[locale].select_keys do |key, _node|
18
23
  !ignore_key?(key, :unused) &&
19
24
  (strict || !used_in_expr?(key)) &&
20
- !used_key_names.include?(depluralize_key(key, locale))
25
+ !key_used?(depluralize_key(key, locale), used_key_names)
21
26
  end)
22
27
  end
28
+
29
+ private
30
+
31
+ def unused_forest(locales, strict:)
32
+ used_key_names = used_tree(strict: true).key_names.to_set
33
+ locales.map { |locale| unused_tree(locale: locale, strict: strict, used_key_names: used_key_names) }.compact.reduce(:merge!)
34
+ end
35
+
36
+ # A key is considered used if it is directly used, or if one of its ancestors is used
37
+ # (e.g. `t(:section)` covers `section.item.title`).
38
+ def key_used?(key, used_key_names)
39
+ return true if used_key_names.include?(key)
40
+
41
+ ancestor = key
42
+ loop do
43
+ next_ancestor = ancestor.sub(/\.[^.]+\z/, "")
44
+ break if next_ancestor == ancestor
45
+
46
+ return true if used_key_names.include?(next_ancestor)
47
+
48
+ ancestor = next_ancestor
49
+ end
50
+
51
+ false
52
+ end
23
53
  end
24
54
  end
25
55
  end
@@ -81,7 +81,8 @@ module I18n::Tasks
81
81
  ActiveSupport::Inflector.constantize(class_name).new(
82
82
  config: merge_scanner_configs(shared_options, args || {}),
83
83
  file_finder_provider: caching_file_finder_provider,
84
- file_reader: caching_file_reader
84
+ file_reader: caching_file_reader,
85
+ parse_cache: parse_cache
85
86
  )
86
87
  end.tap { |scanners| log_verbose { scanners.map { |s| " #{s.class.name} #{s.config.inspect}" } * "\n" } }
87
88
  )
@@ -123,6 +124,10 @@ module I18n::Tasks
123
124
  @caching_file_finder_provider ||= Scanners::Files::CachingFileFinderProvider.new(exclude: ALWAYS_EXCLUDE)
124
125
  end
125
126
 
127
+ def parse_cache
128
+ @parse_cache ||= Scanners::Files::ParseCache.new
129
+ end
130
+
126
131
  def caching_file_reader
127
132
  @caching_file_reader ||= Scanners::Files::CachingFileReader.new
128
133
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module I18n
4
4
  module Tasks
5
- VERSION = "1.1.2"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ def escape_property(value)
6
+ value.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A").gsub(":", "%3A").gsub(",", "%2C")
7
+ end
8
+
9
+ def escape_data(value)
10
+ value.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
11
+ end
12
+
13
+ reports = Dir.glob(File.join(ARGV.fetch(0, "test-results"), "**", "*.xml")).sort
14
+ summary = ["## RSpec results", ""]
15
+
16
+ if reports.empty?
17
+ summary << "No JUnit reports were uploaded."
18
+ puts "::warning::No JUnit reports were uploaded."
19
+ else
20
+ summary << "| Ruby | Tests | Failures | Errors | Skipped |"
21
+ summary << "| --- | ---: | ---: | ---: | ---: |"
22
+
23
+ reports.each do |report|
24
+ ruby_version = File.basename(File.dirname(report)).delete_prefix("test-results-")
25
+ document = REXML::Document.new(File.read(report))
26
+ cases = []
27
+ document.elements.each("//testcase") { |test_case| cases << test_case }
28
+
29
+ failed = cases.count { |test_case| test_case.elements["failure"] }
30
+ errors = cases.count { |test_case| test_case.elements["error"] }
31
+ skipped = cases.count { |test_case| test_case.elements["skipped"] }
32
+ summary << "| #{ruby_version} | #{cases.size} | #{failed} | #{errors} | #{skipped} |"
33
+
34
+ cases.each do |test_case|
35
+ %w[failure error].each do |result|
36
+ issue = test_case.elements[result]
37
+ next unless issue
38
+
39
+ name = [test_case.attributes["classname"], test_case.attributes["name"]].compact.join(" ")
40
+ message = [issue.attributes["message"], issue.text].compact.reject(&:empty?).join(": ")
41
+ location = test_case.attributes["file"]
42
+ details = "#{ruby_version}: #{name} - #{message}"
43
+ summary << "- **#{ruby_version}** `#{name}`: #{message.to_s.gsub("\n", " ")}"
44
+
45
+ properties = ["title=RSpec #{result}"]
46
+ properties << "file=#{location}" if location
47
+ escaped_properties = properties.map { |property| escape_property(property) }.join(",")
48
+ puts "::error #{escaped_properties}::#{escape_data(details)}"
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ if (summary_path = ENV["GITHUB_STEP_SUMMARY"])
55
+ File.open(summary_path, "a") { |file| file.puts(summary.join("\n")) }
56
+ else
57
+ puts summary.join("\n")
58
+ end
@@ -13,7 +13,9 @@ data:
13
13
  ## Provide a custom adapter:
14
14
  # adapter: I18n::Tasks::Data::FileSystem
15
15
 
16
- # Locale files or `Dir.glob` patterns where translations are read from:
16
+ # Locale files or `Dir.glob` patterns where translations are read from.
17
+ # When a key is defined in several files, the file read last wins: patterns are read in order,
18
+ # and the files matching a pattern in the order `Dir.glob` returns them.
17
19
  read:
18
20
  ## Default:
19
21
  # - config/locales/%{locale}.yml
@@ -115,8 +117,7 @@ search:
115
117
  # # deepl_host: "https://api.deepl.com"
116
118
  # # deepl_version: "v2"
117
119
  # # deepl_glossary_ids:
118
- # # - f28106eb-0e06-489e-82c6-8215d6f95089
119
- # # - 2c6415be-1852-4f54-9e1b-d800463496b4
120
+ # # - uuid
120
121
  # # add additional options to the DeepL.translate call: https://www.deepl.com/docs-api/translate-text/translate-text/
121
122
  # deepl_options:
122
123
  # formality: prefer_less
@@ -153,6 +154,13 @@ search:
153
154
  # - 'errors.messages.{accepted,blank,invalid,too_short,too_long}'
154
155
  # - '{devise,simple_form}.*'
155
156
 
157
+ ## Alternatively, ignore missing keys per locale (just like ignore_eq_base):
158
+ # ignore_missing:
159
+ # all:
160
+ # - 'errors.messages.{accepted,blank,invalid,too_short,too_long}'
161
+ # fr,es:
162
+ # - '{devise,simple_form}.*'
163
+
156
164
  ## Consider these keys used:
157
165
  # ignore_unused:
158
166
  # - 'activerecord.attributes.*'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n-tasks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - glebm
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-11-27 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -100,14 +99,14 @@ dependencies:
100
99
  requirements:
101
100
  - - ">="
102
101
  - !ruby/object:Gem::Version
103
- version: '0'
102
+ version: '0.28'
104
103
  type: :runtime
105
104
  prerelease: false
106
105
  version_requirements: !ruby/object:Gem::Requirement
107
106
  requirements:
108
107
  - - ">="
109
108
  - !ruby/object:Gem::Version
110
- version: '0'
109
+ version: '0.28'
111
110
  - !ruby/object:Gem::Dependency
112
111
  name: rails-i18n
113
112
  requirement: !ruby/object:Gem::Requirement
@@ -191,6 +190,14 @@ files:
191
190
  - LICENSE.txt
192
191
  - README.md
193
192
  - Rakefile
193
+ - benchmarks/bench_helper.rb
194
+ - benchmarks/data_bench.rb
195
+ - benchmarks/end_to_end_bench.rb
196
+ - benchmarks/fixtures/generator.rb
197
+ - benchmarks/results/baseline.json
198
+ - benchmarks/run_all.rb
199
+ - benchmarks/scanning_bench.rb
200
+ - benchmarks/tree_bench.rb
194
201
  - bin/i18n-tasks
195
202
  - bin/i18n-tasks.cmd
196
203
  - config/locales/en.yml
@@ -208,6 +215,7 @@ files:
208
215
  - lib/i18n/tasks/command/commands/interpolations.rb
209
216
  - lib/i18n/tasks/command/commands/meta.rb
210
217
  - lib/i18n/tasks/command/commands/missing.rb
218
+ - lib/i18n/tasks/command/commands/prune.rb
211
219
  - lib/i18n/tasks/command/commands/tree.rb
212
220
  - lib/i18n/tasks/command/commands/usages.rb
213
221
  - lib/i18n/tasks/command/dsl.rb
@@ -245,6 +253,7 @@ files:
245
253
  - lib/i18n/tasks/logging.rb
246
254
  - lib/i18n/tasks/missing_keys.rb
247
255
  - lib/i18n/tasks/plural_keys.rb
256
+ - lib/i18n/tasks/prune.rb
248
257
  - lib/i18n/tasks/references.rb
249
258
  - lib/i18n/tasks/reports/base.rb
250
259
  - lib/i18n/tasks/reports/terminal.rb
@@ -259,6 +268,7 @@ files:
259
268
  - lib/i18n/tasks/scanners/files/caching_file_reader.rb
260
269
  - lib/i18n/tasks/scanners/files/file_finder.rb
261
270
  - lib/i18n/tasks/scanners/files/file_reader.rb
271
+ - lib/i18n/tasks/scanners/files/parse_cache.rb
262
272
  - lib/i18n/tasks/scanners/local_ruby_parser.rb
263
273
  - lib/i18n/tasks/scanners/occurrence_from_position.rb
264
274
  - lib/i18n/tasks/scanners/pattern_mapper.rb
@@ -289,6 +299,7 @@ files:
289
299
  - lib/i18n/tasks/unused_keys.rb
290
300
  - lib/i18n/tasks/used_keys.rb
291
301
  - lib/i18n/tasks/version.rb
302
+ - script/report_test_results.rb
292
303
  - templates/config/i18n-tasks.yml
293
304
  - templates/minitest/i18n_test.rb
294
305
  - templates/rspec/i18n_spec.rb
@@ -321,8 +332,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
321
332
  - !ruby/object:Gem::Version
322
333
  version: '0'
323
334
  requirements: []
324
- rubygems_version: 3.2.3
325
- signing_key:
335
+ rubygems_version: 4.0.22
326
336
  specification_version: 4
327
337
  summary: Manage localization and translation with the awesome power of static analysis
328
338
  test_files: []