Repository: vuejs/eslint-plugin-vue (js)

Issue:
Rule proposal: `vue/prefer-separate-static-class`
**Please describe what the rule should do:**
Report static classes in bound class attributes.

**What category should the rule belong to?**
[x] Enforces code style (layout)
[ ] 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:**

```vue
<!-- FAIL -->
<div v-bind:class="'static-class'" />
<div :class="'static-class'" />
<div :class="`static-class`" />
<div :class='"static-class"' />
<div :class="['static-class']" />
<div :class="[`static-class`]" />
<div :class='["static-class"]' />
<div :class="{'static-class': true}" />
<div :class="{foo: true}" />
<div :class='{"static-class": true}' />
<div :class="{['static-class']: true}" />
<div :class="{[`static-class`]: true}" />
<div :class="[{'static-class': true}]" />

<div :class="['static-class', dynamicClass]" />
<div :class="{'static-class': true, 'dynamic-class': foo}" />
```

```vue
<!-- PASS -->
<div class="static-class" />
<div :class="dynamicClass" />
<div class="static-class" :class="dynamicClass" />
<div class="static-class" :class="[dynamicClass]" />
<div class="static-class" :class="{'dynamic-class': foo}" />>
```

Pull request:
Add new rule `vue/prefer-separate-static-class`
Fixes #1726.

Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7bb64653..80e1f88f 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -10,7 +10,7 @@
         "json",
         "jsonc"
     ],
