Repository: intlify/eslint-plugin-vue-i18n (ts)

Issue:
`no-deprecated-modulo-syntax` rule
<!--
  Before proposing new rule, please make sure it hasn't been posted already.
  You can see all open propositions here:
  https://github.com/intlify/eslint-plugin-vue-i18n/issues?q=is%3Aissue+is%3Aopen+label%3A%22Type%3A+Feature%22
-->

**Please describe what the rule should do:**
<!-- A clear and concise description of what the rule should do. -->
vue-i18n v10 will drop modulo Interpolation (rails-style interpolation).
docs is here:
https://vue-i18n.intlify.dev/guide/essentials/syntax.html#rails-i18n-format

I would like to provide this rule to support migration, and support with `--fix` in this rule too.

**What category should the rule belong to?**
<!-- (place an "X" next to just one item) -->

- [x] Enforces code style (layout)
- [x] Warns about a potential error (problem)
- [ ] Suggests an alternate way of doing something (suggestion)
- [ ] Other (please specify:)

**Provide 2-3 code examples that this rule should warn about:**

✗ BAD :
```json
{
  "hello": "%{msg} world"
}
```

✓ GOOD:
```json
{
  "hello": "{msg} world"
}
```

**Additional context**
Not really relevant to this eslint plugin, but I will output a warning with message-compiler if it is also using modulo.


Pull request description:
feat: `no-deprecated-modulo-syntax` rule
resolve #495

Patch:
diff --git a/.changeset/grumpy-forks-brake.md b/.changeset/grumpy-forks-brake.md
new file mode 100644
index 0000000..3a5d9a1
--- /dev/null
+++ b/.changeset/grumpy-forks-brake.md
@@ -0,0 +1,5 @@
+---
+"@intlify/eslint-plugin-vue-i18n": minor
+---
+
+feat: `no-deprecated-modulo-syntax` rule
diff --git a/docs/rules/index.md b/docs/rules/index.md
index 3291d81..80651d6 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -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: |
diff --git a/docs/rules/no-deprecated-modulo-syntax.md b/docs/rules/no-deprecated-modulo-syntax.md
new file mode 100644
index 0000000..61941f2
--- /dev/null
+++ b/docs/rules/no-deprecated-modulo-syntax.md
@@ -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)
diff --git a/lib/index.ts b/lib/index.ts
index 2e6afd4..dbd5a84 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -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'
@@ -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,
diff --git a/lib/rules/no-deprecated-modulo-syntax.ts b/lib/rules/no-deprecated-modulo-syntax.ts
new file mode 100644
index 0000000..6ba4215
--- /dev/null
+++ b/lib/rules/no-deprecated-modulo-syntax.ts
@@ -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
+})
diff --git a/lib/rules/prefer-linked-key-with-paren.ts b/lib/rules/prefer-linked-key-with-paren.ts
index 6120e44..b22685f 100644
--- a/lib/rules/prefer-linked-key-with-paren.ts
+++ b/lib/rules/prefer-linked-key-with-paren.ts
@@ -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'
 )
@@ -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)
@@ -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(
@@ -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 {
diff --git a/lib/types/vue-parser-services.ts b/lib/types/vue-parser-services.ts
index 097cd96..f2a703f 100644
--- a/lib/types/vue-parser-services.ts
+++ b/lib/types/vue-parser-services.ts
@@ -55,4 +55,4 @@ export type CustomBlockVisitorFactory = (
   context: RuleContext & {
     parserServices: SourceCode['parserServices'] & { customBlock: VElement }
   }
-) => RuleListener | null
+) => RuleListener
diff --git a/lib/utils/message-compiler/parser-v8.ts b/lib/utils/message-compiler/parser-v8.ts
index 174b442..100cc62 100644
--- a/lib/utils/message-compiler/parser-v8.ts
+++ b/lib/utils/message-compiler/parser-v8.ts
@@ -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)
diff --git a/lib/utils/rule.ts b/lib/utils/rule.ts
index cb9c1ac..8539c13 100644
--- a/lib/utils/rule.ts
+++ b/lib/utils/rule.ts
@@ -1,4 +1,99 @@
-import type { RuleModule } from '../types'
+import type { RuleModule, RuleListener } from '../types'
+import type { AST as JSONAST } from 'jsonc-eslint-parser'
+import type { AST as YAMLAST } from 'yaml-eslint-parser'
+import type { CustomBlockVisitorFactory } from '../types/vue-parser-services'
+import { getReportIndex } from '../utils/message-compiler/utils'
+
+export type GetReportOffset = (offset: number) => number | null
+export type VerifyMessage = (
+  message: string,
+  reportNode: JSONAST.JSONStringLiteral | YAMLAST.YAMLScalar,
+  getReportOffset: GetReportOffset
+) => void
+
 export function createRule(module: RuleModule) {
   return module
 }
