Skip to content

Commit 5d0516e

Browse files
authored
feat(format-po-gettext): respect Plural-Forms header (#2070)
1 parent d0e45bc commit 5d0516e

File tree

6 files changed

+450
-7
lines changed

6 files changed

+450
-7
lines changed

packages/format-po-gettext/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@lingui/format-po": "4.13.0",
4646
"@lingui/message-utils": "4.13.0",
4747
"@messageformat/parser": "^5.0.0",
48+
"cldr-core": "^45.0.0",
4849
"node-gettext": "^3.0.0",
4950
"plurals-cldr": "^2.0.1",
5051
"pofile": "^1.1.4"
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import {
2+
createLocaleTest,
3+
createSamples,
4+
fillRange,
5+
renameKeys,
6+
} from "./plural-samples"
7+
8+
describe("Plural samples generation util", () => {
9+
test.each([
10+
[{ "pluralRule-count-zero": null }, { zero: null }],
11+
[{ "pluralRule-count-one": null }, { one: null }],
12+
[{ "pluralRule-count-two": null }, { two: null }],
13+
[{ "pluralRule-count-few": null }, { few: null }],
14+
[{ "pluralRule-count-many": null }, { many: null }],
15+
[{ "pluralRule-count-other": null }, { other: null }],
16+
])("renameKeys", (original, expected) => {
17+
expect(renameKeys(original)).toEqual(expected)
18+
})
19+
20+
test("renameKeys multiple", () => {
21+
const original = {
22+
"pluralRule-count-zero":
23+
"n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
24+
"pluralRule-count-one":
25+
"n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
26+
"pluralRule-count-two":
27+
"n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
28+
"pluralRule-count-few":
29+
"n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …",
30+
"pluralRule-count-many":
31+
"n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …",
32+
"pluralRule-count-other":
33+
" @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
34+
}
35+
expect(renameKeys(original)).toEqual({
36+
zero: "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
37+
one: "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
38+
two: "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
39+
few: "n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …",
40+
many: "n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …",
41+
other:
42+
" @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
43+
})
44+
})
45+
46+
test.each([
47+
["0~1", [0, 1]],
48+
["2~19", [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]],
49+
["100~102", [100, 101, 102]],
50+
])("fillRange - integer ranges", (range, values) => {
51+
expect(fillRange(range)).toEqual(values)
52+
})
53+
54+
test.each([
55+
["0.0~1.0", [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]],
56+
// partials
57+
[
58+
"0.4~1.6",
59+
[0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6],
60+
],
61+
["0.04~0.09", [0.04, 0.05, 0.06, 0.07, 0.08, 0.09]],
62+
[
63+
"0.04~0.29",
64+
[
65+
0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.11, 0.12, 0.13, 0.14, 0.15,
66+
0.16, 0.17, 0.18, 0.19, 0.2, 0.21, 0.22, 0.23, 0.24, 0.25, 0.26, 0.27,
67+
0.28, 0.29,
68+
],
69+
],
70+
])("fillRange - decimal ranges", (range, values) => {
71+
expect(fillRange(range)).toEqual(values)
72+
})
73+
74+
test("createSamples - single values", () => {
75+
expect(createSamples("0")).toEqual([0])
76+
expect(createSamples("0, 1, 2")).toEqual([0, 1, 2])
77+
expect(createSamples("0, 1.0, 2.0")).toEqual([0, 1, 2])
78+
})
79+
80+
test("createSamples - integer ranges", () => {
81+
expect(createSamples("0~1")).toEqual([0, 1])
82+
expect(createSamples("0~2")).toEqual([0, 1, 2])
83+
expect(createSamples("0~10")).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
84+
expect(createSamples("2~17, 100, 1000, 10000, 100000, 1000000")).toEqual([
85+
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 100, 1000, 10000,
86+
100000, 1000000,
87+
])
88+
})
89+
90+
test("createSamples - mixed src", () => {
91+
expect(createSamples("0.1~0.9")).toEqual([
92+
0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9,
93+
])
94+
// with ...
95+
expect(
96+
createSamples("0, 2~16, 100, 1000, 10000, 100000, 1000000, …")
97+
).toEqual([
98+
0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 100, 1000, 10000,
99+
100000, 1000000,
100+
])
101+
// mixed with integer ranges
102+
expect(
103+
createSamples("0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0")
104+
).toEqual([
105+
0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6,
106+
1.7, 10, 100, 1000, 10000, 100000,
107+
])
108+
// trailing comma
109+
expect(
110+
createSamples("0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0,")
111+
).toEqual([
112+
0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6,
113+
1.7, 10, 100, 1000, 10000, 100000,
114+
])
115+
})
116+
117+
test("Run on ruleset", () => {
118+
// ruleset for cs
119+
const ruleset = {
120+
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
121+
"pluralRule-count-few": "i = 2..4 and v = 0 @integer 2~4",
122+
"pluralRule-count-many":
123+
"v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
124+
"pluralRule-count-other":
125+
" @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …",
126+
}
127+
expect(createLocaleTest(ruleset)).toMatchInlineSnapshot(`
128+
{
129+
pluralRule-count-few: [
130+
2,
131+
3,
132+
4,
133+
],
134+
pluralRule-count-many: [
135+
0,
136+
0.1,
137+
0.2,
138+
0.3,
139+
0.4,
140+
0.5,
141+
0.6,
142+
0.7,
143+
0.8,
144+
0.9,
145+
1,
146+
1.1,
147+
1.2,
148+
1.3,
149+
1.4,
150+
1.5,
151+
10,
152+
100,
153+
1000,
154+
10000,
155+
100000,
156+
1000000,
157+
],
158+
pluralRule-count-one: [
159+
1,
160+
],
161+
pluralRule-count-other: [
162+
0,
163+
5,
164+
6,
165+
7,
166+
8,
167+
9,
168+
10,
169+
11,
170+
12,
171+
13,
172+
14,
173+
15,
174+
16,
175+
17,
176+
18,
177+
19,
178+
100,
179+
1000,
180+
10000,
181+
100000,
182+
1000000,
183+
],
184+
}
185+
`)
186+
})
187+
})
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import cardinals from "cldr-core/supplemental/plurals.json"
2+
3+
/*
4+
This script is heavily influenced by one that is used to generate plural samples
5+
found here: https://github.com/nodeca/plurals-cldr/blob/master/support/generate.js
6+
7+
Ordinals were removed, and the original script supported strings and numbers,
8+
but for the use case of lingui-gettext formatter, we only want numbers.
9+
*/
10+
11+
type PluralForm = "zero" | "one" | "two" | "few" | "many" | "other"
12+
type FormattedRuleset = Record<PluralForm, string>
13+
14+
// Strip key prefixes to get clear names: zero / one / two / few / many / other
15+
// pluralRule-count-other -> other
16+
export function renameKeys(rules: Record<string, string>): FormattedRuleset {
17+
const result = {}
18+
Object.keys(rules).forEach((k) => {
19+
const newKey = k.match(/[^-]+$/)[0]
20+
result[newKey] = rules[k]
21+
})
22+
return result as FormattedRuleset
23+
}
24+
25+
// Create array of sample values for single range
26+
// 5~16, 0.04~0.09. Both string & integer forms (when possible)
27+
export function fillRange(value: string): number[] {
28+
let [start, end] = value.split("~")
29+
30+
const decimals = (start.split(".")[1] || "").length
31+
// for example 0.1~0.9 has 10 values, need to add that many to list
32+
// 0.004~0.009 has 100 values
33+
let mult = Math.pow(10, decimals)
34+
35+
const startNum = Number(start)
36+
const endNum = Number(end)
37+
38+
let range = Array(Math.ceil(endNum * mult - startNum * mult + 1))
39+
.fill(0)
40+
.map((v, idx) => (idx + startNum * mult) / mult)
41+
42+
let last = range[range.length - 1]
43+
44+
// Number defined in the range should be the last one, i.e. 5~16 should have 16
45+
if (endNum !== last) {
46+
throw new Error(`Range create error for ${value}: last value is ${last}`)
47+
}
48+
49+
return range.map((v) => Number(v))
50+
}
51+
52+
// Create array of test values for @integer or @decimal
53+
export function createSamples(src: string): number[] {
54+
let result: number[] = []
55+
56+
src
57+
.replace(//, "")
58+
.trim()
59+
.replace(/,$/, "")
60+
.split(",")
61+
.map(function (val) {
62+
return val.trim()
63+
})
64+
.forEach((val) => {
65+
if (val.indexOf("~") !== -1) {
66+
result = result.concat(fillRange(val))
67+
} else {
68+
result.push(Number(val))
69+
}
70+
})
71+
72+
return result
73+
}
74+
75+
// Create fixtures for single locale rules
76+
export function createLocaleTest(rules) {
77+
let result = {}
78+
79+
Object.keys(rules).forEach((form) => {
80+
let samples = rules[form].split(/@integer|@decimal/).slice(1)
81+
82+
result[form] = []
83+
samples.forEach((sample) => {
84+
result[form] = result[form].concat(createSamples(sample))
85+
})
86+
})
87+
88+
return result
89+
}
90+
91+
export function getCldrPluralSamples(): Record<
92+
string,
93+
Record<PluralForm, number[]>
94+
> {
95+
const pluralRules = {}
96+
97+
// Parse plural rules
98+
Object.entries(cardinals.supplemental["plurals-type-cardinal"]).forEach(
99+
([loc, ruleset]) => {
100+
let rules = renameKeys(ruleset)
101+
102+
pluralRules[loc.toLowerCase()] = createLocaleTest(rules)
103+
}
104+
)
105+
106+
return pluralRules
107+
}

packages/format-po-gettext/src/po-gettext.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,81 @@ msgstr[2] "# dní"
231231
expect(catalog).toMatchSnapshot()
232232
})
233233

