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
data/Rakefile CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
- RSpec::Core::RakeTask.new(:rspec)
5
+ RSpec::Core::RakeTask.new(:rspec) do |task|
6
+ if ENV["RSPEC_JUNIT_OUTPUT"]
7
+ task.rspec_opts = ["--format", "RspecJunitFormatter", "--out", ENV.fetch("RSPEC_JUNIT_OUTPUT")]
8
+ end
9
+ end
6
10
  task default: :rspec
7
11
 
8
12
  task :irb do
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
4
+ require "bundler/setup"
5
+ require "fileutils"
6
+
7
+ begin
8
+ require "benchmark/ips"
9
+ require "memory_profiler"
10
+ rescue LoadError => e
11
+ warn "#{e.message}\n\nBenchmark gems are missing. Install them with:\n\n bundle install\n\n" \
12
+ "If you have BUNDLE_WITHOUT set, make sure it does not exclude the :bench group.\n"
13
+ exit 1
14
+ end
15
+
16
+ # Load the gem under test
17
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
18
+ require "i18n/tasks"
19
+
20
+ require_relative "fixtures/generator"
21
+
22
+ BENCH_ROOT = File.expand_path("..", __dir__)
23
+
24
+ module BenchHelper
25
+ # Generate all fixture scales (idempotent - reads from cache unless forced).
26
+ def self.prepare_fixtures(force: false)
27
+ BenchmarkFixtures.generate_all(force: force)
28
+ end
29
+
30
+ # Build a configured I18n::Tasks::BaseTask context pointed at a fixture directory
31
+ # and yield it within the correct working directory.
32
+ #
33
+ # i18n-tasks resolves relative search paths against the current working directory
34
+ # at scan time, so the task must be used within Dir.chdir(fixture_dir).
35
+ #
36
+ # @param scale [:small, :medium, :large]
37
+ # @yieldparam task [I18n::Tasks::BaseTask]
38
+ def self.build_context(scale)
39
+ dir = BenchmarkFixtures.generate(scale)
40
+ config_file = File.join(dir, "config", "i18n-tasks.yml")
41
+ Dir.chdir(dir) do
42
+ yield I18n::Tasks::BaseTask.new(config_file: config_file)
43
+ end
44
+ end
45
+
46
+ # Run a block with working directory set to the fixture dir.
47
+ # The context is created fresh each time so memoized caches don't carry over.
48
+ def self.in_fixture_dir(scale)
49
+ dir = BenchmarkFixtures.generate(scale)
50
+ Dir.chdir(dir) { yield }
51
+ end
52
+
53
+ # Pre-warm the context so file-system caches are hot before timing.
54
+ def self.warm_context(context)
55
+ context.used_tree
56
+ context
57
+ rescue => e
58
+ warn "BenchHelper.warm_context failed: #{e.class}: #{e.message}"
59
+ context
60
+ end
61
+
62
+ # Print a section header.
63
+ def self.header(title)
64
+ puts
65
+ puts "=" * 60
66
+ puts " #{title}"
67
+ puts "=" * 60
68
+ puts
69
+ end
70
+
71
+ # Save IPS results to a JSON file for baseline comparison.
72
+ #
73
+ # @param suite [Benchmark::IPS::Suite] the completed suite
74
+ # @param label [String] top-level key in the JSON object
75
+ # @param path [String] path to the results JSON file
76
+ def self.save_results(suite, label, path: File.join(BENCH_ROOT, "benchmarks", "results", "baseline.json"))
77
+ require "json"
78
+ FileUtils.mkdir_p(File.dirname(path))
79
+ existing = File.exist?(path) ? JSON.parse(File.read(path)) : {}
80
+ existing["_env"] = environment_info
81
+ existing[label] ||= {}
82
+ suite.entries.each do |entry|
83
+ existing[label][entry.label] = {
84
+ ips: entry.stats.central_tendency.round(2),
85
+ ips_sd: entry.stats.error.round(2),
86
+ microseconds: (1_000_000.0 / entry.stats.central_tendency).round(2)
87
+ }
88
+ end
89
+ File.write(path, JSON.pretty_generate(existing))
90
+ puts "Results saved to #{path}"
91
+ end
92
+
93
+ # Compare an IPS suite against a stored baseline and warn about regressions.
94
+ #
95
+ # @param suite [Benchmark::IPS::Suite]
96
+ # @param label [String]
97
+ # @param threshold [Float] fraction below baseline that counts as a regression (default 0.10 = 10%)
98
+ # @param path [String] path to the baseline JSON file
99
+ # @return [Boolean] true if no regressions found
100
+ def self.compare_baseline(suite, label, threshold: 0.10,
101
+ path: File.join(BENCH_ROOT, "benchmarks", "results", "baseline.json"))
102
+ require "json"
103
+ return true unless File.exist?(path)
104
+
105
+ baseline = JSON.parse(File.read(path))
106
+
107
+ if (saved_env = baseline["_env"])
108
+ current_env = environment_info
109
+ mismatches = current_env.filter_map do |key, val|
110
+ "#{key}: baseline=#{saved_env[key].inspect} current=#{val.inspect}" if saved_env[key] != val
111
+ end
112
+ if mismatches.any?
113
+ warn "WARNING: baseline environment differs from current — comparison may be unreliable:\n" \
114
+ " #{mismatches.join("\n ")}"
115
+ end
116
+ end
117
+
118
+ section = baseline[label]
119
+ return true unless section
120
+
121
+ regressions = []
122
+ suite.entries.each do |entry|
123
+ baseline_entry = section[entry.label]
124
+ next unless baseline_entry
125
+
126
+ baseline_ips = baseline_entry["ips"].to_f
127
+ next if baseline_ips.zero?
128
+
129
+ current_ips = entry.stats.central_tendency
130
+ ratio = current_ips / baseline_ips
131
+ if ratio < (1 - threshold)
132
+ regressions << format(" %-55s %.1fx slower (%.0f vs %.0f ips)",
133
+ entry.label, 1.0 / ratio, current_ips, baseline_ips)
134
+ end
135
+ end
136
+
137
+ if regressions.any?
138
+ puts
139
+ puts "WARNING: REGRESSIONS DETECTED (>#{(threshold * 100).to_i}% slowdown):"
140
+ regressions.each { |r| puts r }
141
+ puts
142
+ false
143
+ else
144
+ true
145
+ end
146
+ end
147
+
148
+ # Returns a hash of relevant environment metadata for baseline comparison.
149
+ def self.environment_info
150
+ {
151
+ "ruby_version" => RUBY_VERSION,
152
+ "ruby_platform" => RUBY_PLATFORM,
153
+ "ruby_engine" => RUBY_ENGINE
154
+ }
155
+ end
156
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Benchmarks for YAML data loading and writing (the I/O layer).
4
+ #
5
+ # Measures the cost of:
6
+ # - Parsing YAML locale files into Ruby hashes
7
+ # - Dumping Ruby hashes back to YAML strings
8
+ # - Building Siblings trees from parsed YAML hashes
9
+ # - The full data[locale] load path used by BaseTask
10
+ #
11
+ # Usage:
12
+ # bundle exec ruby benchmarks/data_bench.rb
13
+ # bundle exec ruby benchmarks/data_bench.rb --save
14
+ # bundle exec ruby benchmarks/data_bench.rb --compare
15
+ # bundle exec ruby benchmarks/data_bench.rb --memory # include memory profiles
16
+
17
+ require_relative "bench_helper"
18
+
19
+ require "yaml"
20
+ require "i18n/tasks/data/adapter/yaml_adapter"
21
+ require "i18n/tasks/data/tree/siblings"
22
+
23
+ SAVE_RESULTS = ARGV.include?("--save")
24
+ COMPARE_RESULTS = ARGV.include?("--compare")
25
+ MEMORY_PROFILE = ARGV.include?("--memory")
26
+
27
+ Siblings = I18n::Tasks::Data::Tree::Siblings
28
+ YamlAdapter = I18n::Tasks::Data::Adapter::YamlAdapter
29
+
30
+ # Build YAML strings of various sizes in memory (no disk I/O during benchmarks)
31
+ def yaml_string_for_scale(scale)
32
+ dir = BenchmarkFixtures.generate(scale)
33
+ File.read(File.join(dir, "config", "locales", "en.yml"))
34
+ end
35
+
36
+ SMALL_YAML = yaml_string_for_scale(:small).freeze
37
+ MEDIUM_YAML = yaml_string_for_scale(:medium).freeze
38
+ LARGE_YAML = yaml_string_for_scale(:large).freeze
39
+
40
+ SMALL_HASH = YamlAdapter.parse(SMALL_YAML, nil).freeze
41
+ MEDIUM_HASH = YamlAdapter.parse(MEDIUM_YAML, nil).freeze
42
+ LARGE_HASH = YamlAdapter.parse(LARGE_YAML, nil).freeze
43
+
44
+ SMALL_TREE = Siblings.from_nested_hash(SMALL_HASH).freeze
45
+ MEDIUM_TREE = Siblings.from_nested_hash(MEDIUM_HASH).freeze
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # YAML parse benchmarks
49
+ # ---------------------------------------------------------------------------
50
+
51
+ BenchHelper.header("YAML parse (string → hash)")
52
+
53
+ parse_suite = Benchmark.ips do |x|
54
+ x.config(warmup: 3, time: 10)
55
+
56
+ x.report("YAML parse small (~#{SMALL_YAML.size / 1024}KB)") do
57
+ YamlAdapter.parse(SMALL_YAML, nil)
58
+ end
59
+
60
+ x.report("YAML parse medium (~#{MEDIUM_YAML.size / 1024}KB)") do
61
+ YamlAdapter.parse(MEDIUM_YAML, nil)
62
+ end
63
+
64
+ x.report("YAML parse large (~#{LARGE_YAML.size / 1024}KB)") do
65
+ YamlAdapter.parse(LARGE_YAML, nil)
66
+ end
67
+
68
+ x.compare!
69
+ end
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # YAML dump benchmarks
73
+ # ---------------------------------------------------------------------------
74
+
75
+ BenchHelper.header("YAML dump (hash → string)")
76
+
77
+ dump_suite = Benchmark.ips do |x|
78
+ x.config(warmup: 3, time: 10)
79
+
80
+ x.report("YAML dump small tree") do
81
+ YamlAdapter.dump(SMALL_HASH, nil)
82
+ end
83
+
84
+ x.report("YAML dump medium tree") do
85
+ YamlAdapter.dump(MEDIUM_HASH, nil)
86
+ end
87
+
88
+ x.report("YAML dump large tree") do
89
+ YamlAdapter.dump(LARGE_HASH, nil)
90
+ end
91
+
92
+ x.compare!
93
+ end
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Hash → Siblings tree construction
97
+ # ---------------------------------------------------------------------------
98
+
99
+ BenchHelper.header("Hash → Siblings tree construction")
100
+
101
+ load_suite = Benchmark.ips do |x|
102
+ x.config(warmup: 3, time: 10)
103
+
104
+ x.report("from_nested_hash small") do
105
+ Siblings.from_nested_hash(SMALL_HASH)
106
+ end
107
+
108
+ x.report("from_nested_hash medium") do
109
+ Siblings.from_nested_hash(MEDIUM_HASH)
110
+ end
111
+
112
+ x.report("from_nested_hash large") do
113
+ Siblings.from_nested_hash(LARGE_HASH)
114
+ end
115
+
116
+ x.compare!
117
+ end
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Full data load via BaseTask
121
+ # ---------------------------------------------------------------------------
122
+
123
+ BenchHelper.header("Full data[locale] load via BaseTask")
124
+
125
+ data_load_suite = Benchmark.ips do |x|
126
+ x.config(warmup: 2, time: 10)
127
+
128
+ [:small, :medium].each do |scale|
129
+ dir = BenchmarkFixtures.generate(scale)
130
+
131
+ x.report("data['en'] load (#{scale})") do
132
+ Dir.chdir(dir) do
133
+ task = I18n::Tasks::BaseTask.new(config_file: File.join(dir, "config", "i18n-tasks.yml"))
134
+ task.data["en"]
135
+ end
136
+ end
137
+ end
138
+
139
+ x.compare!
140
+ end
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Memory profiles (opt-in via --memory)
144
+ # ---------------------------------------------------------------------------
145
+
146
+ if MEMORY_PROFILE
147
+ BenchHelper.header("Memory — YAML parse + tree build (medium)")
148
+ MemoryProfiler.report do
149
+ hash = YamlAdapter.parse(MEDIUM_YAML, nil)
150
+ Siblings.from_nested_hash(hash)
151
+ end.pretty_print(scale_bytes: true, detailed_report: false)
152
+
153
+ BenchHelper.header("Memory — YAML dump (medium)")
154
+ MemoryProfiler.report { YamlAdapter.dump(MEDIUM_HASH, nil) }
155
+ .pretty_print(scale_bytes: true, detailed_report: false)
156
+ end
157
+
158
+ passed = true
159
+ if SAVE_RESULTS
160
+ BenchHelper.save_results(parse_suite, "data/yaml_parse")
161
+ BenchHelper.save_results(dump_suite, "data/yaml_dump")
162
+ BenchHelper.save_results(load_suite, "data/tree_load")
163
+ BenchHelper.save_results(data_load_suite, "data/full_load")
164
+ end
165
+
166
+ if COMPARE_RESULTS
167
+ passed = false unless BenchHelper.compare_baseline(parse_suite, "data/yaml_parse")
168
+ passed = false unless BenchHelper.compare_baseline(dump_suite, "data/yaml_dump")
169
+ passed = false unless BenchHelper.compare_baseline(load_suite, "data/tree_load")
170
+ passed = false unless BenchHelper.compare_baseline(data_load_suite, "data/full_load")
171
+ end
172
+
173
+ exit(1) if COMPARE_RESULTS && !passed
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # End-to-end benchmarks for the full i18n-tasks pipeline.
4
+ #
5
+ # These measure the complete workflows that users actually run:
6
+ # - missing_keys: find translations present in base locale but missing elsewhere
7
+ # - unused_keys: find keys defined in locale files but not used in source
8
+ # - used_tree: scan all source files and build the usage tree
9
+ #
10
+ # These are the most important benchmarks for catching regressions — any change
11
+ # to the scanner, data layer, or tree operations will show up here.
12
+ #
13
+ # Usage:
14
+ # bundle exec ruby benchmarks/end_to_end_bench.rb
15
+ # bundle exec ruby benchmarks/end_to_end_bench.rb --save # save as new baseline
16
+ # bundle exec ruby benchmarks/end_to_end_bench.rb --compare # compare against baseline
17
+ # bundle exec ruby benchmarks/end_to_end_bench.rb --memory # include memory profiles
18
+
19
+ require_relative "bench_helper"
20
+
21
+ SAVE_RESULTS = ARGV.include?("--save")
22
+ COMPARE_RESULTS = ARGV.include?("--compare")
23
+ MEMORY_PROFILE = ARGV.include?("--memory")
24
+
25
+ all_suites = []
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Benchmark each pipeline operation across all scales
29
+ # ---------------------------------------------------------------------------
30
+
31
+ [:small, :medium, :large].each do |scale|
32
+ dir = BenchmarkFixtures.generate(scale)
33
+
34
+ BenchHelper.header("End-to-end pipeline — #{scale} fixture")
35
+
36
+ suite = Benchmark.ips do |x|
37
+ x.config(warmup: 2, time: 15)
38
+
39
+ x.report("used_tree (#{scale})") do
40
+ Dir.chdir(dir) do
41
+ task = I18n::Tasks::BaseTask.new(config_file: File.join(dir, "config", "i18n-tasks.yml"))
42
+ task.used_tree
43
+ end
44
+ end
45
+
46
+ x.report("missing_keys (#{scale})") do
47
+ Dir.chdir(dir) do
48
+ task = I18n::Tasks::BaseTask.new(config_file: File.join(dir, "config", "i18n-tasks.yml"))
49
+ task.missing_keys
50
+ end
51
+ end
52
+
53
+ x.report("unused_keys (#{scale})") do
54
+ Dir.chdir(dir) do
55
+ task = I18n::Tasks::BaseTask.new(config_file: File.join(dir, "config", "i18n-tasks.yml"))
56
+ task.unused_keys
57
+ end
58
+ end
59
+
60
+ x.compare!
61
+ end
62
+
63
+ all_suites << [suite, "end_to_end/#{scale}"]
64
+ end
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Scanner backend comparison: parser AST vs Prism on medium fixture
68
+ # ---------------------------------------------------------------------------
69
+
70
+ BenchHelper.header("Scanner backend: parser AST vs Prism (medium used_tree)")
71
+
72
+ dir = BenchmarkFixtures.generate(:medium)
73
+
74
+ backend_suite = Benchmark.ips do |x|
75
+ x.config(warmup: 2, time: 15)
76
+
77
+ x.report("used_tree — parser AST (medium)") do
78
+ Dir.chdir(dir) do
79
+ task = I18n::Tasks::BaseTask.new(
80
+ config_file: File.join(dir, "config", "i18n-tasks.yml"),
81
+ search: {scanners: [["::I18n::Tasks::Scanners::RubyScanner", {only: %w[*.rb], prism: nil}],
82
+ ["::I18n::Tasks::Scanners::ErbAstScanner", {only: %w[*.erb]}]]}
83
+ )
84
+ task.used_tree
85
+ end
86
+ end
87
+
88
+ x.report("used_tree — prism (medium)") do
89
+ Dir.chdir(dir) do
90
+ task = I18n::Tasks::BaseTask.new(
91
+ config_file: File.join(dir, "config", "i18n-tasks.yml"),
92
+ search: {scanners: [["::I18n::Tasks::Scanners::RubyScanner", {only: %w[*.rb], prism: "rails"}],
93
+ ["::I18n::Tasks::Scanners::ErbAstScanner", {only: %w[*.erb]}]]}
94
+ )
95
+ task.used_tree
96
+ end
97
+ end
98
+
99
+ x.compare!
100
+ end
101
+
102
+ all_suites << [backend_suite, "end_to_end/backend_comparison"]
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Memory profiling on the most realistic scale (medium, opt-in via --memory)
106
+ # ---------------------------------------------------------------------------
107
+
108
+ if MEMORY_PROFILE
109
+ dir = BenchmarkFixtures.generate(:medium)
110
+
111
+ BenchHelper.header("Memory profile — missing_keys (medium)")
112
+ MemoryProfiler.report do
113
+ Dir.chdir(dir) do
114
+ task = I18n::Tasks::BaseTask.new(config_file: File.join(dir, "config", "i18n-tasks.yml"))
115
+ task.missing_keys
116
+ end
117
+ end.pretty_print(scale_bytes: true, detailed_report: false)
118
+
119
+ BenchHelper.header("Memory profile — unused_keys (medium)")
120
+ MemoryProfiler.report do
121
+ Dir.chdir(dir) do
122
+ task = I18n::Tasks::BaseTask.new(config_file: File.join(dir, "config", "i18n-tasks.yml"))
123
+ task.unused_keys
124
+ end
125
+ end.pretty_print(scale_bytes: true, detailed_report: false)
126
+ end
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Save / compare results
130
+ # ---------------------------------------------------------------------------
131
+
132
+ passed = true
133
+ all_suites.each do |(suite, label)|
134
+ BenchHelper.save_results(suite, label) if SAVE_RESULTS
135
+ passed = false if COMPARE_RESULTS && !BenchHelper.compare_baseline(suite, label)
136
+ end
137
+
138
+ exit(1) if COMPARE_RESULTS && !passed