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
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ # Generates synthetic but realistic i18n-tasks fixtures at three scales.
7
+ #
8
+ # Each scale produces:
9
+ # - config/locales/<locale>.yml for each locale
10
+ # - app/controllers/*.rb with I18n.t() calls
11
+ # - app/views/**/*.html.erb with t() calls (relative and absolute)
12
+ # - app/helpers/*.rb with mixed calls
13
+ #
14
+ # ~10% of keys in base locale are intentionally left out of other locales (missing).
15
+ # ~10% of defined keys are never referenced in source (unused).
16
+ module BenchmarkFixtures
17
+ SCALES = {
18
+ small: {num_keys: 200, num_rb_files: 15, num_erb_files: 15, num_locales: 2},
19
+ medium: {num_keys: 2_000, num_rb_files: 100, num_erb_files: 100, num_locales: 5},
20
+ large: {num_keys: 8_000, num_rb_files: 300, num_erb_files: 300, num_locales: 8}
21
+ }.freeze
22
+
23
+ LOCALES = %w[en es fr de ja zh pt it ko nl].freeze
24
+
25
+ CONTROLLERS = %w[users posts comments orders products invoices sessions registrations
26
+ profiles settings dashboards reports notifications search tags categories].freeze
27
+
28
+ HELPERS = %w[application_helper users_helper posts_helper form_helper date_helper].freeze
29
+
30
+ # Key sections that mirror realistic Rails apps
31
+ SECTIONS = %w[
32
+ users.index users.show users.new users.edit users.form
33
+ posts.index posts.show posts.create posts.update posts.destroy
34
+ comments.index comments.new comments.form
35
+ orders.index orders.show orders.confirm orders.complete
36
+ shared.errors shared.validations shared.navigation shared.buttons shared.labels
37
+ mailers.welcome mailers.reset_password mailers.confirmation mailers.notification
38
+ admin.users.index admin.users.show admin.posts.index admin.dashboard
39
+ errors.not_found errors.unauthorized errors.server_error
40
+ auth.login auth.logout auth.register auth.forgot_password
41
+ ].freeze
42
+
43
+ class << self
44
+ def root
45
+ File.expand_path("../..", __dir__)
46
+ end
47
+
48
+ def generated_root
49
+ File.join(root, "benchmarks", "fixtures", "generated")
50
+ end
51
+
52
+ # Generate all fixtures.
53
+ # @param force [Boolean] regenerate even if files already exist
54
+ def generate_all(force: false)
55
+ SCALES.each_key do |scale|
56
+ generate(scale, force: force)
57
+ end
58
+ end
59
+
60
+ # Generate fixtures for a single scale.
61
+ # @param scale [:small, :medium, :large]
62
+ # @param force [Boolean] regenerate even if files already exist
63
+ # @return [String] path to the generated fixture directory
64
+ def generate(scale, force: false)
65
+ config = SCALES.fetch(scale)
66
+ dir = File.join(generated_root, scale.to_s)
67
+
68
+ sentinel = File.join(dir, ".generated")
69
+ if !force && File.exist?(sentinel)
70
+ return dir
71
+ end
72
+
73
+ FileUtils.rm_rf(dir)
74
+ FileUtils.mkdir_p(dir)
75
+
76
+ locales = LOCALES.first(config[:num_locales])
77
+ base_locale = locales.first
78
+ keys = generate_keys(config[:num_keys])
79
+
80
+ write_config_file(dir, locales, base_locale)
81
+ write_locale_files(dir, locales, base_locale, keys)
82
+ write_ruby_files(dir, keys, config[:num_rb_files])
83
+ write_erb_files(dir, keys, config[:num_erb_files], base_locale)
84
+
85
+ FileUtils.touch(sentinel)
86
+ dir
87
+ end
88
+
89
+ private
90
+
91
+ # Build a flat list of dot-separated keys distributed across realistic sections.
92
+ def generate_keys(count)
93
+ keys = []
94
+ per_section = [count / SECTIONS.size, 1].max
95
+ SECTIONS.cycle do |section|
96
+ break if keys.size >= count
97
+
98
+ per_section.times do |i|
99
+ break if keys.size >= count
100
+
101
+ keys << "#{section}.key_#{i + 1}"
102
+ end
103
+ end
104
+ keys.first(count)
105
+ end
106
+
107
+ def write_config_file(dir, locales, base_locale)
108
+ FileUtils.mkdir_p(File.join(dir, "config"))
109
+
110
+ config = {
111
+ "base_locale" => base_locale,
112
+ "locales" => locales,
113
+ "data" => {
114
+ "read" => ["config/locales/%{locale}.yml"],
115
+ "write" => ["config/locales/%{locale}.yml"]
116
+ },
117
+ "search" => {
118
+ "paths" => ["app/"],
119
+ "relative_roots" => ["app/controllers", "app/helpers", "app/views"]
120
+ }
121
+ }
122
+
123
+ File.write(File.join(dir, "config", "i18n-tasks.yml"), YAML.dump(config))
124
+ end
125
+
126
+ def write_locale_files(dir, locales, base_locale, keys)
127
+ locale_dir = File.join(dir, "config", "locales")
128
+ FileUtils.mkdir_p(locale_dir)
129
+
130
+ locales.each do |locale|
131
+ # Skip ~10% of keys for non-base locales to simulate missing translations.
132
+ locale_keys = if locale == base_locale
133
+ keys
134
+ else
135
+ keys.reject.with_index { |_, i| (i % 10).zero? }
136
+ end
137
+
138
+ # Mark ~10% of keys as "unused" by never referencing them in source.
139
+ # We track unused indices and write them in the locale files but skip them in source.
140
+ tree = keys_to_nested_hash(locale, locale_keys) { |key, i| "#{locale}.#{key}.value_#{i}" }
141
+ File.write(File.join(locale_dir, "#{locale}.yml"), YAML.dump(tree))
142
+ end
143
+ end
144
+
145
+ def keys_to_nested_hash(locale, keys)
146
+ root = {locale => {}}
147
+ keys.each_with_index do |key, i|
148
+ parts = key.split(".")
149
+ node = root[locale]
150
+ parts[0..-2].each do |part|
151
+ node[part] ||= {}
152
+ node = node[part]
153
+ end
154
+ node[parts.last] = block_given? ? yield(key, i) : "#{locale}.#{key}"
155
+ end
156
+ root
157
+ end
158
+
159
+ # Write Ruby controller/helper files referencing ~90% of keys (leaving ~10% unused).
160
+ def write_ruby_files(dir, keys, num_files)
161
+ app_dir = File.join(dir, "app")
162
+ FileUtils.mkdir_p(File.join(app_dir, "controllers"))
163
+ FileUtils.mkdir_p(File.join(app_dir, "helpers"))
164
+
165
+ # Keys that will be referenced from Ruby (skip every 10th = ~10% unused in source)
166
+ source_keys = keys.reject.with_index { |_, i| (i % 10).zero? }
167
+ keys_per_file = [source_keys.size / num_files, 1].max
168
+
169
+ num_files.times do |n|
170
+ file_keys = source_keys.slice(n * keys_per_file, keys_per_file) || []
171
+ controller_name = "#{CONTROLLERS[n % CONTROLLERS.size]}_#{n}_controller"
172
+ content = generate_ruby_file_content(controller_name, file_keys)
173
+
174
+ if n < num_files / 2
175
+ File.write(File.join(app_dir, "controllers", "#{controller_name}.rb"), content)
176
+ else
177
+ helper_name = "helper_#{n}"
178
+ File.write(File.join(app_dir, "helpers", "#{helper_name}.rb"), content)
179
+ end
180
+ end
181
+ end
182
+
183
+ def generate_ruby_file_content(class_name, keys)
184
+ lines = ["class #{camelize(class_name)} < ApplicationController"]
185
+ lines << " def index"
186
+ keys.each_slice(3).with_index do |slice, idx|
187
+ case idx % 4
188
+ when 0
189
+ lines << " I18n.t(#{slice[0].inspect})"
190
+ lines << " I18n.t(#{slice[1].inspect})" if slice[1]
191
+ when 1
192
+ scope_parts = slice[0].split(".")
193
+ lines << " t(#{scope_parts.last.inspect}, scope: #{scope_parts[0..-2].inspect})"
194
+ when 2
195
+ lines << " I18n.translate(#{slice[0].inspect})"
196
+ else
197
+ lines << " t #{slice[0].inspect}"
198
+ lines << " t #{slice[1].inspect}" if slice[1]
199
+ end
200
+ lines << " t #{slice[2].inspect}" if slice[2]
201
+ end
202
+ lines << " end"
203
+
204
+ # Add a method with dynamic key (exercises expr_key_re)
205
+ if keys.any?
206
+ section = keys.first.split(".").first(2).join(".")
207
+ lines << " def show"
208
+ lines << " # dynamic key - should not count as unused"
209
+ lines << " t(\"#{section}.\#{some_dynamic_value}\")"
210
+ lines << " end"
211
+ end
212
+
213
+ lines << "end"
214
+ lines.join("\n")
215
+ end
216
+
217
+ # Write ERB view files with relative t() calls and some absolute ones.
218
+ def write_erb_files(dir, keys, num_files, _base_locale)
219
+ views_dir = File.join(dir, "app", "views")
220
+
221
+ # Use only non-"unused" keys in views to keep ~10% unused
222
+ source_keys = keys.reject.with_index { |_, i| (i % 10).zero? }
223
+ keys_per_file = [source_keys.size / [num_files, 1].max, 1].max
224
+
225
+ num_files.times do |n|
226
+ file_keys = source_keys.slice(n * keys_per_file, keys_per_file) || []
227
+ section = file_keys.first&.split(".")&.first(2)&.join("/") || "shared"
228
+ FileUtils.mkdir_p(File.join(views_dir, section))
229
+
230
+ content = generate_erb_file_content(file_keys)
231
+ File.write(File.join(views_dir, section, "view_#{n}.html.erb"), content)
232
+ end
233
+ end
234
+
235
+ def generate_erb_file_content(keys)
236
+ lines = ["<div>"]
237
+ keys.each_with_index do |key, i|
238
+ case i % 3
239
+ when 0
240
+ lines << " <%= t(#{key.inspect}) %>"
241
+ when 1
242
+ # relative key - use just the last segment
243
+ leaf = key.split(".").last
244
+ lines << " <%= t(#{".#{leaf}".inspect}) %>"
245
+ else
246
+ lines << " <%= I18n.t(#{key.inspect}) %>"
247
+ end
248
+ end
249
+ lines << "</div>"
250
+ lines.join("\n")
251
+ end
252
+
253
+ def camelize(str)
254
+ str.split(/[_\/]/).map(&:capitalize).join
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,250 @@
1
+ {
2
+ "scanning/small": {
3
+ "RubyScanner/parser (small)": {
4
+ "ips": 85.14,
5
+ "ips_sd": 1,
6
+ "microseconds": 11745.33
7
+ },
8
+ "RubyScanner/prism (small)": {
9
+ "ips": 520.55,
10
+ "ips_sd": 5,
11
+ "microseconds": 1921.04
12
+ },
13
+ "ErbAstScanner (small)": {
14
+ "ips": 80.25,
15
+ "ips_sd": 1,
16
+ "microseconds": 12461.32
17
+ },
18
+ "PatternScanner (small)": {
19
+ "ips": 366.24,
20
+ "ips_sd": 19,
21
+ "microseconds": 2730.47
22
+ }
23
+ },
24
+ "scanning/medium": {
25
+ "RubyScanner/parser (medium)": {
26
+ "ips": 9.34,
27
+ "ips_sd": 0,
28
+ "microseconds": 107064.63
29
+ },
30
+ "RubyScanner/prism (medium)": {
31
+ "ips": 53.15,
32
+ "ips_sd": 2,
33
+ "microseconds": 18816.42
34
+ },
35
+ "ErbAstScanner (medium)": {
36
+ "ips": 7.82,
37
+ "ips_sd": 0,
38
+ "microseconds": 127814.44
39
+ },
40
+ "PatternScanner (medium)": {
41
+ "ips": 31.25,
42
+ "ips_sd": 1,
43
+ "microseconds": 32003.1
44
+ }
45
+ },
46
+ "tree/construction": {
47
+ "from_key_names (200 keys)": {
48
+ "ips": 336.11,
49
+ "ips_sd": 5,
50
+ "microseconds": 2975.22
51
+ },
52
+ "from_key_names (2k keys)": {
53
+ "ips": 28.24,
54
+ "ips_sd": 2,
55
+ "microseconds": 35410.79
56
+ },
57
+ "from_key_names (8k keys)": {
58
+ "ips": 6.51,
59
+ "ips_sd": 0,
60
+ "microseconds": 153567.77
61
+ },
62
+ "from_nested_hash (200 keys)": {
63
+ "ips": 286.18,
64
+ "ips_sd": 7,
65
+ "microseconds": 3494.35
66
+ },
67
+ "from_nested_hash (2k keys)": {
68
+ "ips": 23.43,
69
+ "ips_sd": 2,
70
+ "microseconds": 42674.17
71
+ }
72
+ },
73
+ "tree/merge": {
74
+ "merge! (200 into 200 keys)": {
75
+ "ips": 963.35,
76
+ "ips_sd": 23,
77
+ "microseconds": 1038.04
78
+ },
79
+ "merge! (2k into 2k keys)": {
80
+ "ips": 96.47,
81
+ "ips_sd": 3,
82
+ "microseconds": 10365.85
83
+ }
84
+ },
85
+ "tree/traversal": {
86
+ "leaves (2k keys)": {
87
+ "ips": 409.96,
88
+ "ips_sd": 5,
89
+ "microseconds": 2439.29
90
+ },
91
+ "key_names (2k keys)": {
92
+ "ips": 382.42,
93
+ "ips_sd": 7,
94
+ "microseconds": 2614.92
95
+ },
96
+ "key_names (8k keys)": {
97
+ "ips": 92.16,
98
+ "ips_sd": 4,
99
+ "microseconds": 10850.73
100
+ },
101
+ "select_keys (2k, match half)": {
102
+ "ips": 53.54,
103
+ "ips_sd": 1,
104
+ "microseconds": 18678.8
105
+ },
106
+ "nodes block iteration (2k keys)": {
107
+ "ips": 450.94,
108
+ "ips_sd": 5,
109
+ "microseconds": 2217.57
110
+ }
111
+ },
112
+ "tree/subtract": {
113
+ "subtract_by_key! (200 keys)": {
114
+ "ips": 374.7,
115
+ "ips_sd": 6,
116
+ "microseconds": 2668.77
117
+ },
118
+ "subtract_by_key! (2k keys)": {
119
+ "ips": 35.83,
120
+ "ips_sd": 1,
121
+ "microseconds": 27912.64
122
+ }
123
+ },
124
+ "data/yaml_parse": {
125
+ "YAML parse small (~9KB)": {
126
+ "ips": 1933.54,
127
+ "ips_sd": 54,
128
+ "microseconds": 517.19
129
+ },
130
+ "YAML parse medium (~97KB)": {
131
+ "ips": 223.54,
132
+ "ips_sd": 5,
133
+ "microseconds": 4473.41
134
+ },
135
+ "YAML parse large (~400KB)": {
136
+ "ips": 56.35,
137
+ "ips_sd": 2,
138
+ "microseconds": 17747.21
139
+ }
140
+ },
141
+ "data/yaml_dump": {
142
+ "YAML dump small tree": {
143
+ "ips": 1366.38,
144
+ "ips_sd": 33,
145
+ "microseconds": 731.86
146
+ },
147
+ "YAML dump medium tree": {
148
+ "ips": 153.11,
149
+ "ips_sd": 3,
150
+ "microseconds": 6531.45
151
+ },
152
+ "YAML dump large tree": {
153
+ "ips": 37.9,
154
+ "ips_sd": 1,
155
+ "microseconds": 26386.17
156
+ }
157
+ },
158
+ "data/tree_load": {
159
+ "from_nested_hash small": {
160
+ "ips": 1325.81,
161
+ "ips_sd": 55,
162
+ "microseconds": 754.26
163
+ },
164
+ "from_nested_hash medium": {
165
+ "ips": 190.78,
166
+ "ips_sd": 7,
167
+ "microseconds": 5241.64
168
+ },
169
+ "from_nested_hash large": {
170
+ "ips": 44.98,
171
+ "ips_sd": 2,
172
+ "microseconds": 22232.79
173
+ }
174
+ },
175
+ "data/full_load": {
176
+ "data['en'] load (small)": {
177
+ "ips": 557.68,
178
+ "ips_sd": 18,
179
+ "microseconds": 1793.15
180
+ },
181
+ "data['en'] load (medium)": {
182
+ "ips": 80.26,
183
+ "ips_sd": 2,
184
+ "microseconds": 12459.21
185
+ }
186
+ },
187
+ "end_to_end/small": {
188
+ "used_tree (small)": {
189
+ "ips": 21.36,
190
+ "ips_sd": 1,
191
+ "microseconds": 46813.31
192
+ },
193
+ "missing_keys (small)": {
194
+ "ips": 15.77,
195
+ "ips_sd": 1,
196
+ "microseconds": 63412.33
197
+ },
198
+ "unused_keys (small)": {
199
+ "ips": 11.4,
200
+ "ips_sd": 1,
201
+ "microseconds": 87687.22
202
+ }
203
+ },
204
+ "end_to_end/medium": {
205
+ "used_tree (medium)": {
206
+ "ips": 2.39,
207
+ "ips_sd": 0,
208
+ "microseconds": 419130.05
209
+ },
210
+ "missing_keys (medium)": {
211
+ "ips": 1.14,
212
+ "ips_sd": 0,
213
+ "microseconds": 877165.79
214
+ },
215
+ "unused_keys (medium)": {
216
+ "ips": 1.05,
217
+ "ips_sd": 0,
218
+ "microseconds": 948448.83
219
+ }
220
+ },
221
+ "end_to_end/large": {
222
+ "used_tree (large)": {
223
+ "ips": 0.54,
224
+ "ips_sd": 0,
225
+ "microseconds": 1856802.63
226
+ },
227
+ "missing_keys (large)": {
228
+ "ips": 0.18,
229
+ "ips_sd": 0,
230
+ "microseconds": 5652702.62
231
+ },
232
+ "unused_keys (large)": {
233
+ "ips": 0.18,
234
+ "ips_sd": 0,
235
+ "microseconds": 5463508.26
236
+ }
237
+ },
238
+ "end_to_end/backend_comparison": {
239
+ "used_tree — parser AST (medium)": {
240
+ "ips": 2.4,
241
+ "ips_sd": 0,
242
+ "microseconds": 415864.89
243
+ },
244
+ "used_tree — prism (medium)": {
245
+ "ips": 3.1,
246
+ "ips_sd": 0,
247
+ "microseconds": 322536.32
248
+ }
249
+ }
250
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rbconfig"
5
+
6
+ # Runs all benchmarks and optionally saves / compares against a baseline.
7
+ #
8
+ # Usage:
9
+ # bundle exec ruby benchmarks/run_all.rb # run everything
10
+ # bundle exec ruby benchmarks/run_all.rb --save # run + save as new baseline
11
+ # bundle exec ruby benchmarks/run_all.rb --compare # run + compare against baseline (exits 1 on regression)
12
+ # bundle exec ruby benchmarks/run_all.rb --memory # include memory profiles
13
+ # bundle exec ruby benchmarks/run_all.rb --only=tree # run only tree benchmarks
14
+ #
15
+ # The --only flag accepts comma-separated values: tree, data, scanning, e2e
16
+
17
+ bench_dir = File.dirname(__FILE__)
18
+
19
+ save = ARGV.include?("--save")
20
+ compare = ARGV.include?("--compare")
21
+ memory = ARGV.include?("--memory")
22
+
23
+ only_arg = ARGV.grep(/\A--only=/).first&.sub("--only=", "")
24
+ only_filter = only_arg&.split(",") || []
25
+
26
+ all_benches = {
27
+ "scanning" => File.join(bench_dir, "scanning_bench.rb"),
28
+ "tree" => File.join(bench_dir, "tree_bench.rb"),
29
+ "data" => File.join(bench_dir, "data_bench.rb"),
30
+ "e2e" => File.join(bench_dir, "end_to_end_bench.rb")
31
+ }
32
+
33
+ if only_filter.any?
34
+ unknown = only_filter - all_benches.keys
35
+ if unknown.any?
36
+ warn "Unknown --only values: #{unknown.join(", ")}. Available: #{all_benches.keys.join(", ")}"
37
+ exit 1
38
+ end
39
+ benches = all_benches.slice(*only_filter)
40
+ else
41
+ benches = all_benches
42
+ end
43
+
44
+ flags = []
45
+ flags << "--save" if save
46
+ flags << "--compare" if compare
47
+ flags << "--memory" if memory
48
+
49
+ failed = false
50
+ benches.each do |name, path|
51
+ puts
52
+ puts "-" * 70
53
+ puts " Running: #{name}"
54
+ puts "-" * 70
55
+
56
+ result = system(RbConfig.ruby, path, *flags)
57
+ failed = true unless result
58
+ end
59
+
60
+ exit(failed ? 1 : 0)
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Benchmarks for the source-code scanning hot path.
4
+ #
5
+ # Measures per-scanner throughput (IPS) for Ruby (parser AST and Prism backends)
6
+ # and ERB scanning across fixture scales.
7
+ #
8
+ # Usage:
9
+ # bundle exec ruby benchmarks/scanning_bench.rb
10
+ # bundle exec ruby benchmarks/scanning_bench.rb --save # save as new baseline
11
+ # bundle exec ruby benchmarks/scanning_bench.rb --compare # compare against baseline
12
+ # bundle exec ruby benchmarks/scanning_bench.rb --memory # include memory profiles
13
+
14
+ require_relative "bench_helper"
15
+
16
+ require "i18n/tasks/scanners/ruby_scanner"
17
+ require "i18n/tasks/scanners/erb_ast_scanner"
18
+ require "i18n/tasks/scanners/pattern_with_scope_scanner"
19
+ require "i18n/tasks/scanners/files/caching_file_finder_provider"
20
+ require "i18n/tasks/scanners/files/caching_file_reader"
21
+
22
+ SAVE_RESULTS = ARGV.include?("--save")
23
+ COMPARE_RESULTS = ARGV.include?("--compare")
24
+ MEMORY_PROFILE = ARGV.include?("--memory")
25
+
26
+ def build_scanner(scanner_class, app_dir, only_pattern:, shared_provider:, shared_reader:, **extra_config)
27
+ config = {
28
+ paths: [app_dir],
29
+ only: Array(only_pattern),
30
+ exclude: [],
31
+ relative_roots: [
32
+ File.join(app_dir, "controllers"),
33
+ File.join(app_dir, "helpers"),
34
+ File.join(app_dir, "views")
35
+ ],
36
+ relative_exclude_method_name_paths: [],
37
+ **extra_config
38
+ }
39
+ scanner_class.new(
40
+ config: config,
41
+ file_finder_provider: shared_provider,
42
+ file_reader: shared_reader
43
+ )
44
+ end
45
+
46
+ all_suites = []
47
+
48
+ [:small, :medium].each do |scale|
49
+ dir = BenchmarkFixtures.generate(scale)
50
+ app_dir = File.join(dir, "app")
51
+
52
+ BenchHelper.header("Scanning — #{scale} fixture")
53
+
54
+ # Build shared provider/reader and warm their caches for every pattern used below,
55
+ # so the timed block measures only parse throughput, not file-system traversal or I/O.
56
+ shared_provider = I18n::Tasks::Scanners::Files::CachingFileFinderProvider.new
57
+ shared_reader = I18n::Tasks::Scanners::Files::CachingFileReader.new
58
+ build_scanner(I18n::Tasks::Scanners::RubyScanner, app_dir,
59
+ only_pattern: ["*.rb"], shared_provider: shared_provider,
60
+ shared_reader: shared_reader, prism: nil).keys
61
+ build_scanner(I18n::Tasks::Scanners::RubyScanner, app_dir,
62
+ only_pattern: ["*.rb"], shared_provider: shared_provider,
63
+ shared_reader: shared_reader, prism: "rails").keys
64
+ build_scanner(I18n::Tasks::Scanners::ErbAstScanner, app_dir,
65
+ only_pattern: ["*.erb"], shared_provider: shared_provider,
66
+ shared_reader: shared_reader).keys
67
+ build_scanner(I18n::Tasks::Scanners::PatternWithScopeScanner, app_dir,
68
+ only_pattern: ["*.rb", "*.erb"], shared_provider: shared_provider,
69
+ shared_reader: shared_reader).keys
70
+
71
+ suite = Benchmark.ips do |x|
72
+ x.config(warmup: 3, time: 10)
73
+
74
+ x.report("RubyScanner/parser (#{scale})") do
75
+ build_scanner(I18n::Tasks::Scanners::RubyScanner, app_dir,
76
+ only_pattern: ["*.rb"], shared_provider: shared_provider,
77
+ shared_reader: shared_reader, prism: nil).keys
78
+ end
79
+
80
+ x.report("RubyScanner/prism (#{scale})") do
81
+ build_scanner(I18n::Tasks::Scanners::RubyScanner, app_dir,
82
+ only_pattern: ["*.rb"], shared_provider: shared_provider,
83
+ shared_reader: shared_reader, prism: "rails").keys
84
+ end
85
+
86
+ x.report("ErbAstScanner (#{scale})") do
87
+ build_scanner(I18n::Tasks::Scanners::ErbAstScanner, app_dir,
88
+ only_pattern: ["*.erb"], shared_provider: shared_provider,
89
+ shared_reader: shared_reader).keys
90
+ end
91
+
92
+ x.report("PatternScanner (#{scale})") do
93
+ build_scanner(I18n::Tasks::Scanners::PatternWithScopeScanner, app_dir,
94
+ only_pattern: ["*.rb", "*.erb"], shared_provider: shared_provider,
95
+ shared_reader: shared_reader).keys
96
+ end
97
+
98
+ x.compare!
99
+ end
100
+
101
+ all_suites << [suite, "scanning/#{scale}"]
102
+ end
103
+
104
+ if MEMORY_PROFILE
105
+ dir = BenchmarkFixtures.generate(:medium)
106
+ app_dir = File.join(dir, "app")
107
+ shared_provider = I18n::Tasks::Scanners::Files::CachingFileFinderProvider.new
108
+ shared_reader = I18n::Tasks::Scanners::Files::CachingFileReader.new
109
+ # Warm caches so memory report reflects only parse allocations
110
+ build_scanner(I18n::Tasks::Scanners::RubyScanner, app_dir,
111
+ only_pattern: ["*.rb"], shared_provider: shared_provider,
112
+ shared_reader: shared_reader, prism: nil).keys
113
+
114
+ BenchHelper.header("Memory profile — RubyScanner/parser (medium)")
115
+ MemoryProfiler.report do
116
+ build_scanner(I18n::Tasks::Scanners::RubyScanner, app_dir,
117
+ only_pattern: ["*.rb"], shared_provider: shared_provider,
118
+ shared_reader: shared_reader, prism: nil).keys
119
+ end.pretty_print(scale_bytes: true, detailed_report: false)
120
+
121
+ BenchHelper.header("Memory profile — RubyScanner/prism (medium)")
122
+ MemoryProfiler.report do
123
+ build_scanner(I18n::Tasks::Scanners::RubyScanner, app_dir,
124
+ only_pattern: ["*.rb"], shared_provider: shared_provider,
125
+ shared_reader: shared_reader, prism: "rails").keys
126
+ end.pretty_print(scale_bytes: true, detailed_report: false)
127
+ end
128
+
129
+ passed = true
130
+ all_suites.each do |(suite, label)|
131
+ BenchHelper.save_results(suite, label) if SAVE_RESULTS
132
+ passed = false if COMPARE_RESULTS && !BenchHelper.compare_baseline(suite, label)
133
+ end
134
+
135
+ exit(1) if COMPARE_RESULTS && !passed