Skip to content

Commit 5730759

Browse files
committed
fix(rules): correct plural handling
- Remove i18n.plural/select from default allowedCallees - Rewrite consistent-plural-format for <Plural> component props - plural() is not a helper like i18n.date - use <Plural> component
1 parent 948b361 commit 5730759

File tree

6 files changed

+60
-109
lines changed

6 files changed

+60
-109
lines changed
Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,49 @@
11
# consistent-plural-format
22

3-
Ensure consistent plural usage with required keys.
3+
Ensure `<Plural>` component has required plural category props.
44

55
## Why?
66

7-
Proper pluralization requires specific keys for different quantities. Missing keys can cause:
7+
Proper pluralization requires specific props for different quantities. Missing props can cause:
88
- Runtime errors or fallback to incorrect text
99
- Incomplete translations
1010
- Poor user experience for different languages
1111

1212
## Rule Details
1313

14-
This rule ensures that `plural()` calls include all required plural keys.
14+
This rule ensures that `<Plural>` components include all required plural category props.
1515

1616
### ❌ Invalid
1717

1818
```tsx
1919
// Missing 'other' (required by default)
20-
plural(count, { one: '# item' })
20+
<Plural value={count} one="# item" />
2121

2222
// Missing 'one' (required by default)
23-
plural(count, { other: '# items' })
23+
<Plural value={count} other="# items" />
2424

25-
// Missing both required keys
26-
plural(count, { zero: 'None' })
27-
28-
// With i18n prefix
29-
i18n.plural(count, { one: '# item' }) // Missing 'other'
25+
// Missing both required props
26+
<Plural value={count} zero="None" />
3027
```
3128

3229
### ✅ Valid
3330

3431
```tsx
35-
// All required keys present
36-
plural(count, { one: '# item', other: '# items' })
32+
// All required props present
33+
<Plural value={count} one="# item" other="# items" />
3734

38-
// Additional keys are allowed
39-
plural(count, { one: 'One', other: 'Many', zero: 'None' })
35+
// Additional props are allowed
36+
<Plural value={count} one="One" other="Many" zero="None" />
4037

41-
// With i18n prefix
42-
i18n.plural(count, { one: '# item', other: '# items' })
38+
// With expressions
39+
<Plural value={count} one={oneMsg} other={otherMsg} />
4340
```
4441

4542
## Options
4643

4744
### `requiredKeys`
4845

49-
Array of plural keys that must be present. Default: `["one", "other"]`
46+
Array of plural category props that must be present. Default: `["one", "other"]`
5047

5148
Common CLDR plural categories: `zero`, `one`, `two`, `few`, `many`, `other`
5249

@@ -68,5 +65,4 @@ Common CLDR plural categories: `zero`, `one`, `two`, `few`, `many`, `other`
6865

6966
## When Not To Use It
7067

71-
If your project uses a different pluralization approach or handles missing keys gracefully at runtime, you can disable this rule.
72-
68+
If your project uses ICU message format directly in `t` strings instead of `<Plural>` components, this rule won't help. It only checks JSX `<Plural>` components.

docs/rules/no-complex-expressions-in-message.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ t`Hello ${name}`
4848
// Whitelisted Lingui helpers
4949
t`Price: ${i18n.number(price)}`
5050
t`Date: ${i18n.date(date)}`
51-
<Trans>Items: {i18n.plural(count, { one: '#', other: '#' })}</Trans>
51+
<Trans>Price: {i18n.number(price)}</Trans>
5252

5353
// Extract complex logic first
5454
const displayPrice = price * 1.2
@@ -64,7 +64,7 @@ const formattedDate = formatDate(date)
6464

6565
Array of function names that are allowed. Format: dot-separated strings.
6666

67-
Default: `["i18n.number", "i18n.date", "i18n.plural", "i18n.select"]`
67+
Default: `["i18n.number", "i18n.date"]`
6868

