Skip to content

Commit

Permalink
feat: no-deprecated-modulo-syntax rule (#499)
Browse files Browse the repository at this point in the history
* feat: node-deprecated-modulo-syntax rule

* refactor

* fix: add modulo to AST node

* fix: unit test timeout

* docs: add no-deprecated-modulo-syntax

* fix: update docs docs and lib with generate script

* test: fix: add message syntax version

* Create grumpy-forks-brake.md

* docs: fix

* Update grumpy-forks-brake.md

---------

Co-authored-by: Yosuke Ota <[email protected]>
  • Loading branch information
kazupon and ota-meshi authored Apr 14, 2024
1 parent e325ab2 commit 296e6f6
Show file tree
Hide file tree
Showing 18 changed files with 556 additions and 169 deletions.
5 changes: 5 additions & 0 deletions .changeset/grumpy-forks-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@intlify/eslint-plugin-vue-i18n": minor
---

feat: `no-deprecated-modulo-syntax` rule
1 change: 1 addition & 0 deletions docs/rules/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
| [@intlify/vue-i18n/<wbr>no-deprecated-i18n-component](./no-deprecated-i18n-component.html) | disallow using deprecated `<i18n>` components (in Vue I18n 9.0.0+) | :black_nib: |
| [@intlify/vue-i18n/<wbr>no-deprecated-i18n-place-attr](./no-deprecated-i18n-place-attr.html) | disallow using deprecated `place` attribute (Removed in Vue I18n 9.0.0+) | |
| [@intlify/vue-i18n/<wbr>no-deprecated-i18n-places-prop](./no-deprecated-i18n-places-prop.html) | disallow using deprecated `places` prop (Removed in Vue I18n 9.0.0+) | |
| [@intlify/vue-i18n/<wbr>no-deprecated-modulo-syntax](./no-deprecated-modulo-syntax.html) | enforce modulo interpolation to be named interpolation | :black_nib: |
| [@intlify/vue-i18n/<wbr>no-html-messages](./no-html-messages.html) | disallow use HTML localization messages | :star: |
| [@intlify/vue-i18n/<wbr>no-i18n-t-path-prop](./no-i18n-t-path-prop.html) | disallow using `path` prop with `<i18n-t>` | :black_nib: |
| [@intlify/vue-i18n/<wbr>no-missing-keys](./no-missing-keys.html) | disallow missing locale message key at localization methods | :star: |
Expand Down
56 changes: 56 additions & 0 deletions docs/rules/no-deprecated-modulo-syntax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
title: '@intlify/vue-i18n/no-deprecated-modulo-syntax'
description: enforce modulo interpolation to be named interpolation
since: v3.0.0
---

# @intlify/vue-i18n/no-deprecated-modulo-syntax

> enforce modulo interpolation to be named interpolation
- :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 modulo interpolation to be named interpolation

## :book: Rule Details

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

locale messages:

<eslint-code-block fix language="json">

```json
/* eslint @intlify/vue-i18n/no-deprecated-modulo-syntax: 'error' */
{
/* ✗ BAD */
"hello": "%{msg} world"
}
```

</eslint-code-block>

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

locale messages (for vue-i18n v9+):

<eslint-code-block fix message-syntax-version="^9" language="json">

```json
/* eslint @intlify/vue-i18n/no-deprecated-modulo-syntax: 'error' */
{
/* ✓ GOOD */
"hello": "{msg} world"
}
```

</eslint-code-block>

## :rocket: Version

This rule was introduced in `@intlify/eslint-plugin-vue-i18n` v3.0.0

## :mag: Implementation

- [Rule source](https://github.com/intlify/eslint-plugin-vue-i18n/blob/master/lib/rules/no-deprecated-modulo-syntax.ts)
- [Test source](https://github.com/intlify/eslint-plugin-vue-i18n/tree/master/tests/lib/rules/no-deprecated-modulo-syntax.ts)
2 changes: 2 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import keyFormatStyle from './rules/key-format-style'
import noDeprecatedI18nComponent from './rules/no-deprecated-i18n-component'
import noDeprecatedI18nPlaceAttr from './rules/no-deprecated-i18n-place-attr'
import noDeprecatedI18nPlacesProp from './rules/no-deprecated-i18n-places-prop'
import noDeprecatedModuloSyntax from './rules/no-deprecated-modulo-syntax'
import noDuplicateKeysInLocale from './rules/no-duplicate-keys-in-locale'
import noDynamicKeys from './rules/no-dynamic-keys'
import noHtmlMessages from './rules/no-html-messages'
Expand Down Expand Up @@ -41,6 +42,7 @@ export = {
'no-deprecated-i18n-component': noDeprecatedI18nComponent,
'no-deprecated-i18n-place-attr': noDeprecatedI18nPlaceAttr,
'no-deprecated-i18n-places-prop': noDeprecatedI18nPlacesProp,
'no-deprecated-modulo-syntax': noDeprecatedModuloSyntax,
'no-duplicate-keys-in-locale': noDuplicateKeysInLocale,
'no-dynamic-keys': noDynamicKeys,
'no-html-messages': noHtmlMessages,
Expand Down
132 changes: 132 additions & 0 deletions lib/rules/no-deprecated-modulo-syntax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* @author kazuya kawaguchi (a.k.a. kazupon)
*/
import type { AST as JSONAST } from 'jsonc-eslint-parser'
import type { AST as YAMLAST } from 'yaml-eslint-parser'
import type { RuleContext, RuleListener } from '../types'
import type { GetReportOffset } from '../utils/rule'
import type { CustomBlockVisitorFactory } from '../types/vue-parser-services'
import { extname } from 'node:path'
import debugBuilder from 'debug'
import { defineCustomBlocksVisitor, getLocaleMessages } from '../utils/index'
import {
getMessageSyntaxVersions,
NodeTypes
} from '../utils/message-compiler/utils'
import { parse } from '../utils/message-compiler/parser'
import { traverseNode } from '../utils/message-compiler/traverser'
import {
createRule,
defineCreateVisitorForJson,
defineCreateVisitorForYaml
} from '../utils/rule'
import { getFilename, getSourceCode } from '../utils/compat'

const debug = debugBuilder('eslint-plugin-vue-i18n:no-deprecated-modulo-syntax')

function create(context: RuleContext): RuleListener {
const filename = getFilename(context)
const sourceCode = getSourceCode(context)
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.Named || !node.modulo) {
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) {
// Subtract `%` length (1), because we want to fix modulo
range = [start - 1, end]
}
context.report({
loc: range
? {
start: sourceCode.getLocFromIndex(range[0]),
end: sourceCode.getLocFromIndex(range[1])
}
: reportNode.loc,
message:
'The modulo interpolation must be enforced to named interpolation.',
fix(fixer) {
return range ? fixer.removeRange([range[0], range[0] + 1]) : null
}
})
})
}

function verifyMessage(
message: string,
reportNode: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar,
getReportOffset: GetReportOffset
) {
if (messageSyntaxVersions.reportIfMissingSetting()) {
return
}
if (messageSyntaxVersions.v9) {
verifyForV9(message, reportNode, getReportOffset)
} else if (messageSyntaxVersions.v8) {
return
}
}

const createVisitorForJson = defineCreateVisitorForJson(verifyMessage)
const createVisitorForYaml = defineCreateVisitorForYaml(verifyMessage)

if (extname(filename) === '.vue') {
return defineCustomBlocksVisitor(
context,
createVisitorForJson,
createVisitorForYaml
)
} else if (
sourceCode.parserServices.isJSON ||
sourceCode.parserServices.isYAML
) {
const localeMessages = getLocaleMessages(context)
const targetLocaleMessage = localeMessages.findExistLocaleMessage(filename)
if (!targetLocaleMessage) {
debug(`ignore ${filename} in no-deprecated-modulo-syntax`)
return {}
}

if (sourceCode.parserServices.isJSON) {
return createVisitorForJson(
context as Parameters<CustomBlockVisitorFactory>[0]
)
} else if (sourceCode.parserServices.isYAML) {
return createVisitorForYaml(
context as Parameters<CustomBlockVisitorFactory>[0]
)
}
return {}
} else {
debug(`ignore ${filename} in no-deprecated-modulo-syntax`)
return {}
}
}

