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.
- checksums.yaml +4 -4
- data/README.md +321 -242
- data/Rakefile +5 -1
- data/benchmarks/bench_helper.rb +156 -0
- data/benchmarks/data_bench.rb +173 -0
- data/benchmarks/end_to_end_bench.rb +138 -0
- data/benchmarks/fixtures/generator.rb +257 -0
- data/benchmarks/results/baseline.json +250 -0
- data/benchmarks/run_all.rb +60 -0
- data/benchmarks/scanning_bench.rb +135 -0
- data/benchmarks/tree_bench.rb +205 -0
- data/config/locales/en.yml +10 -0
- data/config/locales/ru.yml +14 -0
- data/i18n-tasks.gemspec +1 -1
- data/lib/i18n/tasks/base_task.rb +2 -0
- data/lib/i18n/tasks/command/commands/check_prism.rb +67 -15
- data/lib/i18n/tasks/command/commands/prune.rb +57 -0
- data/lib/i18n/tasks/commands.rb +2 -0
- data/lib/i18n/tasks/data/adapter/json_adapter.rb +1 -1
- data/lib/i18n/tasks/data/file_formats.rb +4 -1
- data/lib/i18n/tasks/data/router/conservative_router.rb +13 -22
- data/lib/i18n/tasks/data/router/pattern_router.rb +26 -14
- data/lib/i18n/tasks/data/tree/node.rb +3 -3
- data/lib/i18n/tasks/data/tree/siblings.rb +11 -3
- data/lib/i18n/tasks/data/tree/traversal.rb +20 -14
- data/lib/i18n/tasks/interpolations.rb +61 -3
- data/lib/i18n/tasks/logging.rb +1 -2
- data/lib/i18n/tasks/missing_keys.rb +7 -38
- data/lib/i18n/tasks/plural_keys.rb +56 -0
- data/lib/i18n/tasks/prune.rb +18 -0
- data/lib/i18n/tasks/references.rb +9 -1
- data/lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb +11 -2
- data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +16 -2
- data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +6 -3
- data/lib/i18n/tasks/scanners/file_scanner.rb +4 -1
- data/lib/i18n/tasks/scanners/files/file_finder.rb +29 -6
- data/lib/i18n/tasks/scanners/files/parse_cache.rb +24 -0
- data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +33 -0
- data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +57 -23
- data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +70 -16
- data/lib/i18n/tasks/scanners/relative_keys.rb +21 -11
- data/lib/i18n/tasks/scanners/ruby_scanner.rb +37 -5
- data/lib/i18n/tasks/split_key.rb +16 -6
- data/lib/i18n/tasks/translators/base_translator.rb +25 -23
- data/lib/i18n/tasks/translators/deepl_translator.rb +18 -15
- data/lib/i18n/tasks/translators/google_translator.rb +41 -14
- data/lib/i18n/tasks/translators/openai_translator.rb +31 -17
- data/lib/i18n/tasks/translators/watsonx_translator.rb +8 -10
- data/lib/i18n/tasks/translators/yandex_translator.rb +4 -0
- data/lib/i18n/tasks/unused_keys.rb +35 -5
- data/lib/i18n/tasks/used_keys.rb +6 -1
- data/lib/i18n/tasks/version.rb +1 -1
- data/script/report_test_results.rb +58 -0
- data/templates/config/i18n-tasks.yml +11 -3
- 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
|