i18n-message_format 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.envrc +10 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/Rakefile +12 -0
- data/devenv.lock +123 -0
- data/devenv.nix +57 -0
- data/devenv.yaml +15 -0
- data/docs/plans/2026-02-25-icu-message-format-design.md +141 -0
- data/docs/plans/2026-02-25-icu-message-format-plan.md +1947 -0
- data/lib/i18n/message_format/backend.rb +172 -0
- data/lib/i18n/message_format/cache.rb +75 -0
- data/lib/i18n/message_format/formatter.rb +179 -0
- data/lib/i18n/message_format/nodes.rb +96 -0
- data/lib/i18n/message_format/ordinal_rules.rb +64 -0
- data/lib/i18n/message_format/parser.rb +328 -0
- data/lib/i18n/message_format/version.rb +8 -0
- data/lib/i18n/message_format.rb +74 -0
- data/sig/i18n/message_format.rbs +6 -0
- metadata +78 -0
|
@@ -0,0 +1,1947 @@
|
|
|
1
|
+
# ICU Message Format Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a Ruby gem that adds full ICU Message Format support to the ruby-i18n gem via a chainable backend.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Three layers — a pure Ruby recursive descent parser (string to AST), a formatter that walks the AST using i18n's existing localization and pluralization infrastructure, and a chainable I18n backend that loads Message Format strings from separate YAML files. An LRU cache sits between backend and parser.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby >= 3.2, i18n gem (runtime), minitest (testing)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Project Setup — Gemspec, Dependencies, and Test Harness
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Modify: `i18n-message_format.gemspec`
|
|
17
|
+
- Modify: `Gemfile`
|
|
18
|
+
- Modify: `Rakefile`
|
|
19
|
+
- Create: `test/test_helper.rb`
|
|
20
|
+
|
|
21
|
+
**Step 1: Update the gemspec with real metadata and i18n dependency**
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# i18n-message_format.gemspec
|
|
25
|
+
# frozen_string_literal: true
|
|
26
|
+
|
|
27
|
+
require_relative "lib/i18n/message_format/version"
|
|
28
|
+
|
|
29
|
+
Gem::Specification.new do |spec|
|
|
30
|
+
spec.name = "i18n-message_format"
|
|
31
|
+
spec.version = I18n::MessageFormat::VERSION
|
|
32
|
+
spec.authors = ["Chris Fung"]
|
|
33
|
+
spec.email = ["aergonaut@gmail.com"]
|
|
34
|
+
|
|
35
|
+
spec.summary = "ICU Message Format support for Ruby i18n"
|
|
36
|
+
spec.description = "A pure Ruby implementation of ICU Message Format that integrates with the ruby-i18n gem via a chainable backend."
|
|
37
|
+
spec.homepage = "https://github.com/aergonaut/i18n-message_format"
|
|
38
|
+
spec.license = "MIT"
|
|
39
|
+
spec.required_ruby_version = ">= 3.2.0"
|
|
40
|
+
|
|
41
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
42
|
+
spec.metadata["source_code_uri"] = "https://github.com/aergonaut/i18n-message_format"
|
|
43
|
+
spec.metadata["changelog_uri"] = "https://github.com/aergonaut/i18n-message_format/blob/main/CHANGELOG.md"
|
|
44
|
+
|
|
45
|
+
gemspec = File.basename(__FILE__)
|
|
46
|
+
spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
|
|
47
|
+
ls.readlines("\x0", chomp: true).reject do |f|
|
|
48
|
+
(f == gemspec) ||
|
|
49
|
+
f.start_with?(*%w[bin/ test/ Gemfile .gitignore])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
spec.bindir = "exe"
|
|
53
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
54
|
+
spec.require_paths = ["lib"]
|
|
55
|
+
|
|
56
|
+
spec.add_dependency "i18n", ">= 1.0"
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Step 2: Update Gemfile to add minitest**
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# Gemfile
|
|
64
|
+
# frozen_string_literal: true
|
|
65
|
+
|
|
66
|
+
source "https://rubygems.org"
|
|
67
|
+
|
|
68
|
+
gemspec
|
|
69
|
+
|
|
70
|
+
gem "irb"
|
|
71
|
+
gem "rake", "~> 13.0"
|
|
72
|
+
gem "minitest", "~> 5.0"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Step 3: Update Rakefile to run tests**
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# Rakefile
|
|
79
|
+
# frozen_string_literal: true
|
|
80
|
+
|
|
81
|
+
require "bundler/gem_tasks"
|
|
82
|
+
require "rake/testtask"
|
|
83
|
+
|
|
84
|
+
Rake::TestTask.new(:test) do |t|
|
|
85
|
+
t.libs << "test"
|
|
86
|
+
t.libs << "lib"
|
|
87
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
task default: :test
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Step 4: Create test helper**
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# test/test_helper.rb
|
|
97
|
+
# frozen_string_literal: true
|
|
98
|
+
|
|
99
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
100
|
+
require "i18n/message_format"
|
|
101
|
+
require "minitest/autorun"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Step 5: Run bundle install and verify rake works**
|
|
105
|
+
|
|
106
|
+
Run: `bundle install && bundle exec rake test`
|
|
107
|
+
Expected: 0 tests, 0 failures (no test files yet)
|
|
108
|
+
|
|
109
|
+
**Step 6: Commit**
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
git add -A
|
|
113
|
+
git commit -m "Set up project: gemspec, dependencies, test harness"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### Task 2: AST Node Classes
|
|
119
|
+
|
|
120
|
+
**Files:**
|
|
121
|
+
- Create: `lib/i18n/message_format/nodes.rb`
|
|
122
|
+
- Create: `test/i18n/message_format/nodes_test.rb`
|
|
123
|
+
- Modify: `lib/i18n/message_format.rb`
|
|
124
|
+
|
|
125
|
+
**Step 1: Write the failing test**
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# test/i18n/message_format/nodes_test.rb
|
|
129
|
+
# frozen_string_literal: true
|
|
130
|
+
|
|
131
|
+
require "test_helper"
|
|
132
|
+
|
|
133
|
+
module I18n
|
|
134
|
+
module MessageFormat
|
|
135
|
+
class NodesTest < Minitest::Test
|
|
136
|
+
def test_text_node
|
|
137
|
+
node = Nodes::TextNode.new("hello")
|
|
138
|
+
assert_equal "hello", node.value
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def test_argument_node
|
|
142
|
+
node = Nodes::ArgumentNode.new("name")
|
|
143
|
+
assert_equal "name", node.name
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def test_number_format_node
|
|
147
|
+
node = Nodes::NumberFormatNode.new("count", "integer")
|
|
148
|
+
assert_equal "count", node.name
|
|
149
|
+
assert_equal "integer", node.style
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def test_number_format_node_default_style
|
|
153
|
+
node = Nodes::NumberFormatNode.new("count")
|
|
154
|
+
assert_equal "count", node.name
|
|
155
|
+
assert_nil node.style
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def test_date_format_node
|
|
159
|
+
node = Nodes::DateFormatNode.new("d", "short")
|
|
160
|
+
assert_equal "d", node.name
|
|
161
|
+
assert_equal "short", node.style
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def test_time_format_node
|
|
165
|
+
node = Nodes::TimeFormatNode.new("t", "short")
|
|
166
|
+
assert_equal "t", node.name
|
|
167
|
+
assert_equal "short", node.style
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_plural_node
|
|
171
|
+
branches = { one: [Nodes::TextNode.new("1 item")], other: [Nodes::TextNode.new("# items")] }
|
|
172
|
+
node = Nodes::PluralNode.new("count", branches, 0)
|
|
173
|
+
assert_equal "count", node.name
|
|
174
|
+
assert_equal branches, node.branches
|
|
175
|
+
assert_equal 0, node.offset
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def test_plural_node_default_offset
|
|
179
|
+
branches = { other: [Nodes::TextNode.new("# items")] }
|
|
180
|
+
node = Nodes::PluralNode.new("count", branches)
|
|
181
|
+
assert_equal 0, node.offset
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def test_select_node
|
|
185
|
+
branches = { male: [Nodes::TextNode.new("He")], other: [Nodes::TextNode.new("They")] }
|
|
186
|
+
node = Nodes::SelectNode.new("gender", branches)
|
|
187
|
+
assert_equal "gender", node.name
|
|
188
|
+
assert_equal branches, node.branches
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def test_select_ordinal_node
|
|
192
|
+
branches = { one: [Nodes::TextNode.new("#st")], other: [Nodes::TextNode.new("#th")] }
|
|
193
|
+
node = Nodes::SelectOrdinalNode.new("pos", branches, 0)
|
|
194
|
+
assert_equal "pos", node.name
|
|
195
|
+
assert_equal branches, node.branches
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Step 2: Run test to verify it fails**
|
|
203
|
+
|
|
204
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/nodes_test.rb`
|
|
205
|
+
Expected: FAIL — `Nodes` not defined
|
|
206
|
+
|
|
207
|
+
**Step 3: Write minimal implementation**
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# lib/i18n/message_format/nodes.rb
|
|
211
|
+
# frozen_string_literal: true
|
|
212
|
+
|
|
213
|
+
module I18n
|
|
214
|
+
module MessageFormat
|
|
215
|
+
module Nodes
|
|
216
|
+
TextNode = Struct.new(:value)
|
|
217
|
+
ArgumentNode = Struct.new(:name)
|
|
218
|
+
NumberFormatNode = Struct.new(:name, :style)
|
|
219
|
+
DateFormatNode = Struct.new(:name, :style)
|
|
220
|
+
TimeFormatNode = Struct.new(:name, :style)
|
|
221
|
+
PluralNode = Struct.new(:name, :branches, :offset) do
|
|
222
|
+
def initialize(name, branches, offset = 0)
|
|
223
|
+
super(name, branches, offset)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
SelectNode = Struct.new(:name, :branches)
|
|
227
|
+
SelectOrdinalNode = Struct.new(:name, :branches, :offset) do
|
|
228
|
+
def initialize(name, branches, offset = 0)
|
|
229
|
+
super(name, branches, offset)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Step 4: Update the main require file**
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
# lib/i18n/message_format.rb
|
|
241
|
+
# frozen_string_literal: true
|
|
242
|
+
|
|
243
|
+
require_relative "message_format/version"
|
|
244
|
+
require_relative "message_format/nodes"
|
|
245
|
+
|
|
246
|
+
module I18n
|
|
247
|
+
module MessageFormat
|
|
248
|
+
class Error < StandardError; end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Step 5: Run test to verify it passes**
|
|
254
|
+
|
|
255
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/nodes_test.rb`
|
|
256
|
+
Expected: All tests PASS
|
|
257
|
+
|
|
258
|
+
**Step 6: Commit**
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
git add -A
|
|
262
|
+
git commit -m "Add AST node classes"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
### Task 3: Parser — Literal Text and Simple Arguments
|
|
268
|
+
|
|
269
|
+
**Files:**
|
|
270
|
+
- Create: `lib/i18n/message_format/parser.rb`
|
|
271
|
+
- Create: `test/i18n/message_format/parser_test.rb`
|
|
272
|
+
- Modify: `lib/i18n/message_format.rb`
|
|
273
|
+
|
|
274
|
+
**Step 1: Write the failing tests**
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
# test/i18n/message_format/parser_test.rb
|
|
278
|
+
# frozen_string_literal: true
|
|
279
|
+
|
|
280
|
+
require "test_helper"
|
|
281
|
+
|
|
282
|
+
module I18n
|
|
283
|
+
module MessageFormat
|
|
284
|
+
class ParserTest < Minitest::Test
|
|
285
|
+
def test_plain_text
|
|
286
|
+
nodes = Parser.new("hello world").parse
|
|
287
|
+
assert_equal 1, nodes.length
|
|
288
|
+
assert_instance_of Nodes::TextNode, nodes[0]
|
|
289
|
+
assert_equal "hello world", nodes[0].value
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def test_empty_string
|
|
293
|
+
nodes = Parser.new("").parse
|
|
294
|
+
assert_equal 0, nodes.length
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def test_simple_argument
|
|
298
|
+
nodes = Parser.new("{name}").parse
|
|
299
|
+
assert_equal 1, nodes.length
|
|
300
|
+
assert_instance_of Nodes::ArgumentNode, nodes[0]
|
|
301
|
+
assert_equal "name", nodes[0].name
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def test_text_with_argument
|
|
305
|
+
nodes = Parser.new("Hello {name}!").parse
|
|
306
|
+
assert_equal 3, nodes.length
|
|
307
|
+
assert_instance_of Nodes::TextNode, nodes[0]
|
|
308
|
+
assert_equal "Hello ", nodes[0].value
|
|
309
|
+
assert_instance_of Nodes::ArgumentNode, nodes[1]
|
|
310
|
+
assert_equal "name", nodes[1].name
|
|
311
|
+
assert_instance_of Nodes::TextNode, nodes[2]
|
|
312
|
+
assert_equal "!", nodes[2].value
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def test_multiple_arguments
|
|
316
|
+
nodes = Parser.new("{first} and {second}").parse
|
|
317
|
+
assert_equal 3, nodes.length
|
|
318
|
+
assert_instance_of Nodes::ArgumentNode, nodes[0]
|
|
319
|
+
assert_instance_of Nodes::TextNode, nodes[1]
|
|
320
|
+
assert_instance_of Nodes::ArgumentNode, nodes[2]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def test_escaped_single_quote
|
|
324
|
+
nodes = Parser.new("it''s").parse
|
|
325
|
+
assert_equal 1, nodes.length
|
|
326
|
+
assert_equal "it's", nodes[0].value
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def test_escaped_open_brace
|
|
330
|
+
nodes = Parser.new("'{' literal").parse
|
|
331
|
+
assert_equal 1, nodes.length
|
|
332
|
+
assert_equal "{ literal", nodes[0].value
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def test_escaped_close_brace
|
|
336
|
+
nodes = Parser.new("literal '}'").parse
|
|
337
|
+
assert_equal 1, nodes.length
|
|
338
|
+
assert_equal "literal }", nodes[0].value
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def test_unclosed_brace_raises_parse_error
|
|
342
|
+
assert_raises(ParseError) do
|
|
343
|
+
Parser.new("{name").parse
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**Step 2: Run test to verify it fails**
|
|
352
|
+
|
|
353
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/parser_test.rb`
|
|
354
|
+
Expected: FAIL — `Parser` not defined
|
|
355
|
+
|
|
356
|
+
**Step 3: Write minimal implementation**
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
# lib/i18n/message_format/parser.rb
|
|
360
|
+
# frozen_string_literal: true
|
|
361
|
+
|
|
362
|
+
module I18n
|
|
363
|
+
module MessageFormat
|
|
364
|
+
class ParseError < Error
|
|
365
|
+
attr_reader :position
|
|
366
|
+
|
|
367
|
+
def initialize(message, position = nil)
|
|
368
|
+
@position = position
|
|
369
|
+
super(position ? "#{message} at position #{position}" : message)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
class Parser
|
|
374
|
+
def initialize(pattern)
|
|
375
|
+
@pattern = pattern
|
|
376
|
+
@pos = 0
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def parse
|
|
380
|
+
nodes = parse_message
|
|
381
|
+
nodes
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
private
|
|
385
|
+
|
|
386
|
+
def parse_message(terminate_on = nil)
|
|
387
|
+
nodes = []
|
|
388
|
+
|
|
389
|
+
until eof?
|
|
390
|
+
char = current_char
|
|
391
|
+
|
|
392
|
+
if terminate_on&.include?(char)
|
|
393
|
+
break
|
|
394
|
+
elsif char == "{"
|
|
395
|
+
@pos += 1
|
|
396
|
+
nodes << parse_argument
|
|
397
|
+
elsif char == "}"
|
|
398
|
+
raise ParseError.new("Unexpected }", @pos)
|
|
399
|
+
elsif char == "'"
|
|
400
|
+
nodes << parse_quoted_or_literal(nodes)
|
|
401
|
+
else
|
|
402
|
+
nodes << parse_text(terminate_on)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
merge_adjacent_text(nodes)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def parse_text(terminate_on = nil)
|
|
410
|
+
start = @pos
|
|
411
|
+
while !eof? && current_char != "{" && current_char != "}" && current_char != "'" && !terminate_on&.include?(current_char)
|
|
412
|
+
@pos += 1
|
|
413
|
+
end
|
|
414
|
+
Nodes::TextNode.new(@pattern[start...@pos])
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def parse_quoted_or_literal(preceding_nodes)
|
|
418
|
+
@pos += 1 # skip opening quote
|
|
419
|
+
|
|
420
|
+
if eof?
|
|
421
|
+
Nodes::TextNode.new("'")
|
|
422
|
+
elsif current_char == "'"
|
|
423
|
+
# '' => literal single quote
|
|
424
|
+
@pos += 1
|
|
425
|
+
Nodes::TextNode.new("'")
|
|
426
|
+
elsif current_char == "{" || current_char == "}"
|
|
427
|
+
# '{ or '} => literal brace, read until closing quote or end
|
|
428
|
+
text = +""
|
|
429
|
+
while !eof? && current_char != "'"
|
|
430
|
+
text << current_char
|
|
431
|
+
@pos += 1
|
|
432
|
+
end
|
|
433
|
+
@pos += 1 unless eof? # skip closing quote
|
|
434
|
+
Nodes::TextNode.new(text)
|
|
435
|
+
else
|
|
436
|
+
# standalone quote, treat as literal
|
|
437
|
+
Nodes::TextNode.new("'")
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def parse_argument
|
|
442
|
+
skip_whitespace
|
|
443
|
+
name = parse_identifier
|
|
444
|
+
skip_whitespace
|
|
445
|
+
|
|
446
|
+
if eof?
|
|
447
|
+
raise ParseError.new("Unclosed argument", @pos)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
if current_char == "}"
|
|
451
|
+
@pos += 1
|
|
452
|
+
return Nodes::ArgumentNode.new(name)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
if current_char == ","
|
|
456
|
+
@pos += 1
|
|
457
|
+
skip_whitespace
|
|
458
|
+
return parse_typed_argument(name)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
raise ParseError.new("Expected ',' or '}' in argument", @pos)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def parse_typed_argument(name)
|
|
465
|
+
type = parse_identifier
|
|
466
|
+
skip_whitespace
|
|
467
|
+
|
|
468
|
+
case type
|
|
469
|
+
when "number"
|
|
470
|
+
parse_number_arg(name)
|
|
471
|
+
when "date"
|
|
472
|
+
parse_date_arg(name)
|
|
473
|
+
when "time"
|
|
474
|
+
parse_time_arg(name)
|
|
475
|
+
when "plural"
|
|
476
|
+
parse_plural_arg(name)
|
|
477
|
+
when "select"
|
|
478
|
+
parse_select_arg(name)
|
|
479
|
+
when "selectordinal"
|
|
480
|
+
parse_select_ordinal_arg(name)
|
|
481
|
+
else
|
|
482
|
+
raise ParseError.new("Unknown argument type '#{type}'", @pos)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def parse_number_arg(name)
|
|
487
|
+
if current_char == "}"
|
|
488
|
+
@pos += 1
|
|
489
|
+
return Nodes::NumberFormatNode.new(name)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
expect(",")
|
|
493
|
+
skip_whitespace
|
|
494
|
+
style = parse_identifier
|
|
495
|
+
skip_whitespace
|
|
496
|
+
expect("}")
|
|
497
|
+
Nodes::NumberFormatNode.new(name, style)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def parse_date_arg(name)
|
|
501
|
+
if current_char == "}"
|
|
502
|
+
@pos += 1
|
|
503
|
+
return Nodes::DateFormatNode.new(name)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
expect(",")
|
|
507
|
+
skip_whitespace
|
|
508
|
+
style = parse_identifier
|
|
509
|
+
skip_whitespace
|
|
510
|
+
expect("}")
|
|
511
|
+
Nodes::DateFormatNode.new(name, style)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def parse_time_arg(name)
|
|
515
|
+
if current_char == "}"
|
|
516
|
+
@pos += 1
|
|
517
|
+
return Nodes::TimeFormatNode.new(name)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
expect(",")
|
|
521
|
+
skip_whitespace
|
|
522
|
+
style = parse_identifier
|
|
523
|
+
skip_whitespace
|
|
524
|
+
expect("}")
|
|
525
|
+
Nodes::TimeFormatNode.new(name, style)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def parse_plural_arg(name)
|
|
529
|
+
expect(",")
|
|
530
|
+
skip_whitespace
|
|
531
|
+
|
|
532
|
+
offset = 0
|
|
533
|
+
if @pattern[@pos..].start_with?("offset:")
|
|
534
|
+
@pos += 7
|
|
535
|
+
skip_whitespace
|
|
536
|
+
offset = parse_number
|
|
537
|
+
skip_whitespace
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
branches = parse_branches
|
|
541
|
+
expect("}")
|
|
542
|
+
Nodes::PluralNode.new(name, branches, offset)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def parse_select_arg(name)
|
|
546
|
+
expect(",")
|
|
547
|
+
skip_whitespace
|
|
548
|
+
branches = parse_branches
|
|
549
|
+
expect("}")
|
|
550
|
+
Nodes::SelectNode.new(name, branches)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def parse_select_ordinal_arg(name)
|
|
554
|
+
expect(",")
|
|
555
|
+
skip_whitespace
|
|
556
|
+
|
|
557
|
+
offset = 0
|
|
558
|
+
if @pattern[@pos..].start_with?("offset:")
|
|
559
|
+
@pos += 7
|
|
560
|
+
skip_whitespace
|
|
561
|
+
offset = parse_number
|
|
562
|
+
skip_whitespace
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
branches = parse_branches
|
|
566
|
+
expect("}")
|
|
567
|
+
Nodes::SelectOrdinalNode.new(name, branches, offset)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def parse_branches
|
|
571
|
+
branches = {}
|
|
572
|
+
|
|
573
|
+
while !eof? && current_char != "}"
|
|
574
|
+
skip_whitespace
|
|
575
|
+
break if eof? || current_char == "}"
|
|
576
|
+
|
|
577
|
+
key = parse_branch_key
|
|
578
|
+
skip_whitespace
|
|
579
|
+
expect("{")
|
|
580
|
+
value = parse_message("}")
|
|
581
|
+
expect("}")
|
|
582
|
+
skip_whitespace
|
|
583
|
+
|
|
584
|
+
branches[key] = value
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
branches
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def parse_branch_key
|
|
591
|
+
if current_char == "="
|
|
592
|
+
@pos += 1
|
|
593
|
+
:"=#{parse_number}"
|
|
594
|
+
else
|
|
595
|
+
parse_identifier.to_sym
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def parse_identifier
|
|
600
|
+
start = @pos
|
|
601
|
+
while !eof? && identifier_char?(current_char)
|
|
602
|
+
@pos += 1
|
|
603
|
+
end
|
|
604
|
+
raise ParseError.new("Expected identifier", start) if @pos == start
|
|
605
|
+
@pattern[start...@pos]
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def parse_number
|
|
609
|
+
start = @pos
|
|
610
|
+
@pos += 1 if !eof? && current_char == "-"
|
|
611
|
+
while !eof? && current_char.match?(/[0-9]/)
|
|
612
|
+
@pos += 1
|
|
613
|
+
end
|
|
614
|
+
raise ParseError.new("Expected number", start) if @pos == start
|
|
615
|
+
@pattern[start...@pos].to_i
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def identifier_char?(char)
|
|
619
|
+
char.match?(/[a-zA-Z0-9_]/)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def skip_whitespace
|
|
623
|
+
@pos += 1 while !eof? && current_char.match?(/\s/)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def expect(char)
|
|
627
|
+
if eof? || current_char != char
|
|
628
|
+
raise ParseError.new("Expected '#{char}'", @pos)
|
|
629
|
+
end
|
|
630
|
+
@pos += 1
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def current_char
|
|
634
|
+
@pattern[@pos]
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def eof?
|
|
638
|
+
@pos >= @pattern.length
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def merge_adjacent_text(nodes)
|
|
642
|
+
merged = []
|
|
643
|
+
nodes.each do |node|
|
|
644
|
+
if node.is_a?(Nodes::TextNode) && merged.last.is_a?(Nodes::TextNode)
|
|
645
|
+
merged.last.value << node.value
|
|
646
|
+
else
|
|
647
|
+
merged << node
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
merged
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
**Step 4: Update the main require file**
|
|
658
|
+
|
|
659
|
+
Add `require_relative "message_format/parser"` to `lib/i18n/message_format.rb`.
|
|
660
|
+
|
|
661
|
+
**Step 5: Run test to verify it passes**
|
|
662
|
+
|
|
663
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/parser_test.rb`
|
|
664
|
+
Expected: All tests PASS
|
|
665
|
+
|
|
666
|
+
**Step 6: Commit**
|
|
667
|
+
|
|
668
|
+
```bash
|
|
669
|
+
git add -A
|
|
670
|
+
git commit -m "Add parser: literal text, simple arguments, escaping"
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
---
|
|
674
|
+
|
|
675
|
+
### Task 4: Parser — Formatted Arguments (number, date, time)
|
|
676
|
+
|
|
677
|
+
**Files:**
|
|
678
|
+
- Modify: `test/i18n/message_format/parser_test.rb`
|
|
679
|
+
- (Parser already handles these from Task 3, add tests to confirm)
|
|
680
|
+
|
|
681
|
+
**Step 1: Write the failing tests**
|
|
682
|
+
|
|
683
|
+
Add to `test/i18n/message_format/parser_test.rb`:
|
|
684
|
+
|
|
685
|
+
```ruby
|
|
686
|
+
def test_number_format
|
|
687
|
+
nodes = Parser.new("{count, number}").parse
|
|
688
|
+
assert_equal 1, nodes.length
|
|
689
|
+
assert_instance_of Nodes::NumberFormatNode, nodes[0]
|
|
690
|
+
assert_equal "count", nodes[0].name
|
|
691
|
+
assert_nil nodes[0].style
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def test_number_format_with_style
|
|
695
|
+
nodes = Parser.new("{count, number, integer}").parse
|
|
696
|
+
assert_equal 1, nodes.length
|
|
697
|
+
assert_instance_of Nodes::NumberFormatNode, nodes[0]
|
|
698
|
+
assert_equal "integer", nodes[0].style
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def test_date_format
|
|
702
|
+
nodes = Parser.new("{d, date}").parse
|
|
703
|
+
assert_equal 1, nodes.length
|
|
704
|
+
assert_instance_of Nodes::DateFormatNode, nodes[0]
|
|
705
|
+
assert_equal "d", nodes[0].name
|
|
706
|
+
assert_nil nodes[0].style
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def test_date_format_with_style
|
|
710
|
+
nodes = Parser.new("{d, date, short}").parse
|
|
711
|
+
assert_instance_of Nodes::DateFormatNode, nodes[0]
|
|
712
|
+
assert_equal "short", nodes[0].style
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def test_time_format
|
|
716
|
+
nodes = Parser.new("{t, time}").parse
|
|
717
|
+
assert_instance_of Nodes::TimeFormatNode, nodes[0]
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def test_time_format_with_style
|
|
721
|
+
nodes = Parser.new("{t, time, short}").parse
|
|
722
|
+
assert_instance_of Nodes::TimeFormatNode, nodes[0]
|
|
723
|
+
assert_equal "short", nodes[0].style
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def test_unknown_type_raises_parse_error
|
|
727
|
+
assert_raises(ParseError) do
|
|
728
|
+
Parser.new("{x, unknown}").parse
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
**Step 2: Run tests to verify they pass** (parser already supports these)
|
|
734
|
+
|
|
735
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/parser_test.rb`
|
|
736
|
+
Expected: All tests PASS
|
|
737
|
+
|
|
738
|
+
**Step 3: Commit**
|
|
739
|
+
|
|
740
|
+
```bash
|
|
741
|
+
git add -A
|
|
742
|
+
git commit -m "Add parser tests for formatted arguments (number, date, time)"
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
---
|
|
746
|
+
|
|
747
|
+
### Task 5: Parser — Plural, Select, SelectOrdinal
|
|
748
|
+
|
|
749
|
+
**Files:**
|
|
750
|
+
- Modify: `test/i18n/message_format/parser_test.rb`
|
|
751
|
+
|
|
752
|
+
**Step 1: Write the failing tests**
|
|
753
|
+
|
|
754
|
+
Add to `test/i18n/message_format/parser_test.rb`:
|
|
755
|
+
|
|
756
|
+
```ruby
|
|
757
|
+
def test_plural
|
|
758
|
+
nodes = Parser.new("{count, plural, one {# item} other {# items}}").parse
|
|
759
|
+
assert_equal 1, nodes.length
|
|
760
|
+
assert_instance_of Nodes::PluralNode, nodes[0]
|
|
761
|
+
assert_equal "count", nodes[0].name
|
|
762
|
+
assert_includes nodes[0].branches, :one
|
|
763
|
+
assert_includes nodes[0].branches, :other
|
|
764
|
+
assert_equal 0, nodes[0].offset
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def test_plural_with_exact_match
|
|
768
|
+
nodes = Parser.new("{count, plural, =0 {none} one {one} other {many}}").parse
|
|
769
|
+
node = nodes[0]
|
|
770
|
+
assert_includes node.branches, :"=0"
|
|
771
|
+
assert_includes node.branches, :one
|
|
772
|
+
assert_includes node.branches, :other
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def test_plural_with_offset
|
|
776
|
+
nodes = Parser.new("{count, plural, offset:1 one {# item} other {# items}}").parse
|
|
777
|
+
assert_equal 1, nodes[0].offset
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
def test_select
|
|
781
|
+
nodes = Parser.new("{gender, select, male {He} female {She} other {They}}").parse
|
|
782
|
+
assert_equal 1, nodes.length
|
|
783
|
+
assert_instance_of Nodes::SelectNode, nodes[0]
|
|
784
|
+
assert_equal "gender", nodes[0].name
|
|
785
|
+
assert_includes nodes[0].branches, :male
|
|
786
|
+
assert_includes nodes[0].branches, :female
|
|
787
|
+
assert_includes nodes[0].branches, :other
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def test_selectordinal
|
|
791
|
+
nodes = Parser.new("{pos, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}").parse
|
|
792
|
+
assert_equal 1, nodes.length
|
|
793
|
+
assert_instance_of Nodes::SelectOrdinalNode, nodes[0]
|
|
794
|
+
assert_equal "pos", nodes[0].name
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def test_nested_plural_in_select
|
|
798
|
+
pattern = "{gender, select, male {{count, plural, one {He has # item} other {He has # items}}} other {{count, plural, one {They have # item} other {They have # items}}}}"
|
|
799
|
+
nodes = Parser.new(pattern).parse
|
|
800
|
+
assert_equal 1, nodes.length
|
|
801
|
+
assert_instance_of Nodes::SelectNode, nodes[0]
|
|
802
|
+
male_branch = nodes[0].branches[:male]
|
|
803
|
+
assert_instance_of Nodes::PluralNode, male_branch[0]
|
|
804
|
+
end
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
**Step 2: Run tests**
|
|
808
|
+
|
|
809
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/parser_test.rb`
|
|
810
|
+
Expected: All tests PASS (parser already implements these)
|
|
811
|
+
|
|
812
|
+
**Step 3: Commit**
|
|
813
|
+
|
|
814
|
+
```bash
|
|
815
|
+
git add -A
|
|
816
|
+
git commit -m "Add parser tests for plural, select, selectordinal, and nesting"
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
---
|
|
820
|
+
|
|
821
|
+
### Task 6: Formatter — Text and Simple Arguments
|
|
822
|
+
|
|
823
|
+
**Files:**
|
|
824
|
+
- Create: `lib/i18n/message_format/formatter.rb`
|
|
825
|
+
- Create: `test/i18n/message_format/formatter_test.rb`
|
|
826
|
+
- Modify: `lib/i18n/message_format.rb`
|
|
827
|
+
|
|
828
|
+
**Step 1: Write the failing tests**
|
|
829
|
+
|
|
830
|
+
```ruby
|
|
831
|
+
# test/i18n/message_format/formatter_test.rb
|
|
832
|
+
# frozen_string_literal: true
|
|
833
|
+
|
|
834
|
+
require "test_helper"
|
|
835
|
+
|
|
836
|
+
module I18n
|
|
837
|
+
module MessageFormat
|
|
838
|
+
class FormatterTest < Minitest::Test
|
|
839
|
+
def test_text_only
|
|
840
|
+
result = Formatter.new([Nodes::TextNode.new("hello")], {}, :en).format
|
|
841
|
+
assert_equal "hello", result
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def test_simple_argument
|
|
845
|
+
nodes = [
|
|
846
|
+
Nodes::TextNode.new("Hello "),
|
|
847
|
+
Nodes::ArgumentNode.new("name"),
|
|
848
|
+
Nodes::TextNode.new("!")
|
|
849
|
+
]
|
|
850
|
+
result = Formatter.new(nodes, { name: "Alice" }, :en).format
|
|
851
|
+
assert_equal "Hello Alice!", result
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
def test_missing_argument_raises
|
|
855
|
+
nodes = [Nodes::ArgumentNode.new("name")]
|
|
856
|
+
assert_raises(MissingArgumentError) do
|
|
857
|
+
Formatter.new(nodes, {}, :en).format
|
|
858
|
+
end
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def test_argument_calls_to_s
|
|
862
|
+
nodes = [Nodes::ArgumentNode.new("count")]
|
|
863
|
+
result = Formatter.new(nodes, { count: 42 }, :en).format
|
|
864
|
+
assert_equal "42", result
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
**Step 2: Run test to verify it fails**
|
|
872
|
+
|
|
873
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/formatter_test.rb`
|
|
874
|
+
Expected: FAIL — `Formatter` not defined
|
|
875
|
+
|
|
876
|
+
**Step 3: Write minimal implementation**
|
|
877
|
+
|
|
878
|
+
```ruby
|
|
879
|
+
# lib/i18n/message_format/formatter.rb
|
|
880
|
+
# frozen_string_literal: true
|
|
881
|
+
|
|
882
|
+
module I18n
|
|
883
|
+
module MessageFormat
|
|
884
|
+
class MissingArgumentError < Error
|
|
885
|
+
attr_reader :argument_name
|
|
886
|
+
|
|
887
|
+
def initialize(argument_name)
|
|
888
|
+
@argument_name = argument_name
|
|
889
|
+
super("Missing argument: #{argument_name}")
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
class Formatter
|
|
894
|
+
def initialize(nodes, arguments, locale)
|
|
895
|
+
@nodes = nodes
|
|
896
|
+
@arguments = arguments
|
|
897
|
+
@locale = locale
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def format
|
|
901
|
+
format_nodes(@nodes)
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
private
|
|
905
|
+
|
|
906
|
+
def format_nodes(nodes)
|
|
907
|
+
nodes.map { |node| format_node(node) }.join
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
def format_node(node)
|
|
911
|
+
case node
|
|
912
|
+
when Nodes::TextNode
|
|
913
|
+
node.value
|
|
914
|
+
when Nodes::ArgumentNode
|
|
915
|
+
fetch_argument(node.name).to_s
|
|
916
|
+
when Nodes::NumberFormatNode
|
|
917
|
+
format_number(node)
|
|
918
|
+
when Nodes::DateFormatNode
|
|
919
|
+
format_date(node)
|
|
920
|
+
when Nodes::TimeFormatNode
|
|
921
|
+
format_time(node)
|
|
922
|
+
when Nodes::PluralNode
|
|
923
|
+
format_plural(node)
|
|
924
|
+
when Nodes::SelectNode
|
|
925
|
+
format_select(node)
|
|
926
|
+
when Nodes::SelectOrdinalNode
|
|
927
|
+
format_select_ordinal(node)
|
|
928
|
+
else
|
|
929
|
+
raise Error, "Unknown node type: #{node.class}"
|
|
930
|
+
end
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
def fetch_argument(name)
|
|
934
|
+
key = name.to_sym
|
|
935
|
+
unless @arguments.key?(key)
|
|
936
|
+
raise MissingArgumentError.new(name)
|
|
937
|
+
end
|
|
938
|
+
@arguments[key]
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
def format_number(node)
|
|
942
|
+
value = fetch_argument(node.name)
|
|
943
|
+
::I18n.localize(value, locale: @locale)
|
|
944
|
+
rescue ::I18n::MissingTranslationData
|
|
945
|
+
value.to_s
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
def format_date(node)
|
|
949
|
+
value = fetch_argument(node.name)
|
|
950
|
+
opts = { locale: @locale }
|
|
951
|
+
opts[:format] = node.style.to_sym if node.style
|
|
952
|
+
::I18n.localize(value, **opts)
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def format_time(node)
|
|
956
|
+
value = fetch_argument(node.name)
|
|
957
|
+
opts = { locale: @locale }
|
|
958
|
+
opts[:format] = node.style.to_sym if node.style
|
|
959
|
+
::I18n.localize(value, **opts)
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
def format_plural(node)
|
|
963
|
+
value = fetch_argument(node.name)
|
|
964
|
+
effective_value = value - node.offset
|
|
965
|
+
|
|
966
|
+
# Check exact matches first
|
|
967
|
+
exact_key = :"=#{value}"
|
|
968
|
+
if node.branches.key?(exact_key)
|
|
969
|
+
return format_branch(node.branches[exact_key], effective_value)
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# Use i18n pluralization rules
|
|
973
|
+
category = pluralize_cardinal(effective_value, @locale)
|
|
974
|
+
branch = node.branches[category] || node.branches[:other]
|
|
975
|
+
raise Error, "No matching plural branch for '#{category}'" unless branch
|
|
976
|
+
|
|
977
|
+
format_branch(branch, effective_value)
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
def format_select(node)
|
|
981
|
+
value = fetch_argument(node.name)
|
|
982
|
+
key = value.to_s.to_sym
|
|
983
|
+
branch = node.branches[key] || node.branches[:other]
|
|
984
|
+
raise Error, "No matching select branch for '#{key}'" unless branch
|
|
985
|
+
|
|
986
|
+
format_nodes(branch)
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
def format_select_ordinal(node)
|
|
990
|
+
value = fetch_argument(node.name)
|
|
991
|
+
effective_value = value - node.offset
|
|
992
|
+
|
|
993
|
+
exact_key = :"=#{value}"
|
|
994
|
+
if node.branches.key?(exact_key)
|
|
995
|
+
return format_branch(node.branches[exact_key], effective_value)
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
category = pluralize_ordinal(effective_value, @locale)
|
|
999
|
+
branch = node.branches[category] || node.branches[:other]
|
|
1000
|
+
raise Error, "No matching selectordinal branch for '#{category}'" unless branch
|
|
1001
|
+
|
|
1002
|
+
format_branch(branch, effective_value)
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
def format_branch(nodes, numeric_value)
|
|
1006
|
+
nodes.map do |node|
|
|
1007
|
+
if node.is_a?(Nodes::TextNode)
|
|
1008
|
+
node.value.gsub("#", numeric_value.to_s)
|
|
1009
|
+
else
|
|
1010
|
+
format_node(node)
|
|
1011
|
+
end
|
|
1012
|
+
end.join
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
def pluralize_cardinal(count, locale)
|
|
1016
|
+
rule = ::I18n.t(:"i18n.plural.rule", locale: locale, default: nil, resolve: false)
|
|
1017
|
+
if rule.respond_to?(:call)
|
|
1018
|
+
rule.call(count)
|
|
1019
|
+
else
|
|
1020
|
+
count == 1 ? :one : :other
|
|
1021
|
+
end
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
def pluralize_ordinal(count, locale)
|
|
1025
|
+
rule = ::I18n.t(:"i18n.ordinal.rule", locale: locale, default: nil, resolve: false)
|
|
1026
|
+
if rule.respond_to?(:call)
|
|
1027
|
+
rule.call(count)
|
|
1028
|
+
else
|
|
1029
|
+
:other
|
|
1030
|
+
end
|
|
1031
|
+
end
|
|
1032
|
+
end
|
|
1033
|
+
end
|
|
1034
|
+
end
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
**Step 4: Update the main require file**
|
|
1038
|
+
|
|
1039
|
+
Add `require_relative "message_format/formatter"` to `lib/i18n/message_format.rb`.
|
|
1040
|
+
|
|
1041
|
+
**Step 5: Run test to verify it passes**
|
|
1042
|
+
|
|
1043
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/formatter_test.rb`
|
|
1044
|
+
Expected: All tests PASS
|
|
1045
|
+
|
|
1046
|
+
**Step 6: Commit**
|
|
1047
|
+
|
|
1048
|
+
```bash
|
|
1049
|
+
git add -A
|
|
1050
|
+
git commit -m "Add formatter: text and simple argument nodes"
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
---
|
|
1054
|
+
|
|
1055
|
+
### Task 7: Formatter — Plural and Select
|
|
1056
|
+
|
|
1057
|
+
**Files:**
|
|
1058
|
+
- Modify: `test/i18n/message_format/formatter_test.rb`
|
|
1059
|
+
|
|
1060
|
+
**Step 1: Write the failing tests**
|
|
1061
|
+
|
|
1062
|
+
Add to `test/i18n/message_format/formatter_test.rb`:
|
|
1063
|
+
|
|
1064
|
+
```ruby
|
|
1065
|
+
def test_plural_one
|
|
1066
|
+
nodes = Parser.new("{count, plural, one {# item} other {# items}}").parse
|
|
1067
|
+
result = Formatter.new(nodes, { count: 1 }, :en).format
|
|
1068
|
+
assert_equal "1 item", result
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def test_plural_other
|
|
1072
|
+
nodes = Parser.new("{count, plural, one {# item} other {# items}}").parse
|
|
1073
|
+
result = Formatter.new(nodes, { count: 5 }, :en).format
|
|
1074
|
+
assert_equal "5 items", result
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
def test_plural_exact_match
|
|
1078
|
+
nodes = Parser.new("{count, plural, =0 {no items} one {# item} other {# items}}").parse
|
|
1079
|
+
result = Formatter.new(nodes, { count: 0 }, :en).format
|
|
1080
|
+
assert_equal "no items", result
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
def test_plural_with_offset
|
|
1084
|
+
nodes = Parser.new("{count, plural, offset:1 =0 {nobody} =1 {just {name}} one {{name} and # other} other {{name} and # others}}").parse
|
|
1085
|
+
result = Formatter.new(nodes, { count: 3, name: "Alice" }, :en).format
|
|
1086
|
+
assert_equal "Alice and 2 others", result
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
def test_select
|
|
1090
|
+
nodes = Parser.new("{gender, select, male {He} female {She} other {They}}").parse
|
|
1091
|
+
result = Formatter.new(nodes, { gender: "female" }, :en).format
|
|
1092
|
+
assert_equal "She", result
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
def test_select_falls_back_to_other
|
|
1096
|
+
nodes = Parser.new("{gender, select, male {He} female {She} other {They}}").parse
|
|
1097
|
+
result = Formatter.new(nodes, { gender: "nonbinary" }, :en).format
|
|
1098
|
+
assert_equal "They", result
|
|
1099
|
+
end
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
**Step 2: Run tests**
|
|
1103
|
+
|
|
1104
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/formatter_test.rb`
|
|
1105
|
+
Expected: All tests PASS (formatter already implements these)
|
|
1106
|
+
|
|
1107
|
+
**Step 3: Commit**
|
|
1108
|
+
|
|
1109
|
+
```bash
|
|
1110
|
+
git add -A
|
|
1111
|
+
git commit -m "Add formatter tests for plural and select"
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
---
|
|
1115
|
+
|
|
1116
|
+
### Task 8: Formatter — Number, Date, Time via I18n.l
|
|
1117
|
+
|
|
1118
|
+
**Files:**
|
|
1119
|
+
- Modify: `test/i18n/message_format/formatter_test.rb`
|
|
1120
|
+
|
|
1121
|
+
**Step 1: Write the failing tests**
|
|
1122
|
+
|
|
1123
|
+
Add to `test/i18n/message_format/formatter_test.rb`:
|
|
1124
|
+
|
|
1125
|
+
```ruby
|
|
1126
|
+
def setup
|
|
1127
|
+
::I18n.backend = ::I18n::Backend::Simple.new
|
|
1128
|
+
::I18n.locale = :en
|
|
1129
|
+
::I18n.backend.store_translations(:en, {
|
|
1130
|
+
date: { formats: { short: "%b %d" } },
|
|
1131
|
+
time: { formats: { short: "%H:%M" } }
|
|
1132
|
+
})
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
def test_date_format
|
|
1136
|
+
nodes = Parser.new("{d, date, short}").parse
|
|
1137
|
+
result = Formatter.new(nodes, { d: Date.new(2026, 1, 15) }, :en).format
|
|
1138
|
+
assert_equal "Jan 15", result
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
def test_time_format
|
|
1142
|
+
nodes = Parser.new("{t, time, short}").parse
|
|
1143
|
+
result = Formatter.new(nodes, { t: Time.new(2026, 1, 15, 14, 30, 0) }, :en).format
|
|
1144
|
+
assert_equal "14:30", result
|
|
1145
|
+
end
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
**Step 2: Run tests**
|
|
1149
|
+
|
|
1150
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/formatter_test.rb`
|
|
1151
|
+
Expected: All tests PASS
|
|
1152
|
+
|
|
1153
|
+
**Step 3: Commit**
|
|
1154
|
+
|
|
1155
|
+
```bash
|
|
1156
|
+
git add -A
|
|
1157
|
+
git commit -m "Add formatter tests for date and time formatting via I18n.l"
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
---
|
|
1161
|
+
|
|
1162
|
+
### Task 9: LRU Cache
|
|
1163
|
+
|
|
1164
|
+
**Files:**
|
|
1165
|
+
- Create: `lib/i18n/message_format/cache.rb`
|
|
1166
|
+
- Create: `test/i18n/message_format/cache_test.rb`
|
|
1167
|
+
- Modify: `lib/i18n/message_format.rb`
|
|
1168
|
+
|
|
1169
|
+
**Step 1: Write the failing tests**
|
|
1170
|
+
|
|
1171
|
+
```ruby
|
|
1172
|
+
# test/i18n/message_format/cache_test.rb
|
|
1173
|
+
# frozen_string_literal: true
|
|
1174
|
+
|
|
1175
|
+
require "test_helper"
|
|
1176
|
+
|
|
1177
|
+
module I18n
|
|
1178
|
+
module MessageFormat
|
|
1179
|
+
class CacheTest < Minitest::Test
|
|
1180
|
+
def test_stores_and_retrieves
|
|
1181
|
+
cache = Cache.new(max_size: 10)
|
|
1182
|
+
cache.set("key", "value")
|
|
1183
|
+
assert_equal "value", cache.get("key")
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
def test_returns_nil_for_missing_key
|
|
1187
|
+
cache = Cache.new(max_size: 10)
|
|
1188
|
+
assert_nil cache.get("missing")
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
def test_evicts_least_recently_used
|
|
1192
|
+
cache = Cache.new(max_size: 2)
|
|
1193
|
+
cache.set("a", 1)
|
|
1194
|
+
cache.set("b", 2)
|
|
1195
|
+
cache.set("c", 3) # should evict "a"
|
|
1196
|
+
assert_nil cache.get("a")
|
|
1197
|
+
assert_equal 2, cache.get("b")
|
|
1198
|
+
assert_equal 3, cache.get("c")
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
def test_get_refreshes_entry
|
|
1202
|
+
cache = Cache.new(max_size: 2)
|
|
1203
|
+
cache.set("a", 1)
|
|
1204
|
+
cache.set("b", 2)
|
|
1205
|
+
cache.get("a") # refresh "a", so "b" is now LRU
|
|
1206
|
+
cache.set("c", 3) # should evict "b"
|
|
1207
|
+
assert_equal 1, cache.get("a")
|
|
1208
|
+
assert_nil cache.get("b")
|
|
1209
|
+
assert_equal 3, cache.get("c")
|
|
1210
|
+
end
|
|
1211
|
+
|
|
1212
|
+
def test_fetch_with_block
|
|
1213
|
+
cache = Cache.new(max_size: 10)
|
|
1214
|
+
result = cache.fetch("key") { "computed" }
|
|
1215
|
+
assert_equal "computed", result
|
|
1216
|
+
assert_equal "computed", cache.get("key")
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
def test_fetch_returns_cached_value
|
|
1220
|
+
cache = Cache.new(max_size: 10)
|
|
1221
|
+
cache.set("key", "original")
|
|
1222
|
+
result = cache.fetch("key") { "new" }
|
|
1223
|
+
assert_equal "original", result
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
def test_clear
|
|
1227
|
+
cache = Cache.new(max_size: 10)
|
|
1228
|
+
cache.set("key", "value")
|
|
1229
|
+
cache.clear
|
|
1230
|
+
assert_nil cache.get("key")
|
|
1231
|
+
end
|
|
1232
|
+
end
|
|
1233
|
+
end
|
|
1234
|
+
end
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
**Step 2: Run test to verify it fails**
|
|
1238
|
+
|
|
1239
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/cache_test.rb`
|
|
1240
|
+
Expected: FAIL — `Cache` not defined
|
|
1241
|
+
|
|
1242
|
+
**Step 3: Write minimal implementation**
|
|
1243
|
+
|
|
1244
|
+
```ruby
|
|
1245
|
+
# lib/i18n/message_format/cache.rb
|
|
1246
|
+
# frozen_string_literal: true
|
|
1247
|
+
|
|
1248
|
+
module I18n
|
|
1249
|
+
module MessageFormat
|
|
1250
|
+
class Cache
|
|
1251
|
+
def initialize(max_size: 1000)
|
|
1252
|
+
@max_size = max_size
|
|
1253
|
+
@data = {}
|
|
1254
|
+
@mutex = Mutex.new
|
|
1255
|
+
end
|
|
1256
|
+
|
|
1257
|
+
def get(key)
|
|
1258
|
+
@mutex.synchronize do
|
|
1259
|
+
return nil unless @data.key?(key)
|
|
1260
|
+
|
|
1261
|
+
# Move to end (most recently used)
|
|
1262
|
+
value = @data.delete(key)
|
|
1263
|
+
@data[key] = value
|
|
1264
|
+
value
|
|
1265
|
+
end
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
def set(key, value)
|
|
1269
|
+
@mutex.synchronize do
|
|
1270
|
+
@data.delete(key) if @data.key?(key)
|
|
1271
|
+
@data[key] = value
|
|
1272
|
+
evict if @data.size > @max_size
|
|
1273
|
+
end
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
def fetch(key)
|
|
1277
|
+
value = get(key)
|
|
1278
|
+
return value unless value.nil? && !@mutex.synchronize { @data.key?(key) }
|
|
1279
|
+
|
|
1280
|
+
value = yield
|
|
1281
|
+
set(key, value)
|
|
1282
|
+
value
|
|
1283
|
+
end
|
|
1284
|
+
|
|
1285
|
+
def clear
|
|
1286
|
+
@mutex.synchronize { @data.clear }
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
private
|
|
1290
|
+
|
|
1291
|
+
def evict
|
|
1292
|
+
@data.delete(@data.keys.first)
|
|
1293
|
+
end
|
|
1294
|
+
end
|
|
1295
|
+
end
|
|
1296
|
+
end
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
**Step 4: Update the main require file**
|
|
1300
|
+
|
|
1301
|
+
Add `require_relative "message_format/cache"` to `lib/i18n/message_format.rb`.
|
|
1302
|
+
|
|
1303
|
+
**Step 5: Run test to verify it passes**
|
|
1304
|
+
|
|
1305
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/cache_test.rb`
|
|
1306
|
+
Expected: All tests PASS
|
|
1307
|
+
|
|
1308
|
+
**Step 6: Commit**
|
|
1309
|
+
|
|
1310
|
+
```bash
|
|
1311
|
+
git add -A
|
|
1312
|
+
git commit -m "Add thread-safe LRU cache"
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
---
|
|
1316
|
+
|
|
1317
|
+
### Task 10: Public API — `I18n::MessageFormat.format`
|
|
1318
|
+
|
|
1319
|
+
**Files:**
|
|
1320
|
+
- Modify: `lib/i18n/message_format.rb`
|
|
1321
|
+
- Create: `test/i18n/message_format/format_test.rb`
|
|
1322
|
+
|
|
1323
|
+
**Step 1: Write the failing tests**
|
|
1324
|
+
|
|
1325
|
+
```ruby
|
|
1326
|
+
# test/i18n/message_format/format_test.rb
|
|
1327
|
+
# frozen_string_literal: true
|
|
1328
|
+
|
|
1329
|
+
require "test_helper"
|
|
1330
|
+
|
|
1331
|
+
module I18n
|
|
1332
|
+
module MessageFormat
|
|
1333
|
+
class FormatTest < Minitest::Test
|
|
1334
|
+
def test_simple_format
|
|
1335
|
+
result = I18n::MessageFormat.format("Hello {name}!", name: "World")
|
|
1336
|
+
assert_equal "Hello World!", result
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
def test_plural_format
|
|
1340
|
+
result = I18n::MessageFormat.format(
|
|
1341
|
+
"{count, plural, one {# item} other {# items}}",
|
|
1342
|
+
count: 3
|
|
1343
|
+
)
|
|
1344
|
+
assert_equal "3 items", result
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1347
|
+
def test_caches_parsed_patterns
|
|
1348
|
+
pattern = "Hello {name}!"
|
|
1349
|
+
I18n::MessageFormat.format(pattern, name: "A")
|
|
1350
|
+
I18n::MessageFormat.format(pattern, name: "B")
|
|
1351
|
+
# No assertion on internals — just verify it works with caching
|
|
1352
|
+
assert_equal "Hello C!", I18n::MessageFormat.format(pattern, name: "C")
|
|
1353
|
+
end
|
|
1354
|
+
|
|
1355
|
+
def test_format_with_locale
|
|
1356
|
+
result = I18n::MessageFormat.format("Hello {name}!", { name: "World" }, locale: :en)
|
|
1357
|
+
assert_equal "Hello World!", result
|
|
1358
|
+
end
|
|
1359
|
+
end
|
|
1360
|
+
end
|
|
1361
|
+
end
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
**Step 2: Run test to verify it fails**
|
|
1365
|
+
|
|
1366
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/format_test.rb`
|
|
1367
|
+
Expected: FAIL — `format` method not defined
|
|
1368
|
+
|
|
1369
|
+
**Step 3: Write minimal implementation**
|
|
1370
|
+
|
|
1371
|
+
Update `lib/i18n/message_format.rb`:
|
|
1372
|
+
|
|
1373
|
+
```ruby
|
|
1374
|
+
# frozen_string_literal: true
|
|
1375
|
+
|
|
1376
|
+
require "i18n"
|
|
1377
|
+
require_relative "message_format/version"
|
|
1378
|
+
require_relative "message_format/nodes"
|
|
1379
|
+
require_relative "message_format/parser"
|
|
1380
|
+
require_relative "message_format/formatter"
|
|
1381
|
+
require_relative "message_format/cache"
|
|
1382
|
+
|
|
1383
|
+
module I18n
|
|
1384
|
+
module MessageFormat
|
|
1385
|
+
class Error < StandardError; end
|
|
1386
|
+
|
|
1387
|
+
@cache = Cache.new
|
|
1388
|
+
|
|
1389
|
+
class << self
|
|
1390
|
+
def format(pattern, arguments = {}, locale: ::I18n.locale)
|
|
1391
|
+
nodes = @cache.fetch(pattern) do
|
|
1392
|
+
Parser.new(pattern).parse
|
|
1393
|
+
end
|
|
1394
|
+
Formatter.new(nodes, arguments, locale).format
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
def clear_cache!
|
|
1398
|
+
@cache.clear
|
|
1399
|
+
end
|
|
1400
|
+
end
|
|
1401
|
+
end
|
|
1402
|
+
end
|
|
1403
|
+
```
|
|
1404
|
+
|
|
1405
|
+
**Step 4: Run test to verify it passes**
|
|
1406
|
+
|
|
1407
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/format_test.rb`
|
|
1408
|
+
Expected: All tests PASS
|
|
1409
|
+
|
|
1410
|
+
**Step 5: Commit**
|
|
1411
|
+
|
|
1412
|
+
```bash
|
|
1413
|
+
git add -A
|
|
1414
|
+
git commit -m "Add public API: I18n::MessageFormat.format with caching"
|
|
1415
|
+
```
|
|
1416
|
+
|
|
1417
|
+
---
|
|
1418
|
+
|
|
1419
|
+
### Task 11: I18n Backend
|
|
1420
|
+
|
|
1421
|
+
**Files:**
|
|
1422
|
+
- Create: `lib/i18n/message_format/backend.rb`
|
|
1423
|
+
- Create: `test/i18n/message_format/backend_test.rb`
|
|
1424
|
+
- Create: `test/fixtures/mf/en.yml`
|
|
1425
|
+
- Modify: `lib/i18n/message_format.rb`
|
|
1426
|
+
|
|
1427
|
+
**Step 1: Create the test fixture**
|
|
1428
|
+
|
|
1429
|
+
```yaml
|
|
1430
|
+
# test/fixtures/mf/en.yml
|
|
1431
|
+
en:
|
|
1432
|
+
greeting: "Hello {name}!"
|
|
1433
|
+
items: "{count, plural, one {# item} other {# items}}"
|
|
1434
|
+
welcome: "{gender, select, male {Welcome Mr. {name}} female {Welcome Ms. {name}} other {Welcome {name}}}"
|
|
1435
|
+
```
|
|
1436
|
+
|
|
1437
|
+
**Step 2: Write the failing tests**
|
|
1438
|
+
|
|
1439
|
+
```ruby
|
|
1440
|
+
# test/i18n/message_format/backend_test.rb
|
|
1441
|
+
# frozen_string_literal: true
|
|
1442
|
+
|
|
1443
|
+
require "test_helper"
|
|
1444
|
+
|
|
1445
|
+
module I18n
|
|
1446
|
+
module MessageFormat
|
|
1447
|
+
class BackendTest < Minitest::Test
|
|
1448
|
+
def setup
|
|
1449
|
+
@backend = Backend.new(File.expand_path("../../fixtures/mf/*.yml", __FILE__))
|
|
1450
|
+
@backend.load_translations
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
def test_translate_simple
|
|
1454
|
+
result = @backend.translate(:en, "greeting", name: "Alice")
|
|
1455
|
+
assert_equal "Hello Alice!", result
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
def test_translate_plural
|
|
1459
|
+
result = @backend.translate(:en, "items", count: 1)
|
|
1460
|
+
assert_equal "1 item", result
|
|
1461
|
+
end
|
|
1462
|
+
|
|
1463
|
+
def test_translate_plural_other
|
|
1464
|
+
result = @backend.translate(:en, "items", count: 5)
|
|
1465
|
+
assert_equal "5 items", result
|
|
1466
|
+
end
|
|
1467
|
+
|
|
1468
|
+
def test_translate_select
|
|
1469
|
+
result = @backend.translate(:en, "welcome", gender: "female", name: "Alice")
|
|
1470
|
+
assert_equal "Welcome Ms. Alice", result
|
|
1471
|
+
end
|
|
1472
|
+
|
|
1473
|
+
def test_missing_key_returns_nil
|
|
1474
|
+
result = @backend.translate(:en, "nonexistent")
|
|
1475
|
+
assert_nil result
|
|
1476
|
+
end
|
|
1477
|
+
|
|
1478
|
+
def test_chain_integration
|
|
1479
|
+
simple = ::I18n::Backend::Simple.new
|
|
1480
|
+
simple.store_translations(:en, { fallback: "from simple" })
|
|
1481
|
+
chain = ::I18n::Backend::Chain.new(@backend, simple)
|
|
1482
|
+
|
|
1483
|
+
# Found in MF backend
|
|
1484
|
+
result = chain.translate(:en, "greeting", name: "Alice")
|
|
1485
|
+
assert_equal "Hello Alice!", result
|
|
1486
|
+
|
|
1487
|
+
# Falls through to Simple backend
|
|
1488
|
+
result = chain.translate(:en, "fallback")
|
|
1489
|
+
assert_equal "from simple", result
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
def test_available_locales
|
|
1493
|
+
assert_includes @backend.available_locales, :en
|
|
1494
|
+
end
|
|
1495
|
+
end
|
|
1496
|
+
end
|
|
1497
|
+
end
|
|
1498
|
+
```
|
|
1499
|
+
|
|
1500
|
+
**Step 3: Run test to verify it fails**
|
|
1501
|
+
|
|
1502
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/backend_test.rb`
|
|
1503
|
+
Expected: FAIL — `Backend` not defined
|
|
1504
|
+
|
|
1505
|
+
**Step 4: Write minimal implementation**
|
|
1506
|
+
|
|
1507
|
+
```ruby
|
|
1508
|
+
# lib/i18n/message_format/backend.rb
|
|
1509
|
+
# frozen_string_literal: true
|
|
1510
|
+
|
|
1511
|
+
require "yaml"
|
|
1512
|
+
|
|
1513
|
+
module I18n
|
|
1514
|
+
module MessageFormat
|
|
1515
|
+
class Backend
|
|
1516
|
+
include ::I18n::Backend::Base
|
|
1517
|
+
|
|
1518
|
+
def initialize(*glob_patterns)
|
|
1519
|
+
@glob_patterns = glob_patterns
|
|
1520
|
+
@translations = {}
|
|
1521
|
+
@cache = Cache.new
|
|
1522
|
+
end
|
|
1523
|
+
|
|
1524
|
+
def load_translations
|
|
1525
|
+
@glob_patterns.each do |pattern|
|
|
1526
|
+
Dir.glob(pattern).each do |file|
|
|
1527
|
+
data = YAML.safe_load_file(file, permitted_classes: [Symbol])
|
|
1528
|
+
data.each do |locale, translations|
|
|
1529
|
+
store_translations(locale.to_sym, translations)
|
|
1530
|
+
end
|
|
1531
|
+
end
|
|
1532
|
+
end
|
|
1533
|
+
end
|
|
1534
|
+
|
|
1535
|
+
def store_translations(locale, data, options = {})
|
|
1536
|
+
@translations[locale] ||= {}
|
|
1537
|
+
deep_merge!(@translations[locale], flatten_hash(data))
|
|
1538
|
+
end
|
|
1539
|
+
|
|
1540
|
+
def translate(locale, key, options = {})
|
|
1541
|
+
pattern = lookup(locale, key)
|
|
1542
|
+
return nil if pattern.nil?
|
|
1543
|
+
return pattern unless pattern.is_a?(String)
|
|
1544
|
+
|
|
1545
|
+
arguments = options.reject { |k, _| [:scope, :default, :separator].include?(k) }
|
|
1546
|
+
nodes = @cache.fetch(pattern) { Parser.new(pattern).parse }
|
|
1547
|
+
Formatter.new(nodes, arguments, locale).format
|
|
1548
|
+
end
|
|
1549
|
+
|
|
1550
|
+
def available_locales
|
|
1551
|
+
@translations.keys
|
|
1552
|
+
end
|
|
1553
|
+
|
|
1554
|
+
def initialized?
|
|
1555
|
+
!@translations.empty?
|
|
1556
|
+
end
|
|
1557
|
+
|
|
1558
|
+
protected
|
|
1559
|
+
|
|
1560
|
+
def lookup(locale, key, scope = [], options = {})
|
|
1561
|
+
keys = ::I18n.normalize_keys(locale, key, scope, options[:separator])
|
|
1562
|
+
keys.shift # remove locale
|
|
1563
|
+
|
|
1564
|
+
result = @translations[locale]
|
|
1565
|
+
return nil unless result
|
|
1566
|
+
|
|
1567
|
+
keys.each do |k|
|
|
1568
|
+
return nil unless result.is_a?(Hash)
|
|
1569
|
+
result = result[k] || result[k.to_s]
|
|
1570
|
+
return nil if result.nil?
|
|
1571
|
+
end
|
|
1572
|
+
|
|
1573
|
+
result
|
|
1574
|
+
end
|
|
1575
|
+
|
|
1576
|
+
private
|
|
1577
|
+
|
|
1578
|
+
def flatten_hash(hash, prefix = nil)
|
|
1579
|
+
result = {}
|
|
1580
|
+
hash.each do |key, value|
|
|
1581
|
+
full_key = prefix ? :"#{prefix}.#{key}" : key.to_sym
|
|
1582
|
+
if value.is_a?(Hash)
|
|
1583
|
+
result.merge!(flatten_hash(value, full_key))
|
|
1584
|
+
else
|
|
1585
|
+
result[full_key] = value
|
|
1586
|
+
end
|
|
1587
|
+
end
|
|
1588
|
+
result
|
|
1589
|
+
end
|
|
1590
|
+
|
|
1591
|
+
def deep_merge!(base, override)
|
|
1592
|
+
override.each do |key, value|
|
|
1593
|
+
base[key] = value
|
|
1594
|
+
end
|
|
1595
|
+
base
|
|
1596
|
+
end
|
|
1597
|
+
end
|
|
1598
|
+
end
|
|
1599
|
+
end
|
|
1600
|
+
```
|
|
1601
|
+
|
|
1602
|
+
**Step 5: Update the main require file**
|
|
1603
|
+
|
|
1604
|
+
Add `require_relative "message_format/backend"` to `lib/i18n/message_format.rb`.
|
|
1605
|
+
|
|
1606
|
+
**Step 6: Run test to verify it passes**
|
|
1607
|
+
|
|
1608
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/backend_test.rb`
|
|
1609
|
+
Expected: All tests PASS
|
|
1610
|
+
|
|
1611
|
+
**Step 7: Commit**
|
|
1612
|
+
|
|
1613
|
+
```bash
|
|
1614
|
+
git add -A
|
|
1615
|
+
git commit -m "Add I18n backend with Chain support"
|
|
1616
|
+
```
|
|
1617
|
+
|
|
1618
|
+
---
|
|
1619
|
+
|
|
1620
|
+
### Task 12: Ordinal Plural Rules
|
|
1621
|
+
|
|
1622
|
+
**Files:**
|
|
1623
|
+
- Create: `lib/i18n/message_format/ordinal_rules.rb`
|
|
1624
|
+
- Create: `test/i18n/message_format/ordinal_rules_test.rb`
|
|
1625
|
+
- Modify: `lib/i18n/message_format.rb`
|
|
1626
|
+
|
|
1627
|
+
**Step 1: Write the failing tests**
|
|
1628
|
+
|
|
1629
|
+
```ruby
|
|
1630
|
+
# test/i18n/message_format/ordinal_rules_test.rb
|
|
1631
|
+
# frozen_string_literal: true
|
|
1632
|
+
|
|
1633
|
+
require "test_helper"
|
|
1634
|
+
|
|
1635
|
+
module I18n
|
|
1636
|
+
module MessageFormat
|
|
1637
|
+
class OrdinalRulesTest < Minitest::Test
|
|
1638
|
+
def setup
|
|
1639
|
+
::I18n.backend = ::I18n::Backend::Simple.new
|
|
1640
|
+
OrdinalRules.install(:en)
|
|
1641
|
+
end
|
|
1642
|
+
|
|
1643
|
+
def test_english_ordinal_1st
|
|
1644
|
+
rule = ::I18n.t(:"i18n.ordinal.rule", locale: :en, resolve: false)
|
|
1645
|
+
assert_equal :one, rule.call(1)
|
|
1646
|
+
end
|
|
1647
|
+
|
|
1648
|
+
def test_english_ordinal_2nd
|
|
1649
|
+
rule = ::I18n.t(:"i18n.ordinal.rule", locale: :en, resolve: false)
|
|
1650
|
+
assert_equal :two, rule.call(2)
|
|
1651
|
+
end
|
|
1652
|
+
|
|
1653
|
+
def test_english_ordinal_3rd
|
|
1654
|
+
rule = ::I18n.t(:"i18n.ordinal.rule", locale: :en, resolve: false)
|
|
1655
|
+
assert_equal :few, rule.call(3)
|
|
1656
|
+
end
|
|
1657
|
+
|
|
1658
|
+
def test_english_ordinal_4th
|
|
1659
|
+
rule = ::I18n.t(:"i18n.ordinal.rule", locale: :en, resolve: false)
|
|
1660
|
+
assert_equal :other, rule.call(4)
|
|
1661
|
+
end
|
|
1662
|
+
|
|
1663
|
+
def test_english_ordinal_11th
|
|
1664
|
+
rule = ::I18n.t(:"i18n.ordinal.rule", locale: :en, resolve: false)
|
|
1665
|
+
assert_equal :other, rule.call(11)
|
|
1666
|
+
end
|
|
1667
|
+
|
|
1668
|
+
def test_english_ordinal_21st
|
|
1669
|
+
rule = ::I18n.t(:"i18n.ordinal.rule", locale: :en, resolve: false)
|
|
1670
|
+
assert_equal :one, rule.call(21)
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
def test_selectordinal_integration
|
|
1674
|
+
OrdinalRules.install(:en)
|
|
1675
|
+
result = I18n::MessageFormat.format(
|
|
1676
|
+
"{pos, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
|
|
1677
|
+
pos: 3,
|
|
1678
|
+
locale: :en
|
|
1679
|
+
)
|
|
1680
|
+
assert_equal "3rd", result
|
|
1681
|
+
end
|
|
1682
|
+
end
|
|
1683
|
+
end
|
|
1684
|
+
end
|
|
1685
|
+
```
|
|
1686
|
+
|
|
1687
|
+
**Step 2: Run test to verify it fails**
|
|
1688
|
+
|
|
1689
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/ordinal_rules_test.rb`
|
|
1690
|
+
Expected: FAIL — `OrdinalRules` not defined
|
|
1691
|
+
|
|
1692
|
+
**Step 3: Write minimal implementation**
|
|
1693
|
+
|
|
1694
|
+
```ruby
|
|
1695
|
+
# lib/i18n/message_format/ordinal_rules.rb
|
|
1696
|
+
# frozen_string_literal: true
|
|
1697
|
+
|
|
1698
|
+
module I18n
|
|
1699
|
+
module MessageFormat
|
|
1700
|
+
module OrdinalRules
|
|
1701
|
+
RULES = {
|
|
1702
|
+
en: lambda { |n|
|
|
1703
|
+
mod10 = n % 10
|
|
1704
|
+
mod100 = n % 100
|
|
1705
|
+
if mod10 == 1 && mod100 != 11
|
|
1706
|
+
:one
|
|
1707
|
+
elsif mod10 == 2 && mod100 != 12
|
|
1708
|
+
:two
|
|
1709
|
+
elsif mod10 == 3 && mod100 != 13
|
|
1710
|
+
:few
|
|
1711
|
+
else
|
|
1712
|
+
:other
|
|
1713
|
+
end
|
|
1714
|
+
}
|
|
1715
|
+
}.freeze
|
|
1716
|
+
|
|
1717
|
+
def self.install(locale)
|
|
1718
|
+
rule = RULES[locale.to_sym]
|
|
1719
|
+
return unless rule
|
|
1720
|
+
|
|
1721
|
+
::I18n.backend.store_translations(locale, { i18n: { ordinal: { rule: rule } } })
|
|
1722
|
+
end
|
|
1723
|
+
|
|
1724
|
+
def self.install_all
|
|
1725
|
+
RULES.each_key { |locale| install(locale) }
|
|
1726
|
+
end
|
|
1727
|
+
end
|
|
1728
|
+
end
|
|
1729
|
+
end
|
|
1730
|
+
```
|
|
1731
|
+
|
|
1732
|
+
**Step 4: Update the main require file**
|
|
1733
|
+
|
|
1734
|
+
Add `require_relative "message_format/ordinal_rules"` to `lib/i18n/message_format.rb`.
|
|
1735
|
+
|
|
1736
|
+
**Step 5: Run test to verify it passes**
|
|
1737
|
+
|
|
1738
|
+
Run: `bundle exec ruby -Itest -Ilib test/i18n/message_format/ordinal_rules_test.rb`
|
|
1739
|
+
Expected: All tests PASS
|
|
1740
|
+
|
|
1741
|
+
**Step 6: Commit**
|
|
1742
|
+
|
|
1743
|
+
```bash
|
|
1744
|
+
git add -A
|
|
1745
|
+
git commit -m "Add ordinal plural rules with English CLDR data"
|
|
1746
|
+
```
|
|
1747
|
+
|
|
1748
|
+
---
|
|
1749
|
+
|
|
1750
|
+
### Task 13: End-to-End Integration Tests
|
|
1751
|
+
|
|
1752
|
+
**Files:**
|
|
1753
|
+
- Create: `test/i18n/message_format/integration_test.rb`
|
|
1754
|
+
- Create: `test/fixtures/mf/fr.yml`
|
|
1755
|
+
|
|
1756
|
+
**Step 1: Create French fixture**
|
|
1757
|
+
|
|
1758
|
+
```yaml
|
|
1759
|
+
# test/fixtures/mf/fr.yml
|
|
1760
|
+
fr:
|
|
1761
|
+
greeting: "Bonjour {name} !"
|
|
1762
|
+
items: "{count, plural, one {# article} other {# articles}}"
|
|
1763
|
+
```
|
|
1764
|
+
|
|
1765
|
+
**Step 2: Write integration tests**
|
|
1766
|
+
|
|
1767
|
+
```ruby
|
|
1768
|
+
# test/i18n/message_format/integration_test.rb
|
|
1769
|
+
# frozen_string_literal: true
|
|
1770
|
+
|
|
1771
|
+
require "test_helper"
|
|
1772
|
+
|
|
1773
|
+
module I18n
|
|
1774
|
+
module MessageFormat
|
|
1775
|
+
class IntegrationTest < Minitest::Test
|
|
1776
|
+
def setup
|
|
1777
|
+
@simple = ::I18n::Backend::Simple.new
|
|
1778
|
+
@mf = Backend.new(File.expand_path("../../fixtures/mf/*.yml", __FILE__))
|
|
1779
|
+
::I18n.backend = ::I18n::Backend::Chain.new(@mf, @simple)
|
|
1780
|
+
::I18n.backend.load_translations
|
|
1781
|
+
|
|
1782
|
+
@simple.store_translations(:en, { simple_key: "I am simple" })
|
|
1783
|
+
@simple.store_translations(:en, {
|
|
1784
|
+
date: { formats: { short: "%b %d", default: "%Y-%m-%d" } },
|
|
1785
|
+
time: { formats: { short: "%H:%M", default: "%Y-%m-%d %H:%M:%S" } }
|
|
1786
|
+
})
|
|
1787
|
+
|
|
1788
|
+
# Install French plural rule
|
|
1789
|
+
@simple.store_translations(:fr, {
|
|
1790
|
+
i18n: {
|
|
1791
|
+
plural: {
|
|
1792
|
+
rule: lambda { |n| n >= 0 && n < 2 ? :one : :other }
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
})
|
|
1796
|
+
end
|
|
1797
|
+
|
|
1798
|
+
def test_mf_key_resolved
|
|
1799
|
+
assert_equal "Hello Alice!", ::I18n.t("greeting", name: "Alice")
|
|
1800
|
+
end
|
|
1801
|
+
|
|
1802
|
+
def test_simple_key_falls_through
|
|
1803
|
+
assert_equal "I am simple", ::I18n.t("simple_key")
|
|
1804
|
+
end
|
|
1805
|
+
|
|
1806
|
+
def test_plural_english
|
|
1807
|
+
assert_equal "1 item", ::I18n.t("items", count: 1)
|
|
1808
|
+
assert_equal "5 items", ::I18n.t("items", count: 5)
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
def test_plural_french
|
|
1812
|
+
assert_equal "1 article", ::I18n.t("items", count: 1, locale: :fr)
|
|
1813
|
+
assert_equal "5 articles", ::I18n.t("items", count: 5, locale: :fr)
|
|
1814
|
+
end
|
|
1815
|
+
|
|
1816
|
+
def test_complex_nested_message
|
|
1817
|
+
pattern = "{gender, select, male {{count, plural, one {He has # item} other {He has # items}}} female {{count, plural, one {She has # item} other {She has # items}}} other {{count, plural, one {They have # item} other {They have # items}}}}"
|
|
1818
|
+
result = I18n::MessageFormat.format(pattern, gender: "female", count: 3)
|
|
1819
|
+
assert_equal "She has 3 items", result
|
|
1820
|
+
end
|
|
1821
|
+
|
|
1822
|
+
def test_date_in_message
|
|
1823
|
+
pattern = "Updated on {d, date, short}"
|
|
1824
|
+
result = I18n::MessageFormat.format(pattern, d: Date.new(2026, 3, 15))
|
|
1825
|
+
assert_equal "Updated on Mar 15", result
|
|
1826
|
+
end
|
|
1827
|
+
|
|
1828
|
+
def test_escaped_braces
|
|
1829
|
+
result = I18n::MessageFormat.format("Use '{ and '} for braces")
|
|
1830
|
+
assert_equal "Use { and } for braces", result
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
def test_escaped_single_quote
|
|
1834
|
+
result = I18n::MessageFormat.format("it''s {name}''s", name: "Alice")
|
|
1835
|
+
assert_equal "it's Alice's", result
|
|
1836
|
+
end
|
|
1837
|
+
end
|
|
1838
|
+
end
|
|
1839
|
+
end
|
|
1840
|
+
```
|
|
1841
|
+
|
|
1842
|
+
**Step 3: Run all tests**
|
|
1843
|
+
|
|
1844
|
+
Run: `bundle exec rake test`
|
|
1845
|
+
Expected: All tests PASS
|
|
1846
|
+
|
|
1847
|
+
**Step 4: Commit**
|
|
1848
|
+
|
|
1849
|
+
```bash
|
|
1850
|
+
git add -A
|
|
1851
|
+
git commit -m "Add end-to-end integration tests"
|
|
1852
|
+
```
|
|
1853
|
+
|
|
1854
|
+
---
|
|
1855
|
+
|
|
1856
|
+
### Task 14: Final Cleanup — Error Classes, README, Version
|
|
1857
|
+
|
|
1858
|
+
**Files:**
|
|
1859
|
+
- Modify: `lib/i18n/message_format.rb` (ensure error classes are properly defined)
|
|
1860
|
+
- Modify: `README.md`
|
|
1861
|
+
|
|
1862
|
+
**Step 1: Update README with real documentation**
|
|
1863
|
+
|
|
1864
|
+
```markdown
|
|
1865
|
+
# I18n::MessageFormat
|
|
1866
|
+
|
|
1867
|
+
ICU Message Format support for the Ruby [i18n](https://github.com/ruby-i18n/i18n) gem. Pure Ruby parser, no native dependencies.
|
|
1868
|
+
|
|
1869
|
+
## Installation
|
|
1870
|
+
|
|
1871
|
+
```bash
|
|
1872
|
+
bundle add i18n-message_format
|
|
1873
|
+
```
|
|
1874
|
+
|
|
1875
|
+
## Usage
|
|
1876
|
+
|
|
1877
|
+
### Standalone
|
|
1878
|
+
|
|
1879
|
+
```ruby
|
|
1880
|
+
require "i18n/message_format"
|
|
1881
|
+
|
|
1882
|
+
I18n::MessageFormat.format(
|
|
1883
|
+
"{name} has {count, plural, one {# item} other {# items}}",
|
|
1884
|
+
name: "Alice", count: 3
|
|
1885
|
+
)
|
|
1886
|
+
# => "Alice has 3 items"
|
|
1887
|
+
```
|
|
1888
|
+
|
|
1889
|
+
### With I18n Backend
|
|
1890
|
+
|
|
1891
|
+
Store your Message Format strings in separate YAML files:
|
|
1892
|
+
|
|
1893
|
+
```yaml
|
|
1894
|
+
# config/locales/mf/en.yml
|
|
1895
|
+
en:
|
|
1896
|
+
greeting: "Hello {name}!"
|
|
1897
|
+
items: "{count, plural, one {# item} other {# items}}"
|
|
1898
|
+
```
|
|
1899
|
+
|
|
1900
|
+
Configure the backend:
|
|
1901
|
+
|
|
1902
|
+
```ruby
|
|
1903
|
+
I18n.backend = I18n::Backend::Chain.new(
|
|
1904
|
+
I18n::MessageFormat::Backend.new("config/locales/mf/*.yml"),
|
|
1905
|
+
I18n::Backend::Simple.new
|
|
1906
|
+
)
|
|
1907
|
+
|
|
1908
|
+
I18n.t("greeting", name: "Alice")
|
|
1909
|
+
# => "Hello Alice!"
|
|
1910
|
+
```
|
|
1911
|
+
|
|
1912
|
+
### Supported Syntax
|
|
1913
|
+
|
|
1914
|
+
- Simple arguments: `{name}`
|
|
1915
|
+
- Number format: `{count, number}`
|
|
1916
|
+
- Date format: `{d, date, short}`
|
|
1917
|
+
- Time format: `{t, time, short}`
|
|
1918
|
+
- Plural: `{count, plural, one {# item} other {# items}}`
|
|
1919
|
+
- Select: `{gender, select, male {He} female {She} other {They}}`
|
|
1920
|
+
- Selectordinal: `{pos, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}`
|
|
1921
|
+
- Nested messages
|
|
1922
|
+
- Escaped braces: `'{ '} ''`
|
|
1923
|
+
|
|
1924
|
+
### Ordinal Rules
|
|
1925
|
+
|
|
1926
|
+
Install built-in ordinal rules for selectordinal support:
|
|
1927
|
+
|
|
1928
|
+
```ruby
|
|
1929
|
+
I18n::MessageFormat::OrdinalRules.install(:en)
|
|
1930
|
+
```
|
|
1931
|
+
|
|
1932
|
+
## License
|
|
1933
|
+
|
|
1934
|
+
MIT
|
|
1935
|
+
```
|
|
1936
|
+
|
|
1937
|
+
**Step 2: Run all tests one final time**
|
|
1938
|
+
|
|
1939
|
+
Run: `bundle exec rake test`
|
|
1940
|
+
Expected: All tests PASS
|
|
1941
|
+
|
|
1942
|
+
**Step 3: Commit**
|
|
1943
|
+
|
|
1944
|
+
```bash
|
|
1945
|
+
git add -A
|
|
1946
|
+
git commit -m "Update README with usage documentation"
|
|
1947
|
+
```
|