Skip to content

Commit e180387

Browse files
authored
Add prefer-linked-key-with-paren rule (#149)
* Add prefer-linked-key-with-paren rule * fix
1 parent 8559e03 commit e180387

File tree

9 files changed

+700
-4
lines changed

9 files changed

+700
-4
lines changed

docs/rules/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@
2323
| [@intlify/vue-i18n/<wbr>no-missing-keys-in-other-locales](./no-missing-keys-in-other-locales.html) | disallow missing locale message keys in other locales | |
2424
| [@intlify/vue-i18n/<wbr>no-unused-keys](./no-unused-keys.html) | disallow unused localization keys | :black_nib: |
2525

26+
## Stylistic Issues
27+
28+
| Rule ID | Description | |
29+
|:--------|:------------|:---|
30+
| [@intlify/vue-i18n/<wbr>prefer-linked-key-with-paren](./prefer-linked-key-with-paren.html) | enforce linked key to be enclosed in parentheses | :black_nib: |
31+
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# @intlify/vue-i18n/prefer-linked-key-with-paren
2+
3+
> enforce linked key to be enclosed in parentheses
4+
5+
- :black_nib:️ The `--fix` option on the [command line](http://eslint.org/docs/user-guide/command-line-interface#fix) can automatically fix some of the problems reported by this rule.
6+
7+
This rule enforces the linked message key to be enclosed in parentheses.
8+
9+
## :book: Rule Details
10+
11+
:-1: Examples of **incorrect** code for this rule:
12+
13+
locale messages:
14+
15+
```json
16+
{
17+
"hello": "Hello @:world.",
18+
"world": "world"
19+
}
20+
```
21+
22+
:+1: Examples of **correct** code for this rule:
23+
24+
locale messages (for vue-i18n v9+):
25+
26+
```json
27+
{
28+
"hello": "Hello @:{'world'}.",
29+
"world": "world"
30+
}
31+
```
32+
33+
locale messages (for vue-i18n v8):
34+
35+
```json
36+
{
37+
"hello": "Hello @:(world).",
38+
"world": "world"
39+
}
40+
```

docs/started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ See [the rule list](../rules/)
7676
- `'file'` ... Determine the locale name from the filename. The resource file should only contain messages for that locale. Use this option if you use `vue-cli-plugin-i18n`. This option is also used when String option is specified.
7777
- `'key'` ... Determine the locale name from the root key name of the file contents. The value of that key should only contain messages for that locale. Used when the resource file is in the format given to the `messages` option of the `VueI18n` constructor option.
7878
- Array option ... An array of String option and Object option. Useful if you have multiple locale directories.
79-
- `messageSyntaxVersion` (Optional) ... Specify the version of `vue-i18n` you are using. If not specified, the message will be parsed twice.
79+
- `messageSyntaxVersion` (Optional) ... Specify the version of `vue-i18n` you are using. If not specified, the message will be parsed twice. Also, some rules require this setting.
8080

8181
### Running ESLint from command line
8282

lib/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import noMissingKeys from './rules/no-missing-keys'
88
import noRawText from './rules/no-raw-text'
99
import noUnusedKeys from './rules/no-unused-keys'
1010
import noVHtml from './rules/no-v-html'
11+
import preferLinkedKeyWithParen from './rules/prefer-linked-key-with-paren'
1112
import validMessageSyntax from './rules/valid-message-syntax'
1213

1314
export = {
@@ -20,5 +21,6 @@ export = {
2021
'no-raw-text': noRawText,
2122
'no-unused-keys': noUnusedKeys,
2223
'no-v-html': noVHtml,
24+
'prefer-linked-key-with-paren': preferLinkedKeyWithParen,
2325
'valid-message-syntax': validMessageSyntax
2426
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/**
2+
* @author Yosuke Ota
3+
*/
4+
import type { AST as JSONAST } from 'jsonc-eslint-parser'
5+
import type { AST as YAMLAST } from 'yaml-eslint-parser'
6+
import { extname } from 'path'
7+
import { defineCustomBlocksVisitor, getLocaleMessages } from '../utils/index'
8+
import debugBuilder from 'debug'
9+
import type { RuleContext, RuleListener } from '../types'
10+
import {
11+
getMessageSyntaxVersions,
12+
getReportIndex
13+
} from '../utils/message-compiler/utils'
14+
import { parse } from '../utils/message-compiler/parser'
15+
import { parse as parseForV8 } from '../utils/message-compiler/parser-v8'
16+
import { traverseNode } from '../utils/message-compiler/traverser'
17+
import { NodeTypes } from '@intlify/message-compiler'
18+
const debug = debugBuilder(
19+
'eslint-plugin-vue-i18n:prefer-linked-key-with-paren'
20+
)
21+
22+
function getSingleQuote(node: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar) {
23+
if (node.type === 'JSONLiteral') {
24+
return node.raw[0] !== "'" ? "'" : "\\'"
25+
}
26+
if (node.style === 'single-quoted') {
27+
return "''"
28+
}
29+
return "'"
30+
}
31+
32+
type GetReportOffset = (offset: number) => number | null
33+
34+
function create(context: RuleContext): RuleListener {
35+
const filename = context.getFilename()
36+
const sourceCode = context.getSourceCode()
37+
const messageSyntaxVersions = getMessageSyntaxVersions(context)
38+
39+
function verifyForV9(
40+
message: string,
41+
reportNode: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar,
42+
getReportOffset: GetReportOffset
43+
) {
44+
const { ast, errors } = parse(message)
45+
if (errors.length) {
46+
return
47+
}
48+
traverseNode(ast, node => {
49+
if (node.type !== NodeTypes.LinkedKey) {
50+
return
51+
}
52+
let range: [number, number] | null = null
53+
const start = getReportOffset(node.loc!.start.offset)
54+
const end = getReportOffset(node.loc!.end.offset)
55+
if (start != null && end != null) {
56+
range = [start, end]
57+
}
58+
context.report({
59+
loc: range
60+
? {
61+
start: sourceCode.getLocFromIndex(range[0]),
62+
end: sourceCode.getLocFromIndex(range[1])
63+
}
64+
: reportNode.loc,
65+
message: 'The linked message key must be enclosed in brackets.',
66+
fix(fixer) {
67+
if (!range) {
68+
return null
69+
}
70+
const single = getSingleQuote(reportNode)
71+
return [
72+
fixer.insertTextBeforeRange(range, `{${single}`),
73+
fixer.insertTextAfterRange(range, `${single}}`)
74+
]
75+
}
76+
})
77+
})
78+
}
79+
80+
function verifyForV8(
81+
message: string,
82+
reportNode: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar,
83+
getReportOffset: GetReportOffset
84+
) {
85+
const { ast, errors } = parseForV8(message)
86+
if (errors.length) {
87+
return
88+
}
89+
traverseNode(ast, node => {
90+
if (node.type !== NodeTypes.LinkedKey) {
91+
return
92+
}
93+
if (message[node.loc!.start.offset - 1] === '(') {
94+
return
95+
}
96+
let range: [number, number] | null = null
97+
const start = getReportOffset(node.loc!.start.offset)
98+
const end = getReportOffset(node.loc!.end.offset)
99+
if (start != null && end != null) {
100+
range = [start, end]
101+
}
102+
context.report({
103+
loc: range
104+
? {
105+
start: sourceCode.getLocFromIndex(range[0]),
106+
end: sourceCode.getLocFromIndex(range[1])
107+
}
108+
: reportNode.loc,
109+
message: 'The linked message key must be enclosed in parentheses.',
110+
fix(fixer) {
111+
if (!range) {
112+
return null
113+
}
114+
return [
115+
fixer.insertTextBeforeRange(range, '('),
116+
fixer.insertTextAfterRange(range, ')')
117+
]
118+
}
119+
})
120+
})
121+
}
122+
123+
function verifyMessage(
124+
message: string,
125+
reportNode: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar,
126+
getReportOffset: GetReportOffset
127+
) {
128+
if (messageSyntaxVersions.reportIfMissingSetting()) {
129+
return
130+
}
131+
if (messageSyntaxVersions.v9 && messageSyntaxVersions.v8) {
132+
// This rule cannot support two versions in the same project.
133+
return
134+
}
135+
136+
if (messageSyntaxVersions.v9) {
137+
verifyForV9(message, reportNode, getReportOffset)
138+
} else if (messageSyntaxVersions.v8) {
139+
verifyForV8(message, reportNode, getReportOffset)
140+
}
141+
}
142+
/**
143+
* Create node visitor for JSON
144+
*/
145+
function createVisitorForJson(): RuleListener {
146+
function verifyExpression(node: JSONAST.JSONExpression) {
147+
if (node.type !== 'JSONLiteral' || typeof node.value !== 'string') {
148+
return
149+
}
150+
verifyMessage(node.value, node as JSONAST.JSONStringLiteral, offset =>
151+
getReportIndex(node, offset)
152+
)
153+
}
154+
return {
155+
JSONProperty(node: JSONAST.JSONProperty) {
156+
verifyExpression(node.value)
157+
},
158+
JSONArrayExpression(node: JSONAST.JSONArrayExpression) {
159+
for (const element of node.elements) {
160+
if (element) verifyExpression(element)
161+
}
162+
}
163+
}
164+
}
165+
166+
/**
167+
* Create node visitor for YAML
168+
*/
169+
function createVisitorForYaml(): RuleListener {
170+
const yamlKeyNodes = new Set<YAMLAST.YAMLContent | YAMLAST.YAMLWithMeta>()
171+
function withinKey(node: YAMLAST.YAMLNode) {
172+
for (const keyNode of yamlKeyNodes) {
173+
if (
174+
keyNode.range[0] <= node.range[0] &&
175+
node.range[0] < keyNode.range[1]
176+
) {
177+
return true
178+
}
179+
}
180+
return false
181+
}
182+
function verifyContent(node: YAMLAST.YAMLContent | YAMLAST.YAMLWithMeta) {
183+
const valueNode = node.type === 'YAMLWithMeta' ? node.value : node
184+
if (
185+
!valueNode ||
186+
valueNode.type !== 'YAMLScalar' ||
187+
typeof valueNode.value !== 'string'
188+
) {
189+
return
190+
}
191+
verifyMessage(valueNode.value, valueNode, offset =>
192+
getReportIndex(valueNode, offset)
193+
)
194+
}
195+
return {
196+
YAMLPair(node: YAMLAST.YAMLPair) {
197+
if (withinKey(node)) {
198+
return
199+
}
200+
if (node.key != null) {
201+
yamlKeyNodes.add(node.key)
202+
}
203+
204+
if (node.value) verifyContent(node.value)
205+
},
206+
YAMLSequence(node: YAMLAST.YAMLSequence) {
207+
if (withinKey(node)) {
208+
return
209+
}
210+
for (const entry of node.entries) {
211+
if (entry) verifyContent(entry)
212+
}
213+
}
214+
}
215+
}
216+
217+
if (extname(filename) === '.vue') {
218+
return defineCustomBlocksVisitor(
219+
context,
220+
createVisitorForJson,
221+
createVisitorForYaml
222+
)
223+
} else if (context.parserServices.isJSON || context.parserServices.isYAML) {
224+
const localeMessages = getLocaleMessages(context)
225+
const targetLocaleMessage = localeMessages.findExistLocaleMessage(filename)
226+
if (!targetLocaleMessage) {
227+
debug(`ignore ${filename} in prefer-linked-key-with-paren`)
228+
return {}
229+
}
230+
231+
if (context.parserServices.isJSON) {
232+
return createVisitorForJson()
233+
} else if (context.parserServices.isYAML) {
234+
return createVisitorForYaml()
235+
}
236+
return {}
237+
} else {
238+
debug(`ignore ${filename} in prefer-linked-key-with-paren`)
239+
return {}
240+
}
241+
}
242+
243+
export = {
244+
meta: {
245+
type: 'layout',
246+
docs: {
247+
description: 'enforce linked key to be enclosed in parentheses',
248+
category: 'Stylistic Issues',
249+
recommended: false
250+
},
251+
fixable: 'code',
252+
schema: []
253+
},
254+
create
255+
}

scripts/lib/rules.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,8 @@ const rules: RuleInfo[] = readdirSync(resolve(__dirname, '../../lib/rules'))
3737
export default rules
3838
export const withCategories = [
3939
'Recommended',
40-
'Best Practices'
41-
/*
40+
'Best Practices',
4241
'Stylistic Issues'
43-
*/
4442
].map(category => ({
4543
category,
4644
rules: rules.filter(rule => rule.category === category && !rule.deprecated)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
null
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
null

0 commit comments

Comments
 (0)