6969
```ts
7070
{

src/rules/consistent-plural-format.test.ts

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,80 +11,74 @@ const ruleTester = new RuleTester({
1111
languageOptions: {
1212
parserOptions: {
1313
ecmaVersion: 2022,
14-
sourceType: "module"
14+
sourceType: "module",
15+
ecmaFeatures: {
16+
jsx: true
17+
}
1518
}
1619
}
1720
})
1821

1922
ruleTester.run("consistent-plural-format", consistentPluralFormat, {
2023
valid: [
21-
// All required keys present (default: one, other)
22-
"plural(count, { one: '# item', other: '# items' })",
23-
"plural(count, { one: 'One', other: 'Many', zero: 'None' })",
24+
// All required props present (default: one, other)
25+
'<Plural value={count} one="# item" other="# items" />',
26+
'<Plural value={count} one="One" other="Many" zero="None" />',
2427

25-
// With i18n prefix
26-
"i18n.plural(count, { one: '# item', other: '# items' })",
28+
// With expressions
29+
"<Plural value={count} one={oneMsg} other={otherMsg} />",
2730

2831
// Custom required keys
2932
{
30-
code: "plural(count, { other: 'items' })",
33+
code: '<Plural value={count} other="items" />',
3134
options: [{ requiredKeys: ["other"] }]
3235
},
3336
{
34-
code: "plural(count, { one: '#', other: '#', zero: 'none' })",
37+
code: '<Plural value={count} one="#" other="#" zero="none" />',
3538
options: [{ requiredKeys: ["one", "other", "zero"] }]
3639
},
3740

38-
// Non-plural calls should be ignored
39-
"select(value, { male: 'He', female: 'She', other: 'They' })",
40-
"someOtherFunction({ one: 'x' })",
41-
42-
// No object argument (edge case)
43-
"plural(count)"
41+
// Non-Plural components should be ignored
42+
'<Select value={gender} male="He" female="She" other="They" />',
43+
'<div one="x" />',
44+
"<Trans>Hello</Trans>"
4445
],
4546
invalid: [
4647
// Missing 'other' (default required)
4748
{
48-
code: "plural(count, { one: '# item' })",
49+
code: '<Plural value={count} one="# item" />',
4950
errors: [{ messageId: "missingPluralKey", data: { key: "other" } }]
5051
},
5152

5253
// Missing 'one' (default required)
5354
{
54-
code: "plural(count, { other: '# items' })",
55+
code: '<Plural value={count} other="# items" />',
5556
errors: [{ messageId: "missingPluralKey", data: { key: "one" } }]
5657
},
5758

5859
// Missing both default required keys
5960
{
60-
code: "plural(count, { zero: 'None' })",
61+
code: '<Plural value={count} zero="None" />',
6162
errors: [
6263
{ messageId: "missingPluralKey", data: { key: "one" } },
6364
{ messageId: "missingPluralKey", data: { key: "other" } }
6465
]
6566
},
6667

67-
// With i18n prefix
68-
{
69-
code: "i18n.plural(count, { one: '# item' })",
70-
errors: [{ messageId: "missingPluralKey", data: { key: "other" } }]
71-
},
72-
7368
// Custom required keys missing
7469
{
75-
code: "plural(count, { one: '#', other: '#' })",
70+
code: '<Plural value={count} one="#" other="#" />',
7671
options: [{ requiredKeys: ["one", "other", "zero"] }],
7772
errors: [{ messageId: "missingPluralKey", data: { key: "zero" } }]
7873
},
7974

80-
// Empty object
75+
// Only value prop
8176
{
82-
code: "plural(count, {})",
77+
code: "<Plural value={count} />",
8378
errors: [
8479
{ messageId: "missingPluralKey", data: { key: "one" } },
8580
{ messageId: "missingPluralKey", data: { key: "other" } }
8681
]
8782
}
8883
]
8984
})
90-

src/rules/consistent-plural-format.ts

Lines changed: 19 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -11,68 +11,29 @@ interface Options {
1111
const DEFAULT_REQUIRED_KEYS = ["one", "other"]
1212

1313
/**
14-
* Extracts property keys from an object expression.
14+
* Extracts attribute names from a JSX opening element.
1515
*/
16-
function getObjectKeys(node: TSESTree.ObjectExpression): string[] {
17-
const keys: string[] = []
16+
function getJSXAttributeNames(node: TSESTree.JSXOpeningElement): string[] {
17+
const names: string[] = []
1818

19-
for (const property of node.properties) {
20-
if (property.type === AST_NODE_TYPES.Property) {
21-
if (property.key.type === AST_NODE_TYPES.Identifier) {
22-
keys.push(property.key.name)
23-
} else if (property.key.type === AST_NODE_TYPES.Literal && typeof property.key.value === "string") {
24-
keys.push(property.key.value)
25-
}
19+
for (const attr of node.attributes) {
20+
if (attr.type === AST_NODE_TYPES.JSXAttribute && attr.name.type === AST_NODE_TYPES.JSXIdentifier) {
21+
names.push(attr.name.name)
2622
}
2723
}
2824

29-
return keys
30-
}
31-
32-
/**
33-
* Checks if a call expression is a plural helper call.
34-
*/
35-
function isPluralCall(node: TSESTree.CallExpression): boolean {
36-
// Check for: plural(...)
37-
if (node.callee.type === AST_NODE_TYPES.Identifier && node.callee.name === "plural") {
38-
return true
39-
}
40-
41-
// Check for: i18n.plural(...)
42-
if (
43-
node.callee.type === AST_NODE_TYPES.MemberExpression &&
44-
node.callee.property.type === AST_NODE_TYPES.Identifier &&
45-
node.callee.property.name === "plural"
46-
) {
47-
return true
48-
}
49-
50-
return false
51-
}
52-
53-
/**
54-
* Gets the options object from a plural call.
55-
* plural(count, { one: '...', other: '...' })
56-
*/
57-
function getPluralOptionsObject(node: TSESTree.CallExpression): TSESTree.ObjectExpression | null {
58-
// Expected format: plural(value, { ... }) or plural({ value, ... })
59-
for (const arg of node.arguments) {
60-
if (arg.type === AST_NODE_TYPES.ObjectExpression) {
61-
return arg
62-
}
63-
}
64-
return null
25+
return names
6526
}
6627

6728
export const consistentPluralFormat = createRule<[Options], MessageId>({
6829
name: "consistent-plural-format",
6930
meta: {
7031
type: "problem",
7132
docs: {
72-
description: "Ensure consistent plural usage with required keys"
33+
description: "Ensure <Plural> component has required plural category props"
7334
},
7435
messages: {
75-
missingPluralKey: "Plural is missing required key '{{key}}'"
36+
missingPluralKey: "<Plural> is missing required prop '{{key}}'"
7637
},
7738
schema: [
7839
{
@@ -95,20 +56,21 @@ export const consistentPluralFormat = createRule<[Options], MessageId>({
9556
],
9657
create(context, [options]) {
9758
return {
98-
CallExpression(node): void {
99-
if (!isPluralCall(node)) {
59+
JSXElement(node): void {
60+
const openingElement = node.openingElement
61+
62+
// Check for <Plural> component
63+
if (
64+
openingElement.name.type !== AST_NODE_TYPES.JSXIdentifier ||
65+
openingElement.name.name !== "Plural"
66+
) {
10067
return
10168
}
10269

103-
const optionsObject = getPluralOptionsObject(node)
104-
if (optionsObject === null) {
105-
return
106-
}
107-
108-
const providedKeys = getObjectKeys(optionsObject)
70+
const providedProps = getJSXAttributeNames(openingElement)
10971

11072
for (const requiredKey of options.requiredKeys) {
111-
if (!providedKeys.includes(requiredKey)) {
73+
if (!providedProps.includes(requiredKey)) {
11274
context.report({
11375
node,
11476
messageId: "missingPluralKey",
@@ -120,4 +82,3 @@ export const consistentPluralFormat = createRule<[Options], MessageId>({
12082
}
12183
}
12284
})
123-

src/rules/no-complex-expressions-in-message.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@ ruleTester.run("no-complex-expressions-in-message", noComplexExpressionsInMessag
3030
"<Trans>Hello {name}</Trans>",
3131
"<Trans>You have {count} items</Trans>",
3232

33-
// Allowed callees (default: i18n.number, i18n.date, etc.)
33+
// Allowed callees (default: i18n.number, i18n.date)
3434
"t`Price: ${i18n.number(price)}`",
3535
"t`Date: ${i18n.date(date)}`",
3636
"<Trans>Price: {i18n.number(price)}</Trans>",
37-
"<Trans>Items: {i18n.plural(count, { one: '#', other: '#' })}</Trans>",
37+
"<Trans>Date: {i18n.date(date)}</Trans>",
3838

3939
// Member expressions when allowed
4040
{

src/rules/no-complex-expressions-in-message.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ interface Options {
1010
maxExpressionDepth: number | null
1111
}
1212

13-
const DEFAULT_ALLOWED_CALLEES = ["i18n.number", "i18n.date", "i18n.plural", "i18n.select"]
13+
const DEFAULT_ALLOWED_CALLEES = ["i18n.number", "i18n.date"]
1414

1515
/**
1616
* Gets the string representation of a callee (e.g., "i18n.number", "Math.random").

0 commit comments

Comments
 (0)