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.
@@ -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
+ ```