Skip to content

Commit c6e1263

Browse files
authored
Add valid-message-syntax rule (#147)
1 parent 28a3098 commit c6e1263

File tree

15 files changed

+1431
-76
lines changed

15 files changed

+1431
-76
lines changed

docs/rules/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
| [@intlify/vue-i18n/<wbr>no-missing-keys](./no-missing-keys.html) | disallow missing locale message key at localization methods | :star: |
1212
| [@intlify/vue-i18n/<wbr>no-raw-text](./no-raw-text.html) | disallow to string literal in template or JSX | :star: |
1313
| [@intlify/vue-i18n/<wbr>no-v-html](./no-v-html.html) | disallow use of localization methods on v-html to prevent XSS attack | :star: |
14+
| [@intlify/vue-i18n/<wbr>valid-message-syntax](./valid-message-syntax.html) | disallow invalid message syntax | |
1415

1516
## Best Practices
1617

docs/rules/valid-message-syntax.md

-4
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
> disallow invalid message syntax
44
5-
- :star: The `"extends": "plugin:@intlify/vue-i18n/recommended"` property in a configuration file enables this rule.
6-
75
This rule warns invalid message syntax.
86

97
This rule is useful localization leaks with incorrect message syntax.
@@ -17,11 +15,9 @@ This rule is useful localization leaks with incorrect message syntax.
1715
{
1816
"list-hello": "Hello! {{0}}",
1917
"named-hello": "Hello! {{name}}",
20-
"linked-hello": "ref:list-hello"
2118
}
2219
```
2320

24-
2521
:+1: Examples of **correct** code for this rule:
2622

2723
```json

lib/rules.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import noMissingKeys from './rules/no-missing-keys'
77
import noRawText from './rules/no-raw-text'
88
import noUnusedKeys from './rules/no-unused-keys'
99
import noVHtml from './rules/no-v-html'
10+
import validMessageSyntax from './rules/valid-message-syntax'
1011

1112
export = {
1213
'key-format-style': keyFormatStyle,
@@ -16,5 +17,6 @@ export = {
1617
'no-missing-keys': noMissingKeys,
1718
'no-raw-text': noRawText,
1819
'no-unused-keys': noUnusedKeys,
19-
'no-v-html': noVHtml
20+
'no-v-html': noVHtml,
21+
'valid-message-syntax': validMessageSyntax
2022
}

lib/rules/valid-message-syntax.ts

+224
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* @author Yosuke Ota
3+
*/
4+
import type { AST as JSONAST } from 'jsonc-eslint-parser'
5+
import { getStaticJSONValue } from 'jsonc-eslint-parser'
6+
import type { AST as YAMLAST } from 'yaml-eslint-parser'
7+
import { getStaticYAMLValue } from 'yaml-eslint-parser'
8+
import { extname } from 'path'
9+
import { defineCustomBlocksVisitor, getLocaleMessages } from '../utils/index'
10+
import debugBuilder from 'debug'
11+
import type { RuleContext, RuleListener } from '../types'
12+
import {
13+
getMessageSyntaxVersions,
14+
getReportIndex
15+
} from '../utils/message-compiler/utils'
16+
import { parse } from '../utils/message-compiler/parser'
17+
import { parse as parseForV8 } from '../utils/message-compiler/parser-v8'
18+
import type { CompileError } from '@intlify/message-compiler'
19+
const debug = debugBuilder('eslint-plugin-vue-i18n:valid-message-syntax')
20+
21+
function create(context: RuleContext): RuleListener {
22+
const filename = context.getFilename()
23+
const sourceCode = context.getSourceCode()
24+
const allowNotString = Boolean(context.options[0]?.allowNotString)
25+
const messageSyntaxVersions = getMessageSyntaxVersions(context)
26+
27+
function* extractMessageErrors(message: string) {
28+
if (messageSyntaxVersions.v9) {
29+
yield* parse(message).errors
30+
}
31+
if (messageSyntaxVersions.v8) {
32+
yield* parseForV8(message).errors
33+
}
34+
}
35+
function verifyMessage(
36+
message: string | number | undefined | null | boolean | bigint | RegExp,
37+
reportNode: JSONAST.JSONNode | YAMLAST.YAMLNode,
38+
getReportOffset: ((error: CompileError) => number | null) | null
39+
) {
40+
if (typeof message !== 'string') {
41+
if (!allowNotString) {
42+
const type =
43+
message === null
44+
? 'null'
45+
: message instanceof RegExp
46+
? 'RegExp'
47+
: typeof message
48+
context.report({
49+
message: `Unexpected '${type}' message`,
50+
loc: reportNode.loc
51+
})
52+
}
53+
} else {
54+
for (const error of extractMessageErrors(message)) {
55+
messageSyntaxVersions.reportIfMissingSetting()
56+
57+
const reportOffset = getReportOffset?.(error)
58+
context.report({
59+
message: error.message,
60+
loc:
61+
reportOffset != null
62+
? sourceCode.getLocFromIndex(reportOffset)
63+
: reportNode.loc
64+
})
65+
}
66+
}
67+
}
68+
/**
69+
* Create node visitor for JSON
70+
*/
71+
function createVisitorForJson(): RuleListener {
72+
function verifyExpression(
73+
node: JSONAST.JSONExpression | null,
74+
parent: JSONAST.JSONNode
75+
) {
76+
let message
77+
let getReportOffset:
78+
| ((error: CompileError) => number | null)
79+
| null = null
80+
if (node) {
81+
if (
82+
node.type === 'JSONArrayExpression' ||
83+
node.type === 'JSONObjectExpression'
84+
) {
85+
return
86+
}
87+
message = getStaticJSONValue(node)
88+
getReportOffset = error =>
89+
getReportIndex(node, error.location!.start.offset)
90+
} else {
91+
message = null
92+
}
93+
94+
verifyMessage(message, node || parent, getReportOffset)
95+
}
96+
return {
97+
JSONProperty(node: JSONAST.JSONProperty) {
98+
verifyExpression(node.value, node)
99+
},
100+
JSONArrayExpression(node: JSONAST.JSONArrayExpression) {
101+
for (const element of node.elements) {
102+
verifyExpression(element, node)
103+
}
104+
}
105+
}
106+
}
107+
108+
/**
109+
* Create node visitor for YAML
110+
*/
111+
function createVisitorForYaml(): RuleListener {
112+
const yamlKeyNodes = new Set<YAMLAST.YAMLContent | YAMLAST.YAMLWithMeta>()
113+
function withinKey(node: YAMLAST.YAMLNode) {
114+
for (const keyNode of yamlKeyNodes) {
115+
if (
116+
keyNode.range[0] <= node.range[0] &&
117+
node.range[0] < keyNode.range[1]
118+
) {
119+
return true
120+
}
121+
}
122+
return false
123+
}
124+
function verifyContent(
125+
node: YAMLAST.YAMLContent | YAMLAST.YAMLWithMeta | null,
126+
parent: YAMLAST.YAMLNode
127+
) {
128+
let message
129+
let getReportOffset:
130+
| ((error: CompileError) => number | null)
131+
| null = null
132+
if (node) {
133+
const valueNode = node.type === 'YAMLWithMeta' ? node.value : node
134+
if (
135+
!valueNode ||
136+
valueNode.type === 'YAMLMapping' ||
137+
valueNode.type === 'YAMLSequence'
138+
) {
139+
return
140+
}
141+
message = getStaticYAMLValue(node) // Calculate the value including the tag.
142+
getReportOffset = error =>
143+
getReportIndex(valueNode, error.location!.start.offset)
144+
} else {
145+
message = null
146+
}
147+
if (message != null && typeof message === 'object') {
148+
return
149+
}
150+
151+
verifyMessage(message, node || parent, getReportOffset)
152+
}
153+
return {
154+
YAMLPair(node: YAMLAST.YAMLPair) {
155+
if (withinKey(node)) {
156+
return
157+
}
158+
if (node.key != null) {
159+
yamlKeyNodes.add(node.key)
160+
}
161+
162+
verifyContent(node.value, node)
163+
},
164+
YAMLSequence(node: YAMLAST.YAMLSequence) {
165+
if (withinKey(node)) {
166+
return
167+
}
168+
for (const entry of node.entries) {
169+
verifyContent(entry, node)
170+
}
171+
}
172+
}
173+
}
174+
175+
if (extname(filename) === '.vue') {
176+
return defineCustomBlocksVisitor(
177+
context,
178+
createVisitorForJson,
179+
createVisitorForYaml
180+
)
181+
} else if (context.parserServices.isJSON || context.parserServices.isYAML) {
182+
const localeMessages = getLocaleMessages(context)
183+
const targetLocaleMessage = localeMessages.findExistLocaleMessage(filename)
184+
if (!targetLocaleMessage) {
185+
debug(`ignore ${filename} in valid-message-syntax`)
186+
return {}
187+
}
188+
189+
if (context.parserServices.isJSON) {
190+
return createVisitorForJson()
191+
} else if (context.parserServices.isYAML) {
192+
return createVisitorForYaml()
193+
}
194+
return {}
195+
} else {
196+
debug(`ignore ${filename} in valid-message-syntax`)
197+
return {}
198+
}
199+
}
200+
201+
export = {
202+
meta: {
203+
type: 'layout',
204+
docs: {
205+
description: 'disallow invalid message syntax',
206+
category: 'Recommended',
207+
// TODO To avoid breaking changes, include it in the configuration at the time of version upgrade.
208+
recommended: false
209+
},
210+
fixable: null,
211+
schema: [
212+
{
213+
type: 'object',
214+
properties: {
215+
allowNotString: {
216+
type: 'boolean'
217+
}
218+
},
219+
additionalProperties: false
220+
}
221+
]
222+
},
223+
create
224+
}

lib/utils/message-compiler/parser-v8.ts

+22-19
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,8 @@ class CodeContext {
103103
}
104104
createCompileError(message: string, offset: number) {
105105
const loc = this.getLocFromIndex(offset)
106-
const error: CompileError = new SyntaxError(
107-
errorMessages[message] || message
108-
) as never
109-
error.code = errorCodes[message] || errorCodes.UNEXPECTED_LEXICAL_ANALYSIS
106+
const error: CompileError = new SyntaxError(message) as never
107+
error.code = 42
110108
error.location = {
111109
start: { ...loc, offset },
112110
end: { ...loc, offset }
@@ -116,18 +114,6 @@ class CodeContext {
116114
}
117115
}
118116

119-
const errorCodes: Record<string, number> = {
120-
UNTERMINATED_CLOSING_BRACE: 6,
121-
EMPTY_PLACEHOLDER: 7,
122-
UNEXPECTED_LEXICAL_ANALYSIS: 11
123-
}
124-
125-
const errorMessages: Record<string, string> = {
126-
UNTERMINATED_CLOSING_BRACE: `Unterminated closing brace`,
127-
EMPTY_PLACEHOLDER: `Empty placeholder`,
128-
UNEXPECTED_LEXICAL_ANALYSIS: `Unexpected lexical analysis in token: '{0}'`
129-
}
130-
131117
function parseAST(code: string, errors: CompileError[]): ResourceNode {
132118
const ctx = new CodeContext(code)
133119
const regexp = /%?\{|@[\.:]|\s*\|\s*/u
@@ -187,7 +173,7 @@ function parseAST(code: string, errors: CompileError[]): ResourceNode {
187173
keyValue = ctx.code.slice(endOffset, endIndex)
188174
} else {
189175
errors.push(
190-
ctx.createCompileError('UNTERMINATED_CLOSING_BRACE', endOffset)
176+
ctx.createCompileError('Unterminated closing brace', endOffset)
191177
)
192178
keyValue = ctx.code.slice(endOffset)
193179
}
@@ -197,12 +183,29 @@ function parseAST(code: string, errors: CompileError[]): ResourceNode {
197183
let node: NamedNode | ListNode | null = null
198184
const trimmedKeyValue = keyValue.trim()
199185
if (trimmedKeyValue) {
186+
if (trimmedKeyValue !== keyValue) {
187+
errors.push(
188+
ctx.createCompileError(
189+
'Unexpected space before or after the placeholder key',
190+
endOffset
191+
)
192+
)
193+
}
200194
if (/^-?\d+$/u.test(trimmedKeyValue)) {
195+
const num = Number(trimmedKeyValue)
201196
const listNode: ListNode = {
202197
type: NodeTypes.List,
203-
index: Number(trimmedKeyValue),
198+
index: num,
204199
...ctx.getNodeLoc(endOffset - 1, placeholderEndOffset)
205200
}
201+
if (num < 0) {
202+
errors.push(
203+
ctx.createCompileError(
204+
'Unexpected minus placeholder index',
205+
endOffset
206+
)
207+
)
208+
}
206209
node = listNode
207210
}
208211
if (!node) {
@@ -222,7 +225,7 @@ function parseAST(code: string, errors: CompileError[]): ResourceNode {
222225
messageNode.items.push(node)
223226
} else {
224227
errors.push(
225-
ctx.createCompileError('EMPTY_PLACEHOLDER', placeholderEndOffset - 1)
228+
ctx.createCompileError('Empty placeholder', placeholderEndOffset - 1)
226229
)
227230
}
228231

0 commit comments

Comments
 (0)