+
+/**
+ * Define create node visitor for JSON
+ */
+export function defineCreateVisitorForJson(
+  verifyMessage: VerifyMessage
+): CustomBlockVisitorFactory {
+  return function (): 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)
+        }
+      }
+    }
+  }
+}
+
+/**
+ * Define Create node visitor for YAML
+ */
+export function defineCreateVisitorForYaml(
+  verifyMessage: VerifyMessage
+): CustomBlockVisitorFactory {
+  return function (): 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)
+        }
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
index 94ef514..1c007f8 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,7 @@
     "lint:prettier": "prettier --check .",
     "prerelease": "pnpm run test && pnpm run build",
     "release": "changeset publish",
-    "test": "mocha --require jiti/register \"./tests/lib/**/*.ts\"",
+    "test": "mocha --require jiti/register \"./tests/lib/**/*.ts\" --timeout 5000",
     "test:debug": "mocha --require jiti/register \"./tests/lib/**/*.ts\"",
     "test:coverage": "nyc mocha --require jiti/register \"./tests/lib/**/*.ts\" --timeout 60000",
     "test:integrations": "mocha --require jiti/register \"./tests/integrations/*.ts\" --timeout 60000",
@@ -60,8 +60,8 @@
   },
   "dependencies": {
     "@eslint/eslintrc": "^3.0.0",
-    "@intlify/core-base": "beta",
-    "@intlify/message-compiler": "beta",
+    "@intlify/core-base": "^9.12.0",
+    "@intlify/message-compiler": "^9.12.0",
     "debug": "^4.3.4",
     "eslint-compat-utils": "^0.5.0",
     "glob": "^10.3.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 64aabc3..367797e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,11 +9,11 @@ dependencies:
     specifier: ^3.0.0
     version: 3.0.0
   '@intlify/core-base':
-    specifier: beta
-    version: 9.3.0-beta.27
+    specifier: ^9.12.0
+    version: 9.12.0
   '@intlify/message-compiler':
-    specifier: beta
-    version: 9.3.0-beta.27
+    specifier: ^9.12.0
+    version: 9.12.0
   debug:
     specifier: ^4.3.4
     version: 4.3.4(supports-color@8.1.1)
@@ -1134,42 +1134,25 @@ packages:
   /@humanwhocodes/object-schema@2.0.3:
     resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
 
-  /@intlify/core-base@9.3.0-beta.27:
-    resolution: {integrity: sha512-hWI8dZh9rRLxDt1IqPJQnXgMW5KZrNX2Z4uJCN348gsPVvsN8eB/J71TcNJs+C1mfIjQPwtmzUWPNhTewi8QGg==}
+  /@intlify/core-base@9.12.0:
+    resolution: {integrity: sha512-6EnWQXHnCh2bMiXT5N/IWwkcYQXjmF8nnEZ3YhTm23h1ZfOylz83D7pJYhcU8CsTiEdgbGiNdqyZPKwrHw03Ng==}
     engines: {node: '>= 16'}
     dependencies:
-      '@intlify/devtools-if': 9.3.0-beta.27
-      '@intlify/message-compiler': 9.3.0-beta.27
-      '@intlify/shared': 9.3.0-beta.27
-      '@intlify/vue-devtools': 9.3.0-beta.27
+      '@intlify/message-compiler': 9.12.0
+      '@intlify/shared': 9.12.0
     dev: false
 
-  /@intlify/devtools-if@9.3.0-beta.27:
-    resolution: {integrity: sha512-hA0rBQmVy7fN6UGZa+o0DrFTYnUvqD3E2Vqw35XV+huLlMQ2Zui9IpVhWozSy+ov/bdRFIhaxW11DAEG3BHa4w==}
+  /@intlify/message-compiler@9.12.0:
+    resolution: {integrity: sha512-2c6VwhvVJ1nur+2cN2NjdrmrV6vXjvyxYVvtUYMXKsWSUwoNURHGds0xJVJmWxbF8qV9oGepcVV6xl9bvadEIg==}
     engines: {node: '>= 16'}
     dependencies:
-      '@intlify/shared': 9.3.0-beta.27
-    dev: false
-
-  /@intlify/message-compiler@9.3.0-beta.27:
-    resolution: {integrity: sha512-GC8rSbd7V67Zu+a9Z0bpV4riBek11YCURJU50YaEhV4Ub2JHEPtoYxK5r2eIsq/kp+M2hJyGLiC4NJUrGa2VwQ==}
-    engines: {node: '>= 16'}
-    dependencies:
-      '@intlify/shared': 9.3.0-beta.27
-      source-map-js: 1.0.2
-    dev: false
-
-  /@intlify/shared@9.3.0-beta.27:
-    resolution: {integrity: sha512-hPMsmVCs+ZUVHHU5VORG6LopzXZT7zmyVNqc9OQG80YpA/N4lT/pkJ4B6DTNIsv2C7mwfGM7RdK+0qPki43YgA==}
-    engines: {node: '>= 16'}
+      '@intlify/shared': 9.12.0
+      source-map-js: 1.2.0
     dev: false
 
-  /@intlify/vue-devtools@9.3.0-beta.27:
-    resolution: {integrity: sha512-XqES1UsntZjyo5sLgmVol42RK7YpAos3bvCh6NbzFMjPZVJf08PYs+KsUvYi2y0D3OSfmXbZSSh4A5s2O9MT0w==}
+  /@intlify/shared@9.12.0:
+    resolution: {integrity: sha512-uBcH55x5CfZynnerWHQxrXbT6yD6j6T7Nt+R2+dHAOAneoMd6BoGvfEzfYscE94rgmjoDqdr+PdGDBLk5I5EjA==}
     engines: {node: '>= 16'}
-    dependencies:
-      '@intlify/core-base': 9.3.0-beta.27
-      '@intlify/shared': 9.3.0-beta.27
     dev: false
 
   /@isaacs/cliui@8.0.2:
@@ -4775,15 +4758,9 @@ packages:
       yargs: 15.4.1
     dev: true
 
-  /source-map-js@1.0.2:
-    resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
-    engines: {node: '>=0.10.0'}
-    dev: false
-
   /source-map-js@1.2.0:
     resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /source-map@0.6.1:
     resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}