234+
test("should use respect Plural-Forms header", () => {
235+
const po = `
236+
msgid ""
237+
msgstr ""
238+
"Language: fr\\n"
239+
"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\\n"
240+
241+
#. js-lingui:icu=%7B0%2C+plural%2C+one+%7B%7Bcount%7D+day%7D+other+%7B%7Bcount%7D+days%7D%7D&pluralize_on=0
242+
msgid "{count} day"
243+
msgid_plural "{count} days"
244+
msgstr[0] "{count} jour"
245+
msgstr[1] "{count} jours"
246+
msgstr[2] "{count} jours"
247+
`
248+
249+
const parsed = format.parse(po, defaultParseCtx)
250+
251+
// Note that the last case must be `other` (the 4th CLDR case name) instead of `many` (the 3rd CLDR case name).
252+
expect(parsed).toMatchInlineSnapshot(`
253+
{
254+
ZETJEQ: {
255+
comments: [],
256+
context: null,
257+
extra: {
258+
flags: [],
259+
translatorComments: [],
260+
},
261+
message: {0, plural, one {{count} day} other {{count} days}},
262+
obsolete: false,
263+
origin: [],
264+
translation: {0, plural, one {{count} jour} many {{count} jours} other {{count} jours}},
265+
},
266+
}
267+
`)
268+
})
269+
270+
it("should correctly handle skipped form", () => {
271+
// in this test Plural-Forms header defines 4 forms via `nplurals=4`
272+
// but expression never returns 2 form, only [0, 1, 3]
273+
const po = `
274+
msgid ""
275+
msgstr ""
276+
"Language: cs\n"
277+
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
278+
279+
#. js-lingui:icu=%7Bcount%2C+plural%2C+one+%7B%7Bcount%7D+day%7D+few+%7B%7Bcount%7D+days%7D+many+%7B%7Bcount%7D+days%7D+other+%7B%7Bcount%7D+days%7D%7D&pluralize_on=#
280+
msgid "# day"
281+
msgid_plural "# days"
282+
msgstr[0] "# den"
283+
msgstr[1] "# dny"
284+
msgstr[2] "# dne"
285+
msgstr[3] "# dní"
286+
`
287+
288+
const parsed = format.parse(po, defaultParseCtx)
289+
290+
// Note that the last case must be `other` (the 4th CLDR case name) instead of `many` (the 3rd CLDR case name).
291+
expect(parsed).toMatchInlineSnapshot(`
292+
{
293+
GMnlGy: {
294+
comments: [],
295+
context: null,
296+
extra: {
297+
flags: [],
298+
translatorComments: [],
299+
},
300+
message: {count, plural, one {{count} day} few {{count} days} many {{count} days} other {{count} days}},
301+
obsolete: false,
302+
origin: [],
303+
translation: {#, plural, one {# den} few {# dny} other {# dní}},
304+
},
305+
}
306+
`)
307+
})
308+
234309
describe("using custom prefix", () => {
235310
it("parses plurals correctly", () => {
236311
const defaultProfile = fs

0 commit comments

Comments
 (0)