Repository: vuejs/eslint-plugin-vue
Pull request #1529 (2021-07-01 10:00:02)

Add `vue/script-setup-uses-vars` rule
This PR adds `vue/script-setup-uses-vars` rule

The `vue/script-setup-uses-vars` rule that will find variables in `<script setup>` used in `<template>` and mark them as used.

refs #1248

ota-meshi: We need to wait for the release of https://github.com/vuejs/vue-eslint-parser/pull/110.

Patch:
diff --git a/docs/.vuepress/components/eslint-code-block.vue b/docs/.vuepress/components/eslint-code-block.vue
index 381d9985..3c37c923 100644
--- a/docs/.vuepress/components/eslint-code-block.vue
+++ b/docs/.vuepress/components/eslint-code-block.vue
@@ -135,6 +135,7 @@ export default {
       linter.defineRule(`vue/${ruleId}`, rules[ruleId])
     }
     linter.defineRule('no-undef', coreRules['no-undef'])
+    linter.defineRule('no-unused-vars', coreRules['no-unused-vars'])
 
     linter.defineParser('vue-eslint-parser', { parseForESLint })
   }
diff --git a/docs/rules/README.md b/docs/rules/README.md
index c5b80f1f..1a0a5fe3 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -25,6 +25,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
 |:--------|:------------|:---|
 | [vue/comment-directive](./comment-directive.md) | support comment-directives in `<template>` |  |
 | [vue/jsx-uses-vars](./jsx-uses-vars.md) | prevent variables used in JSX to be marked as unused |  |
+| [vue/script-setup-uses-vars](./script-setup-uses-vars.md) | prevent `<script setup>` variables used in `<template>` to be marked as unused |  |
 
 ## Priority A: Essential (Error Prevention) <badge text="for Vue.js 3.x" vertical="middle">for Vue.js 3.x</badge>
 
diff --git a/docs/rules/jsx-uses-vars.md b/docs/rules/jsx-uses-vars.md
index eaad8559..a21279b1 100644
--- a/docs/rules/jsx-uses-vars.md
+++ b/docs/rules/jsx-uses-vars.md
@@ -38,6 +38,11 @@ After turning on, `HelloWorld` is being marked as used and `no-unused-vars` rule
 
 If you are not using JSX or if you do not use the `no-unused-vars` rule then you can disable this rule.
 
