Skip to content

Commit 948b361

Browse files
committed
feat(rules): add consistent-plural-format rule
Ensures plural() calls include required keys (default: one, other). Configurable via requiredKeys option.
1 parent 036bdc4 commit 948b361

File tree

5 files changed

+290
-2
lines changed

5 files changed

+290
-2
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export default [
5252

5353
| Rule | Description | Recommended |
5454
|------|-------------|:-----------:|
55+
| [consistent-plural-format](docs/rules/consistent-plural-format.md) | Ensure consistent plural usage ||
5556
| [no-complex-expressions-in-message](docs/rules/no-complex-expressions-in-message.md) | Restrict complexity of expressions in messages ||
5657
| [no-nested-macros](docs/rules/no-nested-macros.md) | Disallow nesting Lingui macros ||
5758
| [no-single-tag-message](docs/rules/no-single-tag-message.md) | Disallow messages with only a single markup tag ||
@@ -66,8 +67,7 @@ export default [
6667

6768
### Planned Rules
6869

69-
- `no-unlocalized-strings` — Detect user-visible strings not wrapped in Lingui
70-
- `consistent-plural-format` — Ensure consistent plural usage
70+
- `no-unlocalized-strings` — Detect user-visible strings not wrapped in Lingui (TypeScript type-aware)
7171

7272
## License
7373

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# consistent-plural-format
2+
3+
Ensure consistent plural usage with required keys.
4+
5+
## Why?
6+
7+
Proper pluralization requires specific keys for different quantities. Missing keys can cause:
8+
- Runtime errors or fallback to incorrect text
9+
- Incomplete translations
10+
- Poor user experience for different languages
11+
12+
## Rule Details
13+
14+
This rule ensures that `plural()` calls include all required plural keys.
15+
16+
### ❌ Invalid
17+
18+
```tsx
19+
// Missing 'other' (required by default)
20+
plural(count, { one: '# item' })
21+
22+
// Missing 'one' (required by default)
23+
plural(count, { other: '# items' })
24+
25+
// Missing both required keys
26+
plural(count, { zero: 'None' })
27+
28+
// With i18n prefix
29+
i18n.plural(count, { one: '# item' }) // Missing 'other'
30+
```
31+
32+
### ✅ Valid
33+
34+
```tsx
35+
// All required keys present
36+
plural(count, { one: '# item', other: '# items' })
37+
38+
// Additional keys are allowed
39+
plural(count, { one: 'One', other: 'Many', zero: 'None' })
40+
41+
// With i18n prefix
42+
i18n.plural(count, { one: '# item', other: '# items' })
43+
```
44+
45+
## Options
46+
47+
### `requiredKeys`
48+
49+
Array of plural keys that must be present. Default: `["one", "other"]`
50+
51+
Common CLDR plural categories: `zero`, `one`, `two`, `few`, `many`, `other`
52+
53+
```ts
54+
// Require only 'other'
55+
{
56+
"lingui-ts/consistent-plural-format": ["error", {
57+
"requiredKeys": ["other"]
58+
}]
59+
}
60+
61+
// Require zero, one, and other
62+
{
63+
"lingui-ts/consistent-plural-format": ["error", {
64+
"requiredKeys": ["zero", "one", "other"]
65+
}]
66+
}
67+
```
68+
69+
## When Not To Use It
70+
71+
If your project uses a different pluralization approach or handles missing keys gracefully at runtime, you can disable this rule.
72+

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* @packageDocumentation
55
*/
66

7+
import { consistentPluralFormat } from "./rules/consistent-plural-format.js"
78
import { noComplexExpressionsInMessage } from "./rules/no-complex-expressions-in-message.js"
89
import { noNestedMacros } from "./rules/no-nested-macros.js"
910
import { noSingleTagMessage } from "./rules/no-single-tag-message.js"
@@ -17,6 +18,7 @@ const plugin = {
1718
version: "1.0.0"
1819
},
1920
rules: {
21+
"consistent-plural-format": consistentPluralFormat,
2022
"no-complex-expressions-in-message": noComplexExpressionsInMessage,
2123
"no-nested-macros": noNestedMacros,
2224
"no-single-tag-message": noSingleTagMessage,
@@ -34,6 +36,7 @@ plugin.configs = {
3436
"lingui-ts": plugin
3537
},
3638
rules: {
39+
"lingui-ts/consistent-plural-format": "error",
3740
"lingui-ts/no-complex-expressions-in-message": "error",
3841
"lingui-ts/no-nested-macros": "error",
3942
"lingui-ts/no-single-tag-message": "error",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { RuleTester } from "@typescript-eslint/rule-tester"
2+
import { afterAll, describe, it } from "vitest"
3+
4+
import { consistentPluralFormat } from "./consistent-plural-format.js"
5+
6+
RuleTester.afterAll = afterAll
7+
RuleTester.describe = describe
8+
RuleTester.it = it
9+
10+
const ruleTester = new RuleTester({
11+
languageOptions: {
12+
parserOptions: {
13+
ecmaVersion: 2022,
14+
sourceType: "module"
15+
}
16+
}
17+
})
18+
19+
ruleTester.run("consistent-plural-format", consistentPluralFormat, {
20+
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+
25+
// With i18n prefix
26+
"i18n.plural(count, { one: '# item', other: '# items' })",
27+
28+
// Custom required keys
29+
{
30+
code: "plural(count, { other: 'items' })",
31+
options: [{ requiredKeys: ["other"] }]
32+
},
33+
{
34+
code: "plural(count, { one: '#', other: '#', zero: 'none' })",
35+
options: [{ requiredKeys: ["one", "other", "zero"] }]
36+
},
37+
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)"
44+
],
45+
invalid: [
46+
// Missing 'other' (default required)
47+
{
48+
code: "plural(count, { one: '# item' })",
49+
errors: [{ messageId: "missingPluralKey", data: { key: "other" } }]
50+
},
51+
52+
// Missing 'one' (default required)
53+
{
54+
code: "plural(count, { other: '# items' })",
55+
errors: [{ messageId: "missingPluralKey", data: { key: "one" } }]
56+
},
57+
58+
// Missing both default required keys
59+
{
60+
code: "plural(count, { zero: 'None' })",
61+
errors: [
62+
{ messageId: "missingPluralKey", data: { key: "one" } },
63+
{ messageId: "missingPluralKey", data: { key: "other" } }
64+
]
65+
},
66+
67+
// With i18n prefix
68+
{
69+
code: "i18n.plural(count, { one: '# item' })",
70+
errors: [{ messageId: "missingPluralKey", data: { key: "other" } }]
71+
},
72+
73+
// Custom required keys missing
74+
{
75+
code: "plural(count, { one: '#', other: '#' })",
76+
options: [{ requiredKeys: ["one", "other", "zero"] }],
77+
errors: [{ messageId: "missingPluralKey", data: { key: "zero" } }]
78+
},
79+
80+
// Empty object
81+
{
82+
code: "plural(count, {})",
83+
errors: [
84+
{ messageId: "missingPluralKey", data: { key: "one" } },
85+
{ messageId: "missingPluralKey", data: { key: "other" } }
86+
]
87+
}
88+
]
89+
})
90+
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils"
2+
3+
import { createRule } from "../utils/create-rule.js"
4+
5+
type MessageId = "missingPluralKey"
6+
7+
interface Options {
8+
requiredKeys: string[]
9+
}
10+
11+
const DEFAULT_REQUIRED_KEYS = ["one", "other"]
12+
13+
/**
14+
* Extracts property keys from an object expression.
15+
*/
16+
function getObjectKeys(node: TSESTree.ObjectExpression): string[] {
17+
const keys: string[] = []
18+
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+
}
26+
}
27+
}
28+
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
65+
}
66+
67+
export const consistentPluralFormat = createRule<[Options], MessageId>({
68+
name: "consistent-plural-format",
69+
meta: {
70+
type: "problem",
71+
docs: {
72+
description: "Ensure consistent plural usage with required keys"
73+
},
74+
messages: {
75+
missingPluralKey: "Plural is missing required key '{{key}}'"
76+
},
77+
schema: [
78+
{
79+
type: "object",
80+
properties: {
81+
requiredKeys: {
82+
type: "array",
83+
items: { type: "string" },
84+
default: DEFAULT_REQUIRED_KEYS
85+
}
86+
},
87+
additionalProperties: false
88+
}
89+
]
90+
},
91+
defaultOptions: [
92+
{
93+
requiredKeys: DEFAULT_REQUIRED_KEYS
94+
}
95+
],
96+
create(context, [options]) {
97+
return {
98+
CallExpression(node): void {
99+
if (!isPluralCall(node)) {
100+
return
101+
}
102+
103+
const optionsObject = getPluralOptionsObject(node)
104+
if (optionsObject === null) {
105+
return
106+
}
107+
108+
const providedKeys = getObjectKeys(optionsObject)
109+
110+
for (const requiredKey of options.requiredKeys) {
111+
if (!providedKeys.includes(requiredKey)) {
112+
context.report({
113+
node,
114+
messageId: "missingPluralKey",
115+
data: { key: requiredKey }
116+
})
117+
}
118+
}
119+
}
120+
}
121+
}
122+
})
123+

0 commit comments

Comments
 (0)