From f2f23dd8b403eb622b375a6f55103bb09df95335 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Fri, 17 Oct 2025 23:18:26 -0700 Subject: [PATCH 01/18] Sync CLDR and add ordinals.xml + README --- .../data/README.md | 3 + .../data/ordinals.xml | 176 ++++++++++++++++++ .../data/plurals.xml | 15 +- 3 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md create mode 100644 src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md new file mode 100644 index 0000000..d934d39 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md @@ -0,0 +1,3 @@ +CLDR supplemental data files obtained from https://cldr.unicode.org/downloads/cldr-47 + +CLDR v47 was released 2025-03-13; refer to https://cldr.unicode.org/index/downloads \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml new file mode 100644 index 0000000..a6636b8 --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml @@ -0,0 +1,176 @@ + + + + + + + + + + + + @integer 0~15, 100, 1000, 10000, 100000, 1000000, … + + + + + + n % 10 = 1,2 and n % 100 != 11,12 @integer 1, 2, 21, 22, 31, 32, 41, 42, 51, 52, 61, 62, 71, 72, 81, 82, 101, 1001, … + @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … + + + n = 1 @integer 1 + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … + + + n = 1,5 @integer 1, 5 + @integer 0, 2~4, 6~17, 100, 1000, 10000, 100000, 1000000, … + + + n = 1..4 @integer 1~4 + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + + + + + + n % 10 = 2,3 and n % 100 != 12,13 @integer 2, 3, 22, 23, 32, 33, 42, 43, 52, 53, 62, 63, 72, 73, 82, 83, 102, 1002, … + @integer 0, 1, 4~17, 100, 1000, 10000, 100000, 1000000, … + + + n % 10 = 3 and n % 100 != 13 @integer 3, 23, 33, 43, 53, 63, 73, 83, 103, 1003, … + @integer 0~2, 4~16, 100, 1000, 10000, 100000, 1000000, … + + + n % 10 = 6,9 or n = 10 @integer 6, 9, 10, 16, 19, 26, 29, 36, 39, 106, 1006, … + @integer 0~5, 7, 8, 11~15, 17, 18, 20, 100, 1000, 10000, 100000, 1000000, … + + + + + + n % 10 = 6 or n % 10 = 9 or n % 10 = 0 and n != 0 @integer 6, 9, 10, 16, 19, 20, 26, 29, 30, 36, 39, 40, 100, 1000, 10000, 100000, 1000000, … + @integer 0~5, 7, 8, 11~15, 17, 18, 21, 101, 1001, … + + + n = 11,8,80,800 @integer 8, 11, 80, 800 + @integer 0~7, 9, 10, 12~17, 100, 1000, 10000, 100000, 1000000, … + + + n = 11,8,80..89,800..899 @integer 8, 11, 80~89, 800~803 + @integer 0~7, 9, 10, 12~17, 100, 1000, 10000, 100000, 1000000, … + + + + + + i = 1 @integer 1 + i = 0 or i % 100 = 2..20,40,60,80 @integer 0, 2~16, 102, 1002, … + @integer 21~36, 100, 1000, 10000, 100000, 1000000, … + + + n = 1 @integer 1 + n % 10 = 4 and n % 100 != 14 @integer 4, 24, 34, 44, 54, 64, 74, 84, 104, 1004, … + @integer 0, 2, 3, 5~17, 100, 1000, 10000, 100000, 1000000, … + + + n = 1..4 or n % 100 = 1..4,21..24,41..44,61..64,81..84 @integer 1~4, 21~24, 41~44, 61~64, 101, 1001, … + n = 5 or n % 100 = 5 @integer 5, 105, 205, 305, 405, 505, 605, 705, 1005, … + @integer 0, 6~20, 100, 1000, 10000, 100000, 1000000, … + + + + + + i = 0 @integer 0 + i = 1 @integer 1 + i = 2,3,4,5,6 @integer 2~6 + @integer 7~22, 100, 1000, 10000, 100000, 1000000, … + + + + + + n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … + n % 10 = 2 and n % 100 != 12 @integer 2, 22, 32, 42, 52, 62, 72, 82, 102, 1002, … + n % 10 = 3 and n % 100 != 13 @integer 3, 23, 33, 43, 53, 63, 73, 83, 103, 1003, … + @integer 0, 4~18, 100, 1000, 10000, 100000, 1000000, … + + + n = 1 @integer 1 + n = 2,3 @integer 2, 3 + n = 4 @integer 4 + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + + + n = 1,11 @integer 1, 11 + n = 2,12 @integer 2, 12 + n = 3,13 @integer 3, 13 + @integer 0, 4~10, 14~21, 100, 1000, 10000, 100000, 1000000, … + + + n = 1,3 @integer 1, 3 + n = 2 @integer 2 + n = 4 @integer 4 + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + + + + + + i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … + i % 10 = 2 and i % 100 != 12 @integer 2, 22, 32, 42, 52, 62, 72, 82, 102, 1002, … + i % 10 = 7,8 and i % 100 != 17,18 @integer 7, 8, 27, 28, 37, 38, 47, 48, 57, 58, 67, 68, 77, 78, 87, 88, 107, 1007, … + @integer 0, 3~6, 9~19, 100, 1000, 10000, 100000, 1000000, … + + + + + + i % 10 = 1,2,5,7,8 or i % 100 = 20,50,70,80 @integer 1, 2, 5, 7, 8, 11, 12, 15, 17, 18, 20~22, 25, 101, 1001, … + i % 10 = 3,4 or i % 1000 = 100,200,300,400,500,600,700,800,900 @integer 3, 4, 13, 14, 23, 24, 33, 34, 43, 44, 53, 54, 63, 64, 73, 74, 100, 1003, … + i = 0 or i % 10 = 6 or i % 100 = 40,60,90 @integer 0, 6, 16, 26, 36, 40, 46, 56, 106, 1006, … + @integer 9, 10, 19, 29, 30, 39, 49, 59, 69, 79, 109, 1000, 10000, 100000, 1000000, … + + + + + + n = 1 @integer 1 + n = 2,3 @integer 2, 3 + n = 4 @integer 4 + n = 6 @integer 6 + @integer 0, 5, 7~20, 100, 1000, 10000, 100000, 1000000, … + + + n = 1,5,7,8,9,10 @integer 1, 5, 7~10 + n = 2,3 @integer 2, 3 + n = 4 @integer 4 + n = 6 @integer 6 + @integer 0, 11~25, 100, 1000, 10000, 100000, 1000000, … + + + n = 1,5,7..9 @integer 1, 5, 7~9 + n = 2,3 @integer 2, 3 + n = 4 @integer 4 + n = 6 @integer 6 + @integer 0, 10~24, 100, 1000, 10000, 100000, 1000000, … + + + + + + n = 0,7,8,9 @integer 0, 7~9 + n = 1 @integer 1 + n = 2 @integer 2 + n = 3,4 @integer 3, 4 + n = 5,6 @integer 5, 6 + @integer 10~25, 100, 1000, 10000, 100000, 1000000, … + + + diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml index 49c49ea..9b1100a 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml @@ -1,9 +1,9 @@ @@ -27,7 +27,7 @@ CLDR data files are interpreted according to the LDML specification (http://unic i = 0,1 @integer 0, 1 @decimal 0.0~1.5 @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … - + i = 1 and v = 0 @integer 1 @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … @@ -35,7 +35,7 @@ CLDR data files are interpreted according to the LDML specification (http://unic n = 0,1 or i = 0 and f = 1 @integer 0, 1 @decimal 0.0, 0.1, 1.0, 0.00, 0.01, 1.00, 0.000, 0.001, 1.000, 0.0000, 0.0001, 1.0000 @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.2~0.9, 1.1~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … - + n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000 @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … @@ -76,6 +76,11 @@ CLDR data files are interpreted according to the LDML specification (http://unic i = 0,1 and n != 0 @integer 1 @decimal 0.1~1.6 @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 @@ -125,7 +130,7 @@ CLDR data files are interpreted according to the LDML specification (http://unic e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … - + i = 1 and v = 0 @integer 1 e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … From a05bfe647da994004bd349ff336c3f21b97f150c Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 01:04:16 -0800 Subject: [PATCH 02/18] Update CLDR to 48.1 --- .../data/README.md | 4 ++-- .../data/ordinals.xml | 10 +++++----- .../data/plurals.xml | 20 ++++++++++--------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md index d934d39..75ddf78 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/README.md @@ -1,3 +1,3 @@ -CLDR supplemental data files obtained from https://cldr.unicode.org/downloads/cldr-47 +CLDR supplemental data files obtained from https://cldr.unicode.org/downloads/cldr-48 -CLDR v47 was released 2025-03-13; refer to https://cldr.unicode.org/index/downloads \ No newline at end of file +CLDR v48.1 was released 2025-01-08; refer to https://cldr.unicode.org/index/downloads \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml index a6636b8..c8ea54b 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/ordinals.xml @@ -1,7 +1,7 @@ - + @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @@ -57,11 +57,11 @@ CLDR data files are interpreted according to the LDML specification (http://unic n % 10 = 6 or n % 10 = 9 or n % 10 = 0 and n != 0 @integer 6, 9, 10, 16, 19, 20, 26, 29, 30, 36, 39, 40, 100, 1000, 10000, 100000, 1000000, … @integer 0~5, 7, 8, 11~15, 17, 18, 21, 101, 1001, … - + n = 11,8,80,800 @integer 8, 11, 80, 800 @integer 0~7, 9, 10, 12~17, 100, 1000, 10000, 100000, 1000000, … - + n = 11,8,80..89,800..899 @integer 8, 11, 80~89, 800~803 @integer 0~7, 9, 10, 12~17, 100, 1000, 10000, 100000, 1000000, … @@ -101,7 +101,7 @@ CLDR data files are interpreted according to the LDML specification (http://unic n % 10 = 3 and n % 100 != 13 @integer 3, 23, 33, 43, 53, 63, 73, 83, 103, 1003, … @integer 0, 4~18, 100, 1000, 10000, 100000, 1000000, … - + n = 1 @integer 1 n = 2,3 @integer 2, 3 n = 4 @integer 4 diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml index 9b1100a..26cca25 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/data/plurals.xml @@ -1,7 +1,7 @@ - + i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04 @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … @@ -27,7 +27,7 @@ CLDR data files are interpreted according to the LDML specification (http://unic i = 0,1 @integer 0, 1 @decimal 0.0~1.5 @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … - + i = 1 and v = 0 @integer 1 @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … @@ -76,12 +76,7 @@ CLDR data files are interpreted according to the LDML specification (http://unic i = 0,1 and n != 0 @integer 1 @decimal 0.1~1.6 @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … - - n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 - n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 - @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … - - + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … @@ -197,6 +192,13 @@ CLDR data files are interpreted according to the LDML specification (http://unic + + n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, … + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n != 2 and n % 10 = 2..9 and n % 100 != 11..19 @integer 3~9, 22~29, 32, 102, 1002, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 22.0, 102.0, 1002.0, … + f != 0 @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, … + @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + n % 10 = 1 and n % 100 != 11,71,91 @integer 1, 21, 31, 41, 51, 61, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 81.0, 101.0, 1001.0, … n % 10 = 2 and n % 100 != 12,72,92 @integer 2, 22, 32, 42, 52, 62, 82, 102, 1002, … @decimal 2.0, 22.0, 32.0, 42.0, 52.0, 62.0, 82.0, 102.0, 1002.0, … From b18aad816bf4eb0e00970f1ccb3ca4cc2afa8394 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 01:06:02 -0800 Subject: [PATCH 03/18] Add plurals xml to metadata csproj --- .../Jeffijoe.MessageFormat.MetadataGenerator.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj b/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj index 57486f9..0b5537b 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj @@ -11,6 +11,7 @@ + From f707fecc492aeb924e6ad4ca57b91b969e7aec1e Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 01:21:46 -0800 Subject: [PATCH 04/18] xmldoc for AST types --- .../Plural/Parsing/AST/Condition.cs | 15 +++++++++++++++ .../Plural/Parsing/AST/PluralRule.cs | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs index b3c0bea..cb9f0ac 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs @@ -3,6 +3,12 @@ namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; +/// +/// Corresponds to a pluralRule tag in CLDR XML. +/// +/// +/// <pluralRule count="one">i = 1 and v = 0 @integer 1</pluralRule> +/// [DebuggerDisplay("{{RuleDescription}}")] public class Condition { @@ -13,9 +19,18 @@ public Condition(string count, string ruleDescription, IReadOnlyList + /// The plural form this condition or rule defines, e.g., "one", "two", "few", "many", "other". + /// public string Count { get; } + /// + /// The original text of this rule, e.g., "i = 1 and v = 0 @integer 1". + /// public string RuleDescription { get; } + /// + /// Parsed representation of . + /// public IReadOnlyList OrConditions { get; } } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs index 54ba99e..09c40ea 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs @@ -2,6 +2,16 @@ namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; +/// +/// Corresponds to a pluralRules tag in CLDR XML (not to be confused with pluralRule). +/// Each instance of this class represents multiple individual rules for a set of locales. +/// +/// +/// <pluralRules locales="ast de en et fi fy gl ia ie io ji lij nl sc sv sw ur yi"> +/// <pluralRule count = "one"> i = 1 and v = 0 @integer 1</pluralRule> +/// ... +/// </pluralRules> +/// public class PluralRule { public PluralRule(string[] locales, IReadOnlyList conditions) From 744e76103b4bf02ff7a7c091f695098774e81a4f Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 01:47:53 -0800 Subject: [PATCH 05/18] parsing basics --- .../Plural/Parsing/PluralParser.cs | 37 +++++++- .../Plural/Parsing/PluralRuleSet.cs | 94 +++++++++++++++++++ .../Plural/PluralLanguagesGenerator.cs | 25 ++--- 3 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs index 38f02cc..de116e5 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Xml; using System.Linq; using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; @@ -16,17 +17,42 @@ public PluralParser(XmlDocument rulesDocument, string[] excludedLocales) _excludedLocales = new HashSet(excludedLocales); } - public IEnumerable Parse() + /// + /// Parses the represented XML document into a new , + /// and returns it. + /// + /// A containing the parsed plural rules of a single type. + public PluralRuleSet Parse() + { + var index = new PluralRuleSet(); + ParseInto(index); + return index; + } + + /// + /// Parses the represented XML document and merges the rules into the given . + /// + /// + /// If the CLDR XML is missing expected attributes. + public void ParseInto(PluralRuleSet ruleIndex) { var root = _rulesDocument.DocumentElement!; - + foreach(XmlNode dataElement in root.ChildNodes) { if (dataElement.Name != "plurals") { continue; } - + + var typeAttr = dataElement.Attributes["type"]; + if (!typeAttr.Specified) + { + throw new ArgumentException("CLDR ruleset document is unexpectedly missing 'type' attribute on 'plurals' element."); + } + + string pluralType = typeAttr.Value; + foreach (XmlNode rule in dataElement.ChildNodes) { if(rule.Name == "pluralRules") @@ -34,7 +60,7 @@ public IEnumerable Parse() var parsed = ParseSingleRule(rule); if (parsed != null) { - yield return parsed; + ruleIndex.Add(pluralType, parsed); } } } @@ -51,6 +77,7 @@ public IEnumerable Parse() } var conditions = new List(); + foreach (XmlNode condition in rule.ChildNodes) { if (condition.Name == "pluralRule") diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs new file mode 100644 index 0000000..e18ce3b --- /dev/null +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; + +namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; + +/// +/// Represents multiple fully parsed documents of instances, each with a given type (i.e., 'cardinal' vs 'ordinals'). +/// +public class PluralRuleSet +{ + /// + /// Indexes a set of instances by plural type & locale. + /// + /// + /// Intended for runtime lookup of plural rules when formatting messages. + /// + private readonly Dictionary _byLocaleAndType = []; + + /// + /// Keeps track of which languages we've seen. + /// + private readonly HashSet _indexedLocales = []; + + /// + /// Gets the unique conditions that have been indexed. Can be used to generate unique helper functions + /// to match specific rules based on an input number. + /// + public IEnumerable RulesWithUniqueConditions => _byLocaleAndType.Values; + + /// + /// Gets the set of observed locale strings. + /// + public IEnumerable IndexedLocales => this._indexedLocales; + + /// + /// Adds the given rule to our index under the given plural type. + /// + /// e.g., 'cardinal' or 'ordinal'. + /// The parsed rule. + public void Add(string pluralType, PluralRule rule) + { + foreach (var locale in rule.Locales) + { + this._indexedLocales.Add(locale); + this._byLocaleAndType.Add( + new RuleKey + { + PluralType = pluralType, + Locale = locale, + }, + rule + ); + } + } + + + + /// + /// Walks the types of plurals we've indexed for a specific locale, along with corresponding rule. + /// Each returned rule is guaranteed to have a unique value. + /// + public IEnumerable GetIndexedRulesByTypeForLocale(string locale) + { + if (_byLocaleType.TryGetValue(locale, out var byType)) + { + foreach (var kvp in byType) + { + yield return kvp.Value; + } + } + } + + /// + /// Attempts to lookup the rule for a specific locale and a specific type. + /// + /// Null if no match. + public PluralRule? Get(string locale, string pluralType) + { + if (_byLocaleType.TryGetValue(locale, out var byType)) + { + byType.TryGetValue(pluralType, out var rule); + return rule; + } + + return null; + } + + /// + /// Used to retrieve a specific . + /// + /// e.g., 'cardinal' or 'ordinal'. + /// + public record struct RuleKey(string PluralType, string Locale); +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/PluralLanguagesGenerator.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/PluralLanguagesGenerator.cs index 9d1e439..d47015c 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/PluralLanguagesGenerator.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/PluralLanguagesGenerator.cs @@ -1,13 +1,10 @@ using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; -using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; using Jeffijoe.MessageFormat.MetadataGenerator.Plural.SourceGeneration; using Microsoft.CodeAnalysis; using System; -using System.Collections.Generic; using System.IO; -using System.Linq; using System.Xml; namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural; @@ -36,20 +33,26 @@ private string[] ReadExcludeLocales(GeneratorExecutionContext context) return Array.Empty(); } - private IReadOnlyList GetRules(string[] excludedLocales) + private PluralRuleSet GetRules(string[] excludedLocales) { - using var rulesStream = GetRulesContentStream(); - var xml = new XmlDocument(); - xml.Load(rulesStream); + PluralRuleSet ruleIndex = new(); + foreach (var ruleset in new[] { "plurals.xml", "ordinals.xml" }) + { + using var rulesStream = GetRulesContentStream(ruleset); + var xml = new XmlDocument(); + xml.Load(rulesStream); + + var parser = new PluralParser(xml, excludedLocales); + parser.ParseInto(ruleIndex); + } - var parser = new PluralParser(xml, excludedLocales); - return parser.Parse().ToArray(); + return ruleIndex; } - private Stream GetRulesContentStream() + private Stream GetRulesContentStream(string cldrFileName) { - return typeof(PluralLanguagesGenerator).Assembly.GetManifestResourceStream("Jeffijoe.MessageFormat.MetadataGenerator.data.plurals.xml")!; + return typeof(PluralLanguagesGenerator).Assembly.GetManifestResourceStream($"Jeffijoe.MessageFormat.MetadataGenerator.data.{cldrFileName}")!; } public void Initialize(GeneratorInitializationContext context) From d177f40ce42f867376841a7d354aa1da63897c38 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 15:38:43 -0800 Subject: [PATCH 06/18] Add some documentation to LMDL types --- .../Plural/Parsing/AST/Condition.cs | 13 +++- .../Formatting/Formatters/PluralContext.cs | 68 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs index cb9f0ac..06fc945 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs @@ -4,11 +4,18 @@ namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; /// -/// Corresponds to a pluralRule tag in CLDR XML. +/// Represents the 'condition' part of the LDML grammar. /// /// +/// Given the following 'pluralRule' tag: /// <pluralRule count="one">i = 1 and v = 0 @integer 1</pluralRule> +/// +/// A Condition instance would represent 'i = 1 and v = 0' as a single . /// +/// +/// The grammar defines a condition as a union of 'and_conditions', which we model as a +/// list of that each internally tracks . +/// [DebuggerDisplay("{{RuleDescription}}")] public class Condition { @@ -27,6 +34,10 @@ public Condition(string count, string ruleDescription, IReadOnlyList /// The original text of this rule, e.g., "i = 1 and v = 0 @integer 1". /// + /// + /// Note - this includes the sample text ('@integer 1') which gets stripped out + /// when parsing the rule's conditional logic. + /// public string RuleDescription { get; } /// diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs index ca7a49c..a09c307 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs @@ -3,6 +3,9 @@ namespace Jeffijoe.MessageFormat.Formatting.Formatters; +/// +/// Represents the 'operations' for a given source number, as defined by Unicode TR35/LDML. +/// internal readonly struct PluralContext { public PluralContext(int number) @@ -26,10 +29,24 @@ public PluralContext(double number) : this(number.ToString(CultureInfo.Invariant { } + /// + /// Represents operands for a source number in string format. + /// This library treats the input as a stringified double and does not currently parse out + /// compact decimal forms (e.g., "1.25c4"). + /// public PluralContext(string number) : this(number, double.Parse(number, CultureInfo.InvariantCulture)) { } + /// + /// Common constructor for parsing out operands from a stringified number. + /// + /// + /// The values of , , , and are all derived + /// from the fractional part of the number, so it's important be parsable as a number. + /// + /// The number in string form, as a decimal (not scientific/compact form). + /// The number pre-parsed as a double. private PluralContext(string number, double parsed) { Number = parsed; @@ -60,26 +77,77 @@ private PluralContext(string number, double parsed) W = fractionSpanWithoutZeroes.Length; F = int.Parse(fractionSpan); T = int.Parse(fractionSpanWithoutZeroes); + + // The compact decimal exponent representations are not used in this library as operands are + // always assumed to be parsable numbers. C = 0; E = 0; } } + /// + /// The 'source number' being evaluated for pluralization. + /// public double Number { get; } + /// + /// The absolute value of . + /// public double N { get; } + /// + /// The integer digits of . + /// + /// + /// 22.6 -> I = 22 + /// public int I { get; } + /// + /// The count of visible fraction digits of , with trailing zeroes. + /// + /// + /// 1.450 -> V = 3 + /// public int V { get; } + /// + /// The count of visible fraction digits of , without trailing zeroes. + /// + /// + /// 1.450 -> W = 2 + /// public int W { get; } + /// + /// The visible fraction digits of , with trailing zeroes, as an integer. + /// + /// + /// 1.450 -> F = 450 + /// public int F { get; } + /// + /// The visible fraction digits of , without trailing zeroes, as an integer. + /// + /// + /// 1.450 -> T = 45 + /// public int T { get; } + /// + /// The compact decimal exponent of , in such cases where + /// is represented as "[x]cC" such that == x * 10^C. + /// + /// + /// 1.25c4 -> C = 4 + /// 125c2 -> C = 2 + /// 12500 -> C = 0, as the number is not represented in compact decimal form. + /// public int C { get; } + /// + /// Deprecated (in LDML) synonym for , reserved for future use by the standard. + /// public int E { get; } } \ No newline at end of file From 03a621c694d437b1da057b86bd153e039e89cbf1 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 15:39:08 -0800 Subject: [PATCH 07/18] Codegen likely happy now --- .../Plural/Parsing/PluralRuleSet.cs | 58 +++++-------------- .../PluralRulesMetadataGenerator.cs | 46 +++++++-------- .../Formatting/Formatters/PluralLookupKey.cs | 9 +++ 3 files changed, 44 insertions(+), 69 deletions(-) create mode 100644 src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralLookupKey.cs diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs index e18ce3b..c0772e5 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs @@ -8,29 +8,27 @@ namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; /// public class PluralRuleSet { + private readonly List _allRules = []; + /// /// Indexes a set of instances by plural type & locale. /// /// /// Intended for runtime lookup of plural rules when formatting messages. /// - private readonly Dictionary _byLocaleAndType = []; - - /// - /// Keeps track of which languages we've seen. - /// - private readonly HashSet _indexedLocales = []; + private readonly Dictionary _byLocaleAndType = []; /// /// Gets the unique conditions that have been indexed. Can be used to generate unique helper functions /// to match specific rules based on an input number. /// - public IEnumerable RulesWithUniqueConditions => _byLocaleAndType.Values; + public IReadOnlyList UniqueRules => this._allRules; /// - /// Gets the set of observed locale strings. + /// Walks indexes plural rules by locale + type, and gets the index into + /// for each tuple. /// - public IEnumerable IndexedLocales => this._indexedLocales; + public IEnumerable> RuleIndicesByKey => this._byLocaleAndType; /// /// Adds the given rule to our index under the given plural type. @@ -39,56 +37,26 @@ public class PluralRuleSet /// The parsed rule. public void Add(string pluralType, PluralRule rule) { + this._allRules.Add(rule); + int newRuleIndex = this._allRules.Count - 1; + foreach (var locale in rule.Locales) { - this._indexedLocales.Add(locale); this._byLocaleAndType.Add( - new RuleKey + new PluralRuleKey { PluralType = pluralType, Locale = locale, }, - rule + newRuleIndex ); } } - - - /// - /// Walks the types of plurals we've indexed for a specific locale, along with corresponding rule. - /// Each returned rule is guaranteed to have a unique value. - /// - public IEnumerable GetIndexedRulesByTypeForLocale(string locale) - { - if (_byLocaleType.TryGetValue(locale, out var byType)) - { - foreach (var kvp in byType) - { - yield return kvp.Value; - } - } - } - - /// - /// Attempts to lookup the rule for a specific locale and a specific type. - /// - /// Null if no match. - public PluralRule? Get(string locale, string pluralType) - { - if (_byLocaleType.TryGetValue(locale, out var byType)) - { - byType.TryGetValue(pluralType, out var rule); - return rule; - } - - return null; - } - /// /// Used to retrieve a specific . /// /// e.g., 'cardinal' or 'ordinal'. /// - public record struct RuleKey(string PluralType, string Locale); + public record struct PluralRuleKey(string PluralType, string Locale); } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs index e797b99..463f9f0 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs @@ -1,16 +1,15 @@ -using System.Collections.Generic; -using System.Text; -using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; +using System.Text; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.SourceGeneration; public class PluralRulesMetadataGenerator { - private readonly IReadOnlyList _rules; + private readonly PluralRuleSet _rules; private readonly StringBuilder _sb; private int _indent; - public PluralRulesMetadataGenerator(IReadOnlyList rules) + public PluralRulesMetadataGenerator(PluralRuleSet rules) { _rules = rules; _sb = new StringBuilder(); @@ -29,18 +28,14 @@ public string GenerateClass() WriteLine("{"); AddIndent(); - for (var ruleIdx = 0; ruleIdx < _rules.Count; ruleIdx++) + // Generate a method for each unique rule, by index, that chooses the plural form + // for a given input source number (the PluralContext) according to that rule. + var uniqueRules = _rules.UniqueRules; + for (var ruleIdx = 0; ruleIdx < uniqueRules.Count; ruleIdx++) { - var rule = _rules[ruleIdx]; - + var rule = uniqueRules[ruleIdx]; var ruleGenerator = new RuleGenerator(rule); - foreach(var locale in rule.Locales) - { - WriteLine($"public static string Locale_{locale.ToUpper()}(PluralContext context) => Rule{ruleIdx}(context);"); - WriteLine(string.Empty); - } - WriteLine($"private static string Rule{ruleIdx}(PluralContext context)"); WriteLine("{"); AddIndent(); @@ -52,18 +47,19 @@ public string GenerateClass() WriteLine(string.Empty); } - WriteLine("private static readonly Dictionary Pluralizers = new Dictionary()"); + // Generate a static lookup dictionary of each (locale, plural type) to the corresponding rule method + // to use for that locale and type. + WriteLine("private static readonly Dictionary Pluralizers = new Dictionary()"); WriteLine("{"); AddIndent(); - for (int ruleIdx = 0; ruleIdx < _rules.Count; ruleIdx++) + foreach (var kvp in _rules.RuleIndicesByKey) { - PluralRule rule = _rules[ruleIdx]; - foreach (var locale in rule.Locales) - { - WriteLine($"{{\"{locale}\", Rule{ruleIdx}}},"); - } - + string locale = kvp.Key.Locale; + string pluralType = kvp.Key.PluralType; + int ruleIdx = kvp.Value; + + WriteLine($"{{new PluralLookupKey(Locale: \"{locale}\", PluralType: \"{pluralType}\"), Rule{ruleIdx}}},"); WriteLine(string.Empty); } @@ -71,11 +67,13 @@ public string GenerateClass() WriteLine("};"); WriteLine(string.Empty); - WriteLine("public static partial bool TryGetRuleByLocale(string locale, out ContextPluralizer contextPluralizer)"); + // Finally generate our public API to the rest of the library, that takes a locale and pluralType + // and tries to retrieve an appropriate localizer to map an input source number to the form for the request. + WriteLine("public static partial bool TryGetRuleByLocale(PluralLookupKey key, out ContextPluralizer contextPluralizer)"); WriteLine("{"); AddIndent(); - WriteLine("return Pluralizers.TryGetValue(locale, out contextPluralizer);"); + WriteLine("return Pluralizers.TryGetValue(key, out contextPluralizer);"); DecreaseIndent(); WriteLine("}"); diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralLookupKey.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralLookupKey.cs new file mode 100644 index 0000000..aef80bf --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralLookupKey.cs @@ -0,0 +1,9 @@ +namespace Jeffijoe.MessageFormat.Formatting.Formatters +{ + /// + /// Used to retrieve a specific . + /// + /// e.g., 'cardinal' or 'ordinal'. + /// + public record struct PluralRuleKey(string PluralType, string Locale); +} From c6fff547faade1364ef86b425a6ea16c2787f345 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 16:19:10 -0800 Subject: [PATCH 08/18] Building and tests passing --- .../PluralRulesMetadataGenerator.cs | 7 +- .../Formatters/PluralFormatterTests.cs | 6 +- .../MessageFormatterFullIntegrationTests.cs | 3 +- .../GeneratedPluralRulesTests.cs | 6 +- .../MetadataGenerator/ParserTests.cs | 30 ++--- .../PluralMetadataClassGeneratorTests.cs | 45 ++++++-- .../Formatting/Formatters/PluralFormatter.cs | 106 +++++++++++++++--- .../Formatting/Formatters/PluralLookupKey.cs | 9 -- .../Formatting/Formatters/PluralRuleKey.cs | 20 ++++ .../Formatters/PluralRulesMetadata.cs | 6 +- .../MessageFormatter.cs | 4 +- 11 files changed, 176 insertions(+), 66 deletions(-) delete mode 100644 src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralLookupKey.cs create mode 100644 src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs index 463f9f0..6ead4a6 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs @@ -49,7 +49,7 @@ public string GenerateClass() // Generate a static lookup dictionary of each (locale, plural type) to the corresponding rule method // to use for that locale and type. - WriteLine("private static readonly Dictionary Pluralizers = new Dictionary()"); + WriteLine("private static readonly Dictionary Pluralizers = new Dictionary()"); WriteLine("{"); AddIndent(); @@ -59,8 +59,7 @@ public string GenerateClass() string pluralType = kvp.Key.PluralType; int ruleIdx = kvp.Value; - WriteLine($"{{new PluralLookupKey(Locale: \"{locale}\", PluralType: \"{pluralType}\"), Rule{ruleIdx}}},"); - WriteLine(string.Empty); + WriteLine($"{{new PluralRuleKey(Locale: \"{locale}\", PluralType: \"{pluralType}\"), Rule{ruleIdx}}},"); } DecreaseIndent(); @@ -69,7 +68,7 @@ public string GenerateClass() // Finally generate our public API to the rest of the library, that takes a locale and pluralType // and tries to retrieve an appropriate localizer to map an input source number to the form for the request. - WriteLine("public static partial bool TryGetRuleByLocale(PluralLookupKey key, out ContextPluralizer contextPluralizer)"); + WriteLine("public static partial bool TryGetRuleByLocale(PluralRuleKey key, out ContextPluralizer contextPluralizer)"); WriteLine("{"); AddIndent(); diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs index e870bde..94cfe51 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs @@ -48,7 +48,7 @@ public void Pluralize(double n, string expected) }, Array.Empty()); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - var actual = subject.Pluralize("en", arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + var actual = subject.Pluralize(PluralRuleKey.Cardinal("en"), arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); Assert.Equal(expected, actual); } @@ -70,7 +70,7 @@ public void Pluralize_defaults_to_en_locale_when_specified_locale_is_not_found() }, Array.Empty()); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - var actual = subject.Pluralize("unknown", arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + var actual = subject.Pluralize(PluralRuleKey.Cardinal("unknown"), arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); Assert.Equal("just one", actual); } @@ -91,7 +91,7 @@ public void Pluralize_throws_when_missing_other_block() }, Array.Empty()); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - Assert.Throws(() => subject.Pluralize("unknown", arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0)); + Assert.Throws(() => subject.Pluralize(PluralRuleKey.Cardinal("unknown"), arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0)); } /// diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs index 6651ff3..4e591c9 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using Jeffijoe.MessageFormat.Formatting; +using Jeffijoe.MessageFormat.Formatting.Formatters; using Jeffijoe.MessageFormat.Tests.TestHelpers; using Xunit; using Xunit.Abstractions; @@ -603,7 +604,7 @@ public void ReadMe_test_to_make_sure_I_dont_look_like_a_fool() { var mf = new MessageFormatter(useCache: true, locale: "en"); - mf.Pluralizers!["en"] = n => + mf.Pluralizers![PluralRuleKey.Cardinal("en")] = n => { // ´n´ is the number being pluralized. // ReSharper disable once CompareOfFloatsByEqualityOperator diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs index 0db8c6b..868c581 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs @@ -30,7 +30,7 @@ public void Uk_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - var actual = subject.Pluralize("uk", arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + var actual = subject.Pluralize(PluralRuleKey.Cardinal("uk"), arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); Assert.Equal(expected, actual); } @@ -55,7 +55,7 @@ public void Ru_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - var actual = subject.Pluralize("ru", arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var actual = subject.Pluralize(PluralRuleKey.Cardinal("ru"), arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); Assert.Equal(expected, actual); } @@ -78,7 +78,7 @@ public void En_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - var actual = subject.Pluralize("en", arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var actual = subject.Pluralize(PluralRuleKey.Cardinal("en"), arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); Assert.Equal(expected, actual); } } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/ParserTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/ParserTests.cs index 4cecb75..a17f8a5 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/ParserTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/ParserTests.cs @@ -23,7 +23,7 @@ public void CanParseLocales() "); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var expected = new[] { "am", "as", "bn", "doi", "fa", "gu", "hi", "kn", "pcm", "zu" @@ -44,7 +44,7 @@ public void OtherCountIsIgnored() "); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); Assert.Empty(rule.Conditions); } @@ -53,7 +53,7 @@ public void CanParseSingleCount_RuleDescription_WithoutRelations() { var rules = ParseRules(GenerateXmlWithRuleContent("@integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var expected = "@integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …"; Assert.Equal(expected, condition.RuleDescription); @@ -64,7 +64,7 @@ public void CanParseSingleCount_VisibleDigitsNumber() { var rules = ParseRules( GenerateXmlWithRuleContent(@"v = 0 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var orCondition = Assert.Single(condition.OrConditions); var actual = Assert.Single(orCondition.AndConditions); @@ -78,7 +78,7 @@ public void CanParseSingleCount_IntegerDigits() { var rules = ParseRules( GenerateXmlWithRuleContent(@"i = 0 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var orCondition = Assert.Single(condition.OrConditions); var actual = Assert.Single(orCondition.AndConditions); @@ -92,7 +92,7 @@ public void CanParseSingleCount_AbsoluteNumber() { var rules = ParseRules( GenerateXmlWithRuleContent("n = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var orCondition = Assert.Single(condition.OrConditions); var actual = Assert.Single(orCondition.AndConditions); @@ -107,7 +107,7 @@ public void CanParseSingleCount_AbsoluteNumber() public void CanParseVariousRelations(string ruleText, Relation expectedRelation) { var rules = ParseRules(GenerateXmlWithRuleContent(ruleText)); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var orCondition = Assert.Single(condition.OrConditions); var actual = Assert.Single(orCondition.AndConditions); @@ -120,7 +120,7 @@ public void CanParseVariousRelations(string ruleText, Relation expectedRelation) public void CanParseOrRules() { var rules = ParseRules(GenerateXmlWithRuleContent("n = 2 or n = 1 or n = 0 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); Assert.Equal(3, condition.OrConditions.Count); @@ -142,7 +142,7 @@ public void CanParseOrRules() public void CanParseAndRules() { var rules = ParseRules(GenerateXmlWithRuleContent("n = 2 and n = 1 and n = 0 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var orCondition = Assert.Single(condition.OrConditions); @@ -166,7 +166,7 @@ public void CanParseModuloInLeftOperator() { var rules = ParseRules( GenerateXmlWithRuleContent("n % 5 = 3 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var orCondition = Assert.Single(condition.OrConditions); var actual = Assert.Single(orCondition.AndConditions); @@ -181,7 +181,7 @@ public void CanParseRangeInRightOperator() { var rules = ParseRules( GenerateXmlWithRuleContent("n = 3..5 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var orCondition = Assert.Single(condition.OrConditions); var actual = Assert.Single(orCondition.AndConditions); @@ -196,7 +196,7 @@ public void CanParseCommaSeparatedInRightOperator() { var rules = ParseRules( GenerateXmlWithRuleContent("n = 3,5,8, 10 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var orCondition = Assert.Single(condition.OrConditions); var actual = Assert.Single(orCondition.AndConditions); @@ -211,7 +211,7 @@ public void CanParseMixedCommaSeparatedAndRangeInRightOperator() { var rules = ParseRules( GenerateXmlWithRuleContent("n = 3,5..7,12,15 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var orCondition = Assert.Single(condition.OrConditions); var actual = Assert.Single(orCondition.AndConditions); @@ -234,7 +234,7 @@ public void MapsVariable_ToCorrectOperator(char variable, OperandSymbol symbol) { var rules = ParseRules( GenerateXmlWithRuleContent($"{variable} = 3")); - var rule = Assert.Single(rules); + var rule = Assert.Single(rules.UniqueRules); var condition = Assert.Single(rule.Conditions); var orCondition = Assert.Single(condition.OrConditions); var actual = Assert.Single(orCondition.AndConditions); @@ -264,7 +264,7 @@ private static void AssertOperationEqual(Operation expected, Operation actual) Assert.Equal(expected.OperandRight, actual.OperandRight); } - private static IEnumerable ParseRules(string xmlText) + private static PluralRuleSet ParseRules(string xmlText) { var xml = new XmlDocument(); xml.LoadXml(xmlText); diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs index b10ed33..5779466 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs @@ -1,4 +1,5 @@ -using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; using Jeffijoe.MessageFormat.MetadataGenerator.Plural.SourceGeneration; using Xunit; @@ -22,9 +23,25 @@ public void CanGenerateClassFromRules() new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] {new NumberOperand(3) }) }) }) - }) + }), + new PluralRule(new[] {"en"}, + new[] + { + new Condition("many", string.Empty, new [] + { + new OrCondition(new[] + { + new Operation(new VariableOperand(OperandSymbol.AbsoluteValue), Relation.Equals, new[] {new NumberOperand(120) }) + }) + }) + }), }; - var generator = new PluralRulesMetadataGenerator(rules); + + var ruleSet = new PluralRuleSet(); + ruleSet.Add("cardinal", rules[0]); + ruleSet.Add("ordinal", rules[1]); + + var generator = new PluralRulesMetadataGenerator(ruleSet); var actual = generator.GenerateClass(); @@ -35,10 +52,6 @@ namespace Jeffijoe.MessageFormat.Formatting.Formatters { internal static partial class PluralRulesMetadata { - public static string Locale_EN(PluralContext context) => Rule0(context); - - public static string Locale_UK(PluralContext context) => Rule0(context); - private static string Rule0(PluralContext context) { if ((context.N == 3)) @@ -47,16 +60,24 @@ private static string Rule0(PluralContext context) return ""other""; } - private static readonly Dictionary Pluralizers = new Dictionary() + private static string Rule1(PluralContext context) { - {""en"", Rule0}, - {""uk"", Rule0}, + if ((context.N == 120)) + return ""many""; + return ""other""; + } + + private static readonly Dictionary Pluralizers = new Dictionary() + { + {new PluralRuleKey(Locale: ""en"", PluralType: ""cardinal""), Rule0}, + {new PluralRuleKey(Locale: ""uk"", PluralType: ""cardinal""), Rule0}, + {new PluralRuleKey(Locale: ""en"", PluralType: ""ordinal""), Rule1}, }; - public static partial bool TryGetRuleByLocale(string locale, out ContextPluralizer contextPluralizer) + public static partial bool TryGetRuleByLocale(PluralRuleKey key, out ContextPluralizer contextPluralizer) { - return Pluralizers.TryGetValue(locale, out contextPluralizer); + return Pluralizers.TryGetValue(key, out contextPluralizer); } } } diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs index 844f854..47129f1 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs @@ -15,6 +15,36 @@ namespace Jeffijoe.MessageFormat.Formatting.Formatters; /// public class PluralFormatter : BaseFormatter, IFormatter { + /// + /// CLDR plural type attribute for counting number ruleset. + /// + internal const string CardinalType = "cardinal"; + + /// + /// CLDR plural type attribute for ordered number ruleset. + /// + internal const string OrdinalType = "ordinal"; + + /// + /// ICU MessageFormat function name for "default" pluralization, based on cardinal numbers. + /// + private const string PluralFunction = "plural"; + + /// + /// ICU MessageFormat function name for ordinal pluralization. + /// + private const string OrdinalFunction = "selectordinal"; + + /// + /// Maps supported parser names to CLDR plural types. + /// The plural language rule schema is identical between these types and we just need to pick the correct set. + /// + private static readonly Dictionary CldrTypeForFunction = new() + { + { PluralFunction, CardinalType }, + { OrdinalFunction, OrdinalType } + }; + #region Constructors and Destructors /// @@ -22,7 +52,7 @@ public class PluralFormatter : BaseFormatter, IFormatter /// public PluralFormatter() { - this.Pluralizers = new Dictionary(); + this.Pluralizers = new Dictionary(); this.AddStandardPluralizers(); } @@ -37,12 +67,12 @@ public PluralFormatter() /// - /// Gets the pluralizers dictionary. Key is the locale. + /// Gets the pluralizers dictionary. Key is the locale and plural type. /// /// /// The pluralizers. /// - public IDictionary Pluralizers { get; private set; } + public IDictionary Pluralizers { get; private set; } #endregion @@ -59,7 +89,12 @@ public PluralFormatter() /// public bool CanFormat(FormatterRequest request) { - return request.FormatterName == "plural"; + if (request.FormatterName is null) + { + return false; + } + + return CldrTypeForFunction.ContainsKey(request.FormatterName); } /// @@ -84,6 +119,9 @@ public bool CanFormat(FormatterRequest request) /// /// The . /// + /// + /// If does not specify a formatter name supported by . + /// public string Format(string locale, FormatterRequest request, IReadOnlyDictionary args, @@ -98,8 +136,19 @@ public string Format(string locale, offset = Convert.ToDouble(offsetExtension.Value); } + // Get CLDR plural ruleset from request. + // CanFormat() should have guaranteed this is valid, but we'll be defensive just in case. + if (!CldrTypeForFunction.TryGetValue(request.FormatterName ?? string.Empty, out var pluralType)) + { + throw new MessageFormatterException($"Unsupported plural formatter name: {request.FormatterName}"); + } + var ctx = CreatePluralContext(value, offset); - var pluralized = this.Pluralize(locale, arguments, ctx, offset); + var pluralized = this.Pluralize( + new PluralRuleKey(PluralType: pluralType, Locale: locale), + arguments, + ctx, + offset); var result = this.ReplaceNumberLiterals(pluralized, ctx.Number); var formatted = messageFormatter.FormatMessage(result, args); return formatted; @@ -112,8 +161,8 @@ public string Format(string locale, /// /// Returns the correct plural block. /// - /// - /// The locale. + /// + /// The locale and pluralType. /// /// /// The parsed arguments string. @@ -132,20 +181,20 @@ public string Format(string locale, /// [SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1126:PrefixCallsCorrectly", Justification = "Reviewed. Suppression is OK here.")] - internal string Pluralize(string locale, ParsedArguments arguments, PluralContext context, double offset) + internal string Pluralize(PluralRuleKey ruleKey, ParsedArguments arguments, PluralContext context, double offset) { string pluralForm; - if (this.Pluralizers.TryGetValue(locale, out var pluralizer)) + if (this.Pluralizers.TryGetValue(ruleKey, out var pluralizer)) { pluralForm = pluralizer(context.Number); } - else if (PluralRulesMetadata.TryGetRuleByLocale(locale, out var contextPluralizer)) + else if (PluralRulesMetadata.TryGetRuleByLocale(ruleKey, out var contextPluralizer)) { - pluralForm= contextPluralizer(context); + pluralForm = contextPluralizer(context); } else { - pluralForm = this.Pluralizers["en"](context.Number); + pluralForm = this.Pluralizers[new PluralRuleKey(Locale: "en", PluralType: ruleKey.PluralType)](context.Number); } KeyedBlock? other = null; @@ -287,10 +336,10 @@ internal string ReplaceNumberLiterals(string pluralized, double n) /// /// Adds the standard pluralizers. /// - private void AddStandardPluralizers() + protected virtual void AddStandardPluralizers() { this.Pluralizers.Add( - "en", + PluralRuleKey.Cardinal("en"), n => { // ReSharper disable CompareOfFloatsByEqualityOperator @@ -306,7 +355,34 @@ private void AddStandardPluralizers() // ReSharper restore CompareOfFloatsByEqualityOperator return "other"; - }); + } + ); + this.Pluralizers.Add( + PluralRuleKey.Ordinal("en"), + n => + { + // e.g., 1st + if (n % 10 == 1 && n % 100 != 11) + { + return "one"; + } + + // e.g., 2nd + if (n % 10 == 2 && n % 100 != 12) + { + return "two"; + } + + // e.g., 3rd + if (n % 10 == 3 && n % 100 != 13) + { + return "few"; + } + + // e.g., 4th, 11th, etc + return "other"; + } + ); } /// diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralLookupKey.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralLookupKey.cs deleted file mode 100644 index aef80bf..0000000 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralLookupKey.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Jeffijoe.MessageFormat.Formatting.Formatters -{ - /// - /// Used to retrieve a specific . - /// - /// e.g., 'cardinal' or 'ordinal'. - /// - public record struct PluralRuleKey(string PluralType, string Locale); -} diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs new file mode 100644 index 0000000..a665ae4 --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs @@ -0,0 +1,20 @@ +namespace Jeffijoe.MessageFormat.Formatting.Formatters +{ + /// + /// Used to retrieve a specific . + /// + /// e.g., 'cardinal' or 'ordinal'. + /// + public record struct PluralRuleKey(string PluralType, string Locale) + { + /// + /// Helper to generate a cardinal rule look up for a locale, suitable for the 'plural' MessageFormat function. + /// + public static PluralRuleKey Cardinal(string locale) => new(PluralType: PluralFormatter.CardinalType, Locale: locale); + + /// + /// Helper to generate an ordinal rule look up for a locale, suitable for the 'selectordinal' MessageFormat function. + /// + public static PluralRuleKey Ordinal(string locale) => new(PluralType: PluralFormatter.OrdinalType, Locale: locale); + } +} diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs index 7c98e93..ccf5bb2 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs @@ -1,7 +1,9 @@ -namespace Jeffijoe.MessageFormat.Formatting.Formatters; +using System.Diagnostics.CodeAnalysis; + +namespace Jeffijoe.MessageFormat.Formatting.Formatters; [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal static partial class PluralRulesMetadata { - public static partial bool TryGetRuleByLocale(string locale, out ContextPluralizer contextPluralizer); + public static partial bool TryGetRuleByLocale(PluralRuleKey key, out ContextPluralizer contextPluralizer); } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/MessageFormatter.cs b/src/Jeffijoe.MessageFormat/MessageFormatter.cs index f258efe..4c37b78 100644 --- a/src/Jeffijoe.MessageFormat/MessageFormatter.cs +++ b/src/Jeffijoe.MessageFormat/MessageFormatter.cs @@ -145,12 +145,12 @@ public IFormatterLibrary Formatters public string Locale { get; set; } /// - /// Gets the pluralizers dictionary from the , if set. Key is the locale. + /// Gets the pluralizers dictionary from the , if set. Key is the locale, then the plural type. /// /// /// The pluralizers, or null if the plural formatter has not been added. /// - public IDictionary? Pluralizers + public IDictionary? Pluralizers { get { From eff20d1150aca8319ba15455600cac5df9abed7b Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 16:58:31 -0800 Subject: [PATCH 09/18] Add new README test for selectordinal --- README.md | 1 + .../MessageFormatterFullIntegrationTests.cs | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/README.md b/README.md index afbbe36..315eca6 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ MessageFormat.NET supports the most commonly used formats: * Select Format: `{gender, select, male{He likes} female{She likes} other{They like}} cheeseburgers` * Plural Format: `There {msgCount, plural, zero {are no unread messages} one {is 1 unread message} other{are # unread messages}}.` (where `#` is the actual number, with the offset (if any) subtracted). +* Ordinal Format: `You are the {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} person in line.` * Simple variable replacement: `Your name is {name}` * Numbers: `Your age is {age, number}` * Dates: `You were born {birthday, date}` diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs index 4e591c9..cba63f1 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs @@ -577,6 +577,16 @@ public void ReadMe_test_to_make_sure_I_dont_look_like_a_fool() Assert.Equal("Your messages go here.", formatted); } + { + var mf = new MessageFormatter(false); + const string Str = @"You are the {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} person in line."; + var formatted = mf.FormatMessage(Str, new Dictionary { { "position", 23 } }); + Assert.Equal("You are the 23rd person in line.", formatted); + + formatted = mf.FormatMessage(Str, new Dictionary { { "position", 1 } }); + Assert.Equal("You are the 1st person in line.", formatted); + } + { var mf = new MessageFormatter(false); const string Str = @"His name is {LAST_NAME}... {FIRST_NAME} {LAST_NAME}"; From 22c151be0af4b9c6473310efaee534748d58e4c9 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 20:07:59 -0800 Subject: [PATCH 10/18] Fix bad test --- .../MessageFormatterFullIntegrationTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs index cba63f1..579be27 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs @@ -203,6 +203,14 @@ public static IEnumerable Tests other {and # others added this to their profiles} }."; + const string Case7 = @"Your {count, selectordinal, + =0 {nonexistent} + one {#st} + two {#nd} + few {#rd} + other {#th} + } notification is the most recent one."; + yield return new object[] { @@ -349,6 +357,20 @@ public static IEnumerable Tests new Dictionary { { "count", 3 } }, "You and 2 others added this to their profiles." }; + yield return + new object[] + { + Case7, + new Dictionary { { "count", 0 } }, + "Your nonexistent notification is the most recent one." + }; + yield return + new object[] + { + Case7, + new Dictionary { { "count", 2 } }, + "Your 2nd notification is the most recent one." + }; yield return new object[] { From 550fa53339de78f69ddbbe3ed180ce9c098403f2 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 21:14:22 -0800 Subject: [PATCH 11/18] readonly record struct --- .../Plural/Parsing/PluralRuleSet.cs | 21 ++++++++++++------- .../Formatting/Formatters/PluralRuleKey.cs | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs index c0772e5..473963f 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs @@ -43,11 +43,7 @@ public void Add(string pluralType, PluralRule rule) foreach (var locale in rule.Locales) { this._byLocaleAndType.Add( - new PluralRuleKey - { - PluralType = pluralType, - Locale = locale, - }, + new PluralRuleKey(PluralType: pluralType, Locale: locale), newRuleIndex ); } @@ -56,7 +52,16 @@ public void Add(string pluralType, PluralRule rule) /// /// Used to retrieve a specific . /// - /// e.g., 'cardinal' or 'ordinal'. - /// - public record struct PluralRuleKey(string PluralType, string Locale); + public readonly record struct PluralRuleKey(string PluralType, string Locale) + { + /// + /// e.g., 'cardinal' or 'ordinal'. + /// + public string PluralType { get; } = PluralType; + + /// + /// The language to query. + /// + public string Locale { get; } = Locale; + } } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs index a665ae4..7f7357c 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs @@ -5,7 +5,7 @@ /// /// e.g., 'cardinal' or 'ordinal'. /// - public record struct PluralRuleKey(string PluralType, string Locale) + public readonly record struct PluralRuleKey(string PluralType, string Locale) { /// /// Helper to generate a cardinal rule look up for a locale, suitable for the 'plural' MessageFormat function. From fec0a92e04b381d7525544487633bba53dc2da6e Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Sun, 15 Feb 2026 21:36:48 -0800 Subject: [PATCH 12/18] More tests --- .../GeneratedPluralRulesTests.cs | 48 +++++++++++++++++-- .../Formatting/Formatters/PluralFormatter.cs | 5 +- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs index 868c581..d243171 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs @@ -18,6 +18,8 @@ public class GeneratedPluralRulesTests public void Uk_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); + subject.Pluralizers.Clear(); + var args = new Dictionary { { "test", n } }; var arguments = new ParsedArguments( @@ -29,7 +31,7 @@ public void Uk_PluralizerTests(double n, string expected) new KeyedBlock("other", "дня") }, new FormatterExtension[0]); - var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); var actual = subject.Pluralize(PluralRuleKey.Cardinal("uk"), arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); Assert.Equal(expected, actual); } @@ -43,6 +45,8 @@ public void Uk_PluralizerTests(double n, string expected) public void Ru_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); + subject.Pluralizers.Clear(); + var args = new Dictionary { { "test", n } }; var arguments = new ParsedArguments( @@ -54,7 +58,7 @@ public void Ru_PluralizerTests(double n, string expected) new KeyedBlock("other", "дня") }, new FormatterExtension[0]); - var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); var actual = subject.Pluralize(PluralRuleKey.Cardinal("ru"), arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); Assert.Equal(expected, actual); } @@ -65,20 +69,56 @@ public void Ru_PluralizerTests(double n, string expected) [InlineData(101, "days")] [InlineData(102, "days")] [InlineData(105, "days")] - public void En_PluralizerTests(double n, string expected) + public void En_Cardinal_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); + subject.Pluralizers.Clear(); + var args = new Dictionary { { "test", n } }; var arguments = new ParsedArguments( new[] { + // 'zero' is a red herring to confirm CLDR rules are used instead of built-in; + // CLDR does not specify an English 'zero' form, so 0 should fallthrough to 'other'. + new KeyedBlock("zero", "FAIL"), new KeyedBlock("one", "day"), new KeyedBlock("other", "days") }, new FormatterExtension[0]); - var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); var actual = subject.Pluralize(PluralRuleKey.Cardinal("en"), arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); Assert.Equal(expected, actual); } + + [Theory] + [InlineData(0, "0th")] + [InlineData(1, "1st")] + [InlineData(2, "2nd")] + [InlineData(3, "3rd")] + [InlineData(4, "4th")] + [InlineData(9, "9th")] + [InlineData(11, "11th")] + [InlineData(21, "21st")] + public void En_Ordinal_PluralizerTests(double n, string expected) + { + var subject = new PluralFormatter(); + subject.Pluralizers.Clear(); + + var args = new Dictionary { { "test", n } }; + var arguments = + new ParsedArguments( + new[] + { + new KeyedBlock("one", "#st"), + new KeyedBlock("two", "#nd"), + new KeyedBlock("few", "#rd"), + new KeyedBlock("other", "#th"), + }, + new FormatterExtension[0]); + var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.OrdinalFunction, null); + var pluralized = subject.Pluralize(PluralRuleKey.Ordinal("en"), arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var actual = subject.ReplaceNumberLiterals(pluralized, n); + Assert.Equal(expected, actual); + } } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs index 47129f1..e00a786 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs @@ -28,12 +28,12 @@ public class PluralFormatter : BaseFormatter, IFormatter /// /// ICU MessageFormat function name for "default" pluralization, based on cardinal numbers. /// - private const string PluralFunction = "plural"; + internal const string PluralFunction = "plural"; /// /// ICU MessageFormat function name for ordinal pluralization. /// - private const string OrdinalFunction = "selectordinal"; + internal const string OrdinalFunction = "selectordinal"; /// /// Maps supported parser names to CLDR plural types. @@ -65,7 +65,6 @@ public PluralFormatter() /// public bool VariableMustExist => true; - /// /// Gets the pluralizers dictionary. Key is the locale and plural type. /// From 8b7ebb6914c7040d30f51fc7de73e113cd70fbc1 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Mon, 16 Feb 2026 09:02:14 -0800 Subject: [PATCH 13/18] PR feedback --- ...joe.MessageFormat.MetadataGenerator.csproj | 4 ++ .../Plural/Parsing/AST/Condition.cs | 22 ++++---- .../Plural/Parsing/AST/PluralRule.cs | 4 +- .../Plural/Parsing/PluralParser.cs | 3 +- .../Plural/Parsing/PluralRuleSet.cs | 15 ++---- .../Formatting/Formatters/PluralContext.cs | 50 +++++++++---------- .../Formatting/Formatters/PluralFormatter.cs | 2 +- .../Formatting/Formatters/PluralRuleKey.cs | 33 ++++++------ 8 files changed, 63 insertions(+), 70 deletions(-) diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj b/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj index 0b5537b..4a55968 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Jeffijoe.MessageFormat.MetadataGenerator.csproj @@ -20,6 +20,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs index 06fc945..05b0ae6 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/Condition.cs @@ -4,17 +4,17 @@ namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; /// -/// Represents the 'condition' part of the LDML grammar. +/// Represents the 'condition' part of the LDML grammar. /// /// -/// Given the following 'pluralRule' tag: -/// <pluralRule count="one">i = 1 and v = 0 @integer 1</pluralRule> +/// Given the following 'pluralRule' tag: +/// <pluralRule count="one">i = 1 and v = 0 @integer 1</pluralRule> /// -/// A Condition instance would represent 'i = 1 and v = 0' as a single . +/// A Condition instance would represent 'i = 1 and v = 0' as a single . /// /// -/// The grammar defines a condition as a union of 'and_conditions', which we model as a -/// list of that each internally tracks . +/// The grammar defines a condition as a union of 'and_conditions', which we model as a +/// list of that each internally tracks . /// [DebuggerDisplay("{{RuleDescription}}")] public class Condition @@ -27,21 +27,21 @@ public Condition(string count, string ruleDescription, IReadOnlyList - /// The plural form this condition or rule defines, e.g., "one", "two", "few", "many", "other". + /// The plural form this condition or rule defines, e.g., "one", "two", "few", "many", "other". /// public string Count { get; } /// - /// The original text of this rule, e.g., "i = 1 and v = 0 @integer 1". + /// The original text of this rule, e.g., "i = 1 and v = 0 @integer 1". /// /// - /// Note - this includes the sample text ('@integer 1') which gets stripped out - /// when parsing the rule's conditional logic. + /// Note - this includes the sample text ('@integer 1') which gets stripped out + /// when parsing the rule's conditional logic. /// public string RuleDescription { get; } /// - /// Parsed representation of . + /// Parsed representation of . /// public IReadOnlyList OrConditions { get; } } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs index 09c40ea..c37efeb 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/AST/PluralRule.cs @@ -3,8 +3,8 @@ namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; /// -/// Corresponds to a pluralRules tag in CLDR XML (not to be confused with pluralRule). -/// Each instance of this class represents multiple individual rules for a set of locales. +/// Corresponds to a pluralRules tag in CLDR XML (not to be confused with pluralRule). +/// Each instance of this class represents multiple individual rules for a set of locales. /// /// /// <pluralRules locales="ast de en et fi fy gl ia ie io ji lij nl sc sv sw ur yi"> diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs index de116e5..793e79d 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs @@ -18,8 +18,7 @@ public PluralParser(XmlDocument rulesDocument, string[] excludedLocales) } /// - /// Parses the represented XML document into a new , - /// and returns it. + /// Parses the represented XML document into a new , and returns it. /// /// A containing the parsed plural rules of a single type. public PluralRuleSet Parse() diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs index 473963f..c4636b1 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs @@ -52,16 +52,7 @@ public void Add(string pluralType, PluralRule rule) /// /// Used to retrieve a specific . /// - public readonly record struct PluralRuleKey(string PluralType, string Locale) - { - /// - /// e.g., 'cardinal' or 'ordinal'. - /// - public string PluralType { get; } = PluralType; - - /// - /// The language to query. - /// - public string Locale { get; } = Locale; - } + /// e.g., 'cardinal' or 'ordinal'. + /// Two-letter language tag. + public readonly record struct PluralRuleKey(string PluralType, string Locale); } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs index a09c307..1c60f70 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralContext.cs @@ -4,7 +4,7 @@ namespace Jeffijoe.MessageFormat.Formatting.Formatters; /// -/// Represents the 'operations' for a given source number, as defined by Unicode TR35/LDML. +/// Represents the 'operations' for a given source number, as defined by Unicode TR35/LDML. /// internal readonly struct PluralContext { @@ -30,20 +30,20 @@ public PluralContext(double number) : this(number.ToString(CultureInfo.Invariant } /// - /// Represents operands for a source number in string format. - /// This library treats the input as a stringified double and does not currently parse out - /// compact decimal forms (e.g., "1.25c4"). + /// Represents operands for a source number in string format. + /// This library treats the input as a stringified double and does not currently parse out + /// compact decimal forms (e.g., "1.25c4"). /// public PluralContext(string number) : this(number, double.Parse(number, CultureInfo.InvariantCulture)) { } /// - /// Common constructor for parsing out operands from a stringified number. + /// Common constructor for parsing out operands from a stringified number. /// /// - /// The values of , , , and are all derived - /// from the fractional part of the number, so it's important be parsable as a number. + /// The values of , , , and are all derived + /// from the fractional part of the number, so it's important be parsable as a number. /// /// The number in string form, as a decimal (not scientific/compact form). /// The number pre-parsed as a double. @@ -86,68 +86,68 @@ private PluralContext(string number, double parsed) } /// - /// The 'source number' being evaluated for pluralization. + /// The 'source number' being evaluated for pluralization. /// public double Number { get; } /// - /// The absolute value of . + /// The absolute value of . /// public double N { get; } /// - /// The integer digits of . + /// The integer digits of . /// /// - /// 22.6 -> I = 22 + /// 22.6 -> I = 22 /// public int I { get; } /// - /// The count of visible fraction digits of , with trailing zeroes. + /// The count of visible fraction digits of , with trailing zeroes. /// /// - /// 1.450 -> V = 3 + /// 1.450 -> V = 3 /// public int V { get; } /// - /// The count of visible fraction digits of , without trailing zeroes. + /// The count of visible fraction digits of , without trailing zeroes. /// /// - /// 1.450 -> W = 2 + /// 1.450 -> W = 2 /// public int W { get; } /// - /// The visible fraction digits of , with trailing zeroes, as an integer. + /// The visible fraction digits of , with trailing zeroes, as an integer. /// /// - /// 1.450 -> F = 450 + /// 1.450 -> F = 450 /// public int F { get; } /// - /// The visible fraction digits of , without trailing zeroes, as an integer. + /// The visible fraction digits of , without trailing zeroes, as an integer. /// /// - /// 1.450 -> T = 45 + /// 1.450 -> T = 45 /// public int T { get; } /// - /// The compact decimal exponent of , in such cases where - /// is represented as "[x]cC" such that == x * 10^C. + /// The compact decimal exponent of , in such cases where + /// is represented as "[x]cC" such that == x * 10^C. /// /// - /// 1.25c4 -> C = 4 - /// 125c2 -> C = 2 - /// 12500 -> C = 0, as the number is not represented in compact decimal form. + /// 1.25c4 -> C = 4 + /// 125c2 -> C = 2 + /// 12500 -> C = 0, as the number is not represented in compact decimal form. /// public int C { get; } /// - /// Deprecated (in LDML) synonym for , reserved for future use by the standard. + /// Deprecated (in LDML) synonym for , reserved for future use by the standard. /// public int E { get; } } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs index e00a786..7932268 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs @@ -335,7 +335,7 @@ internal string ReplaceNumberLiterals(string pluralized, double n) /// /// Adds the standard pluralizers. /// - protected virtual void AddStandardPluralizers() + private void AddStandardPluralizers() { this.Pluralizers.Add( PluralRuleKey.Cardinal("en"), diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs index 7f7357c..60a5fbb 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs @@ -1,20 +1,19 @@ -namespace Jeffijoe.MessageFormat.Formatting.Formatters +namespace Jeffijoe.MessageFormat.Formatting.Formatters; + +/// +/// Used to retrieve a specific . +/// +/// e.g., 'cardinal' or 'ordinal'. +/// Two-letter language tag. +public readonly record struct PluralRuleKey(string PluralType, string Locale) { /// - /// Used to retrieve a specific . + /// Helper to generate a cardinal rule look up for a locale, suitable for the 'plural' MessageFormat function. /// - /// e.g., 'cardinal' or 'ordinal'. - /// - public readonly record struct PluralRuleKey(string PluralType, string Locale) - { - /// - /// Helper to generate a cardinal rule look up for a locale, suitable for the 'plural' MessageFormat function. - /// - public static PluralRuleKey Cardinal(string locale) => new(PluralType: PluralFormatter.CardinalType, Locale: locale); - - /// - /// Helper to generate an ordinal rule look up for a locale, suitable for the 'selectordinal' MessageFormat function. - /// - public static PluralRuleKey Ordinal(string locale) => new(PluralType: PluralFormatter.OrdinalType, Locale: locale); - } -} + public static PluralRuleKey Cardinal(string locale) => new(PluralType: PluralFormatter.CardinalType, Locale: locale); + + /// + /// Helper to generate an ordinal rule look up for a locale, suitable for the 'selectordinal' MessageFormat function. + /// + public static PluralRuleKey Ordinal(string locale) => new(PluralType: PluralFormatter.OrdinalType, Locale: locale); +} \ No newline at end of file From 23cb4634c15082bdd0078ee8760d823486fb4553 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Mon, 16 Feb 2026 21:25:06 -0800 Subject: [PATCH 14/18] Remove inbox pluralizers; add root locale support; refactor codegen --- README.md | 13 +- .../Plural/Parsing/PluralRuleSet.cs | 117 +++++++++++--- .../PluralRulesMetadataGenerator.cs | 71 +++++++-- .../Formatters/PluralFormatterTests.cs | 18 +-- .../MessageFormatterFullIntegrationTests.cs | 26 +++- .../GeneratedPluralRulesTests.cs | 8 +- .../PluralMetadataClassGeneratorTests.cs | 40 ++++- .../Formatting/Formatters/PluralFormatter.cs | 143 +++++++----------- .../Formatting/Formatters/PluralRuleKey.cs | 19 --- .../Formatters/PluralRulesMetadata.cs | 4 +- .../MessageFormatter.cs | 19 ++- 11 files changed, 308 insertions(+), 170 deletions(-) delete mode 100644 src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs diff --git a/README.md b/README.md index 315eca6..c5ece6f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ https://unicode-org.github.io/icu/userguide/format_parse/messages/ var mf = new MessageFormatter(); var str = @"You have {notifications, plural, - zero {no notifications} + =0 {no notifications} one {one notification} =42 {a universal amount of notifications} other {# notifications} @@ -86,7 +86,7 @@ and about 3 seconds (3236ms) without it. **These results are with a debug build, MessageFormat.NET supports the most commonly used formats: * Select Format: `{gender, select, male{He likes} female{She likes} other{They like}} cheeseburgers` -* Plural Format: `There {msgCount, plural, zero {are no unread messages} one {is 1 unread message} other{are # unread messages}}.` (where `#` is the actual number, with the offset (if any) subtracted). +* Plural Format: `There {msgCount, plural, =0 {are no unread messages} one {is 1 unread message} other{are # unread messages}}.` (where `#` is the actual number, with the offset (if any) subtracted). * Ordinal Format: `You are the {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} person in line.` * Simple variable replacement: `Your name is {name}` * Numbers: `Your age is {age, number}` @@ -140,8 +140,11 @@ var message = formatter.FormatMessage("{value, number, $0.0}", new { value = 23 > with the package, so this is no longer needed. Same thing as with [MessageFormat.js][0], you can add your own pluralizer function. -The `Pluralizers` property is a `IDictionary`, so you can remove the built-in -ones if you want. +The `Pluralizers` property is a `IDictionary` that starts empty, along +with `OrdinalPluralizers` for ordinal numbers. + +Adding to these Dictionaries will take precedence over the CLDR data for exact matches on +the input locales. ````csharp var mf = new MessageFormatter(); @@ -163,8 +166,6 @@ var mf = new MessageFormatter(true, "en"); // true = use cache mf.Pluralizers["en"] = n => { // ´n´ is the number being pluralized. - if (n == 0) - return "zero"; if (n == 1) return "one"; if (n > 1000) diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs index c4636b1..16eacd2 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralRuleSet.cs @@ -1,22 +1,35 @@ -using System.Collections.Generic; -using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; +using Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing.AST; +using System; +using System.Collections.Generic; namespace Jeffijoe.MessageFormat.MetadataGenerator.Plural.Parsing; /// -/// Represents multiple fully parsed documents of instances, each with a given type (i.e., 'cardinal' vs 'ordinals'). +/// Represents multiple fully parsed documents of instances, each with a given type (i.e., 'cardinal' vs 'ordinals'). /// public class PluralRuleSet { - private readonly List _allRules = []; + /// + /// Special CLDR locale ID to use as the default for inheritance. All locales can chain to this + /// during lookups. + /// + public const string RootLocale = "root"; /// - /// Indexes a set of instances by plural type & locale. + /// CLDR plural type attribute for the counting number ruleset. + /// Used to translate strings that contain a count, e.g., to pluralize nouns. /// - /// - /// Intended for runtime lookup of plural rules when formatting messages. - /// - private readonly Dictionary _byLocaleAndType = []; + public const string CardinalType = "cardinal"; + + /// + /// CLDR plural type attribute for the ordered number ruleset. + /// Used to translate strings containing ordinal numbers, e.g., "1st", "2nd", "3rd". + /// + public const string OrdinalType = "ordinal"; + + // Backing fields for the public properties below. + private readonly List _allRules = []; + private readonly Dictionary _indicesByLocale = new(StringComparer.OrdinalIgnoreCase); /// /// Gets the unique conditions that have been indexed. Can be used to generate unique helper functions @@ -25,34 +38,96 @@ public class PluralRuleSet public IReadOnlyList UniqueRules => this._allRules; /// - /// Walks indexes plural rules by locale + type, and gets the index into - /// for each tuple. + /// Maps normalized CLDR locale IDs to indices within + /// for their cardinal and ordinal rules, if defined. /// - public IEnumerable> RuleIndicesByKey => this._byLocaleAndType; + public IReadOnlyDictionary RuleIndicesByLocale => this._indicesByLocale; /// - /// Adds the given rule to our index under the given plural type. + /// Adds the given rule to our indices under the given plural type. /// /// e.g., 'cardinal' or 'ordinal'. /// The parsed rule. + /// Thrown when a nonstandard plural type is provided. public void Add(string pluralType, PluralRule rule) { this._allRules.Add(rule); int newRuleIndex = this._allRules.Count - 1; + int? cardinalIndex = null; + int? ordinalIndex = null; + if (pluralType == CardinalType) + { + cardinalIndex = newRuleIndex; + } + else if (pluralType == OrdinalType) + { + ordinalIndex = newRuleIndex; + } + else + { + throw new ArgumentOutOfRangeException(nameof(pluralType), pluralType, "Unexpected plural type"); + } + + // Loop over each locale for this rule and update our indices with the new value. + // If we've seen it before (for a different plural type), we'll update it in-place. foreach (var locale in rule.Locales) { - this._byLocaleAndType.Add( - new PluralRuleKey(PluralType: pluralType, Locale: locale), - newRuleIndex - ); + var normalized = this.NormalizeCldrLocale(locale); + + PluralRuleIndices newIndices; + if (this._indicesByLocale.TryGetValue(normalized, out var existingIndices)) + { + // Merge any previous indices we've observed for this locale + newIndices = existingIndices with + { + CardinalRuleIndex = cardinalIndex ?? existingIndices.CardinalRuleIndex, + OrdinalRuleIndex = ordinalIndex ?? existingIndices.OrdinalRuleIndex + }; + } + else + { + newIndices = new PluralRuleIndices( + CardinalRuleIndex: cardinalIndex, + OrdinalRuleIndex: ordinalIndex + ); + + } + + this._indicesByLocale[normalized] = newIndices; + if (normalized != locale) + { + this._indicesByLocale[locale] = newIndices; + } } } /// - /// Used to retrieve a specific . + /// Converts a CLDR locale ID to a normalized form for indexing. + /// + /// See the LDML spec + /// for an explanation of the forms that Unicode locale IDs can take. + /// + /// Notably, CLDR locale IDs use underscores as separators, while BCP 47 (which is the primary form + /// we expect as inputs at runtime) use dashes. + /// + /// + /// The return string is intended to be used for case-insensitive runtime lookup of input locales, + /// but the string itself is not strictly BCP 47 or CLDR compliant. For example, the CLDR 'root' + /// language is passed through instead of being remapped to 'und'. + /// + private string NormalizeCldrLocale(string cldrLocaleId) + { + return cldrLocaleId.Replace('_', '-'); + } + + /// + /// Helper type to represent the pluralization rules for a given locale, which may include both + /// cardinal and ordinal rules, or just one of the two. /// - /// e.g., 'cardinal' or 'ordinal'. - /// Two-letter language tag. - public readonly record struct PluralRuleKey(string PluralType, string Locale); + /// + /// For example, in CLDR 48.1, "pt_PT" has a defined plural rule but is expected to chain to "pt" + /// for ordinal lookup. + /// + public record PluralRuleIndices(int? CardinalRuleIndex, int? OrdinalRuleIndex); } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs index 6ead4a6..caf0dec 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/SourceGeneration/PluralRulesMetadataGenerator.cs @@ -17,8 +17,10 @@ public PluralRulesMetadataGenerator(PluralRuleSet rules) public string GenerateClass() { + WriteLine("#nullable enable"); WriteLine("using System;"); WriteLine("using System.Collections.Generic;"); + WriteLine("using System.Diagnostics.CodeAnalysis;"); WriteLine("namespace Jeffijoe.MessageFormat.Formatting.Formatters"); WriteLine("{"); @@ -28,6 +30,12 @@ public string GenerateClass() WriteLine("{"); AddIndent(); + // Export a constant for the normalized root locale to match the logic we're using internally. + // This way the rest of the lib's locale chaining can continue to work if we swap out + // normalization internally. + var rootRules = _rules.RuleIndicesByLocale[PluralRuleSet.RootLocale]; + WriteLine($"public static readonly string RootLocale = \"{PluralRuleSet.RootLocale}\";"); + // Generate a method for each unique rule, by index, that chooses the plural form // for a given input source number (the PluralContext) according to that rule. var uniqueRules = _rules.UniqueRules; @@ -47,19 +55,29 @@ public string GenerateClass() WriteLine(string.Empty); } - // Generate a static lookup dictionary of each (locale, plural type) to the corresponding rule method - // to use for that locale and type. - WriteLine("private static readonly Dictionary Pluralizers = new Dictionary()"); + // Generate a static lookup dictionary of locale (case-insensitive) to LocalePluralizers for that locale. + // e.g., + // en -> { + // Cardinal = Rule0, + // Ordinal = Rule1, + // }, + // [etc for other locales, with some null values for unmapped locales] + WriteLine("private static readonly Dictionary Pluralizers = new(StringComparer.OrdinalIgnoreCase)"); WriteLine("{"); AddIndent(); - foreach (var kvp in _rules.RuleIndicesByKey) + foreach (var kvp in _rules.RuleIndicesByLocale) { - string locale = kvp.Key.Locale; - string pluralType = kvp.Key.PluralType; - int ruleIdx = kvp.Value; - - WriteLine($"{{new PluralRuleKey(Locale: \"{locale}\", PluralType: \"{pluralType}\"), Rule{ruleIdx}}},"); + string locale = kvp.Key; + + // When index is defined, we want "Rule#" as a reference to the delegate generated above; + // otherwise we want null. + int? cardinalIdx = kvp.Value.CardinalRuleIndex; + int? ordinalIdx = kvp.Value.OrdinalRuleIndex; + string cardinalValue = cardinalIdx is not null ? $"Rule{cardinalIdx}" : "null"; + string ordinalValue = ordinalIdx is not null ? $"Rule{ordinalIdx}" : "null"; + + WriteLine($"{{\"{locale}\", new LocalePluralizers(Cardinal: {cardinalValue}, Ordinal: {ordinalValue})}},"); } DecreaseIndent(); @@ -68,14 +86,45 @@ public string GenerateClass() // Finally generate our public API to the rest of the library, that takes a locale and pluralType // and tries to retrieve an appropriate localizer to map an input source number to the form for the request. - WriteLine("public static partial bool TryGetRuleByLocale(PluralRuleKey key, out ContextPluralizer contextPluralizer)"); + WriteLine("public static partial bool TryGetCardinalRuleByLocale(string locale, [NotNullWhen(true)] out ContextPluralizer? contextPluralizer)"); WriteLine("{"); AddIndent(); - WriteLine("return Pluralizers.TryGetValue(key, out contextPluralizer);"); + WriteLine("if (!Pluralizers.TryGetValue(locale, out var pluralizersForLocale))"); + WriteLine("{"); + AddIndent(); + WriteLine("contextPluralizer = null;"); + WriteLine("return false;"); + DecreaseIndent(); + WriteLine("}"); + WriteLine("contextPluralizer = pluralizersForLocale.Cardinal;"); + WriteLine("return contextPluralizer != null;"); DecreaseIndent(); WriteLine("}"); + WriteLine(string.Empty); + + // Repeat the above for ordinal rules. + WriteLine("public static partial bool TryGetOrdinalRuleByLocale(string locale, [NotNullWhen(true)] out ContextPluralizer? contextPluralizer)"); + WriteLine("{"); + AddIndent(); + + WriteLine("if (!Pluralizers.TryGetValue(locale, out var pluralizersForLocale))"); + WriteLine("{"); + AddIndent(); + WriteLine("contextPluralizer = null;"); + WriteLine("return false;"); + DecreaseIndent(); + WriteLine("}"); + WriteLine("contextPluralizer = pluralizersForLocale.Ordinal;"); + WriteLine("return contextPluralizer != null;"); + + DecreaseIndent(); + WriteLine("}"); + + // Generate the helper record and then clean up. + WriteLine(string.Empty); + WriteLine("private record LocalePluralizers(ContextPluralizer? Cardinal, ContextPluralizer? Ordinal);"); DecreaseIndent(); WriteLine("}"); diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs index 94cfe51..01d6d0b 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs @@ -42,13 +42,13 @@ public void Pluralize(double n, string expected) new ParsedArguments( new[] { - new KeyedBlock("zero", "nothing"), + new KeyedBlock("=0", "nothing"), new KeyedBlock("one", "just one"), new KeyedBlock("other", "wow") }, Array.Empty()); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - var actual = subject.Pluralize(PluralRuleKey.Cardinal("en"), arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + var actual = subject.Pluralize("en", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); Assert.Equal(expected, actual); } @@ -56,7 +56,7 @@ public void Pluralize(double n, string expected) /// The pluralize_defaults_to_en_locale_when_specified_locale_is_not_found /// [Fact] - public void Pluralize_defaults_to_en_locale_when_specified_locale_is_not_found() + public void Pluralize_defaults_to_root_locale_when_specified_locale_is_not_found() { var subject = new PluralFormatter(); var args = new Dictionary { { "test", 1 } }; @@ -64,14 +64,14 @@ public void Pluralize_defaults_to_en_locale_when_specified_locale_is_not_found() new ParsedArguments( new[] { - new KeyedBlock("zero", "nothing"), + new KeyedBlock("=0", "nothing"), new KeyedBlock("one", "just one"), - new KeyedBlock("other", "wow") + new KeyedBlock("other", "wow") // Root locale should resolve "1" to "other" }, Array.Empty()); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - var actual = subject.Pluralize(PluralRuleKey.Cardinal("unknown"), arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); - Assert.Equal("just one", actual); + var actual = subject.Pluralize("unknown", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + Assert.Equal("wow", actual); } /// @@ -86,12 +86,12 @@ public void Pluralize_throws_when_missing_other_block() new ParsedArguments( new[] { - new KeyedBlock("zero", "nothing"), + new KeyedBlock("=0", "nothing"), new KeyedBlock("one", "just one") }, Array.Empty()); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - Assert.Throws(() => subject.Pluralize(PluralRuleKey.Cardinal("unknown"), arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0)); + Assert.Throws(() => subject.Pluralize(PluralRulesMetadata.RootLocale, PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0)); } /// diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs index 579be27..2c296b8 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs @@ -403,6 +403,24 @@ public void FormatMessage(string source, Dictionary args, strin { var subject = new MessageFormatter(false); + // Historically these tests relied on a default English pluralizer that mapped + // 0 to "zero"; adding that back in manually to ensure we maintain test coverage + // for multiple forms. + subject.Pluralizers!.Add("en", (number) => + { + if (number == 0) + { + return "zero"; + } else if (number == 1) + { + return "one"; + } + else + { + return "other"; + } + }); + // Warmup subject.FormatMessage(source, args); Benchmark.Start("Formatting", this.outputHelper); @@ -486,7 +504,7 @@ public void FormatMessage_with_reflection_overload() { var subject = new MessageFormatter(false); const string Pattern = "You have {UnreadCount, plural, " - + "zero {no unread messages}" + + "=0 {no unread messages}" + "one {just one unread message}" + "other {# unread messages}" + "} today."; var actual = subject.FormatMessage(Pattern, new { UnreadCount = 0 }); Assert.Equal("You have no unread messages today.", actual); @@ -513,7 +531,7 @@ public void ReadMe_test_to_make_sure_I_dont_look_like_a_fool() { var mf = new MessageFormatter(false); const string Str = @"You have {notifications, plural, - zero {no notifications} + =0 {no notifications} one {one notification} =42 {a universal amount of notifications} other {# notifications} @@ -528,7 +546,7 @@ public void ReadMe_test_to_make_sure_I_dont_look_like_a_fool() var mf = new MessageFormatter(false); const string Str = @"You {NUM_ADDS, plural, offset:1 =0{didnt add this to your profile} - zero{added this to your profile} + =1{added this to your profile} one{and one other person added this to their profile} other{and # others added this to their profiles} }."; @@ -636,7 +654,7 @@ public void ReadMe_test_to_make_sure_I_dont_look_like_a_fool() { var mf = new MessageFormatter(useCache: true, locale: "en"); - mf.Pluralizers![PluralRuleKey.Cardinal("en")] = n => + mf.Pluralizers!["en"] = n => { // ´n´ is the number being pluralized. // ReSharper disable once CompareOfFloatsByEqualityOperator diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs index d243171..7852024 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs @@ -32,7 +32,7 @@ public void Uk_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); - var actual = subject.Pluralize(PluralRuleKey.Cardinal("uk"), arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + var actual = subject.Pluralize("uk", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); Assert.Equal(expected, actual); } @@ -59,7 +59,7 @@ public void Ru_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); - var actual = subject.Pluralize(PluralRuleKey.Cardinal("ru"), arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var actual = subject.Pluralize("ru", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); Assert.Equal(expected, actual); } @@ -87,7 +87,7 @@ public void En_Cardinal_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); - var actual = subject.Pluralize(PluralRuleKey.Cardinal("en"), arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var actual = subject.Pluralize("en", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); Assert.Equal(expected, actual); } @@ -117,7 +117,7 @@ public void En_Ordinal_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.OrdinalFunction, null); - var pluralized = subject.Pluralize(PluralRuleKey.Ordinal("en"), arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var pluralized = subject.Pluralize("en", PluralRulesMetadata.TryGetOrdinalRuleByLocale, subject.OrdinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); var actual = subject.ReplaceNumberLiterals(pluralized, n); Assert.Equal(expected, actual); } diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs index 5779466..7060ae2 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/PluralMetadataClassGeneratorTests.cs @@ -13,7 +13,7 @@ public void CanGenerateClassFromRules() { var rules = new[] { - new PluralRule(new[] {"en", "uk"}, + new PluralRule(new[] {"root", "en", "uk"}, new[] { new Condition("one", string.Empty, new [] @@ -24,7 +24,7 @@ public void CanGenerateClassFromRules() }) }) }), - new PluralRule(new[] {"en"}, + new PluralRule(new[] {"root", "en", "pt_PT"}, new[] { new Condition("many", string.Empty, new [] @@ -46,12 +46,15 @@ public void CanGenerateClassFromRules() var actual = generator.GenerateClass(); var expected = @" +#nullable enable using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace Jeffijoe.MessageFormat.Formatting.Formatters { internal static partial class PluralRulesMetadata { + public static readonly string RootLocale = ""root""; private static string Rule0(PluralContext context) { if ((context.N == 3)) @@ -68,17 +71,38 @@ private static string Rule1(PluralContext context) return ""other""; } - private static readonly Dictionary Pluralizers = new Dictionary() + private static readonly Dictionary Pluralizers = new(StringComparer.OrdinalIgnoreCase) { - {new PluralRuleKey(Locale: ""en"", PluralType: ""cardinal""), Rule0}, - {new PluralRuleKey(Locale: ""uk"", PluralType: ""cardinal""), Rule0}, - {new PluralRuleKey(Locale: ""en"", PluralType: ""ordinal""), Rule1}, + {""root"", new LocalePluralizers(Cardinal: Rule0, Ordinal: Rule1)}, + {""en"", new LocalePluralizers(Cardinal: Rule0, Ordinal: Rule1)}, + {""uk"", new LocalePluralizers(Cardinal: Rule0, Ordinal: null)}, + {""pt-PT"", new LocalePluralizers(Cardinal: null, Ordinal: Rule1)}, + {""pt_PT"", new LocalePluralizers(Cardinal: null, Ordinal: Rule1)}, }; - public static partial bool TryGetRuleByLocale(PluralRuleKey key, out ContextPluralizer contextPluralizer) + public static partial bool TryGetCardinalRuleByLocale(string locale, [NotNullWhen(true)] out ContextPluralizer? contextPluralizer) { - return Pluralizers.TryGetValue(key, out contextPluralizer); + if (!Pluralizers.TryGetValue(locale, out var pluralizersForLocale)) + { + contextPluralizer = null; + return false; + } + contextPluralizer = pluralizersForLocale.Cardinal; + return contextPluralizer != null; } + + public static partial bool TryGetOrdinalRuleByLocale(string locale, [NotNullWhen(true)] out ContextPluralizer? contextPluralizer) + { + if (!Pluralizers.TryGetValue(locale, out var pluralizersForLocale)) + { + contextPluralizer = null; + return false; + } + contextPluralizer = pluralizersForLocale.Ordinal; + return contextPluralizer != null; + } + + private record LocalePluralizers(ContextPluralizer? Cardinal, ContextPluralizer? Ordinal); } } ".TrimStart(); diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs index 7932268..f61319d 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs @@ -15,16 +15,6 @@ namespace Jeffijoe.MessageFormat.Formatting.Formatters; /// public class PluralFormatter : BaseFormatter, IFormatter { - /// - /// CLDR plural type attribute for counting number ruleset. - /// - internal const string CardinalType = "cardinal"; - - /// - /// CLDR plural type attribute for ordered number ruleset. - /// - internal const string OrdinalType = "ordinal"; - /// /// ICU MessageFormat function name for "default" pluralization, based on cardinal numbers. /// @@ -36,14 +26,9 @@ public class PluralFormatter : BaseFormatter, IFormatter internal const string OrdinalFunction = "selectordinal"; /// - /// Maps supported parser names to CLDR plural types. - /// The plural language rule schema is identical between these types and we just need to pick the correct set. + /// Delegate type to try to look up a specific plural rule for a given locale. /// - private static readonly Dictionary CldrTypeForFunction = new() - { - { PluralFunction, CardinalType }, - { OrdinalFunction, OrdinalType } - }; + internal delegate bool TryGetRuleForLocale(string locale, [NotNullWhen(true)] out ContextPluralizer? contextPluralizer); #region Constructors and Destructors @@ -52,8 +37,8 @@ public class PluralFormatter : BaseFormatter, IFormatter /// public PluralFormatter() { - this.Pluralizers = new Dictionary(); - this.AddStandardPluralizers(); + this.Pluralizers = new Dictionary(); + this.OrdinalPluralizers = new Dictionary(); } #endregion @@ -66,12 +51,20 @@ public PluralFormatter() public bool VariableMustExist => true; /// - /// Gets the pluralizers dictionary. Key is the locale and plural type. + /// Gets the pluralizers dictionary to use for cardinal numbers. Key is the locale. /// /// /// The pluralizers. /// - public IDictionary Pluralizers { get; private set; } + public IDictionary Pluralizers { get; private set; } + + /// + /// Gets the pluralizers dictionary to use for ordinal numbers. Key is the locale. + /// + /// + /// The ordinal pluralizers. + /// + public IDictionary OrdinalPluralizers { get; private set; } #endregion @@ -93,7 +86,7 @@ public bool CanFormat(FormatterRequest request) return false; } - return CldrTypeForFunction.ContainsKey(request.FormatterName); + return request.FormatterName == PluralFunction || request.FormatterName == OrdinalFunction; } /// @@ -137,14 +130,28 @@ public string Format(string locale, // Get CLDR plural ruleset from request. // CanFormat() should have guaranteed this is valid, but we'll be defensive just in case. - if (!CldrTypeForFunction.TryGetValue(request.FormatterName ?? string.Empty, out var pluralType)) + TryGetRuleForLocale cldrPluralLookup; + IDictionary customLookup; + if (request.FormatterName == PluralFunction) + { + cldrPluralLookup = PluralRulesMetadata.TryGetCardinalRuleByLocale; + customLookup = this.Pluralizers; + } + else if (request.FormatterName == OrdinalFunction) + { + cldrPluralLookup = PluralRulesMetadata.TryGetOrdinalRuleByLocale; + customLookup = this.OrdinalPluralizers; + } + else { throw new MessageFormatterException($"Unsupported plural formatter name: {request.FormatterName}"); } var ctx = CreatePluralContext(value, offset); var pluralized = this.Pluralize( - new PluralRuleKey(PluralType: pluralType, Locale: locale), + locale, + cldrPluralLookup, + customLookup, arguments, ctx, offset); @@ -160,8 +167,15 @@ public string Format(string locale, /// /// Returns the correct plural block. /// - /// - /// The locale and pluralType. + /// + /// The locale. + /// + /// + /// Delegate to retrieve a for a given locale. + /// + /// + /// Dictionary to retrieve a for a given locale, to be evaluated + /// before resolving against . /// /// /// The parsed arguments string. @@ -176,27 +190,38 @@ public string Format(string locale, /// The . /// /// - /// The 'other' option was not found in pattern. + /// The 'other' option was not found in pattern, or is missing + /// both the provided locale and the CLDR root locale. /// [SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1126:PrefixCallsCorrectly", Justification = "Reviewed. Suppression is OK here.")] - internal string Pluralize(PluralRuleKey ruleKey, ParsedArguments arguments, PluralContext context, double offset) + internal string Pluralize( + string locale, + TryGetRuleForLocale cldrPluralLookup, + IDictionary customLookup, + ParsedArguments arguments, + PluralContext context, + double offset) { string pluralForm; - if (this.Pluralizers.TryGetValue(ruleKey, out var pluralizer)) + if (customLookup.TryGetValue(locale, out var pluralizer)) { pluralForm = pluralizer(context.Number); } - else if (PluralRulesMetadata.TryGetRuleByLocale(ruleKey, out var contextPluralizer)) + else if (cldrPluralLookup(locale, out var contextPluralizer)) { pluralForm = contextPluralizer(context); } + else if (cldrPluralLookup(PluralRulesMetadata.RootLocale, out var rootPluralizer)) + { + pluralForm = rootPluralizer(context); + } else { - pluralForm = this.Pluralizers[new PluralRuleKey(Locale: "en", PluralType: ruleKey.PluralType)](context.Number); + throw new MessageFormatterException($"Could not find either locale {locale} or root locale {PluralRulesMetadata.RootLocale} in specified plural rule lookup"); } - - KeyedBlock? other = null; + + KeyedBlock? other = null; foreach (var keyedBlock in arguments.KeyedBlocks) { if (keyedBlock.Key == OtherKey) @@ -332,58 +357,6 @@ internal string ReplaceNumberLiterals(string pluralized, double n) } } - /// - /// Adds the standard pluralizers. - /// - private void AddStandardPluralizers() - { - this.Pluralizers.Add( - PluralRuleKey.Cardinal("en"), - n => - { - // ReSharper disable CompareOfFloatsByEqualityOperator - if (n == 0) - { - return "zero"; - } - - if (n == 1) - { - return "one"; - } - - // ReSharper restore CompareOfFloatsByEqualityOperator - return "other"; - } - ); - this.Pluralizers.Add( - PluralRuleKey.Ordinal("en"), - n => - { - // e.g., 1st - if (n % 10 == 1 && n % 100 != 11) - { - return "one"; - } - - // e.g., 2nd - if (n % 10 == 2 && n % 100 != 12) - { - return "two"; - } - - // e.g., 3rd - if (n % 10 == 3 && n % 100 != 13) - { - return "few"; - } - - // e.g., 4th, 11th, etc - return "other"; - } - ); - } - /// /// Creates a for the specified value. /// diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs deleted file mode 100644 index 60a5fbb..0000000 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRuleKey.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Jeffijoe.MessageFormat.Formatting.Formatters; - -/// -/// Used to retrieve a specific . -/// -/// e.g., 'cardinal' or 'ordinal'. -/// Two-letter language tag. -public readonly record struct PluralRuleKey(string PluralType, string Locale) -{ - /// - /// Helper to generate a cardinal rule look up for a locale, suitable for the 'plural' MessageFormat function. - /// - public static PluralRuleKey Cardinal(string locale) => new(PluralType: PluralFormatter.CardinalType, Locale: locale); - - /// - /// Helper to generate an ordinal rule look up for a locale, suitable for the 'selectordinal' MessageFormat function. - /// - public static PluralRuleKey Ordinal(string locale) => new(PluralType: PluralFormatter.OrdinalType, Locale: locale); -} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs index ccf5bb2..3c18382 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralRulesMetadata.cs @@ -5,5 +5,7 @@ namespace Jeffijoe.MessageFormat.Formatting.Formatters; [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal static partial class PluralRulesMetadata { - public static partial bool TryGetRuleByLocale(PluralRuleKey key, out ContextPluralizer contextPluralizer); + public static partial bool TryGetCardinalRuleByLocale(string locale, out ContextPluralizer? contextPluralizer); + + public static partial bool TryGetOrdinalRuleByLocale(string locale, out ContextPluralizer? contextPluralizer); } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/MessageFormatter.cs b/src/Jeffijoe.MessageFormat/MessageFormatter.cs index 4c37b78..da147a2 100644 --- a/src/Jeffijoe.MessageFormat/MessageFormatter.cs +++ b/src/Jeffijoe.MessageFormat/MessageFormatter.cs @@ -145,12 +145,12 @@ public IFormatterLibrary Formatters public string Locale { get; set; } /// - /// Gets the pluralizers dictionary from the , if set. Key is the locale, then the plural type. + /// Gets the pluralizers dictionary from the , if set. Key is the locale. /// /// /// The pluralizers, or null if the plural formatter has not been added. /// - public IDictionary? Pluralizers + public IDictionary? Pluralizers { get { @@ -159,6 +159,21 @@ public IDictionary? Pluralizers } } + /// + /// Gets the ordinal number pluralizers dictionary from the , if set. Key is the locale. + /// + /// + /// The pluralizers, or null if the plural formatter has not been added. + /// + public IDictionary? OrdinalPluralizers + { + get + { + var pluralFormatter = this.Formatters.OfType().FirstOrDefault(); + return pluralFormatter?.OrdinalPluralizers; + } + } + #endregion #region Public Methods and Operators From e9cea37c6ab561588a07a71ddf24683a66d874bf Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Mon, 16 Feb 2026 22:45:21 -0800 Subject: [PATCH 15/18] Locale fallback --- .../Helpers/LocaleHelperTests.cs | 61 +++++++++++++++++++ .../GeneratedPluralRulesTests.cs | 34 +++++++++-- .../Formatting/Formatters/PluralFormatter.cs | 26 +++++--- .../Helpers/LocaleHelper.cs | 57 +++++++++++++++++ 4 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 src/Jeffijoe.MessageFormat.Tests/Helpers/LocaleHelperTests.cs create mode 100644 src/Jeffijoe.MessageFormat/Helpers/LocaleHelper.cs diff --git a/src/Jeffijoe.MessageFormat.Tests/Helpers/LocaleHelperTests.cs b/src/Jeffijoe.MessageFormat.Tests/Helpers/LocaleHelperTests.cs new file mode 100644 index 0000000..e785d5f --- /dev/null +++ b/src/Jeffijoe.MessageFormat.Tests/Helpers/LocaleHelperTests.cs @@ -0,0 +1,61 @@ +using Jeffijoe.MessageFormat.Formatting.Formatters; +using Jeffijoe.MessageFormat.Helpers; +using System.Linq; +using Xunit; + +namespace Jeffijoe.MessageFormat.Tests.Helpers; + +/// +/// The locale helper tests. +/// +public class LocaleHelperTests +{ + /// + /// Tests that both '-' and '_' are supported when extracting the base language. + /// + [Fact] + public void GetInheritanceChain_HandlesBothSeparators() + { + Assert.Equal( + ["en-US", "en", PluralRulesMetadata.RootLocale], + LocaleHelper.GetInheritanceChain("en-US").ToList() + ); + + Assert.Equal( + ["en_US", "en", PluralRulesMetadata.RootLocale], + LocaleHelper.GetInheritanceChain("en_US").ToList() + ); + } + + /// + /// Confirms that our implementation only returns the original locale, + /// the language, and the root. + /// + /// + /// This is a perf optimization given the CLDR data set we're using. + /// + [Fact] + public void GetInheritanceChain_SkipsIntermediateTags() + { + Assert.Equal( + ["th-TH-u-nu-thai", "th", PluralRulesMetadata.RootLocale], + LocaleHelper.GetInheritanceChain("th-TH-u-nu-thai").ToList() + ); + } + + [Theory] + [InlineData("")] + [InlineData("-")] + [InlineData("_")] + [InlineData("x")] + [InlineData("x-")] + [InlineData("x-test")] + [InlineData("i-test")] + public void GetInheritanceChain_HandlesBadInput(string input) + { + Assert.Equal( + [PluralRulesMetadata.RootLocale], + LocaleHelper.GetInheritanceChain(input).ToList() + ); + } +} \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs index 7852024..6f154b6 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Formatting.Formatters; +using Jeffijoe.MessageFormat.Helpers; using Jeffijoe.MessageFormat.Parsing; using Xunit; @@ -69,7 +71,7 @@ public void Ru_PluralizerTests(double n, string expected) [InlineData(101, "days")] [InlineData(102, "days")] [InlineData(105, "days")] - public void En_Cardinal_PluralizerTests(double n, string expected) + public void EnUS_Cardinal_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); subject.Pluralizers.Clear(); @@ -87,7 +89,7 @@ public void En_Cardinal_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); - var actual = subject.Pluralize("en", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var actual = subject.Pluralize("en_US", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); Assert.Equal(expected, actual); } @@ -100,7 +102,7 @@ public void En_Cardinal_PluralizerTests(double n, string expected) [InlineData(9, "9th")] [InlineData(11, "11th")] [InlineData(21, "21st")] - public void En_Ordinal_PluralizerTests(double n, string expected) + public void EnUS_Ordinal_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); subject.Pluralizers.Clear(); @@ -117,8 +119,32 @@ public void En_Ordinal_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.OrdinalFunction, null); - var pluralized = subject.Pluralize("en", PluralRulesMetadata.TryGetOrdinalRuleByLocale, subject.OrdinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var pluralized = subject.Pluralize("en-US", PluralRulesMetadata.TryGetOrdinalRuleByLocale, subject.OrdinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); var actual = subject.ReplaceNumberLiterals(pluralized, n); Assert.Equal(expected, actual); } + + [Fact] + public void RootLocale_MatchesRules() + { + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale(PluralRulesMetadata.RootLocale, out _)); + Assert.True(PluralRulesMetadata.TryGetOrdinalRuleByLocale(PluralRulesMetadata.RootLocale, out _)); + } + + /// + /// Tests to confirm that separators normalize properly in the data, + /// and that language lookups are case insensitive. + /// + [Fact] + public void Fallback_PluralizerTests() + { + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale("kok_Latn", out _)); + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale("pt-PT", out _)); + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale("pt-pt", out _)); + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale("PT_PT", out _)); + Assert.True(PluralRulesMetadata.TryGetCardinalRuleByLocale("pT", out _)); + + Assert.True(PluralRulesMetadata.TryGetOrdinalRuleByLocale("kok_Latn", out _)); + Assert.False(PluralRulesMetadata.TryGetOrdinalRuleByLocale("pt-PT", out _)); + } } \ No newline at end of file diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs index f61319d..93f3c88 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs @@ -3,6 +3,7 @@ // Author: Jeff Hansen // Copyright (C) Jeff Hansen 2014. All rights reserved. +using Jeffijoe.MessageFormat.Helpers; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -203,25 +204,30 @@ internal string Pluralize( PluralContext context, double offset) { - string pluralForm; + string? pluralForm = null; if (customLookup.TryGetValue(locale, out var pluralizer)) { pluralForm = pluralizer(context.Number); } - else if (cldrPluralLookup(locale, out var contextPluralizer)) - { - pluralForm = contextPluralizer(context); - } - else if (cldrPluralLookup(PluralRulesMetadata.RootLocale, out var rootPluralizer)) + else { - pluralForm = rootPluralizer(context); + foreach (var candidate in LocaleHelper.GetInheritanceChain(locale)) + { + if (cldrPluralLookup(candidate, out var contextPluralizer)) + { + pluralForm = contextPluralizer(context); + break; + } + } } - else + + if (pluralForm is null) { - throw new MessageFormatterException($"Could not find either locale {locale} or root locale {PluralRulesMetadata.RootLocale} in specified plural rule lookup"); + // GetInheritanceChain should resolve the root CLDR locale as a last attempt, so this should never happen... + throw new MessageFormatterException($"Could not find locale {locale} in specified plural rule lookup"); } - KeyedBlock? other = null; + KeyedBlock? other = null; foreach (var keyedBlock in arguments.KeyedBlocks) { if (keyedBlock.Key == OtherKey) diff --git a/src/Jeffijoe.MessageFormat/Helpers/LocaleHelper.cs b/src/Jeffijoe.MessageFormat/Helpers/LocaleHelper.cs new file mode 100644 index 0000000..a5a31cc --- /dev/null +++ b/src/Jeffijoe.MessageFormat/Helpers/LocaleHelper.cs @@ -0,0 +1,57 @@ +using Jeffijoe.MessageFormat.Formatting.Formatters; +using System.Collections.Generic; + +namespace Jeffijoe.MessageFormat.Helpers; + +/// +/// Helpers for working with locale strings. +/// +internal class LocaleHelper +{ + /// + /// Partial implementation of locale inheritance + /// from the LDML spec. + /// + /// Given an input locale in BCP 47 format, yields back various strings to use as lookups in CLDR data. + /// + /// + /// This function doesn't perform any canonicalization of input or fully implement the LDML spec. + /// It first yields the input as-is, then the base language tag, then the CLDR "root" value. + /// + /// This is because at the time of authorship, the only lookups needed by this library are for CLDR plurals, + /// which almost exclusively use languages without subtags. + /// + /// + /// Given "language-Script-REGION", yields: + /// - language-Script-REGION + /// - language + /// - root + /// + /// A BCP 47 locale tag + public static IEnumerable GetInheritanceChain(string locale) + { + // 0 or 1 characters do not form a valid language ID, so we can skip those + // Also skip x- and i- as those BCP 47 tags will never match CLDR and should + // only resolve to 'root'. + if (locale.Length >= 2 && locale[1] != '-') + { + yield return locale; + } + + // If the length is 2, we don't have any subtags for valid input + if (locale.Length >= 3 && locale[1] != '-') + { + // Find the first separator character, Substring to that, and break + for (int i = 2; i < locale.Length; i++) + { + if (locale[i] == '_' || locale[i] == '-') + { + yield return locale.Substring(0, i); + break; + } + } + } + + yield return PluralRulesMetadata.RootLocale; + } +} From e464a9a452a29278c80f611620e27612c33dc828 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Mon, 16 Feb 2026 22:51:42 -0800 Subject: [PATCH 16/18] Cleanup formatting --- .../Plural/Parsing/PluralParser.cs | 3 +-- .../MetadataGenerator/GeneratedPluralRulesTests.cs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs index 793e79d..1656357 100644 --- a/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs +++ b/src/Jeffijoe.MessageFormat.MetadataGenerator/Plural/Parsing/PluralParser.cs @@ -29,7 +29,7 @@ public PluralRuleSet Parse() } /// - /// Parses the represented XML document and merges the rules into the given . + /// Parses the represented XML document and merges the rules into the given . /// /// /// If the CLDR XML is missing expected attributes. @@ -76,7 +76,6 @@ public void ParseInto(PluralRuleSet ruleIndex) } var conditions = new List(); - foreach (XmlNode condition in rule.ChildNodes) { if (condition.Name == "pluralRule") diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs index 6f154b6..87182c1 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs @@ -81,8 +81,7 @@ public void EnUS_Cardinal_PluralizerTests(double n, string expected) new ParsedArguments( new[] { - // 'zero' is a red herring to confirm CLDR rules are used instead of built-in; - // CLDR does not specify an English 'zero' form, so 0 should fallthrough to 'other'. + // Regression test to ensure 0 does not match 'zero' for English new KeyedBlock("zero", "FAIL"), new KeyedBlock("one", "day"), new KeyedBlock("other", "days") From 13c6ae2a24816a123eb0b0b0801636d76cb79d32 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Tue, 17 Feb 2026 10:03:47 -0800 Subject: [PATCH 17/18] Rename properties --- README.md | 8 ++++---- .../Formatters/PluralFormatterTests.cs | 6 +++--- .../MessageFormatterFullIntegrationTests.cs | 4 ++-- .../GeneratedPluralRulesTests.cs | 14 +++++++------- .../Formatting/Formatters/PluralFormatter.cs | 8 ++++---- src/Jeffijoe.MessageFormat/MessageFormatter.cs | 18 ++++++++++++++---- 6 files changed, 34 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index c5ece6f..52ad3dd 100644 --- a/README.md +++ b/README.md @@ -137,10 +137,10 @@ var message = formatter.FormatMessage("{value, number, $0.0}", new { value = 23 ## Adding your own pluralizer functions > Since MessageFormat 5.0, pluralizers based on the [official CLDR data][plural-cldr] ship -> with the package, so this is no longer needed. +> with the package, so this is no longer needed except when overriding specific custom locales. Same thing as with [MessageFormat.js][0], you can add your own pluralizer function. -The `Pluralizers` property is a `IDictionary` that starts empty, along +The `CardinalPluralizers` property is a `IDictionary` that starts empty, along with `OrdinalPluralizers` for ordinal numbers. Adding to these Dictionaries will take precedence over the CLDR data for exact matches on @@ -148,7 +148,7 @@ the input locales. ````csharp var mf = new MessageFormatter(); -mf.Pluralizers.Add("", n => { +mf.CardinalPluralizers.Add("", n => { // ´n´ is the number being pluralized. if(n == 0) return "zero"; @@ -163,7 +163,7 @@ you may use in your pluralization block. ````csharp var mf = new MessageFormatter(true, "en"); // true = use cache -mf.Pluralizers["en"] = n => +mf.CardinalPluralizers["en"] = n => { // ´n´ is the number being pluralized. if (n == 1) diff --git a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs index 01d6d0b..9ad7cc7 100644 --- a/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/Formatting/Formatters/PluralFormatterTests.cs @@ -48,7 +48,7 @@ public void Pluralize(double n, string expected) }, Array.Empty()); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - var actual = subject.Pluralize("en", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + var actual = subject.Pluralize("en", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); Assert.Equal(expected, actual); } @@ -70,7 +70,7 @@ public void Pluralize_defaults_to_root_locale_when_specified_locale_is_not_found }, Array.Empty()); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - var actual = subject.Pluralize("unknown", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + var actual = subject.Pluralize("unknown", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); Assert.Equal("wow", actual); } @@ -91,7 +91,7 @@ public void Pluralize_throws_when_missing_other_block() }, Array.Empty()); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", "plural", null); - Assert.Throws(() => subject.Pluralize(PluralRulesMetadata.RootLocale, PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0)); + Assert.Throws(() => subject.Pluralize(PluralRulesMetadata.RootLocale, PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0)); } /// diff --git a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs index 2c296b8..ddb6497 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MessageFormatterFullIntegrationTests.cs @@ -406,7 +406,7 @@ public void FormatMessage(string source, Dictionary args, strin // Historically these tests relied on a default English pluralizer that mapped // 0 to "zero"; adding that back in manually to ensure we maintain test coverage // for multiple forms. - subject.Pluralizers!.Add("en", (number) => + subject.CardinalPluralizers!.Add("en", (number) => { if (number == 0) { @@ -654,7 +654,7 @@ public void ReadMe_test_to_make_sure_I_dont_look_like_a_fool() { var mf = new MessageFormatter(useCache: true, locale: "en"); - mf.Pluralizers!["en"] = n => + mf.CardinalPluralizers!["en"] = n => { // ´n´ is the number being pluralized. // ReSharper disable once CompareOfFloatsByEqualityOperator diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs index 87182c1..75fbc0d 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs @@ -20,7 +20,7 @@ public class GeneratedPluralRulesTests public void Uk_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); - subject.Pluralizers.Clear(); + subject.CardinalPluralizers.Clear(); var args = new Dictionary { { "test", n } }; var arguments = @@ -34,7 +34,7 @@ public void Uk_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); - var actual = subject.Pluralize("uk", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); + var actual = subject.Pluralize("uk", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(Convert.ToDouble(args[request.Variable]))), 0); Assert.Equal(expected, actual); } @@ -47,7 +47,7 @@ public void Uk_PluralizerTests(double n, string expected) public void Ru_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); - subject.Pluralizers.Clear(); + subject.CardinalPluralizers.Clear(); var args = new Dictionary { { "test", n } }; var arguments = @@ -61,7 +61,7 @@ public void Ru_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); - var actual = subject.Pluralize("ru", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var actual = subject.Pluralize("ru", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); Assert.Equal(expected, actual); } @@ -74,7 +74,7 @@ public void Ru_PluralizerTests(double n, string expected) public void EnUS_Cardinal_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); - subject.Pluralizers.Clear(); + subject.CardinalPluralizers.Clear(); var args = new Dictionary { { "test", n } }; var arguments = @@ -88,7 +88,7 @@ public void EnUS_Cardinal_PluralizerTests(double n, string expected) }, new FormatterExtension[0]); var request = new FormatterRequest(new Literal(1, 1, 1, 1, ""), "test", PluralFormatter.PluralFunction, null); - var actual = subject.Pluralize("en_US", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.Pluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); + var actual = subject.Pluralize("en_US", PluralRulesMetadata.TryGetCardinalRuleByLocale, subject.CardinalPluralizers, arguments, new PluralContext(Convert.ToDecimal(args[request.Variable])), 0); Assert.Equal(expected, actual); } @@ -104,7 +104,7 @@ public void EnUS_Cardinal_PluralizerTests(double n, string expected) public void EnUS_Ordinal_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); - subject.Pluralizers.Clear(); + subject.CardinalPluralizers.Clear(); var args = new Dictionary { { "test", n } }; var arguments = diff --git a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs index 93f3c88..3d005ff 100644 --- a/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs +++ b/src/Jeffijoe.MessageFormat/Formatting/Formatters/PluralFormatter.cs @@ -38,7 +38,7 @@ public class PluralFormatter : BaseFormatter, IFormatter /// public PluralFormatter() { - this.Pluralizers = new Dictionary(); + this.CardinalPluralizers = new Dictionary(); this.OrdinalPluralizers = new Dictionary(); } @@ -57,7 +57,7 @@ public PluralFormatter() /// /// The pluralizers. /// - public IDictionary Pluralizers { get; private set; } + public IDictionary CardinalPluralizers { get; } /// /// Gets the pluralizers dictionary to use for ordinal numbers. Key is the locale. @@ -65,7 +65,7 @@ public PluralFormatter() /// /// The ordinal pluralizers. /// - public IDictionary OrdinalPluralizers { get; private set; } + public IDictionary OrdinalPluralizers { get; } #endregion @@ -136,7 +136,7 @@ public string Format(string locale, if (request.FormatterName == PluralFunction) { cldrPluralLookup = PluralRulesMetadata.TryGetCardinalRuleByLocale; - customLookup = this.Pluralizers; + customLookup = this.CardinalPluralizers; } else if (request.FormatterName == OrdinalFunction) { diff --git a/src/Jeffijoe.MessageFormat/MessageFormatter.cs b/src/Jeffijoe.MessageFormat/MessageFormatter.cs index da147a2..3e90160 100644 --- a/src/Jeffijoe.MessageFormat/MessageFormatter.cs +++ b/src/Jeffijoe.MessageFormat/MessageFormatter.cs @@ -145,23 +145,33 @@ public IFormatterLibrary Formatters public string Locale { get; set; } /// - /// Gets the pluralizers dictionary from the , if set. Key is the locale. + /// Gets the custom cardinal pluralizers dictionary from the , if set. Key is the locale. + /// These are the pluralizers used to translate e.g., {count, plural, one {1 book} other {# books}} /// + /// + /// The library relies on Unicode CLDR rules for locales by default, and any values in this dictionary override those behaviors + /// for the specified locales. + /// /// /// The pluralizers, or null if the plural formatter has not been added. /// - public IDictionary? Pluralizers + public IDictionary? CardinalPluralizers { get { var pluralFormatter = this.Formatters.OfType().FirstOrDefault(); - return pluralFormatter?.Pluralizers; + return pluralFormatter?.CardinalPluralizers; } } /// - /// Gets the ordinal number pluralizers dictionary from the , if set. Key is the locale. + /// Gets the custom ordinal number pluralizers dictionary from the , if set. Key is the locale. + /// These are the pluralizers used to translate e.g., {count, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} /// + /// + /// The library relies on Unicode CLDR rules for locales by default, and any values in this dictionary override those behaviors + /// for the specified locales. + /// /// /// The pluralizers, or null if the plural formatter has not been added. /// From f587a11a6899d57b8e2962b5a9f91adcd4f9a757 Mon Sep 17 00:00:00 2001 From: Steven Fuqua Date: Tue, 17 Feb 2026 13:27:24 -0800 Subject: [PATCH 18/18] Revert redundant Clear() calls --- .../MetadataGenerator/GeneratedPluralRulesTests.cs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs index 75fbc0d..09f56bc 100644 --- a/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs +++ b/src/Jeffijoe.MessageFormat.Tests/MetadataGenerator/GeneratedPluralRulesTests.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; -using System.Linq; using Jeffijoe.MessageFormat.Formatting; using Jeffijoe.MessageFormat.Formatting.Formatters; -using Jeffijoe.MessageFormat.Helpers; using Jeffijoe.MessageFormat.Parsing; using Xunit; @@ -20,8 +18,6 @@ public class GeneratedPluralRulesTests public void Uk_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); - subject.CardinalPluralizers.Clear(); - var args = new Dictionary { { "test", n } }; var arguments = new ParsedArguments( @@ -47,8 +43,6 @@ public void Uk_PluralizerTests(double n, string expected) public void Ru_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); - subject.CardinalPluralizers.Clear(); - var args = new Dictionary { { "test", n } }; var arguments = new ParsedArguments( @@ -74,8 +68,6 @@ public void Ru_PluralizerTests(double n, string expected) public void EnUS_Cardinal_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); - subject.CardinalPluralizers.Clear(); - var args = new Dictionary { { "test", n } }; var arguments = new ParsedArguments( @@ -104,8 +96,6 @@ public void EnUS_Cardinal_PluralizerTests(double n, string expected) public void EnUS_Ordinal_PluralizerTests(double n, string expected) { var subject = new PluralFormatter(); - subject.CardinalPluralizers.Clear(); - var args = new Dictionary { { "test", n } }; var arguments = new ParsedArguments(