+## :couple: Related Rules
+
+- [vue/script-setup-uses-vars](./script-setup-uses-vars.md)
+- [no-unused-vars](https://eslint.org/docs/rules/no-unused-vars)
+
 ## :rocket: Version
 
 This rule was introduced in eslint-plugin-vue v2.0.0
diff --git a/docs/rules/script-setup-uses-vars.md b/docs/rules/script-setup-uses-vars.md
new file mode 100644
index 00000000..0612c75a
--- /dev/null
+++ b/docs/rules/script-setup-uses-vars.md
@@ -0,0 +1,64 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/script-setup-uses-vars
+description: prevent `<script setup>` variables used in `<template>` to be marked as unused
+---
+# vue/script-setup-uses-vars
+
+> prevent `<script setup>` variables used in `<template>` to be marked as unused
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+- :gear: This rule is included in all of `"plugin:vue/base"`, `"plugin:vue/essential"`, `"plugin:vue/vue3-essential"`, `"plugin:vue/strongly-recommended"`, `"plugin:vue/vue3-strongly-recommended"`, `"plugin:vue/recommended"` and `"plugin:vue/vue3-recommended"`.
+
+ESLint `no-unused-vars` rule does not detect variables in `<script setup>` used in `<template>`.
+This rule will find variables in `<script setup>` used in `<template>` and mark them as used.
+
+This rule only has an effect when the `no-unused-vars` rule is enabled.
+
+## :book: Rule Details
+
+Without this rule this code triggers warning:
+
+<eslint-code-block :rules="{'vue/script-setup-uses-vars': ['error'], 'no-unused-vars': ['error']}">
+
+```vue
+<script setup>
+  // imported components are also directly usable in template
+  import Foo from './Foo.vue'
+  import { ref } from 'vue'
+
+  // write Composition API code just like in a normal setup()
+  // but no need to manually return everything
+  const count = ref(0)
+  const inc = () => {
+    count.value++
+  }
+</script>
+
+<template>
+  <Foo :count="count" @click="inc" />
+</template>
+```
+
+</eslint-code-block>
+
+After turning on, `Foo` is being marked as used and `no-unused-vars` rule doesn't report an issue.
+
+## :mute: When Not To Use It
+
+If you are not using `<script setup>` or if you do not use the `no-unused-vars` rule then you can disable this rule.
+
+## :couple: Related Rules
+
+- [vue/jsx-uses-vars](./jsx-uses-vars.md)
+- [no-unused-vars](https://eslint.org/docs/rules/no-unused-vars)
+
+## :books: Further Reading
+
+- [Vue RFCs - 0040-script-setup](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0040-script-setup.md)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/script-setup-uses-vars.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/script-setup-uses-vars.js)
diff --git a/lib/configs/base.js b/lib/configs/base.js
index 2521a623..5b1135b4 100644
--- a/lib/configs/base.js
+++ b/lib/configs/base.js
@@ -16,6 +16,7 @@ module.exports = {
   plugins: ['vue'],
   rules: {
     'vue/comment-directive': 'error',
-    'vue/jsx-uses-vars': 'error'
+    'vue/jsx-uses-vars': 'error',
+    'vue/script-setup-uses-vars': 'error'
   }
 }
diff --git a/lib/index.js b/lib/index.js
index 313cc693..f07aea55 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -155,6 +155,7 @@ module.exports = {
     'return-in-computed-property': require('./rules/return-in-computed-property'),
     'return-in-emits-validator': require('./rules/return-in-emits-validator'),
     'script-indent': require('./rules/script-indent'),
+    'script-setup-uses-vars': require('./rules/script-setup-uses-vars'),
     'singleline-html-element-content-newline': require('./rules/singleline-html-element-content-newline'),
     'sort-keys': require('./rules/sort-keys'),
     'space-in-parens': require('./rules/space-in-parens'),
diff --git a/lib/rules/no-reserved-component-names.js b/lib/rules/no-reserved-component-names.js
index 890f1a30..e32e182c 100644
--- a/lib/rules/no-reserved-component-names.js
+++ b/lib/rules/no-reserved-component-names.js
@@ -10,6 +10,13 @@ const casing = require('../utils/casing')
 const htmlElements = require('../utils/html-elements.json')
 const deprecatedHtmlElements = require('../utils/deprecated-html-elements.json')
 const svgElements = require('../utils/svg-elements.json')
+const RESERVED_NAMES_IN_VUE = new Set(
+  require('../utils/vue2-builtin-components')
+)
+
+const RESERVED_NAMES_IN_VUE3 = new Set(
+  require('../utils/vue3-builtin-components')
+)
 
 const kebabCaseElements = [
   'annotation-xml',
@@ -22,17 +29,6 @@ const kebabCaseElements = [
   'missing-glyph'
 ]
 
-// https://vuejs.org/v2/api/index.html#Built-In-Components
-const vueBuiltInComponents = [
-  'component',
-  'transition',
-  'transition-group',
-  'keep-alive',
-  'slot'
-]
-
-const vue3BuiltInComponents = ['teleport', 'suspense']
-
 /** @param {string} word  */
 function isLowercase(word) {
   return /^[a-z]*$/.test(word)
@@ -42,15 +38,6 @@ const RESERVED_NAMES_IN_HTML = new Set([
   ...htmlElements,
   ...htmlElements.map(casing.capitalize)
 ])
-const RESERVED_NAMES_IN_VUE = new Set([
-  ...vueBuiltInComponents,
-  ...vueBuiltInComponents.map(casing.pascalCase)
-])
-const RESERVED_NAMES_IN_VUE3 = new Set([
-  ...RESERVED_NAMES_IN_VUE,
-  ...vue3BuiltInComponents,
-  ...vue3BuiltInComponents.map(casing.pascalCase)
-])
 const RESERVED_NAMES_IN_OTHERS = new Set([
   ...deprecatedHtmlElements,
   ...deprecatedHtmlElements.map(casing.capitalize),
diff --git a/lib/rules/no-unregistered-components.js b/lib/rules/no-unregistered-components.js
index 07d9e800..58e2324c 100644
--- a/lib/rules/no-unregistered-components.js
+++ b/lib/rules/no-unregistered-components.js
@@ -15,15 +15,6 @@ const casing = require('../utils/casing')
 // Rule helpers
 // ------------------------------------------------------------------------------
 
-const VUE_BUILT_IN_COMPONENTS = [
-  'component',
-  'suspense',
-  'teleport',
-  'transition',
-  'transition-group',
-  'keep-alive',
-  'slot'
-]
 /**
  * Check whether the given node is a built-in component or not.
  *
@@ -37,7 +28,7 @@ const isBuiltInComponent = (node) => {
   return (
     utils.isHtmlElementNode(node) &&
     !utils.isHtmlWellKnownElementName(node.rawName) &&
-    VUE_BUILT_IN_COMPONENTS.indexOf(rawName) > -1
+    utils.isBuiltInComponentName(rawName)
   )
 }
 
diff --git a/lib/rules/script-setup-uses-vars.js b/lib/rules/script-setup-uses-vars.js
new file mode 100644
index 00000000..e08af640
--- /dev/null
+++ b/lib/rules/script-setup-uses-vars.js
@@ -0,0 +1,115 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const utils = require('../utils')
+const casing = require('../utils/casing')
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description:
+        'prevent `<script setup>` variables used in `<template>` to be marked as unused', // eslint-disable-line consistent-docs-description
+      categories: ['base'],
+      url: 'https://eslint.vuejs.org/rules/script-setup-uses-vars.html'
+    },
+    schema: []
+  },
+  /**
+   * @param {RuleContext} context - The rule context.
+   * @returns {RuleListener} AST event handlers.
+   */
+  create(context) {
+    if (!utils.isScriptSetup(context)) {
+      return {}
+    }
+    /** @type {Set<string>} */
+    const scriptVariableNames = new Set()
+    const globalScope = context.getSourceCode().scopeManager.globalScope
+    if (globalScope) {
+      for (const variable of globalScope.variables) {
+        scriptVariableNames.add(variable.name)
+      }
+      const moduleScope = globalScope.childScopes.find(
+        (scope) => scope.type === 'module'
+      )
+      for (const variable of (moduleScope && moduleScope.variables) || []) {
+        scriptVariableNames.add(variable.name)
+      }
+    }
+
+    /**
+     * `casing.camelCase()` converts the beginning to lowercase,
+     * but does not convert the case of the beginning character when converting with Vue3.
+     * @see https://github.com/vuejs/vue-next/blob/1ffd48a2f5fd3eead3ea29dae668b7ed1c6f6130/packages/shared/src/index.ts#L116
+     * @param {string} str
+     */
+    function camelize(str) {
+      return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''))
+    }
+    /**
+     * @see https://github.com/vuejs/vue-next/blob/1ffd48a2f5fd3eead3ea29dae668b7ed1c6f6130/packages/compiler-core/src/transforms/transformElement.ts#L321
+     * @param {string} name
+     */
+    function markElementVariableAsUsed(name) {
+      if (scriptVariableNames.has(name)) {
+        context.markVariableAsUsed(name)
+      }
+      const camelName = camelize(name)
+      if (scriptVariableNames.has(camelName)) {
+        context.markVariableAsUsed(camelName)
+      }
+      const pascalName = casing.capitalize(camelName)
+      if (scriptVariableNames.has(pascalName)) {
+        context.markVariableAsUsed(pascalName)
+      }
+    }
+
+    return utils.defineTemplateBodyVisitor(
+      context,
+      {
+        VExpressionContainer(node) {
+          for (const ref of node.references.filter(
+            (ref) => ref.variable == null
+          )) {
+            context.markVariableAsUsed(ref.id.name)
+          }
+        },
+        VElement(node) {
+          if (
+            (!utils.isHtmlElementNode(node) && !utils.isSvgElementNode(node)) ||
+            (node.rawName === node.name &&
+              (utils.isHtmlWellKnownElementName(node.rawName) ||
+                utils.isSvgWellKnownElementName(node.rawName))) ||
+            utils.isBuiltInComponentName(node.rawName)
+          ) {
+            return
+          }
+          markElementVariableAsUsed(node.rawName)
+        },
+        /** @param {VDirective} node */
+        'VAttribute[directive=true]'(node) {
+          if (utils.isBuiltInDirectiveName(node.key.name.name)) {
+            return
+          }
+          markElementVariableAsUsed(`v-${node.key.name.rawName}`)
+        }
+      },
+      undefined,
+      {
+        templateBodyTriggerSelector: 'Program'
+      }
+    )
+  }
+}
diff --git a/lib/utils/index.js b/lib/utils/index.js
index 06ef5142..bfe786aa 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -99,6 +99,12 @@
 const HTML_ELEMENT_NAMES = new Set(require('./html-elements.json'))
 const SVG_ELEMENT_NAMES = new Set(require('./svg-elements.json'))
 const VOID_ELEMENT_NAMES = new Set(require('./void-elements.json'))
+const VUE2_BUILTIN_COMPONENT_NAMES = new Set(
+  require('./vue2-builtin-components')
+)
+const VUE3_BUILTIN_COMPONENT_NAMES = new Set(
+  require('./vue3-builtin-components')
+)
 const path = require('path')
 const vueEslintParser = require('vue-eslint-parser')
 const traverseNodes = vueEslintParser.AST.traverseNodes
@@ -277,6 +283,7 @@ module.exports = {
    * @param {RuleContext} context The rule context to use parser services.
    * @param {TemplateListener} templateBodyVisitor The visitor to traverse the template body.
    * @param {RuleListener} [scriptVisitor] The visitor to traverse the script.
+   * @param { { templateBodyTriggerSelector: "Program" | "Program:exit" } } [options] The options.
    * @returns {RuleListener} The merged visitor.
    */
   defineTemplateBodyVisitor,
@@ -399,28 +406,6 @@ module.exports = {
     return null
   },
 
-  /**
-   * Check whether the given start tag has specific directive.
-   * @param {VElement} node The start tag node to check.
-   * @param {string} name The attribute name to check.
-   * @param {string} [value] The attribute value to check.
-   * @returns {boolean} `true` if the start tag has the attribute.
-   */
-  hasAttribute(node, name, value) {
-    return Boolean(this.getAttribute(node, name, value))
-  },
-
-  /**
-   * Check whether the given start tag has specific directive.
-   * @param {VElement} node The start tag node to check.
-   * @param {string} name The directive name to check.
-   * @param {string} [argument] The directive argument to check.
-   * @returns {boolean} `true` if the start tag has the directive.
-   */
-  hasDirective(node, name, argument) {
-    return Boolean(this.getDirective(node, name, argument))
-  },
-
   /**
    * Check whether the given directive attribute has their empty value (`=""`).
    * @param {VDirective} node The directive attribute node to check.
@@ -508,24 +493,16 @@ module.exports = {
    * @param {string} [value] The attribute value to check.
    * @returns {VAttribute | null} The found attribute.
    */
-  getAttribute(node, name, value) {
-    return (
-      node.startTag.attributes.find(
-        /**
-         * @param {VAttribute | VDirective} node
-         * @returns {node is VAttribute}
-         */
-        (node) => {
-          return (
-            !node.directive &&
-            node.key.name === name &&
-            (value === undefined ||
-              (node.value != null && node.value.value === value))
-          )
-        }
-      ) || null
-    )
-  },
+  getAttribute,
+
+  /**
+   * Check whether the given start tag has specific directive.
+   * @param {VElement} node The start tag node to check.
+   * @param {string} name The attribute name to check.
+   * @param {string} [value] The attribute value to check.
+   * @returns {boolean} `true` if the start tag has the attribute.
+   */
+  hasAttribute,
 
   /**
    * Get the directive list which has the given name.
@@ -533,19 +510,7 @@ module.exports = {
    * @param {string} name The directive name to check.
    * @returns {VDirective[]} The array of `v-slot` directives.
    */
-  getDirectives(node, name) {
-    const attributes =
-      node.type === 'VElement' ? node.startTag.attributes : node.attributes
-    return attributes.filter(
-      /**
-       * @param {VAttribute | VDirective} node
-       * @returns {node is VDirective}
-       */
-      (node) => {
-        return node.directive && node.key.name.name === name
-      }
-    )
-  },
+  getDirectives,
   /**
    * Get the directive which has the given name.
    * @param {VElement} node The start tag node to check.
@@ -553,26 +518,16 @@ module.exports = {
    * @param {string} [argument] The directive argument to check.
    * @returns {VDirective | null} The found directive.
    */
-  getDirective(node, name, argument) {
-    return (
-      node.startTag.attributes.find(
-        /**
-         * @param {VAttribute | VDirective} node
-         * @returns {node is VDirective}
-         */
-        (node) => {
-          return (
-            node.directive &&
-            node.key.name.name === name &&
-            (argument === undefined ||
-              (node.key.argument &&
-                node.key.argument.type === 'VIdentifier' &&
-                node.key.argument.name) === argument)
-          )
-        }
-      ) || null
-    )
-  },
+  getDirective,
+
+  /**
+   * Check whether the given start tag has specific directive.
+   * @param {VElement} node The start tag node to check.
+   * @param {string} name The directive name to check.
+   * @param {string} [argument] The directive argument to check.
+   * @returns {boolean} `true` if the start tag has the directive.
+   */
+  hasDirective,
 
   /**
    * Returns the list of all registered components
@@ -636,13 +591,13 @@ module.exports = {
     for (const childNode of node.children) {
       if (childNode.type === 'VElement') {
         let connected
-        if (this.hasDirective(childNode, 'if')) {
+        if (hasDirective(childNode, 'if')) {
           connected = false
           vIf = true
-        } else if (this.hasDirective(childNode, 'else-if')) {
+        } else if (hasDirective(childNode, 'else-if')) {
           connected = vIf
           vIf = true
-        } else if (this.hasDirective(childNode, 'else')) {
+        } else if (hasDirective(childNode, 'else')) {
           connected = vIf
           vIf = false
         } else {
@@ -678,9 +633,9 @@ module.exports = {
         !this.isHtmlWellKnownElementName(node.rawName)) ||
       (this.isSvgElementNode(node) &&
         !this.isSvgWellKnownElementName(node.rawName)) ||
-      this.hasAttribute(node, 'is') ||
-      this.hasDirective(node, 'bind', 'is') ||
-      this.hasDirective(node, 'is')
+      hasAttribute(node, 'is') ||
+      hasDirective(node, 'bind', 'is') ||
+      hasDirective(node, 'is')
     )
   },
 
@@ -737,6 +692,44 @@ module.exports = {
   isHtmlVoidElementName(name) {
     return VOID_ELEMENT_NAMES.has(name)
   },
+
+  /**
+   * Check whether the given name is Vue builtin component name or not.
+   * @param {string} name The name to check.
+   * @returns {boolean} `true` if the name is a builtin component name
+   */
+  isBuiltInComponentName(name) {
+    return (
+      VUE3_BUILTIN_COMPONENT_NAMES.has(name) ||
+      VUE2_BUILTIN_COMPONENT_NAMES.has(name)
+    )
+  },
+
+  /**
+   * Check whether the given name is Vue builtin directive name or not.
+   * @param {string} name The name to check.
+   * @returns {boolean} `true` if the name is a builtin Directive name
+   */
+  isBuiltInDirectiveName(name) {
+    return (
+      name === 'bind' ||
+      name === 'on' ||
+      name === 'text' ||
+      name === 'html' ||
+      name === 'show' ||
+      name === 'if' ||
+      name === 'else' ||
+      name === 'else-if' ||
+      name === 'for' ||
+      name === 'model' ||
+      name === 'slot' ||
+      name === 'pre' ||
+      name === 'cloak' ||
+      name === 'once' ||
+      name === 'is'
+    )
+  },
+
   /**
    * Gets the property name of a given node.
    * @param {Property|AssignmentProperty|MethodDefinition|MemberExpression} node - The node to get.
@@ -978,6 +971,28 @@ module.exports = {
 
   isVueFile,
 
+  /**
+   * Checks whether the current file is uses `<script setup>`
+   * @param {RuleContext} context The ESLint rule context object.
+   */
+  isScriptSetup(context) {
+    const df =
+      context.parserServices.getDocumentFragment &&
+      context.parserServices.getDocumentFragment()
+    if (!df) {
+      return false
+    }
+    const scripts = df.children
+      .filter(isVElement)
+      .filter((e) => e.name === 'script')
+    if (scripts.length === 2) {
+      return scripts.some((e) => hasAttribute(e, 'setup'))
+    } else {
+      const script = scripts[0]
+      return script && hasAttribute(script, 'setup')
+    }
+  },
+
   /**
    * Check if current file is a Vue instance or component and call callback
    * @param {RuleContext} context The ESLint rule context object.
@@ -1668,12 +1683,14 @@ function isDef(v) {
  * @param {RuleContext} context The rule context to use parser services.
  * @param {TemplateListener} templateBodyVisitor The visitor to traverse the template body.
  * @param {RuleListener} [scriptVisitor] The visitor to traverse the script.
+ * @param { { templateBodyTriggerSelector: "Program" | "Program:exit" } } [options] The options.
  * @returns {RuleListener} The merged visitor.
  */
 function defineTemplateBodyVisitor(
   context,
   templateBodyVisitor,
-  scriptVisitor
+  scriptVisitor,
+  options
 ) {
   if (context.parserServices.defineTemplateBodyVisitor == null) {
     const filename = context.getFilename()
@@ -1688,7 +1705,8 @@ function defineTemplateBodyVisitor(
   }
   return context.parserServices.defineTemplateBodyVisitor(
     templateBodyVisitor,
-    scriptVisitor
+    scriptVisitor,
+    options
   )
 }
 
@@ -2151,3 +2169,98 @@ function* iterateWatchHandlerValues(property) {
     yield value
   }
 }
+
+/**
+ * Get the attribute which has the given name.
+ * @param {VElement} node The start tag node to check.
+ * @param {string} name The attribute name to check.
+ * @param {string} [value] The attribute value to check.
+ * @returns {VAttribute | null} The found attribute.
+ */
+function getAttribute(node, name, value) {
+  return (
+    node.startTag.attributes.find(
+      /**
+       * @param {VAttribute | VDirective} node
+       * @returns {node is VAttribute}
+       */
+      (node) => {
+        return (
+          !node.directive &&
+          node.key.name === name &&
+          (value === undefined ||
+            (node.value != null && node.value.value === value))
+        )
+      }
+    ) || null
+  )
+}
+
+/**
+ * Get the directive list which has the given name.
+ * @param {VElement | VStartTag} node The start tag node to check.
+ * @param {string} name The directive name to check.
+ * @returns {VDirective[]} The array of `v-slot` directives.
+ */
+function getDirectives(node, name) {
+  const attributes =
+    node.type === 'VElement' ? node.startTag.attributes : node.attributes
+  return attributes.filter(
+    /**
+     * @param {VAttribute | VDirective} node
+     * @returns {node is VDirective}
+     */
+    (node) => {
+      return node.directive && node.key.name.name === name
+    }
+  )
+}
+/**
+ * Get the directive which has the given name.
+ * @param {VElement} node The start tag node to check.
+ * @param {string} name The directive name to check.
+ * @param {string} [argument] The directive argument to check.
+ * @returns {VDirective | null} The found directive.
+ */
+function getDirective(node, name, argument) {
+  return (
+    node.startTag.attributes.find(
+      /**
+       * @param {VAttribute | VDirective} node
+       * @returns {node is VDirective}
+       */
+      (node) => {
+        return (
+          node.directive &&
+          node.key.name.name === name &&
+          (argument === undefined ||
+            (node.key.argument &&
+              node.key.argument.type === 'VIdentifier' &&
+              node.key.argument.name) === argument)
+        )
+      }
+    ) || null
+  )
+}
+
+/**
+ * Check whether the given start tag has specific directive.
+ * @param {VElement} node The start tag node to check.
+ * @param {string} name The attribute name to check.
+ * @param {string} [value] The attribute value to check.
+ * @returns {boolean} `true` if the start tag has the attribute.
+ */
+function hasAttribute(node, name, value) {
+  return Boolean(getAttribute(node, name, value))
+}
+
+/**
+ * Check whether the given start tag has specific directive.
+ * @param {VElement} node The start tag node to check.
+ * @param {string} name The directive name to check.
+ * @param {string} [argument] The directive argument to check.
+ * @returns {boolean} `true` if the start tag has the directive.
+ */
+function hasDirective(node, name, argument) {
+  return Boolean(getDirective(node, name, argument))
+}
diff --git a/lib/utils/vue2-builtin-components.js b/lib/utils/vue2-builtin-components.js
new file mode 100644
index 00000000..54acbc9e
--- /dev/null
+++ b/lib/utils/vue2-builtin-components.js
@@ -0,0 +1,12 @@
+module.exports = [
+  'template',
+  'slot',
+  'component',
+  'Component',
+  'transition',
+  'Transition',
+  'transition-group',
+  'TransitionGroup',
+  'keep-alive',
+  'KeepAlive'
+]
diff --git a/lib/utils/vue3-builtin-components.js b/lib/utils/vue3-builtin-components.js
new file mode 100644
index 00000000..da000397
--- /dev/null
+++ b/lib/utils/vue3-builtin-components.js
@@ -0,0 +1,7 @@
+module.exports = [
+  ...require('./vue2-builtin-components'),
+  'teleport',
+  'Teleport',
+  'suspense',
+  'Suspense'
+]
diff --git a/package.json b/package.json
index c7c7b10a..ded9cd55 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,7 @@
     "eslint-utils": "^2.1.0",
     "natural-compare": "^1.4.0",
     "semver": "^7.3.2",
-    "vue-eslint-parser": "^7.6.0"
+    "vue-eslint-parser": "^7.7.1"
   },
   "devDependencies": {
     "@types/eslint": "^7.2.0",
diff --git a/typings/eslint-plugin-vue/util-types/parser-services.ts b/typings/eslint-plugin-vue/util-types/parser-services.ts
index 8f6e2a62..18351677 100644
--- a/typings/eslint-plugin-vue/util-types/parser-services.ts
+++ b/typings/eslint-plugin-vue/util-types/parser-services.ts
@@ -15,7 +15,10 @@ export interface ParserServices {
   getTemplateBodyTokenStore: () => ParserServices.TokenStore
   defineTemplateBodyVisitor?: (
     templateBodyVisitor: TemplateListener,
-    scriptVisitor?: eslint.Rule.RuleListener
+    scriptVisitor?: eslint.Rule.RuleListener,
+    options?: {
+      templateBodyTriggerSelector: 'Program' | 'Program:exit'
+    }
   ) => eslint.Rule.RuleListener
   getDocumentFragment?: () => VAST.VDocumentFragment | null
 }


Test patch:
diff --git a/tests/lib/rules/jsx-uses-vars.js b/tests/lib/rules/jsx-uses-vars.js
index 875fb928..ea00e836 100644
--- a/tests/lib/rules/jsx-uses-vars.js
+++ b/tests/lib/rules/jsx-uses-vars.js
@@ -30,10 +30,11 @@ linter.defineRule('jsx-uses-vars', rule)
 // Tests
 // ------------------------------------------------------------------------------
 
-ruleTester.run('no-unused-vars', ruleNoUnusedVars, {
-  valid: [
-    {
-      code: `
+describe('jsx-uses-vars', () => {
+  ruleTester.run('no-unused-vars', ruleNoUnusedVars, {
+    valid: [
+      {
+        code: `
         /* eslint jsx-uses-vars: 1 */
         import SomeComponent from './SomeComponent.jsx';
         export default {
@@ -44,9 +45,9 @@ ruleTester.run('no-unused-vars', ruleNoUnusedVars, {
           },
         };
       `
-    },
-    {
-      code: `
+      },
+      {
+        code: `
         /* eslint jsx-uses-vars: 1 */
         import SomeComponent from './SomeComponent.vue';
         import OtherComponent from './OtherComponent.vue';
@@ -69,9 +70,9 @@ ruleTester.run('no-unused-vars', ruleNoUnusedVars, {
           }
         }
       `
-    },
-    {
-      code: `
+      },
+      {
+        code: `
         /* eslint jsx-uses-vars: 1 */
         export default {
           render () {
@@ -81,12 +82,12 @@ ruleTester.run('no-unused-vars', ruleNoUnusedVars, {
           }
         }
       `
-    }
-  ],
+      }
+    ],
 
-  invalid: [
-    {
-      code: `
+    invalid: [
+      {
+        code: `
         /* eslint jsx-uses-vars: 1 */
         import SomeComponent from './SomeComponent.jsx';
         export default {
@@ -95,14 +96,14 @@ ruleTester.run('no-unused-vars', ruleNoUnusedVars, {
           },
         };
       `,
-      errors: [
-        {
-          message: "'SomeComponent' is defined but never used."
-        }
-      ]
-    },
-    {
-      code: `
+        errors: [
+          {
+            message: "'SomeComponent' is defined but never used."
+          }
+        ]
+      },
+      {
+        code: `
         /* eslint jsx-uses-vars: 1 */
         import SomeComponent from './SomeComponent.jsx';
         const wrapper = {
@@ -115,11 +116,12 @@ ruleTester.run('no-unused-vars', ruleNoUnusedVars, {
           },
         };
       `,
-      errors: [
-        {
-          message: "'wrapper' is assigned a value but never used."
-        }
-      ]
-    }
-  ]
+        errors: [
+          {
+            message: "'wrapper' is assigned a value but never used."
+          }
+        ]
+      }
+    ]
+  })
 })
diff --git a/tests/lib/rules/script-setup-uses-vars.js b/tests/lib/rules/script-setup-uses-vars.js
new file mode 100644
index 00000000..aed89417
--- /dev/null
+++ b/tests/lib/rules/script-setup-uses-vars.js
@@ -0,0 +1,295 @@
+/**
+ * @fileoverview Prevent `<script setup>` variables used in `<template>` to be marked as unused
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const eslint = require('eslint')
+const rule = require('../../../lib/rules/script-setup-uses-vars')
+const ruleNoUnusedVars = require('eslint/lib/rules/no-unused-vars')
+
+const RuleTester = eslint.RuleTester
+const ruleTester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 6,
+    sourceType: 'module'
+  }
+})
+
+const linter = ruleTester.linter || eslint.linter
+linter.defineRule('script-setup-uses-vars', rule)
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+describe('script-setup-uses-vars', () => {
+  ruleTester.run('no-unused-vars', ruleNoUnusedVars, {
+    valid: [
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          // imported components are also directly usable in template
+          import Foo from './Foo.vue'
+          import { ref } from 'vue'
+
+          // write Composition API code just like in a normal setup()
+          // but no need to manually return everything
+          const count = ref(0)
+          const inc = () => {
+            count.value++
+          }
+        </script>
+
+        <template>
+          <Foo :count="count" @click="inc" />
+        </template>
+        `
+      },
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          const msg = 'Hello!'
+        </script>
+
+        <template>
+          <div>{{ msg }}</div>
+        </template>
+        `
+      },
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          import Foo from './Foo.vue'
+          import MyComponent from './MyComponent.vue'
+        </script>
+
+        <template>
+          <Foo />
+          <!-- kebab-case also works -->
+          <my-component />
+        </template>
+        `
+      },
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          import Foo from './Foo.vue'
+          import Bar from './Bar.vue'
+        </script>
+
+        <template>
+          <component :is="Foo" />
+          <component :is="someCondition ? Foo : Bar" />
+        </template>
+        `
+      },
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          import { directive as vClickOutside } from 'v-click-outside'
+        </script>
+
+        <template>
+          <div v-click-outside />
+        </template>
+        `
+      },
+
+      // Resolve component name
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          import FooPascalCase from './component.vue'
+          import BarPascalCase from './component.vue'
+          import BazPascalCase from './component.vue'
+        </script>
+
+        <template>
+          <FooPascalCase />
+          <bar-pascal-case />
+          <bazPascalCase />
+        </template>
+        `
+      },
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          import fooCamelCase from './component.vue'
+          import barCamelCase from './component.vue'
+        </script>
+
+        <template>
+          <fooCamelCase />
+          <bar-camel-case />
+        </template>
+        `
+      },
+
+      // TopLevel await
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          const post = await fetch(\`/api/post/1\`).then((r) => r.json())
+        </script>
+
+        <template>
+          {{post}}
+        </template>
+        `,
+        parserOptions: {
+          ecmaVersion: 2022,
+          sourceType: 'module'
+        }
+      }
+    ],
+
+    invalid: [
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          // imported components are also directly usable in template
+          import Foo from './Foo.vue'
+          import Bar from './Bar.vue'
+          import { ref } from 'vue'
+
+          // write Composition API code just like in a normal setup()
+          // but no need to manually return everything
+          const count = ref(0)
+          const inc = () => {
+            count.value++
+          }
+          const foo = ref(42)
+          console.log(foo.value)
+          const bar = ref(42)
+          bar.value++
+          const baz = ref(42)
+        </script>
+
+        <template>
+          <Foo :count="count" @click="inc" />
+        </template>
+        `,
+        errors: [
+          {
+            message: "'Bar' is defined but never used.",
+            line: 6
+          },
+          {
+            message: "'baz' is assigned a value but never used.",
+            line: 19
+          }
+        ]
+      },
+
+      // Resolve component name
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          import camelCase from './component.vue'
+        </script>
+
+        <template>
+          <CamelCase />
+        </template>
+        `,
+        errors: [
+          {
+            message: "'camelCase' is defined but never used.",
+            line: 4
+          }
+        ]
+      },
+
+      // Scope tests
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          if (a) {
+            const msg = 'Hello!'
+          }
+        </script>
+
+        <template>
+          <div>{{ msg }}</div>
+        </template>
+        `,
+        errors: [
+          {
+            message: "'msg' is assigned a value but never used.",
+            line: 5
+          }
+        ]
+      },
+      {
+        filename: 'test.vue',
+        code: `
+        <script setup>
+          /* eslint script-setup-uses-vars: 1 */
+          const i = 42
+          const list = [1,2,3]
+        </script>
+
+        <template>
+          <div v-for="i in list">{{ i }}</div>
+        </template>
+      `,
+        errors: [
+          {
+            message: "'i' is assigned a value but never used.",
+            line: 4
+          }
+        ]
+      },
+
+      // Not `<script setup>`
+      {
+        filename: 'test.vue',
+        code: `
+        <script>
+          /* eslint script-setup-uses-vars: 1 */
+          const msg = 'Hello!'
+        </script>
+
+        <template>
+          <div>{{ msg }}</div>
+        </template>
+        `,
+        errors: [
+          {
+            message: "'msg' is assigned a value but never used.",
+            line: 4
+          }
+        ]
+      }
+    ]
+  })
+})