export = createRule({
meta: {
type: 'problem',
docs: {
description: 'enforce modulo interpolation to be named interpolation',
category: 'Recommended',
url: 'https://eslint-plugin-vue-i18n.intlify.dev/rules/no-deprecated-modulo-syntax.html',
recommended: false
},
fixable: 'code',
schema: []
},
create
})
101 changes: 19 additions & 82 deletions lib/rules/prefer-linked-key-with-paren.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,26 @@
*/
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 type { GetReportOffset } from '../utils/rule'
import type { CustomBlockVisitorFactory } from '../types/vue-parser-services'
import { extname } from 'node:path'
import debugBuilder from 'debug'
import {
createRule,
defineCreateVisitorForJson,
defineCreateVisitorForYaml
} from '../utils/rule'
import { defineCustomBlocksVisitor, getLocaleMessages } from '../utils/index'
import {
getMessageSyntaxVersions,
getReportIndex,
NodeTypes
} 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 { createRule } from '../utils/rule'
import { getFilename, getSourceCode } from '../utils/compat'

const debug = debugBuilder(
'eslint-plugin-vue-i18n:prefer-linked-key-with-paren'
)
Expand All @@ -31,8 +37,6 @@ function getSingleQuote(node: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar) {
return "'"
}

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

function create(context: RuleContext): RuleListener {
const filename = getFilename(context)
const sourceCode = getSourceCode(context)
Expand Down Expand Up @@ -141,80 +145,9 @@ function create(context: RuleContext): RuleListener {
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)
}
}
}
}
const createVisitorForJson = defineCreateVisitorForJson(verifyMessage)
const createVisitorForYaml = defineCreateVisitorForYaml(verifyMessage)

if (extname(filename) === '.vue') {
return defineCustomBlocksVisitor(
Expand All @@ -234,9 +167,13 @@ function create(context: RuleContext): RuleListener {
}

if (sourceCode.parserServices.isJSON) {
return createVisitorForJson()
return createVisitorForJson(
context as Parameters<CustomBlockVisitorFactory>[0]
)
} else if (sourceCode.parserServices.isYAML) {
return createVisitorForYaml()
return createVisitorForYaml(
context as Parameters<CustomBlockVisitorFactory>[0]
)
}
return {}
} else {
Expand Down
2 changes: 1 addition & 1 deletion lib/types/vue-parser-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ export type CustomBlockVisitorFactory = (
context: RuleContext & {
parserServices: SourceCode['parserServices'] & { customBlock: VElement }
}
) => RuleListener | null
) => RuleListener
3 changes: 3 additions & 0 deletions lib/utils/message-compiler/parser-v8.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ function parseAST(code: string, errors: CompileError[]): ResourceNode {
key: trimmedKeyValue,
...ctx.getNodeLoc(endOffset - 1, placeholderEndOffset)
}
if (key === '%{') {
namedNode.modulo = true
}
if (!/^[a-zA-Z][a-zA-Z0-9_$]*$/.test(namedNode.key)) {
errors.push(
ctx.createCompileError('Unexpected placeholder key', endOffset)
Expand Down
Loading

0 comments on commit 296e6f6

Please sign in to comment.