Skip to content

Add prefer-linked-key-with-paren rule #149

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@
| [@intlify/vue-i18n/<wbr>no-dynamic-keys](./no-dynamic-keys.html) | disallow localization dynamic keys at localization methods | |
| [@intlify/vue-i18n/<wbr>no-unused-keys](./no-unused-keys.html) | disallow unused localization keys | :black_nib: |

## Stylistic Issues

| Rule ID | Description | |
|:--------|:------------|:---|
| [@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: |

40 changes: 40 additions & 0 deletions docs/rules/prefer-linked-key-with-paren.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# @intlify/vue-i18n/prefer-linked-key-with-paren

> enforce linked key to be enclosed in parentheses

- :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.

This rule enforces the linked message key to be enclosed in parentheses.

## :book: Rule Details

:-1: Examples of **incorrect** code for this rule:

locale messages:

```json
{
"hello": "Hello @:world.",
"world": "world"
}
```

:+1: Examples of **correct** code for this rule:

locale messages (for vue-i18n v9+):

```json
{
"hello": "Hello @:{'world'}.",
"world": "world"
}
```

locale messages (for vue-i18n v8):

```json
{
"hello": "Hello @:(world).",
"world": "world"
}
```
2 changes: 1 addition & 1 deletion docs/started.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ See [the rule list](../rules/)
- `'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.
- `'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.
- Array option ... An array of String option and Object option. Useful if you have multiple locale directories.
- `messageSyntaxVersion` (Optional) ... Specify the version of `vue-i18n` you are using. If not specified, the message will be parsed twice.
- `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.

### Running ESLint from command line

Expand Down
2 changes: 2 additions & 0 deletions lib/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import noMissingKeys from './rules/no-missing-keys'
import noRawText from './rules/no-raw-text'
import noUnusedKeys from './rules/no-unused-keys'
import noVHtml from './rules/no-v-html'
import preferLinkedKeyWithParen from './rules/prefer-linked-key-with-paren'
import validMessageSyntax from './rules/valid-message-syntax'

export = {
Expand All @@ -18,5 +19,6 @@ export = {
'no-raw-text': noRawText,
'no-unused-keys': noUnusedKeys,
'no-v-html': noVHtml,
'prefer-linked-key-with-paren': preferLinkedKeyWithParen,
'valid-message-syntax': validMessageSyntax
}
255 changes: 255 additions & 0 deletions lib/rules/prefer-linked-key-with-paren.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/**
* @author Yosuke Ota
*/
import type { AST as JSONAST } from 'jsonc-eslint-parser'
import type { AST as YAMLAST } from 'yaml-eslint-parser'
import { extname } from 'path'
import { defineCustomBlocksVisitor, getLocaleMessages } from '../utils/index'
import debugBuilder from 'debug'
import type { RuleContext, RuleListener } from '../types'
import {
getMessageSyntaxVersions,
getReportIndex
} from '../utils/message-compiler/utils'
import { parse } from '../utils/message-compiler/parser'
import { parse as parseForV8 } from '../utils/message-compiler/parser-v8'
import { traverseNode } from '../utils/message-compiler/traverser'
import { NodeTypes } from '@intlify/message-compiler'
const debug = debugBuilder(
'eslint-plugin-vue-i18n:prefer-linked-key-with-paren'
)

function getSingleQuote(node: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar) {
if (node.type === 'JSONLiteral') {
return node.raw[0] !== "'" ? "'" : "\\'"
}
if (node.style === 'single-quoted') {
return "''"
}
return "'"
}

type GetReportOffset = (offset: number) => number | null

function create(context: RuleContext): RuleListener {
const filename = context.getFilename()
const sourceCode = context.getSourceCode()
const messageSyntaxVersions = getMessageSyntaxVersions(context)

function verifyForV9(
message: string,
reportNode: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar,
getReportOffset: GetReportOffset
) {
const { ast, errors } = parse(message)
if (errors.length) {
return
}
traverseNode(ast, node => {
if (node.type !== NodeTypes.LinkedKey) {
return
}
let range: [number, number] | null = null
const start = getReportOffset(node.loc!.start.offset)
const end = getReportOffset(node.loc!.end.offset)
if (start != null && end != null) {
range = [start, end]
}
context.report({
loc: range
? {
start: sourceCode.getLocFromIndex(range[0]),
end: sourceCode.getLocFromIndex(range[1])
}
: reportNode.loc,
message: 'The linked message key must be enclosed in brackets.',
fix(fixer) {
if (!range) {
return null
}
const single = getSingleQuote(reportNode)
return [
fixer.insertTextBeforeRange(range, `{${single}`),
fixer.insertTextAfterRange(range, `${single}}`)
]
}
})
})
}

function verifyForV8(
message: string,
reportNode: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar,
getReportOffset: GetReportOffset
) {
const { ast, errors } = parseForV8(message)
if (errors.length) {
return
}
traverseNode(ast, node => {
if (node.type !== NodeTypes.LinkedKey) {
return
}
if (message[node.loc!.start.offset - 1] === '(') {
return
}
let range: [number, number] | null = null
const start = getReportOffset(node.loc!.start.offset)
const end = getReportOffset(node.loc!.end.offset)
if (start != null && end != null) {
range = [start, end]
}
context.report({
loc: range
? {
start: sourceCode.getLocFromIndex(range[0]),
end: sourceCode.getLocFromIndex(range[1])
}
: reportNode.loc,
message: 'The linked message key must be enclosed in parentheses.',
fix(fixer) {
if (!range) {
return null
}
return [
fixer.insertTextBeforeRange(range, '('),
fixer.insertTextAfterRange(range, ')')
]
}
})
})
}

function verifyMessage(
message: string,
reportNode: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar,
getReportOffset: GetReportOffset
) {
if (messageSyntaxVersions.reportIfMissingSetting()) {
return
}
if (messageSyntaxVersions.v9 && messageSyntaxVersions.v8) {
// This rule cannot support two versions in the same project.
return
}

if (messageSyntaxVersions.v9) {
verifyForV9(message, reportNode, getReportOffset)
} else if (messageSyntaxVersions.v8) {
verifyForV8(message, reportNode, getReportOffset)
}
}
/**
* Create node visitor for JSON
*/
function createVisitorForJson(): RuleListener {
function verifyExpression(node: JSONAST.JSONExpression) {
if (node.type !== 'JSONLiteral' || typeof node.value !== 'string') {
return
}
verifyMessage(node.value, node as JSONAST.JSONStringLiteral, offset =>
getReportIndex(node, offset)
)
}
return {
JSONProperty(node: JSONAST.JSONProperty) {
verifyExpression(node.value)
},
JSONArrayExpression(node: JSONAST.JSONArrayExpression) {
for (const element of node.elements) {
if (element) verifyExpression(element)
}
}
}
}

/**
* Create node visitor for YAML
*/
function createVisitorForYaml(): RuleListener {
const yamlKeyNodes = new Set<YAMLAST.YAMLContent | YAMLAST.YAMLWithMeta>()
function withinKey(node: YAMLAST.YAMLNode) {
for (const keyNode of yamlKeyNodes) {
if (
keyNode.range[0] <= node.range[0] &&
node.range[0] < keyNode.range[1]
) {
return true
}
}
return false
}
function verifyContent(node: YAMLAST.YAMLContent | YAMLAST.YAMLWithMeta) {
const valueNode = node.type === 'YAMLWithMeta' ? node.value : node
if (
!valueNode ||
valueNode.type !== 'YAMLScalar' ||
typeof valueNode.value !== 'string'
) {
return
}
verifyMessage(valueNode.value, valueNode, offset =>
getReportIndex(valueNode, offset)
)
}
return {
YAMLPair(node: YAMLAST.YAMLPair) {
if (withinKey(node)) {
return
}
if (node.key != null) {
yamlKeyNodes.add(node.key)
}

if (node.value) verifyContent(node.value)
},
YAMLSequence(node: YAMLAST.YAMLSequence) {
if (withinKey(node)) {
return
}
for (const entry of node.entries) {
if (entry) verifyContent(entry)
}
}
}
}

if (extname(filename) === '.vue') {
return defineCustomBlocksVisitor(
context,
createVisitorForJson,
createVisitorForYaml
)
} else if (context.parserServices.isJSON || context.parserServices.isYAML) {
const localeMessages = getLocaleMessages(context)
const targetLocaleMessage = localeMessages.findExistLocaleMessage(filename)
if (!targetLocaleMessage) {
debug(`ignore ${filename} in prefer-linked-key-with-paren`)
return {}
}

if (context.parserServices.isJSON) {
return createVisitorForJson()
} else if (context.parserServices.isYAML) {
return createVisitorForYaml()
}
return {}
} else {
debug(`ignore ${filename} in prefer-linked-key-with-paren`)
return {}
}
}

export = {
meta: {
type: 'layout',
docs: {
description: 'enforce linked key to be enclosed in parentheses',
category: 'Stylistic Issues',
recommended: false
},
fixable: 'code',
schema: []
},
create
}
4 changes: 1 addition & 3 deletions scripts/lib/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,8 @@ const rules: RuleInfo[] = readdirSync(resolve(__dirname, '../../lib/rules'))
export default rules
export const withCategories = [
'Recommended',
'Best Practices'
/*
'Best Practices',
'Stylistic Issues'
*/
].map(category => ({
category,
rules: rules.filter(rule => rule.category === category && !rule.deprecated)
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/prefer-linked-key-with-paren/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
1 change: 1 addition & 0 deletions tests/fixtures/prefer-linked-key-with-paren/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
Loading