Test patch:
diff --git a/tests/fixtures/no-deprecated-modulo-syntax/test.json b/tests/fixtures/no-deprecated-modulo-syntax/test.json
new file mode 100644
index 0000000..19765bd
--- /dev/null
+++ b/tests/fixtures/no-deprecated-modulo-syntax/test.json
@@ -0,0 +1,1 @@
+null
diff --git a/tests/fixtures/no-deprecated-modulo-syntax/test.yaml b/tests/fixtures/no-deprecated-modulo-syntax/test.yaml
new file mode 100644
index 0000000..19765bd
--- /dev/null
+++ b/tests/fixtures/no-deprecated-modulo-syntax/test.yaml
@@ -0,0 +1,1 @@
+null
diff --git a/tests/integrations/flat-config/eslint.config.js b/tests/integrations/flat-config/eslint.config.js
index 4ab5e45..ae73e5d 100644
--- a/tests/integrations/flat-config/eslint.config.js
+++ b/tests/integrations/flat-config/eslint.config.js
@@ -10,7 +10,8 @@ export default [
     },
     settings: {
       'vue-i18n': {
-        localeDir: './src/resources/*.json'
+        localeDir: './src/resources/*.json',
+        messageSyntaxVersion: '^9.0.0'
       }
     }
   }
