Skip to content

Commit b22ccfd

Browse files
authored
Fix: Apply absolute value logic in PluralRules (#506)
This PR addresses a long-standing inconsistency in the pluralization logic used by `PluralRules`. Previously, the implementation did not explicitly evaluate the absolute value of the input number, contrary to the CLDR specification. This led to incorrect plural category resolution for negative numbers, which typically defaulted to the `OTHER` category. #### Changes - The decimal argument passed to all `PluralRules` delegates is now transformed to its absolute value before evaluation. - This change ensures that plural category resolution aligns with CLDR expectations, particularly because locales with plural forms depend on numeric value regardless of sign. #### Impact - Fixes incorrect plural category resolution for negative numbers in locales such as `DualOneOther`, where the number of plural word variants influences the outcome. - May affect pluralization behavior in edge cases across multiple locales. Unit tests have been updated to reflect the corrected logic. - The change affects `PluralLocalizationFormatter` and `TimeFormatter`. - **Thoroughly test the new version in your localization workflows**, especially if you rely on custom pluralization delegates or locale-specific plural form resolution. This change may surface previously masked edge cases. #### Versioning - The PR will be published with a new **minor version**, as this change corrects a bug but may alter behavior in downstream formatting logic. #### Background See discussion in #503 and #497 for context.
1 parent 040f236 commit b22ccfd

2 files changed

Lines changed: 140 additions & 58 deletions

File tree

src/SmartFormat.Tests/Extensions/PluralLocalizationFormatterTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public void Test_Default()
9999
new CultureInfo("en-US"),
100100
"There {0:plural:is|are} {0} {0:plural:item|items} remaining",
101101
new ExpectedResults {
102-
{ -1, "There are -1 items remaining"},
102+
{ -1, "There is -1 item remaining"},
103103
{ 0, "There are 0 items remaining"},
104104
{0.5m, "There are 0.5 items remaining"},
105105
{ 1, "There is 1 item remaining"},
@@ -116,7 +116,7 @@ public void Test_English()
116116
new CultureInfo("en-US"),
117117
"There {0:plural:is|are} {0} {0:plural:item|items} remaining",
118118
new ExpectedResults {
119-
{ -1, "There are -1 items remaining"},
119+
{ -1, "There is -1 item remaining"},
120120
{ 0, "There are 0 items remaining"},
121121
{0.5m, "There are 0.5 items remaining"},
122122
{ 1, "There is 1 item remaining"},
@@ -184,7 +184,7 @@ public void Test_French_3words(int count, string expected)
184184
Assert.That(actual, Is.EqualTo(string.Format(ci, expected, count)));
185185
}
186186

187-
[TestCase(-1, "-")]
187+
[TestCase(-1, "une personne")] // -1 is treated as 1 (singular)
188188
[TestCase(0, "pas de personne")] // 0 is singular
189189
[TestCase(1, "une personne")] // 1 is singular
190190
[TestCase(2, "{0} personnes")] // 2 is plural
@@ -214,7 +214,7 @@ public void Test_Turkish()
214214
new CultureInfo("tr"),
215215
"Seçili {0:plural:nesneyi|nesneleri} silmek istiyor musunuz?",
216216
new ExpectedResults {
217-
{ -1, "Seçili nesneleri silmek istiyor musunuz?"},
217+
{ -1, "Seçili nesneyi silmek istiyor musunuz?"}, // -1 is treated as 1 (singular)
218218
{ 0, "Seçili nesneleri silmek istiyor musunuz?"},
219219
{0.5m, "Seçili nesneleri silmek istiyor musunuz?"},
220220
{ 1, "Seçili nesneyi silmek istiyor musunuz?"},

src/SmartFormat/Utilities/PluralRules.cs

Lines changed: 136 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
using System;
66
using System.Collections.Generic;
7-
using System.Text.RegularExpressions;
87

98
namespace SmartFormat.Utilities;
109
#pragma warning disable S3776 // disable sonar cognitive complexity warnings
@@ -211,6 +210,7 @@ public static void RestoreDefault()
211210

212211
private static PluralRuleDelegate DualOneOther => (value, pluralWordsCount) =>
213212
{
213+
value = Math.Abs(value);
214214
return pluralWordsCount switch {
215215
2 => value == 1 ? 0 : 1,
216216
3 => value switch {
@@ -224,10 +224,15 @@ public static void RestoreDefault()
224224
}; // Dual: one (n == 1), other
225225

226226
private static PluralRuleDelegate DualWithZero =>
227-
(value, pluralWordsCount) => value == 0 || value == 1 ? 0 : 1; // DualWithZero: one (n == 0..1), other
227+
(value, pluralWordsCount) =>
228+
{
229+
value = Math.Abs(value);
230+
return value == 0 || value == 1 ? 0 : 1;
231+
}; // DualWithZero: one (n == 0..1), other
228232

229233
private static PluralRuleDelegate DualFromZeroToTwo => (value, pluralWordsCount) =>
230234
{
235+
value = Math.Abs(value);
231236
if (pluralWordsCount == 2) return value is >= 0 and < 2 ? 0 : 1;
232237

233238
if (pluralWordsCount == 3) return GetWordsCount3Value(value);
@@ -239,6 +244,7 @@ public static void RestoreDefault()
239244

240245
private static int GetWordsCount3Value(decimal n)
241246
{
247+
n = Math.Abs(n);
242248
return n switch
243249
{
244250
0 => 0,
@@ -249,6 +255,7 @@ private static int GetWordsCount3Value(decimal n)
249255

250256
private static int GetWordsCount4Value(decimal n)
251257
{
258+
n = Math.Abs(n);
252259
return n switch
253260
{
254261
< 0 => 0,
@@ -258,37 +265,59 @@ private static int GetWordsCount4Value(decimal n)
258265
};
259266
}
260267

261-
private static PluralRuleDelegate TripleOneTwoOther => (value, pluralWordsCount) => value == 1 ? 0 : value == 2 ? 1 : 2; // Triple: one (n == 1), two (n == 2), other
268+
private static PluralRuleDelegate TripleOneTwoOther => (value, pluralWordsCount) =>
269+
{
270+
value = Math.Abs(value);
271+
return value == 1 ? 0 : value == 2 ? 1 : 2;
272+
}; // Triple: one (n == 1), two (n == 2), other
273+
262274
private static PluralRuleDelegate RussianSerboCroatian => (value, pluralWordsCount) =>
263-
value % 10 == 1 && value % 100 != 11 ? 0 : // one
264-
(value % 10).BetweenWithoutFraction(2, 4) && !(value % 100).BetweenWithoutFraction(12, 14) ? 1 : // few
265-
2; // Russian & Serbo-Croatian
275+
{
276+
value = Math.Abs(value);
277+
return value % 10 == 1 && value % 100 != 11 ? 0 : // one
278+
(value % 10).BetweenWithoutFraction(2, 4) && !(value % 100).BetweenWithoutFraction(12, 14) ? 1 : // few
279+
2;
280+
}; // Russian & Serbo-Croatian
281+
266282
private static PluralRuleDelegate Arabic => (value, pluralWordsCount) =>
267-
value == 0 ? 0 : // zero
268-
value == 1 ? 1 : // one
269-
value == 2 ? 2 : // two
270-
(value % 100).BetweenWithoutFraction(3, 10) ? 3 : // few
271-
(value % 100).BetweenWithoutFraction(11, 99) ? 4 : // many
272-
5; // other
283+
{
284+
value = Math.Abs(value);
285+
return value == 0 ? 0 : // zero
286+
value == 1 ? 1 : // one
287+
value == 2 ? 2 : // two
288+
(value % 100).BetweenWithoutFraction(3, 10) ? 3 : // few
289+
(value % 100).BetweenWithoutFraction(11, 99) ? 4 : // many
290+
5;
291+
}; // other
292+
273293
private static PluralRuleDelegate Breton => (value, pluralWordsCount) =>
274-
value switch
294+
{
295+
value = Math.Abs(value);
296+
return value switch
275297
{
276298
0 => 0, // zero
277299
1 => 1, // one
278300
2 => 2, // two
279301
3 => 3, // few
280302
6 => 4, // many
281303
_ => 5 // other
282-
};
304+
};
305+
};
306+
283307
private static PluralRuleDelegate Czech => (value, pluralWordsCount) =>
284-
value == 0 ? 0 : // zero
285-
value == 1 ? 1 : // one
286-
value.BetweenWithoutFraction(2, 4) ? 2 : // few
287-
value % 1 == 0 ? 3 : // many
288-
4; // other
308+
{
309+
value = Math.Abs(value);
310+
return value == 0 ? 0 : // zero
311+
value == 1 ? 1 : // one
312+
value.BetweenWithoutFraction(2, 4) ? 2 : // few
313+
value % 1 == 0 ? 3 : // many
314+
4; // other
315+
};
289316

290317
private static PluralRuleDelegate Welsh => (value, pluralWordsCount) =>
291-
value switch
318+
{
319+
value = Math.Abs(value);
320+
return value switch
292321
{
293322
0 => 0, // zero
294323
1 => 1, // one
@@ -297,67 +326,120 @@ private static int GetWordsCount4Value(decimal n)
297326
6 => 4, // many
298327
_ => 5 // other
299328
};
329+
};
300330
private static PluralRuleDelegate Manx => (value, pluralWordsCount) =>
301-
(value % 10).BetweenWithoutFraction(1, 2) || value % 20 == 0
331+
{
332+
value = Math.Abs(value);
333+
return (value % 10).BetweenWithoutFraction(1, 2) || value % 20 == 0
302334
? 0
303335
: // one
304336
1;
337+
};
338+
305339
private static PluralRuleDelegate Langi => (value, pluralWordsCount) =>
306-
value switch
340+
{
341+
value = Math.Abs(value);
342+
return value switch
307343
{
308344
0 => 0,
309345
> 0 and < 2 => 1,
310346
_ => 2
311347
};
348+
};
349+
312350
private static PluralRuleDelegate Lithuanian => (value, pluralWordsCount) =>
313-
value % 10 == 1 && !(value % 100).BetweenWithoutFraction(11, 19) ? 0 : // one
314-
(value % 10).BetweenWithoutFraction(2, 9) && !(value % 100).BetweenWithoutFraction(11, 19) ? 1 : // few
315-
2;
351+
{
352+
value = Math.Abs(value);
353+
return value % 10 == 1 && !(value % 100).BetweenWithoutFraction(11, 19) ? 0 : // one
354+
(value % 10).BetweenWithoutFraction(2, 9) && !(value % 100).BetweenWithoutFraction(11, 19) ? 1 : // few
355+
2;
356+
};
357+
316358
private static PluralRuleDelegate Latvian => (value, pluralWordsCount) =>
317-
value == 0 ? 0 : // zero
318-
value % 10 == 1 && value % 100 != 11 ? 1 :
319-
2;
359+
{
360+
value = Math.Abs(value);
361+
return value == 0 ? 0 : // zero
362+
value % 10 == 1 && value % 100 != 11 ? 1 :
363+
2;
364+
};
365+
320366
private static PluralRuleDelegate Macedonian => (value, pluralWordsCount) =>
321-
value % 10 == 1 && value != 11
367+
{
368+
value = Math.Abs(value);
369+
return value % 10 == 1 && value != 11
322370
? 0
323371
: // one
324372
1;
373+
};
374+
325375
private static PluralRuleDelegate Moldavian => (value, pluralWordsCount) =>
326-
value == 1 ? 0 : // one
327-
value == 0 || value != 1 && (value % 100).BetweenWithoutFraction(1, 19) ? 1 : // few
328-
2;
376+
{
377+
value = Math.Abs(value);
378+
return value == 1 ? 0 : // one
379+
value == 0 || value != 1 && (value % 100).BetweenWithoutFraction(1, 19) ? 1 : // few
380+
2;
381+
};
382+
329383
private static PluralRuleDelegate Maltese => (value, pluralWordsCount) =>
330-
value == 1 ? 0 : // one
331-
value == 0 || (value % 100).BetweenWithoutFraction(2, 10) ? 1 : // few
332-
(value % 100).BetweenWithoutFraction(11, 19) ? 2 : // many
333-
3;
384+
{
385+
value = Math.Abs(value);
386+
return value == 1 ? 0 : // one
387+
value == 0 || (value % 100).BetweenWithoutFraction(2, 10) ? 1 : // few
388+
(value % 100).BetweenWithoutFraction(11, 19) ? 2 : // many
389+
3;
390+
};
391+
334392
private static PluralRuleDelegate Polish => (value, pluralWordsCount) =>
335-
value == 1 ? 0 : // one
336-
(value % 10).BetweenWithoutFraction(2, 4) && !(value % 100).BetweenWithoutFraction(12, 14) ? 1 : // few
337-
(value % 10).BetweenWithoutFraction(0, 1) || (value % 10).BetweenWithoutFraction(5, 9) || (value % 100).BetweenWithoutFraction(12, 14) ? 2 : // many
338-
3;
393+
{
394+
value = Math.Abs(value);
395+
return value == 1 ? 0 : // one
396+
(value % 10).BetweenWithoutFraction(2, 4) && !(value % 100).BetweenWithoutFraction(12, 14) ? 1 : // few
397+
(value % 10).BetweenWithoutFraction(0, 1) || (value % 10).BetweenWithoutFraction(5, 9) ||
398+
(value % 100).BetweenWithoutFraction(12, 14) ? 2 : // many
399+
3;
400+
};
401+
339402
private static PluralRuleDelegate Romanian => (value, pluralWordsCount) =>
340-
value == 1 ? 0 : // one
341-
value == 0 || (value % 100).BetweenWithoutFraction(1, 19) ? 1 : // few
342-
2;
403+
{
404+
value = Math.Abs(value);
405+
return value == 1 ? 0 : // one
406+
value == 0 || (value % 100).BetweenWithoutFraction(1, 19) ? 1 : // few
407+
2;
408+
};
409+
343410
private static PluralRuleDelegate Tachelhit => (value, pluralWordsCount) =>
344-
value >= 0 && value <= 1 ? 0 : // one
345-
value.BetweenWithoutFraction(2, 10) ? 1 : // few
346-
2;
411+
{
412+
value = Math.Abs(value);
413+
return value >= 0 && value <= 1 ? 0 : // one
414+
value.BetweenWithoutFraction(2, 10) ? 1 : // few
415+
2;
416+
};
417+
347418
private static PluralRuleDelegate Slovak => (value, pluralWordsCount) =>
348-
value == 1 ? 0 : // one
349-
value.BetweenWithoutFraction(2, 4) ? 1 : // few
350-
2;
419+
{
420+
value = Math.Abs(value);
421+
return value == 1 ? 0 : // one
422+
value.BetweenWithoutFraction(2, 4) ? 1 : // few
423+
2;
424+
};
425+
351426
private static PluralRuleDelegate Slovenian => (value, pluralWordsCount) =>
352-
value % 100 == 1 ? 0 : // one
353-
value % 100 == 2 ? 1 : // two
354-
(value % 100).BetweenWithoutFraction(3, 4) ? 2 : // few
355-
3;
427+
{
428+
value = Math.Abs(value);
429+
return value % 100 == 1 ? 0 : // one
430+
value % 100 == 2 ? 1 : // two
431+
(value % 100).BetweenWithoutFraction(3, 4) ? 2 : // few
432+
3;
433+
};
434+
356435
private static PluralRuleDelegate CentralMoroccoTamazight => (value, pluralWordsCount) =>
357-
value.BetweenWithoutFraction(0, 1) || value.BetweenWithoutFraction(11, 99)
436+
{
437+
value = Math.Abs(value);
438+
return value.BetweenWithoutFraction(0, 1) || value.BetweenWithoutFraction(11, 99)
358439
? 0
359440
: // one
360441
1;
442+
};
361443

362444
/// <summary>
363445
/// This delegate determines which singular or plural word should be chosen for the given quantity.

0 commit comments

Comments
 (0)