-    "typescript.tsdk": "node_modules/typescript/lib",
+    "typescript.tsdk": "./node_modules/typescript/lib",
     "vetur.validation.script": false,
     "[typescript]": {
         "editor.formatOnSave": true,
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 5794cc3e..735dc87d 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -311,6 +311,7 @@ For example:
 | [vue/block-tag-newline](./block-tag-newline.md) | enforce line breaks after opening and before closing block-level tags | :wrench: |
 | [vue/component-api-style](./component-api-style.md) | enforce component API style |  |
 | [vue/component-name-in-template-casing](./component-name-in-template-casing.md) | enforce specific casing for the component naming style in template | :wrench: |
+| [vue/component-options-name-casing](./component-options-name-casing.md) | enforce the casing of component name in `components` options | :wrench::bulb: |
 | [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce specific casing for custom event name |  |
 | [vue/html-button-has-type](./html-button-has-type.md) | disallow usage of button without an explicit type attribute |  |
 | [vue/html-comment-content-newline](./html-comment-content-newline.md) | enforce unified line brake in HTML comments | :wrench: |
@@ -350,6 +351,7 @@ For example:
 | [vue/no-useless-v-bind](./no-useless-v-bind.md) | disallow unnecessary `v-bind` directives | :wrench: |
 | [vue/no-v-text](./no-v-text.md) | disallow use of v-text |  |
 | [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: |
+| [vue/prefer-separate-static-class](./prefer-separate-static-class.md) | require static class names in template to be in a separate `class` attribute | :wrench: |
 | [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported |  |
 | [vue/require-emit-validator](./require-emit-validator.md) | require type definitions in emits | :bulb: |
 | [vue/require-expose](./require-expose.md) | require declare public properties using `expose` | :bulb: |
diff --git a/docs/rules/component-definition-name-casing.md b/docs/rules/component-definition-name-casing.md
index 5e89ace7..615b876f 100644
--- a/docs/rules/component-definition-name-casing.md
+++ b/docs/rules/component-definition-name-casing.md
@@ -31,7 +31,7 @@ Default casing is set to `PascalCase`.
 - `"PascalCase"` (default) ... enforce component definition names to pascal case.
 - `"kebab-case"` ... enforce component definition names to kebab case.
 
-### `"PascalCase" (default)
+### `"PascalCase"` (default)
 
 <eslint-code-block fix :rules="{'vue/component-definition-name-casing': ['error']}">
 
@@ -64,18 +64,18 @@ export default {
 ```js
 /* ✓ GOOD */
 Vue.component('MyComponent', {
-  
+
 })
 
 /* ✗ BAD */
 Vue.component('my-component', {
-  
+
 })
 ```
 
 </eslint-code-block>
 
-### `"kebab-case"
+### `"kebab-case"`
 
 <eslint-code-block fix :rules="{'vue/component-definition-name-casing': ['error', 'kebab-case']}">
 
@@ -108,12 +108,12 @@ export default {
 ```js
 /* ✓ GOOD */
 Vue.component('my-component', {
-  
+
 })
 
 /* ✗ BAD */
 Vue.component('MyComponent', {
-  
+
 })
 ```
 
diff --git a/docs/rules/component-options-name-casing.md b/docs/rules/component-options-name-casing.md
new file mode 100644
index 00000000..9a956553
--- /dev/null
+++ b/docs/rules/component-options-name-casing.md
@@ -0,0 +1,165 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/component-options-name-casing
+description: enforce the casing of component name in `components` options
+---
+# vue/component-options-name-casing
+
+> enforce the casing of component name in `components` options
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+- :bulb: Some problems reported by this rule are manually fixable by editor [suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
+
+## :book: Rule Details
+
+This rule aims to enforce casing of the component names in `components` options.
+
+## :wrench: Options
+
+```json
+{
+  "vue/component-options-name-casing": ["error", "PascalCase" | "kebab-case" | "camelCase"]
+}
+```
+
+This rule has an option which can be one of these values:
+
+- `"PascalCase"` (default) ... enforce component names to pascal case.
+- `"kebab-case"` ... enforce component names to kebab case.
+- `"camelCase"` ... enforce component names to camel case.
+
+Please note that if you use kebab case in `components` options,
+you can **only** use kebab case in template;
+and if you use camel case in `components` options,
+you **can't** use pascal case in template.
+
+For demonstration, the code example is invalid:
+
+```vue
+<template>
+  <div>
+    <!-- All are invalid. DO NOT use like these. -->
+    <KebabCase />
+    <kebabCase />
+    <CamelCase />
+  </div>
+</template>
+
+<script>
+export default {
+  components: {
+    camelCase: MyComponent,
+    'kebab-case': MyComponent
+  }
+}
+</script>
+```
+
+### `"PascalCase"` (default)
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error']}">
+
+```vue
+<script>
+export default {
+  /* ✓ GOOD */
+  components: {
+    AppHeader,
+    AppSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error']}">
+
+```vue
+<script>
+export default {
+  /* ✗ BAD */
+  components: {
+    appHeader,
+    'app-sidebar': AppSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+### `"kebab-case"`
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error', 'kebab-case']}">
+
+```vue
+<script>
+export default {
+  /* ✓ GOOD */
+  components: {
+    'app-header': AppHeader,
+    'app-sidebar': appSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error', 'kebab-case']}">
+
+```vue
+<script>
+export default {
+  /* ✗ BAD */
+  components: {
+    AppHeader,
+    appSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+### `"camelCase"`
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error', 'camelCase']}">
+
+```vue
+<script>
+export default {
+  /* ✓ GOOD */
+  components: {
+    appHeader,
+    appSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/component-options-name-casing': ['error', 'camelCase']}">
+
+```vue
+<script>
+export default {
+  /* ✗ BAD */
+  components: {
+    AppHeader,
+    'app-sidebar': appSidebar
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/component-options-name-casing.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/component-options-name-casing.js)
diff --git a/docs/rules/prefer-separate-static-class.md b/docs/rules/prefer-separate-static-class.md
new file mode 100644
index 00000000..155c4fb5
--- /dev/null
+++ b/docs/rules/prefer-separate-static-class.md
@@ -0,0 +1,43 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/prefer-separate-static-class
+description: require static class names in template to be in a separate `class` attribute
+---
+# vue/prefer-separate-static-class
+
+> require static class names in template to be in a separate `class` attribute
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+## :book: Rule Details
+
+This rule reports static class names in dynamic class attributes.
+
+<eslint-code-block fix :rules="{'vue/prefer-separate-static-class': ['error']}">
+
+```vue
+<template>
+  <!-- ✗ BAD -->
+  <div :class="'static-class'" />
+  <div :class="{'static-class': true, 'dynamic-class': foo}" />
+  <div :class="['static-class', dynamicClass]" />
+
+  <!-- ✓ GOOD -->
+  <div class="static-class" />
+  <div class="static-class" :class="{'dynamic-class': foo}" />
+  <div class="static-class" :class="[dynamicClass]" />
+</template>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/prefer-separate-static-class.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/prefer-separate-static-class.js)
diff --git a/lib/index.js b/lib/index.js
index c9969917..3c628c55 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -24,6 +24,7 @@ module.exports = {
     'component-api-style': require('./rules/component-api-style'),
     'component-definition-name-casing': require('./rules/component-definition-name-casing'),
     'component-name-in-template-casing': require('./rules/component-name-in-template-casing'),
+    'component-options-name-casing': require('./rules/component-options-name-casing'),
     'component-tags-order': require('./rules/component-tags-order'),
     'custom-event-name-casing': require('./rules/custom-event-name-casing'),
     'dot-location': require('./rules/dot-location'),
@@ -153,6 +154,7 @@ module.exports = {
     'operator-linebreak': require('./rules/operator-linebreak'),
     'order-in-components': require('./rules/order-in-components'),
     'padding-line-between-blocks': require('./rules/padding-line-between-blocks'),
+    'prefer-separate-static-class': require('./rules/prefer-separate-static-class'),
     'prefer-template': require('./rules/prefer-template'),
     'prop-name-casing': require('./rules/prop-name-casing'),
     'require-component-is': require('./rules/require-component-is'),
diff --git a/lib/rules/component-options-name-casing.js b/lib/rules/component-options-name-casing.js
new file mode 100644
index 00000000..ad70c154
--- /dev/null
+++ b/lib/rules/component-options-name-casing.js
@@ -0,0 +1,115 @@
+/**
+ * @author Pig Fang
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const utils = require('../utils')
+const casing = require('../utils/casing')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * @param {import('../../typings/eslint-plugin-vue/util-types/ast').Expression} node
+ * @returns {string | null}
+ */
+function getOptionsComponentName(node) {
+  if (node.type === 'Identifier') {
+    return node.name
+  }
+  if (node.type === 'Literal') {
+    return typeof node.value === 'string' ? node.value : null
+  }
+  return null
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description:
+        'enforce the casing of component name in `components` options',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/component-options-name-casing.html'
+    },
+    fixable: 'code',
+    hasSuggestions: true,
+    schema: [{ enum: casing.allowedCaseOptions }],
+    messages: {
+      caseNotMatched: 'Component name "{{component}}" is not {{caseType}}.',
+      possibleRenaming: 'Rename component name to be in {{caseType}}.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const caseType = context.options[0] || 'PascalCase'
+
+    const canAutoFix = caseType === 'PascalCase'
+    const checkCase = casing.getChecker(caseType)
+    const convert = casing.getConverter(caseType)
+
+    return utils.executeOnVue(context, (obj) => {
+      const node = utils.findProperty(obj, 'components')
+      if (!node || node.value.type !== 'ObjectExpression') {
+        return
+      }
+
+      node.value.properties.forEach((property) => {
+        if (property.type !== 'Property') {
+          return
+        }
+
+        const name = getOptionsComponentName(property.key)
+        if (!name || checkCase(name)) {
+          return
+        }
+
+        context.report({
+          node: property.key,
+          messageId: 'caseNotMatched',
+          data: {
+            component: name,
+            caseType
+          },
+          fix: canAutoFix
+            ? (fixer) => {
+                const converted = convert(name)
+                return property.shorthand
+                  ? fixer.replaceText(property, `${converted}: ${name}`)
+                  : fixer.replaceText(property.key, converted)
+              }
+            : undefined,
+          suggest: canAutoFix
+            ? undefined
+            : [
+                {
+                  messageId: 'possibleRenaming',
+                  data: { caseType },
+                  fix: (fixer) => {
+                    const converted = convert(name)
+                    if (caseType === 'kebab-case') {
+                      return property.shorthand
+                        ? fixer.replaceText(property, `'${converted}': ${name}`)
+                        : fixer.replaceText(property.key, `'${converted}'`)
+                    }
+                    return property.shorthand
+                      ? fixer.replaceText(property, `${converted}: ${name}`)
+                      : fixer.replaceText(property.key, converted)
+                  }
+                }
+              ]
+        })
+      })
+    })
+  }
+}
diff --git a/lib/rules/no-dupe-keys.js b/lib/rules/no-dupe-keys.js
index 61ffda40..8b5e8e54 100644
--- a/lib/rules/no-dupe-keys.js
+++ b/lib/rules/no-dupe-keys.js
@@ -14,14 +14,7 @@ const utils = require('../utils')
 // Rule Definition
 // ------------------------------------------------------------------------------
 /** @type {GroupName[]} */
-const GROUP_NAMES = [
-  'props',
-  'computed',
-  'data',
-  'asyncData',
-  'methods',
-  'setup'
-]
+const GROUP_NAMES = ['props', 'computed', 'data', 'methods', 'setup']
 
 module.exports = {
   meta: {
diff --git a/lib/rules/prefer-separate-static-class.js b/lib/rules/prefer-separate-static-class.js
new file mode 100644
index 00000000..504d95d6
--- /dev/null
+++ b/lib/rules/prefer-separate-static-class.js
@@ -0,0 +1,231 @@
+/**
+ * @author Flo Edelmann
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const { defineTemplateBodyVisitor, getStringLiteralValue } = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * @param {ASTNode} node
+ * @returns {node is Literal | TemplateLiteral}
+ */
+function isStringLiteral(node) {
+  return (
+    (node.type === 'Literal' && typeof node.value === 'string') ||
+    (node.type === 'TemplateLiteral' && node.expressions.length === 0)
+  )
+}
+
+/**
+ * @param {Expression | VForExpression | VOnExpression | VSlotScopeExpression | VFilterSequenceExpression} expressionNode
+ * @returns {(Literal | TemplateLiteral | Identifier)[]}
+ */
+function findStaticClasses(expressionNode) {
+  if (isStringLiteral(expressionNode)) {
+    return [expressionNode]
+  }
+
+  if (expressionNode.type === 'ArrayExpression') {
+    return expressionNode.elements.flatMap((element) => {
+      if (element === null || element.type === 'SpreadElement') {
+        return []
+      }
+      return findStaticClasses(element)
+    })
+  }
+
+  if (expressionNode.type === 'ObjectExpression') {
+    return expressionNode.properties.flatMap((property) => {
+      if (
+        property.type === 'Property' &&
+        property.value.type === 'Literal' &&
+        property.value.value === true &&
+        (isStringLiteral(property.key) ||
+          (property.key.type === 'Identifier' && !property.computed))
+      ) {
+        return [property.key]
+      }
+      return []
+    })
+  }
+
+  return []
+}
+
+/**
+ * @param {VAttribute | VDirective} attributeNode
+ * @returns {attributeNode is VAttribute & { value: VLiteral }}
+ */
+function isStaticClassAttribute(attributeNode) {
+  return (
+    !attributeNode.directive &&
+    attributeNode.key.name === 'class' &&
+    attributeNode.value !== null
+  )
+}
+
+/**
+ * Removes the node together with the comma before or after the node.
+ * @param {RuleFixer} fixer
+ * @param {ParserServices.TokenStore} tokenStore
+ * @param {ASTNode} node
+ */
+function* removeNodeWithComma(fixer, tokenStore, node) {
+  const prevToken = tokenStore.getTokenBefore(node)
+  if (prevToken.type === 'Punctuator' && prevToken.value === ',') {
+    yield fixer.removeRange([prevToken.range[0], node.range[1]])
+    return
+  }
+
+  const [nextToken, nextNextToken] = tokenStore.getTokensAfter(node, {
+    count: 2
+  })
+  if (
+    nextToken.type === 'Punctuator' &&
+    nextToken.value === ',' &&
+    (nextNextToken.type !== 'Punctuator' ||
+      (nextNextToken.value !== ']' && nextNextToken.value !== '}'))
+  ) {
+    yield fixer.removeRange([node.range[0], nextNextToken.range[0]])
+    return
+  }
+
+  yield fixer.remove(node)
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description:
+        'require static class names in template to be in a separate `class` attribute',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/prefer-separate-static-class.html'
+    },
+    fixable: 'code',
+    schema: [],
+    messages: {
+      preferSeparateStaticClass:
+        'Static class "{{className}}" should be in a static `class` attribute.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    return defineTemplateBodyVisitor(context, {
+      /** @param {VDirectiveKey} directiveKeyNode */
+      "VAttribute[directive=true] > VDirectiveKey[name.name='bind'][argument.name='class']"(
+        directiveKeyNode
+      ) {
+        const attributeNode = directiveKeyNode.parent
+        if (!attributeNode.value || !attributeNode.value.expression) {
+          return
+        }
+
+        const expressionNode = attributeNode.value.expression
+        const staticClassNameNodes = findStaticClasses(expressionNode)
+
+        for (const staticClassNameNode of staticClassNameNodes) {
+          const className =
+            staticClassNameNode.type === 'Identifier'
+              ? staticClassNameNode.name
+              : getStringLiteralValue(staticClassNameNode, true)
+
+          if (className === null) {
+            continue
+          }
+
+          context.report({
+            node: staticClassNameNode,
+            messageId: 'preferSeparateStaticClass',
+            data: { className },
+            *fix(fixer) {
+              let dynamicClassDirectiveRemoved = false
+
+              yield* removeFromClassDirective()
+              yield* addToClassAttribute()
+
+              /**
+               * Remove class from dynamic `:class` directive.
+               */
+              function* removeFromClassDirective() {
+                if (isStringLiteral(expressionNode)) {
+                  yield fixer.remove(attributeNode)
+                  dynamicClassDirectiveRemoved = true
+                  return
+                }
+
+                const listElement =
+                  staticClassNameNode.parent.type === 'Property'
+                    ? staticClassNameNode.parent
+                    : staticClassNameNode
+
+                const listNode = listElement.parent
+                if (
+                  listNode.type === 'ArrayExpression' ||
+                  listNode.type === 'ObjectExpression'
+                ) {
+                  const elements =
+                    listNode.type === 'ObjectExpression'
+                      ? listNode.properties
+                      : listNode.elements
+
+                  if (elements.length === 1 && listNode === expressionNode) {
+                    yield fixer.remove(attributeNode)
+                    dynamicClassDirectiveRemoved = true
+                    return
+                  }
+
+                  const tokenStore =
+                    context.parserServices.getTemplateBodyTokenStore()
+
+                  if (elements.length === 1) {
+                    yield* removeNodeWithComma(fixer, tokenStore, listNode)
+                    return
+                  }
+
+                  yield* removeNodeWithComma(fixer, tokenStore, listElement)
+                }
+              }
+
+              /**
+               * Add class to static `class` attribute.
+               */
+              function* addToClassAttribute() {
+                const existingStaticClassAttribute =
+                  attributeNode.parent.attributes.find(isStaticClassAttribute)
+                if (existingStaticClassAttribute) {
+                  const literalNode = existingStaticClassAttribute.value
+                  yield fixer.replaceText(
+                    literalNode,
+                    `"${literalNode.value} ${className}"`
+                  )
+                  return
+                }
+
+                // new static `class` attribute
+                const separator = dynamicClassDirectiveRemoved ? '' : ' '
+                yield fixer.insertTextBefore(
+                  attributeNode,
+                  `class="${className}"${separator}`
+                )
+              }
+            }
+          })
+        }
+      }
+    })
+  }
+}
diff --git a/lib/utils/indent-common.js b/lib/utils/indent-common.js
index 39ef5867..d208d5a0 100644
--- a/lib/utils/indent-common.js
+++ b/lib/utils/indent-common.js
@@ -19,7 +19,8 @@ const {
   isNotOpeningBraceToken,
   isOpeningBracketToken,
   isClosingBracketToken,
-  isSemicolonToken
+  isSemicolonToken,
+  isNotSemicolonToken
 } = require('eslint-utils')
 const {
   isComment,
@@ -1288,13 +1289,13 @@ module.exports.defineVisitor = function create(
     },
     /** @param {ExportAllDeclaration} node */
     ExportAllDeclaration(node) {
-      const tokens = tokenStore.getTokens(node)
-      const firstToken = /** @type {Token} */ (tokens.shift())
-      if (isSemicolonToken(tokens[tokens.length - 1])) {
-        tokens.pop()
-      }
+      const exportToken = tokenStore.getFirstToken(node)
+      const tokens = [
+        ...tokenStore.getTokensBetween(exportToken, node.source),
+        tokenStore.getFirstToken(node.source)
+      ]
       if (!node.exported) {
-        setOffset(tokens, 1, firstToken)
+        setOffset(tokens, 1, exportToken)
       } else {
         // export * as foo from "mod"
         const starToken = /** @type {Token} */ (tokens.find(isWildcard))
@@ -1302,10 +1303,28 @@ module.exports.defineVisitor = function create(
         const exportedToken = tokenStore.getTokenAfter(asToken)
         const afterTokens = tokens.slice(tokens.indexOf(exportedToken) + 1)
 
-        setOffset(starToken, 1, firstToken)
+        setOffset(starToken, 1, exportToken)
         setOffset(asToken, 1, starToken)
         setOffset(exportedToken, 1, starToken)
-        setOffset(afterTokens, 1, firstToken)
+        setOffset(afterTokens, 1, exportToken)
+      }
+
+      // assertions
+      const lastToken = /** @type {Token} */ (
+        tokenStore.getLastToken(node, isNotSemicolonToken)
+      )
+      const assertionTokens = tokenStore.getTokensBetween(
+        node.source,
+        lastToken
+      )
+      if (assertionTokens.length) {
+        const assertToken = /** @type {Token} */ (assertionTokens.shift())
+        setOffset(assertToken, 0, exportToken)
+        const assertionOpen = assertionTokens.shift()
+        if (assertionOpen) {
+          setOffset(assertionOpen, 1, assertToken)
+          processNodeList(assertionTokens, assertionOpen, lastToken, 1)
+        }
       }
     },
     /** @param {ExportDefaultDeclaration} node */
@@ -1328,28 +1347,66 @@ module.exports.defineVisitor = function create(
         const firstSpecifier = node.specifiers[0]
         if (!firstSpecifier || firstSpecifier.type === 'ExportSpecifier') {
           // export {foo, bar}; or export {foo, bar} from "mod";
-          const leftParenToken = tokenStore.getFirstToken(node, 1)
-          const rightParenToken = /** @type {Token} */ (
-            tokenStore.getLastToken(node, isClosingBraceToken)
+          const leftBraceTokens = firstSpecifier
+            ? tokenStore.getTokensBetween(exportToken, firstSpecifier)
+            : [tokenStore.getTokenAfter(exportToken)]
+          const rightBraceToken = /** @type {Token} */ (
+            node.source
+              ? tokenStore.getTokenBefore(node.source, isClosingBraceToken)
+              : tokenStore.getLastToken(node, isClosingBraceToken)
+          )
+          setOffset(leftBraceTokens, 0, exportToken)
+          processNodeList(
+            node.specifiers,
+            /** @type {Token} */ (last(leftBraceTokens)),
+            rightBraceToken,
+            1
           )
-          setOffset(leftParenToken, 0, exportToken)
-          processNodeList(node.specifiers, leftParenToken, rightParenToken, 1)
-
-          const maybeFromToken = tokenStore.getTokenAfter(rightParenToken)
-          if (maybeFromToken != null && maybeFromToken.value === 'from') {
-            const fromToken = maybeFromToken
-            const nameToken = tokenStore.getTokenAfter(fromToken)
-            setOffset([fromToken, nameToken], 1, exportToken)
+
+          if (node.source) {
+            const tokens = tokenStore.getTokensBetween(
+              rightBraceToken,
+              node.source
+            )
+            setOffset(
+              [...tokens, sourceCode.getFirstToken(node.source)],
+              1,
+              exportToken
+            )
+
+            // assertions
+            const lastToken = /** @type {Token} */ (
+              tokenStore.getLastToken(node, isNotSemicolonToken)
+            )
+            const assertionTokens = tokenStore.getTokensBetween(
+              node.source,
+              lastToken
+            )
+            if (assertionTokens.length) {
+              const assertToken = /** @type {Token} */ (assertionTokens.shift())
+              setOffset(assertToken, 0, exportToken)
+              const assertionOpen = assertionTokens.shift()
+              if (assertionOpen) {
+                setOffset(assertionOpen, 1, assertToken)
+                processNodeList(assertionTokens, assertionOpen, lastToken, 1)
+              }
+            }
           }
         } else {
           // maybe babel parser
         }
       }
     },
-    /** @param {ExportSpecifier} node */
-    ExportSpecifier(node) {
+    /** @param {ExportSpecifier | ImportSpecifier} node */
+    'ExportSpecifier, ImportSpecifier'(node) {
       const tokens = tokenStore.getTokens(node)
-      const firstToken = /** @type {Token} */ (tokens.shift())
+      let firstToken = /** @type {Token} */ (tokens.shift())
+      if (firstToken.value === 'type') {
+        const typeToken = firstToken
+        firstToken = /** @type {Token} */ (tokens.shift())
+        setOffset(firstToken, 0, typeToken)
+      }
+
       setOffset(tokens, 1, firstToken)
     },
     /** @param {ForInStatement | ForOfStatement} node */
@@ -1540,13 +1597,23 @@ module.exports.defineVisitor = function create(
         setOffset(fromToken, 1, importToken)
         setOffset(afterTokens, 0, fromToken)
       }
-    },
-    /** @param {ImportSpecifier} node */
-    ImportSpecifier(node) {
-      if (node.local.range[0] !== node.imported.range[0]) {
-        const tokens = tokenStore.getTokens(node)
-        const firstToken = /** @type {Token} */ (tokens.shift())
-        setOffset(tokens, 1, firstToken)
+
+      // assertions
+      const lastToken = /** @type {Token} */ (
+        tokenStore.getLastToken(node, isNotSemicolonToken)
+      )
+      const assertionTokens = tokenStore.getTokensBetween(
+        node.source,
+        lastToken
+      )
+      if (assertionTokens.length) {
+        const assertToken = /** @type {Token} */ (assertionTokens.shift())
+        setOffset(assertToken, 0, importToken)
+        const assertionOpen = assertionTokens.shift()
+        if (assertionOpen) {
+          setOffset(assertionOpen, 1, assertToken)
+          processNodeList(assertionTokens, assertionOpen, lastToken, 1)
+        }
       }
     },
     /** @param {ImportNamespaceSpecifier} node */
diff --git a/lib/utils/indent-ts.js b/lib/utils/indent-ts.js
index b98e9d10..496e892c 100644
--- a/lib/utils/indent-ts.js
+++ b/lib/utils/indent-ts.js
@@ -1302,6 +1302,27 @@ function defineVisitor({
         setOffset(atToken, 0, tokenStore.getFirstToken(decorators[0]))
       }
     },
+    ImportAttribute(node) {
+      const firstToken = tokenStore.getFirstToken(node)
+      const keyTokens = getFirstAndLastTokens(node.key)
+      const prefixTokens = tokenStore.getTokensBetween(
+        firstToken,
+        keyTokens.firstToken
+      )
+      setOffset(prefixTokens, 0, firstToken)
+
+      setOffset(keyTokens.firstToken, 0, firstToken)
+
+      const initToken = tokenStore.getFirstToken(node.value)
+      setOffset(
+        [
+          ...tokenStore.getTokensBetween(keyTokens.lastToken, initToken),
+          initToken
+        ],
+        1,
+        keyTokens.lastToken
+      )
+    },
 
     // ----------------------------------------------------------------------
     // DEPRECATED  NODES
diff --git a/package.json b/package.json
index 2c9bcbf1..96da9be8 100644
--- a/package.json
+++ b/package.json
@@ -65,7 +65,7 @@
     "@types/natural-compare": "^1.4.0",
     "@types/node": "^13.13.5",
     "@types/semver": "^7.2.0",
-    "@typescript-eslint/parser": "^5.4.0",
+    "@typescript-eslint/parser": "^5.5.0",
     "@vuepress/plugin-pwa": "^1.4.1",
     "acorn": "^8.5.0",
     "env-cmd": "^10.1.0",
@@ -82,7 +82,7 @@
     "mocha": "^7.1.2",
     "nyc": "^15.1.0",
     "prettier": "^2.4.1",
-    "typescript": "^4.5.0-0",
+    "typescript": "^4.5.0",
     "vue-eslint-editor": "^1.1.0",
     "vuepress": "^1.8.2"
   }


Test patch:
diff --git a/tests/fixtures/script-indent/ts-import-assertion-01.vue b/tests/fixtures/script-indent/ts-import-assertion-01.vue
new file mode 100644
index 00000000..f1476184
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-import-assertion-01.vue
@@ -0,0 +1,9 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.1 || ^5.4.1-0" } }-->
+<script lang="ts">
+import foo from "./foo.json" assert
+  {
+    type
+      :
+      "json"
+  }
+</script>
diff --git a/tests/fixtures/script-indent/ts-import-assertion-02.vue b/tests/fixtures/script-indent/ts-import-assertion-02.vue
new file mode 100644
index 00000000..86fe80b4
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-import-assertion-02.vue
@@ -0,0 +1,15 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.1 || ^5.4.1-0" } }-->
+<script lang="ts">
+import(
+  "./foo.json",
+  {
+    assert
+      :
+      {
+        type
+          :
+          "json"
+      }
+  }
+)
+</script>
diff --git a/tests/fixtures/script-indent/ts-import-assertion-03.vue b/tests/fixtures/script-indent/ts-import-assertion-03.vue
new file mode 100644
index 00000000..ee88e92c
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-import-assertion-03.vue
@@ -0,0 +1,10 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.1 || ^5.4.1-0" } }-->
+<script lang="ts">
+export * from "mod" assert
+  {
+    type
+      :
+      "json"
+  }
+;
+</script>
diff --git a/tests/fixtures/script-indent/ts-import-assertion-04.vue b/tests/fixtures/script-indent/ts-import-assertion-04.vue
new file mode 100644
index 00000000..76d2f286
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-import-assertion-04.vue
@@ -0,0 +1,10 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.1 || ^5.4.1-0" } }-->
+<script lang="ts">
+export { foo } from "mod" assert
+  {
+    type
+      :
+      "json"
+  }
+;
+</script>
diff --git a/tests/fixtures/script-indent/ts-type-only-import-export-01.vue b/tests/fixtures/script-indent/ts-type-only-import-export-01.vue
new file mode 100644
index 00000000..97c6dc19
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-type-only-import-export-01.vue
@@ -0,0 +1,17 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.0 || ^5.4.1-0" } }-->
+<script lang="ts">
+import
+  type
+  Foo
+  from
+  'foo'
+import
+  type
+  {
+    Foo
+  }
+  from
+  'foo';
+export
+  type Foo = {}
+</script>
diff --git a/tests/fixtures/script-indent/ts-type-only-import-export-02.vue b/tests/fixtures/script-indent/ts-type-only-import-export-02.vue
new file mode 100644
index 00000000..0ef5f110
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-type-only-import-export-02.vue
@@ -0,0 +1,23 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.0 || ^5.4.1-0" } }-->
+<script lang="ts">
+import
+{
+  type
+  Foo
+}
+  from
+  'foo';
+export
+{
+  type
+  Bar
+}
+  from
+  'bar';
+export
+{
+  type
+  Baz
+}
+;
+</script>
diff --git a/tests/fixtures/script-indent/ts-type-only-import-export-03.vue b/tests/fixtures/script-indent/ts-type-only-import-export-03.vue
new file mode 100644
index 00000000..ca280f03
--- /dev/null
+++ b/tests/fixtures/script-indent/ts-type-only-import-export-03.vue
@@ -0,0 +1,26 @@
+<!--{"parserOptions": {"parser":"@typescript-eslint/parser"}, "requirements": { "@typescript-eslint/parser": ">=5.4.0 || ^5.4.1-0" } }-->
+<script lang="ts">
+import
+{
+  type
+  Foo
+    as Foo2
+}
+  from
+  'foo';
+export
+{
+  type
+  Bar
+    as Bar2
+}
+  from
+  'bar';
+export
+{
+  type
+  Baz
+    as Baz2
+}
+;
+</script>
diff --git a/tests/lib/rules/component-options-name-casing.js b/tests/lib/rules/component-options-name-casing.js
new file mode 100644
index 00000000..504d98b0
--- /dev/null
+++ b/tests/lib/rules/component-options-name-casing.js
@@ -0,0 +1,558 @@
+/**
+ * @author Pig Fang
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/component-options-name-casing')
+
+const tester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('component-options-name-casing', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          ...components
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar: fooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar
+          }
+        }
+      `,
+      options: ['PascalCase']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar
+          }
+        }
+      `,
+      options: ['camelCase']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar: FooBar
+          }
+        }
+      `,
+      options: ['camelCase']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            'foo-bar': fooBar
+          }
+        }
+      `,
+      options: ['kebab-case']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            'foo-bar': FooBar
+          }
+        }
+      `,
+      options: ['kebab-case']
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar
+          }
+        }
+      `,
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'PascalCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19
+        }
+      ],
+      output: `
+        export default {
+          components: {
+            FooBar: fooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar: FooBar
+          }
+        }
+      `,
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'PascalCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19
+        }
+      ],
+      output: `
+        export default {
+          components: {
+            FooBar: FooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar
+          }
+        }
+      `,
+      options: ['PascalCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'PascalCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19
+        }
+      ],
+      output: `
+        export default {
+          components: {
+            FooBar: fooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar: FooBar
+          }
+        }
+      `,
+      options: ['PascalCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'PascalCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19
+        }
+      ],
+      output: `
+        export default {
+          components: {
+            FooBar: FooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            'foo-bar': FooBar
+          }
+        }
+      `,
+      options: ['PascalCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'foo-bar',
+            caseType: 'PascalCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 22
+        }
+      ],
+      output: `
+        export default {
+          components: {
+            FooBar: FooBar
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar
+          }
+        }
+      `,
+      options: ['camelCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'FooBar',
+            caseType: 'camelCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'camelCase' },
+              output: `
+        export default {
+          components: {
+            fooBar: FooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar: fooBar
+          }
+        }
+      `,
+      options: ['camelCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'FooBar',
+            caseType: 'camelCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'camelCase' },
+              output: `
+        export default {
+          components: {
+            fooBar: fooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            'foo-bar': fooBar
+          }
+        }
+      `,
+      options: ['camelCase'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'foo-bar',
+            caseType: 'camelCase'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 22,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'camelCase' },
+              output: `
+        export default {
+          components: {
+            fooBar: fooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar
+          }
+        }
+      `,
+      options: ['kebab-case'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'FooBar',
+            caseType: 'kebab-case'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'kebab-case' },
+              output: `
+        export default {
+          components: {
+            'foo-bar': FooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar: fooBar
+          }
+        }
+      `,
+      options: ['kebab-case'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'FooBar',
+            caseType: 'kebab-case'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'kebab-case' },
+              output: `
+        export default {
+          components: {
+            'foo-bar': fooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar
+          }
+        }
+      `,
+      options: ['kebab-case'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'kebab-case'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'kebab-case' },
+              output: `
+        export default {
+          components: {
+            'foo-bar': fooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            fooBar: FooBar
+          }
+        }
+      `,
+      options: ['kebab-case'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'fooBar',
+            caseType: 'kebab-case'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'kebab-case' },
+              output: `
+        export default {
+          components: {
+            'foo-bar': FooBar
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          components: {
+            FooBar,
+            'my-component': MyComponent
+          }
+        }
+      `,
+      options: ['kebab-case'],
+      errors: [
+        {
+          messageId: 'caseNotMatched',
+          data: {
+            component: 'FooBar',
+            caseType: 'kebab-case'
+          },
+          line: 4,
+          column: 13,
+          endColumn: 19,
+          suggestions: [
+            {
+              messageId: 'possibleRenaming',
+              data: { caseType: 'kebab-case' },
+              output: `
+        export default {
+          components: {
+            'foo-bar': FooBar,
+            'my-component': MyComponent
+          }
+        }
+      `
+            }
+          ]
+        }
+      ],
+      output: null
+    }
+  ]
+})
diff --git a/tests/lib/rules/no-dupe-keys.js b/tests/lib/rules/no-dupe-keys.js
index 9e01f085..2abae46d 100644
--- a/tests/lib/rules/no-dupe-keys.js
+++ b/tests/lib/rules/no-dupe-keys.js
@@ -380,6 +380,24 @@ ruleTester.run('no-dupe-keys', rule, {
         }
       }
       `
+    },
+    {
+      // https://github.com/vuejs/eslint-plugin-vue/issues/1687
+      filename: 'test.vue',
+      code: `
+        export default {
+          asyncData() {
+            return {
+              foo: 1
+            }
+          },
+          data() {
+            return {
+              foo: 2
+            }
+          },
+        }
+      `
     }
   ],
 
@@ -709,29 +727,6 @@ ruleTester.run('no-dupe-keys', rule, {
         }
       ]
     },
-    {
-      filename: 'test.vue',
-      code: `
-        export default {
-          asyncData() {
-            return {
-              foo: 1
-            }
-          },
-          data() {
-            return {
-              foo: 2
-            }
-          },
-        }
-      `,
-      errors: [
-        {
-          message: "Duplicated key 'foo'.",
-          line: 10
-        }
-      ]
-    },
     {
       filename: 'test.js',
       code: `
diff --git a/tests/lib/rules/prefer-separate-static-class.js b/tests/lib/rules/prefer-separate-static-class.js
new file mode 100644
index 00000000..498fc90d
--- /dev/null
+++ b/tests/lib/rules/prefer-separate-static-class.js
@@ -0,0 +1,333 @@
+/**
+ * @author Flo Edelmann
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/prefer-separate-static-class')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('prefer-separate-static-class', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `<template><div class="static-class" /></template>`
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="dynamicClass" /></template>`
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div :class="`dynamic-class-${foo}`" /></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="'dynamic-class-' + foo" /></template>`
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div class="static-class" :class="dynamicClass" /></template>`
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div class="static-class" :class="[dynamicClass]" /></template>`
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div class="static-class" :class="{'dynamic-class': foo}" /></template>`
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div class="static-class" :class="{foo, [computedName]: true}" /></template>`
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div class="static-class" :class="[dynamicClass, {'dynamic-class': foo}]" /></template>`
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `<template><div v-bind:class="'static-class'" /></template>`,
+      output: `<template><div class="static-class" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 30,
+          endColumn: 44
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="'static-class'" /></template>`,
+      output: `<template><div class="static-class" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 24,
+          endColumn: 38
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div :class="`static-class`" /></template>',
+      output: '<template><div class="static-class" /></template>',
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 24,
+          endColumn: 38
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class='"static-class"' /></template>`,
+      output: `<template><div class="static-class" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 24,
+          endColumn: 38
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="['static-class']" /></template>`,
+      output: `<template><div class="static-class" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 25,
+          endColumn: 39
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="{'static-class': true}" /></template>`,
+      output: `<template><div class="static-class" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 25,
+          endColumn: 39
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="{foo: true}" /></template>`,
+      output: `<template><div class="foo" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "foo" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 25,
+          endColumn: 28
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="{['static-class']: true}" /></template>`,
+      output: `<template><div class="static-class" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 26,
+          endColumn: 40
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="['static-class', dynamicClass]" /></template>`,
+      output: `<template><div class="static-class" :class="[dynamicClass]" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 25,
+          endColumn: 39
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="[dynamicClass, otherDynamicClass, 'static-class']" /></template>`,
+      output: `<template><div class="static-class" :class="[dynamicClass, otherDynamicClass]" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 58,
+          endColumn: 72
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="{'static-class': true, 'dynamic-class': foo}" /></template>`,
+      output: `<template><div class="static-class" :class="{'dynamic-class': foo}" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 25,
+          endColumn: 39
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="[{'dynamic-class': foo, 'static-class': true}]" /></template>`,
+      output: `<template><div class="static-class" :class="[{'dynamic-class': foo}]" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 48,
+          endColumn: 62
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :class="[dynamicClass, {staticClass: true}]" /></template>`,
+      output: `<template><div class="staticClass" :class="[dynamicClass]" /></template>`,
+      errors: [
+        {
+          message:
+            'Static class "staticClass" should be in a static `class` attribute.',
+          line: 1,
+          endLine: 1,
+          column: 40,
+          endColumn: 51
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div
+            class="other-class"
+            other-attribute
+            :class="[
+              {'dynamic-class-a': foo, 'static-class': true},
+              dynamicClassB,
+            ]" />
+        </template>
+      `,
+      output: `
+        <template>
+          <div
+            class="other-class static-class"
+            other-attribute
+            :class="[
+              {'dynamic-class-a': foo},
+              dynamicClassB,
+            ]" />
+        </template>
+      `,
+      errors: [
+        {
+          message:
+            'Static class "static-class" should be in a static `class` attribute.',
+          line: 7,
+          endLine: 7,
+          column: 40,
+          endColumn: 54
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <template>
+          <div
+            class="other-class-a other-class-b"
+            other-attribute
+            :class="[
+              'static-class-a',
+              {'static-class-b': true, 'dynamic-class-a': foo},
+              dynamicClassB,
+            ]" />
+        </template>
+      `,
+      output: `
+        <template>
+          <div
+            class="other-class-a other-class-b static-class-a"
+            other-attribute
+            :class="[
+              {'static-class-b': true, 'dynamic-class-a': foo},
+              dynamicClassB,
+            ]" />
+        </template>
+      `,
+      errors: [
+        {
+          message:
+            'Static class "static-class-a" should be in a static `class` attribute.',
+          line: 7,
+          endLine: 7,
+          column: 15,
+          endColumn: 31
+        },
+        {
+          message:
+            'Static class "static-class-b" should be in a static `class` attribute.',
+          line: 8,
+          endLine: 8,
+          column: 16,
+          endColumn: 32
+        }
+      ]
+    }
+  ]
+})