diff --git a/tests/integrations/legacy-config/.eslintrc.cjs b/tests/integrations/legacy-config/.eslintrc.cjs
index 9cf0c4a..cfe0e2f 100644
--- a/tests/integrations/legacy-config/.eslintrc.cjs
+++ b/tests/integrations/legacy-config/.eslintrc.cjs
@@ -8,7 +8,8 @@ module.exports = {
   },
   settings: {
     'vue-i18n': {
-      localeDir: `./src/resources/*.json`
+      localeDir: `./src/resources/*.json`,
+      messageSyntaxVersion: '^9.0.0'
     }
   }
 }
diff --git a/tests/lib/rules/no-deprecated-modulo-syntax.ts b/tests/lib/rules/no-deprecated-modulo-syntax.ts
new file mode 100644
index 0000000..448d0b9
--- /dev/null
+++ b/tests/lib/rules/no-deprecated-modulo-syntax.ts
@@ -0,0 +1,169 @@
+/**
+ * @author kazuya kawaguchi (a.k.a. kazupon)
+ */
+import { join } from 'node:path'
+import { RuleTester } from '../eslint-compat'
+import { getRuleTesterTestCaseOptions } from '../test-utils'
+import rule from '../../../lib/rules/no-deprecated-modulo-syntax'
+import * as vueParser from 'vue-eslint-parser'
+
+const FIXTURES_ROOT = join(
+  __dirname,
+  '../../fixtures/no-deprecated-modulo-syntax'
+)
+
+const options = getRuleTesterTestCaseOptions(FIXTURES_ROOT)
+
+const tester = new RuleTester({
+  languageOptions: { parser: vueParser, ecmaVersion: 2015 }
+})
+
+tester.run('no-deprecated-module-syntax', rule as never, {
+  valid: [
+    // text only
+    {
+      code: `
+      {
+        "hello": "world"
+      }
+      `,
+      ...options.json()
+    },
+    // named interpolation
+    {
+      code: `
+      {
+        "hello": "{msg} world"
+      }
+      `,
+      ...options.json()
+    },
+    // list interpolation
+    {
+      code: `
+      {
+        "hello": "{0} world"
+      }
+      `,
+      ...options.json()
+    },
+    // linked messages
+    {
+      code: `
+      {
+        "hello": "@:{'baz'} world"
+      }
+      `,
+      ...options.json()
+    },
+    // pluralization
+    {
+      code: `
+      {
+        "hello": "world1 | world2"
+      }
+      `,
+      ...options.json()
+    },
+    // yaml
+    {
+      code: `
+        hello: '{msg} world'
+      `,
+      ...options.yaml()
+    },
+    // SFC
+    {
+      code: `
+      <i18n lang="json5">
+      { hello: "{msg} world" }
+      </i18n>
+      <i18n lang="yaml">
+      hello: '{msg} world'
+      </i18n>
+      `,
+      ...options.vue()
+    }
+  ],
+
+  invalid: [
+    // modulo for json
+    {
+      code: `{
+        "hello": "%{msg} world"
+      }
+      `,
+      ...options.json(),
+      output: `{
+        "hello": "{msg} world"
+      }
+      `,
+      errors: [
+        {
+          message:
+            'The modulo interpolation must be enforced to named interpolation.',
+          line: 2,
+          column: 19,
+          endLine: 2,
+          endColumn: 25
+        }
+      ]
+    },
+    // modulo for yaml
+    {
+      code: `hello: "%{msg} world"
+      `,
+      ...options.yaml(),
+      output: `hello: "{msg} world"
+      `,
+      errors: [
+        {
+          message:
+            'The modulo interpolation must be enforced to named interpolation.',
+          line: 1,
+          column: 9,
+          endLine: 1,
+          endColumn: 15
+        }
+      ]
+    },
+    // modulo for SFC
+    {
+      code: `
+      <i18n>
+      { "hello": "%{msg} world" }
+      </i18n>
+      <i18n lang="yaml">
+      hello: '%{msg} world'
+      </i18n>
+      `,
+      ...options.vue(),
+      output: `
+      <i18n>
+      { "hello": "{msg} world" }
+      </i18n>
+      <i18n lang="yaml">
+      hello: '{msg} world'
+      </i18n>
+      `,
+      errors: [
+        {
+          message:
+            'The modulo interpolation must be enforced to named interpolation.',
+          line: 3,
+          column: 19,
+          endLine: 3,
+          endColumn: 25
+        },
+        {
+          message:
+            'The modulo interpolation must be enforced to named interpolation.',
+          line: 6,
+          column: 15,
+          endLine: 6,
+          endColumn: 21
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/prefer-linked-key-with-paren.ts b/tests/lib/rules/prefer-linked-key-with-paren.ts
index c26a146..c45c68f 100644
--- a/tests/lib/rules/prefer-linked-key-with-paren.ts
+++ b/tests/lib/rules/prefer-linked-key-with-paren.ts
@@ -3,57 +3,16 @@
  */
 import { join } from 'node:path'
 import { RuleTester, TEST_RULE_ID_PREFIX } from '../eslint-compat'
+import { getRuleTesterTestCaseOptions } from '../test-utils'
 import rule from '../../../lib/rules/prefer-linked-key-with-paren'
 import * as vueParser from 'vue-eslint-parser'
-import * as jsonParser from 'jsonc-eslint-parser'
-import * as yamlParser from 'yaml-eslint-parser'
 
 const FIXTURES_ROOT = join(
   __dirname,
   '../../fixtures/prefer-linked-key-with-paren'
 )
 
-const options = {
-  json(messageSyntaxVersion: string | null = '^9.0.0') {
-    return {
-      languageOptions: { parser: jsonParser },
-      filename: join(FIXTURES_ROOT, 'test.json'),
-      settings: {
-        'vue-i18n': {
-          localeDir: {
-            pattern: `${FIXTURES_ROOT}/*.{json,yaml,yml}`
-          },
-          messageSyntaxVersion
-        }
-      }
-    }
-  },
-  yaml(messageSyntaxVersion: string | null = '^9.0.0') {
-    return {
-      languageOptions: { parser: yamlParser },
-      filename: join(FIXTURES_ROOT, 'test.yaml'),
-      settings: {
-        'vue-i18n': {
-          localeDir: {
-            pattern: `${FIXTURES_ROOT}/*.{json,yaml,yml}`
-          },
-          messageSyntaxVersion
-        }
-      }
-    }
-  },
-  vue(messageSyntaxVersion: string | null = '^9.0.0') {
-    return {
-      languageOptions: { parser: vueParser },
-      filename: join(FIXTURES_ROOT, 'test.vue'),
-      settings: {
-        'vue-i18n': {
-          messageSyntaxVersion
-        }
-      }
-    }
-  }
-}
+const options = getRuleTesterTestCaseOptions(FIXTURES_ROOT)
 
 const tester = new RuleTester({
   languageOptions: { parser: vueParser, ecmaVersion: 2015 }
diff --git a/tests/lib/test-utils.ts b/tests/lib/test-utils.ts
index 1e890c1..65a8eec 100644
--- a/tests/lib/test-utils.ts
+++ b/tests/lib/test-utils.ts
@@ -12,6 +12,53 @@ type LanguageOptions = {
   parser: object
 }
 
+export function getRuleTesterTestCaseOptions(
+  root: string,
+  filebaseName: string = 'test'
+) {
+  return {
+    json(messageSyntaxVersion: string | null = '^9.0.0') {
+      return {
+        languageOptions: { parser: jsonParser },
+        filename: join(root, `${filebaseName}.json`),
+        settings: {
+          'vue-i18n': {
+            localeDir: {
+              pattern: `${root}/*.{json,yaml,yml}`
+            },
+            messageSyntaxVersion
+          }
+        }
+      }
+    },
+    yaml(messageSyntaxVersion: string | null = '^9.0.0') {
+      return {
+        languageOptions: { parser: yamlParser },
+        filename: join(root, `${filebaseName}.yaml`),
+        settings: {
+          'vue-i18n': {
+            localeDir: {
+              pattern: `${root}/*.{json,yaml,yml}`
+            },
+            messageSyntaxVersion
+          }
+        }
+      }
+    },
+    vue(messageSyntaxVersion: string | null = '^9.0.0') {
+      return {
+        languageOptions: { parser: vueParser },
+        filename: join(root, `${filebaseName}.vue`),
+        settings: {
+          'vue-i18n': {
+            messageSyntaxVersion
+          }
+        }
+      }
+    }
+  }
+}
+
 export function getTestCasesFromFixtures(testOptions: {
   eslint?: string
   cwd: string