Repository: vuejs/eslint-plugin-vue
Pull request #1171 (2020-05-28 16:49:03)

Add `vue/no-useless-concat` rule
This PR adds the wrapper rule of the [no-useless-concat](https://eslint.org/docs/rules/no-useless-concat) core rule to apply to the expressions in `<template>`.


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 9b32f097..51b32d5a 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -38,6 +38,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
 
 | Rule ID | Description |    |
 |:--------|:------------|:---|
+| [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce custom event names always use "kebab-case" |  |
 | [vue/no-arrow-functions-in-watch](./no-arrow-functions-in-watch.md) | disallow using arrow functions to define watcher |  |
 | [vue/no-async-in-computed-properties](./no-async-in-computed-properties.md) | disallow asynchronous actions in computed properties |  |
 | [vue/no-deprecated-data-object-declaration](./no-deprecated-data-object-declaration.md) | disallow using deprecated object declaration on data (in Vue.js 3.0.0+) | :wrench: |
@@ -160,6 +161,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi
 
 | Rule ID | Description |    |
 |:--------|:------------|:---|
+| [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce custom event names always use "kebab-case" |  |
 | [vue/no-arrow-functions-in-watch](./no-arrow-functions-in-watch.md) | disallow using arrow functions to define watcher |  |
 | [vue/no-async-in-computed-properties](./no-async-in-computed-properties.md) | disallow asynchronous actions in computed properties |  |
 | [vue/no-custom-modifiers-on-v-model](./no-custom-modifiers-on-v-model.md) | disallow custom modifiers on v-model used on the component |  |
@@ -302,6 +304,7 @@ For example:
 | [vue/no-unregistered-components](./no-unregistered-components.md) | disallow using components that are not registered inside templates |  |
 | [vue/no-unsupported-features](./no-unsupported-features.md) | disallow unsupported Vue.js syntax on the specified version | :wrench: |
 | [vue/no-unused-properties](./no-unused-properties.md) | disallow unused properties |  |
+| [vue/no-useless-concat](./no-useless-concat.md) | disallow unnecessary concatenation of literals or template literals |  |
 | [vue/object-curly-spacing](./object-curly-spacing.md) | enforce consistent spacing inside braces | :wrench: |
 | [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: |
 | [vue/prefer-template](./prefer-template.md) | require template literals instead of string concatenation | :wrench: |
diff --git a/docs/rules/comment-directive.md b/docs/rules/comment-directive.md
index 33ad1fa9..b6c30b2a 100644
--- a/docs/rules/comment-directive.md
+++ b/docs/rules/comment-directive.md
@@ -21,8 +21,6 @@ It supports usage of the following comments:
 We can't write HTML comments in tags.
 :::
 
-This rule doesn't throw any warning.
-
 ## :book: Rule Details
 
 ESLint doesn't provide any API to enhance `eslint-disable` functionality and ESLint rules cannot affect other rules. But ESLint provides [processors API](https://eslint.org/docs/developer-guide/working-with-plugins#processors-in-plugins).
@@ -88,9 +86,45 @@ The `eslint-disable`-like comments can include descriptions to explain why the c
 
 </eslint-code-block>
 
+## :wrench: Options
+
+```json
+{
+  "vue/comment-directive": ["error", {
+    "reportUnusedDisableDirectives": false
+  }]
+}
+```
+
+- `reportUnusedDisableDirectives` ... If `true`, to report unused `eslint-disable` HTML comments. default `false`
+
+### `{ "reportUnusedDisableDirectives": true }`
+
+<eslint-code-block :rules="{'vue/comment-directive': ['error', {reportUnusedDisableDirectives: true} ], 'vue/max-attributes-per-line': ['error']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <!-- eslint-disable-next-line vue/max-attributes-per-line -->
+  <div a="1" b="2" c="3" d="4" />
+
+  <!-- ✗ BAD -->
+  <!-- eslint-disable-next-line vue/max-attributes-per-line -->
+  <div a="1" />
+</template>
+```
+
+</eslint-code-block>
+
+::: warning Note
+Unused reports cannot be suppressed with `eslint-disable` HTML comments.
+:::
+
 ## :books: Further reading
 
-- [Disabling rules with inline comments](https://eslint.org/docs/user-guide/configuring#disabling-rules-with-inline-comments)
+- [Disabling rules with inline comments]
+
+[Disabling rules with inline comments]: https://eslint.org/docs/user-guide/configuring#disabling-rules-with-inline-comments
 
 ## :mag: Implementation
 
diff --git a/docs/rules/custom-event-name-casing.md b/docs/rules/custom-event-name-casing.md
new file mode 100644
index 00000000..cc0153c8
--- /dev/null
+++ b/docs/rules/custom-event-name-casing.md
@@ -0,0 +1,63 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/custom-event-name-casing
+description: enforce custom event names always use "kebab-case"
+---
+# vue/custom-event-name-casing
+> enforce custom event names always use "kebab-case"
+
+- :gear: This rule is included in all of `"plugin:vue/vue3-essential"`, `"plugin:vue/essential"`, `"plugin:vue/vue3-strongly-recommended"`, `"plugin:vue/strongly-recommended"`, `"plugin:vue/vue3-recommended"` and `"plugin:vue/recommended"`.
+
+## :book: Rule Details
+
+This rule enforces using kebab-case custom event names.
+
+> Event names will never be used as variable or property names in JavaScript, so there’s no reason to use camelCase or PascalCase. Additionally, `v-on` event listeners inside DOM templates will be automatically transformed to lowercase (due to HTML’s case-insensitivity), so `v-on:myEvent` would become `v-on:myevent` – making `myEvent` impossible to listen to.
+>
+> For these reasons, we recommend you **always use kebab-case for event names**.
+
+See [Guide - Custom Events] for more details.
+
+<eslint-code-block :rules="{'vue/custom-event-name-casing': ['error']}">
+
+```vue
+<template>
+  <!-- ✔ GOOD -->
+  <button @click="$emit('my-event')" />
+
+  <!-- ✘ BAD -->
+  <button @click="$emit('myEvent')" />
+</template>
+<script>
+export default {
+  methods: {
+    onClick () {
+      /* ✔ GOOD */
+      this.$emit('my-event')
+      this.$emit('update:myProp', myProp)
+
+      /* ✘ BAD */
+      this.$emit('myEvent')
+    }
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further Reading
+
+- [Guide - Custom Events]
+
+[Guide - Custom Events]: https://vuejs.org/v2/guide/components-custom-events.html
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/custom-event-name-casing.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/custom-event-name-casing.js)
diff --git a/docs/rules/html-indent.md b/docs/rules/html-indent.md
index 81e22585..d82ff86e 100644
--- a/docs/rules/html-indent.md
+++ b/docs/rules/html-indent.md
@@ -76,7 +76,11 @@ This rule enforces a consistent indentation style in `<template>`. The default s
 - `type` (`number | "tab"`) ... The type of indentation. Default is `2`. If this is a number, it's the number of spaces for one indent. If this is `"tab"`, it uses one tab for one indent.
 - `attribute` (`integer`) ... The multiplier of indentation for attributes. Default is `1`.
 - `baseIndent` (`integer`) ... The multiplier of indentation for top-level statements. Default is `1`.
-- `closeBracket` (`integer`) ... The multiplier of indentation for right brackets. Default is `0`.
+- `closeBracket` (`integer | object`) ... The multiplier of indentation for right brackets. Default is `0`.  
+  You can apply all of the following by setting a number value.
+  - `closeBracket.startTag` (`integer`) ... The multiplier of indentation for right brackets of start tags (`<div>`). Default is `0`.
+  - `closeBracket.endTag` (`integer`) ... The multiplier of indentation for right brackets of end tags (`</div>`). Default is `0`.
+  - `closeBracket.selfClosingTag` (`integer`) ... The multiplier of indentation for right brackets of start tags (`<div/>`). Default is `0`.
 - `alignAttributesVertically` (`boolean`) ... Condition for whether attributes should be vertically aligned to the first attribute in multiline case or not. Default is `true`
 - `ignores` (`string[]`) ... The selector to ignore nodes. The AST spec is [here](https://github.com/mysticatea/vue-eslint-parser/blob/master/docs/ast.md). You can use [esquery](https://github.com/estools/esquery#readme) to select nodes. Default is an empty array.
 
diff --git a/docs/rules/no-useless-concat.md b/docs/rules/no-useless-concat.md
new file mode 100644
index 00000000..d05841f1
--- /dev/null
+++ b/docs/rules/no-useless-concat.md
@@ -0,0 +1,21 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-useless-concat
+description: disallow unnecessary concatenation of literals or template literals
+---
+# vue/no-useless-concat
+> disallow unnecessary concatenation of literals or template literals
+
+This rule is the same rule as core [no-useless-concat] rule but it applies to the expressions in `<template>`.
+
+## :books: Further reading
+
+- [no-useless-concat]
+
+[no-useless-concat]: https://eslint.org/docs/rules/no-useless-concat
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-useless-concat.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-useless-concat.js)
diff --git a/lib/configs/essential.js b/lib/configs/essential.js
index 92a9e740..9005982b 100644
--- a/lib/configs/essential.js
+++ b/lib/configs/essential.js
@@ -6,6 +6,7 @@
 module.exports = {
   extends: require.resolve('./base'),
   rules: {
+    'vue/custom-event-name-casing': 'error',
     'vue/no-arrow-functions-in-watch': 'error',
     'vue/no-async-in-computed-properties': 'error',
     'vue/no-custom-modifiers-on-v-model': 'error',
diff --git a/lib/configs/vue3-essential.js b/lib/configs/vue3-essential.js
index 9ae00e5e..b2aec42d 100644
--- a/lib/configs/vue3-essential.js
+++ b/lib/configs/vue3-essential.js
@@ -6,6 +6,7 @@
 module.exports = {
   extends: require.resolve('./base'),
   rules: {
+    'vue/custom-event-name-casing': 'error',
     'vue/no-arrow-functions-in-watch': 'error',
     'vue/no-async-in-computed-properties': 'error',
     'vue/no-deprecated-data-object-declaration': 'error',
diff --git a/lib/index.js b/lib/index.js
index 6b5ea201..1f2f1ffd 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -21,6 +21,7 @@ module.exports = {
     'component-definition-name-casing': require('./rules/component-definition-name-casing'),
     'component-name-in-template-casing': require('./rules/component-name-in-template-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'),
     eqeqeq: require('./rules/eqeqeq'),
     'html-closing-bracket-newline': require('./rules/html-closing-bracket-newline'),
@@ -91,6 +92,7 @@ module.exports = {
     'no-unused-properties': require('./rules/no-unused-properties'),
     'no-unused-vars': require('./rules/no-unused-vars'),
     'no-use-v-if-with-v-for': require('./rules/no-use-v-if-with-v-for'),
+    'no-useless-concat': require('./rules/no-useless-concat'),
     'no-v-html': require('./rules/no-v-html'),
     'no-v-model-argument': require('./rules/no-v-model-argument'),
     'no-watch-after-await': require('./rules/no-watch-after-await'),
diff --git a/lib/processor.js b/lib/processor.js
index a4582763..b018e0bb 100644
--- a/lib/processor.js
+++ b/lib/processor.js
@@ -3,64 +3,161 @@
  */
 'use strict'
 
+/**
+ * @typedef {import('eslint').Linter.LintMessage} LintMessage
+ */
+/**
+ * @typedef {object} GroupState
+ * @property {Set<string>} GroupState.disableAllKeys
+ * @property {Map<string, string[]>} GroupState.disableRuleKeys
+ */
+
 module.exports = {
   preprocess(code) {
     return [code]
   },
 
+  /**
+   * @param {LintMessage[][]} messages
+   * @returns {LintMessage[]}
+   */
   postprocess(messages) {
     const state = {
+      /** @type {GroupState} */
       block: {
-        disableAll: false,
-        disableRules: new Set()
+        disableAllKeys: new Set(),
+        disableRuleKeys: new Map()
       },
+      /** @type {GroupState} */
       line: {
-        disableAll: false,
-        disableRules: new Set()
+        disableAllKeys: new Set(),
+        disableRuleKeys: new Map()
       }
     }
+    const usedDisableDirectiveKeys = []
+    /** @type {Map<string,LintMessage>} */
+    const unusedDisableDirectiveReports = new Map()
 
     // Filter messages which are in disabled area.
-    return messages[0].filter((message) => {
+    const filteredMessages = messages[0].filter((message) => {
       if (message.ruleId === 'vue/comment-directive') {
-        const rules = message.message.split(' ')
-        const type = rules.shift()
-        const group = rules.shift()
-        switch (type) {
-          case '--':
-            state[group].disableAll = true
+        const directiveType = message.messageId
+        const data = message.message.split(' ')
+        switch (directiveType) {
+          case 'disableBlock':
+            state.block.disableAllKeys.add(data[1])
+            break
+          case 'disableLine':
+            state.line.disableAllKeys.add(data[1])
+            break
+          case 'enableBlock':
+            state.block.disableAllKeys.clear()
+            break
+          case 'enableLine':
+            state.line.disableAllKeys.clear()
             break
-          case '++':
-            state[group].disableAll = false
+          case 'disableBlockRule':
+            addDisableRule(state.block.disableRuleKeys, data[1], data[2])
             break
-          case '-':
-            for (const rule of rules) {
-              state[group].disableRules.add(rule)
-            }
+          case 'disableLineRule':
+            addDisableRule(state.line.disableRuleKeys, data[1], data[2])
             break
-          case '+':
-            for (const rule of rules) {
-              state[group].disableRules.delete(rule)
-            }
+          case 'enableBlockRule':
+            state.block.disableRuleKeys.delete(data[1])
+            break
+          case 'enableLineRule':
+            state.line.disableRuleKeys.delete(data[1])
             break
           case 'clear':
-            state.block.disableAll = false
-            state.block.disableRules.clear()
-            state.line.disableAll = false
-            state.line.disableRules.clear()
+            state.block.disableAllKeys.clear()
+            state.block.disableRuleKeys.clear()
+            state.line.disableAllKeys.clear()
+            state.line.disableRuleKeys.clear()
+            break
+          default:
+            // unused eslint-disable comments report
+            unusedDisableDirectiveReports.set(messageToKey(message), message)
             break
         }
         return false
       } else {
-        return !(
-          state.block.disableAll ||
-          state.line.disableAll ||
-          state.block.disableRules.has(message.ruleId) ||
-          state.line.disableRules.has(message.ruleId)
-        )
+        const disableDirectiveKeys = []
+        if (state.block.disableAllKeys.size) {
+          disableDirectiveKeys.push(...state.block.disableAllKeys)
+        }
+        if (state.line.disableAllKeys.size) {
+          disableDirectiveKeys.push(...state.line.disableAllKeys)
+        }
+        if (state.block.disableRuleKeys.has(message.ruleId)) {
+          disableDirectiveKeys.push(
+            ...state.block.disableRuleKeys.get(message.ruleId)
+          )
+        }
+        if (state.line.disableRuleKeys.has(message.ruleId)) {
+          disableDirectiveKeys.push(
+            ...state.line.disableRuleKeys.get(message.ruleId)
+          )
+        }
+
+        if (disableDirectiveKeys.length) {
+          // Store used eslint-disable comment key
+          usedDisableDirectiveKeys.push(...disableDirectiveKeys)
+          return false
+        } else {
+          return true
+        }
       }
     })
+
+    if (unusedDisableDirectiveReports.size) {
+      for (const key of usedDisableDirectiveKeys) {
+        // Remove used eslint-disable comments
+        unusedDisableDirectiveReports.delete(key)
+      }
+      // Reports unused eslint-disable comments
+      filteredMessages.push(...unusedDisableDirectiveReports.values())
+      filteredMessages.sort(compareLocations)
+    }
+
+    return filteredMessages
   },
 
   supportsAutofix: true
 }
+
+/**
+ * @param {Map<string, string[]>} disableRuleKeys
+ * @param {string} rule
+ * @param {string} key
+ */
+function addDisableRule(disableRuleKeys, rule, key) {
+  let keys = disableRuleKeys.get(rule)
+  if (keys) {
+    keys.push(key)
+  } else {
+    keys = [key]
+    disableRuleKeys.set(rule, keys)
+  }
+}
+
+/**
+ * @param {LintMessage} message
+ * @returns {string} message key
+ */
+function messageToKey(message) {
+  return `line:${message.line},column${
+    // -1 because +1 by ESLint's `report-translator`.
+    message.column - 1
+  }`
+}
+
+/**
+ * Compares the locations of two objects in a source file
+ * @param {{line: number, column: number}} itemA The first object
+ * @param {{line: number, column: number}} itemB The second object
+ * @returns {number} A value less than 1 if itemA appears before itemB in the source file, greater than 1 if
+ * itemA appears after itemB in the source file, or 0 if itemA and itemB have the same location.
+ */
+function compareLocations(itemA, itemB) {
+  return itemA.line - itemB.line || itemA.column - itemB.column
+}
diff --git a/lib/rules/comma-style.js b/lib/rules/comma-style.js
index 4f7f32b2..40772dc3 100644
--- a/lib/rules/comma-style.js
+++ b/lib/rules/comma-style.js
@@ -10,7 +10,9 @@ module.exports = wrapCoreRule(require('eslint/lib/rules/comma-style'), {
   create(_context, { coreHandlers }) {
     return {
       VSlotScopeExpression(node) {
-        coreHandlers.FunctionExpression(node)
+        if (coreHandlers.FunctionExpression) {
+          coreHandlers.FunctionExpression(node)
+        }
       }
     }
   }
diff --git a/lib/rules/comment-directive.js b/lib/rules/comment-directive.js
index 9b3842ca..74d528a9 100644
--- a/lib/rules/comment-directive.js
+++ b/lib/rules/comment-directive.js
@@ -5,12 +5,24 @@
 
 'use strict'
 
+/**
+ * @typedef {import('eslint').Rule.RuleContext} RuleContext
+ * @typedef {import('vue-eslint-parser').AST.VDocumentFragment} VDocumentFragment
+ * @typedef {import('vue-eslint-parser').AST.VElement} VElement
+ * @typedef {import('vue-eslint-parser').AST.Token} Token
+ * @typedef {import('vue-eslint-parser').AST.Location} Location
+ */
+/**
+ * @typedef {object} RuleAndLocation
+ * @property {string} RuleAndLocation.ruleId
+ * @property {number} RuleAndLocation.index
+ */
 // -----------------------------------------------------------------------------
 // Helpers
 // -----------------------------------------------------------------------------
 
-const COMMENT_DIRECTIVE_B = /^\s*(eslint-(?:en|dis)able)(?:\s+(\S|\S[\s\S]*\S))?\s*$/
-const COMMENT_DIRECTIVE_L = /^\s*(eslint-disable(?:-next)?-line)(?:\s+(\S|\S[\s\S]*\S))?\s*$/
+const COMMENT_DIRECTIVE_B = /^\s*(eslint-(?:en|dis)able)(?:\s+|$)/
+const COMMENT_DIRECTIVE_L = /^\s*(eslint-disable(?:-next)?-line)(?:\s+|$)/
 
 /**
  * Remove the ignored part from a given directive comment and trim it.
@@ -18,26 +30,40 @@ const COMMENT_DIRECTIVE_L = /^\s*(eslint-disable(?:-next)?-line)(?:\s+(\S|\S[\s\
  * @returns {string} The stripped text.
  */
 function stripDirectiveComment(value) {
-  return value.split(/\s-{2,}\s/u)[0].trim()
+  return value.split(/\s-{2,}\s/u)[0]
 }
 
 /**
  * Parse a given comment.
  * @param {RegExp} pattern The RegExp pattern to parse.
  * @param {string} comment The comment value to parse.
- * @returns {({type:string,rules:string[]})|null} The parsing result.
+ * @returns {({type:string,rules:RuleAndLocation[]})|null} The parsing result.
  */
 function parse(pattern, comment) {
-  const match = pattern.exec(stripDirectiveComment(comment))
+  const text = stripDirectiveComment(comment)
+  const match = pattern.exec(text)
   if (match == null) {
     return null
   }
 
   const type = match[1]
-  const rules = (match[2] || '')
-    .split(',')
-    .map((s) => s.trim())
-    .filter(Boolean)
+
+  /** @type {RuleAndLocation[]} */
+  const rules = []
+
+  const rulesRe = /([^,\s]+)[,\s]*/g
+  let startIndex = match[0].length
+  rulesRe.lastIndex = startIndex
+
+  let res
+  while ((res = rulesRe.exec(text))) {
+    const ruleId = res[1].trim()
+    rules.push({
+      ruleId,
+      index: startIndex
+    })
+    startIndex = rulesRe.lastIndex
+  }
 
   return { type, rules }
 }
@@ -46,18 +72,21 @@ function parse(pattern, comment) {
  * Enable rules.
  * @param {RuleContext} context The rule context.
  * @param {{line:number,column:number}} loc The location information to enable.
- * @param {string} group The group to enable.
- * @param {string[]} rules The rule IDs to enable.
+ * @param { 'block' | 'line' } group The group to enable.
+ * @param {string | null} rule The rule ID to enable.
  * @returns {void}
  */
-function enable(context, loc, group, rules) {
-  if (rules.length === 0) {
-    context.report({ loc, message: '++ {{group}}', data: { group } })
+function enable(context, loc, group, rule) {
+  if (!rule) {
+    context.report({
+      loc,
+      messageId: group === 'block' ? 'enableBlock' : 'enableLine'
+    })
   } else {
     context.report({
       loc,
-      message: '+ {{group}} {{rules}}',
-      data: { group, rules: rules.join(' ') }
+      messageId: group === 'block' ? 'enableBlockRule' : 'enableLineRule',
+      data: { rule }
     })
   }
 }
@@ -66,18 +95,23 @@ function enable(context, loc, group, rules) {
  * Disable rules.
  * @param {RuleContext} context The rule context.
  * @param {{line:number,column:number}} loc The location information to disable.
- * @param {string} group The group to disable.
- * @param {string[]} rules The rule IDs to disable.
+ * @param { 'block' | 'line' } group The group to disable.
+ * @param {string | null} rule The rule ID to disable.
+ * @param {string} key The disable directive key.
  * @returns {void}
  */
-function disable(context, loc, group, rules) {
-  if (rules.length === 0) {
-    context.report({ loc, message: '-- {{group}}', data: { group } })
+function disable(context, loc, group, rule, key) {
+  if (!rule) {
+    context.report({
+      loc,
+      messageId: group === 'block' ? 'disableBlock' : 'disableLine',
+      data: { key }
+    })
   } else {
     context.report({
       loc,
-      message: '- {{group}} {{rules}}',
-      data: { group, rules: rules.join(' ') }
+      messageId: group === 'block' ? 'disableBlockRule' : 'disableLineRule',
+      data: { rule, key }
     })
   }
 }
@@ -87,15 +121,40 @@ function disable(context, loc, group, rules) {
  * If the comment is `eslint-disable` or `eslint-enable` then it reports the comment.
  * @param {RuleContext} context The rule context.
  * @param {Token} comment The comment token to process.
+ * @param {boolean} reportUnusedDisableDirectives To report unused eslint-disable comments.
  * @returns {void}
  */
-function processBlock(context, comment) {
+function processBlock(context, comment, reportUnusedDisableDirectives) {
   const parsed = parse(COMMENT_DIRECTIVE_B, comment.value)
   if (parsed != null) {
     if (parsed.type === 'eslint-disable') {
-      disable(context, comment.loc.start, 'block', parsed.rules)
+      if (parsed.rules.length) {
+        const rules = reportUnusedDisableDirectives
+          ? reportUnusedRules(context, comment, parsed.type, parsed.rules)
+          : parsed.rules
+        for (const rule of rules) {
+          disable(
+            context,
+            comment.loc.start,
+            'block',
+            rule.ruleId,
+            rule.key || '*'
+          )
+        }
+      } else {
+        const key = reportUnusedDisableDirectives
+          ? reportUnused(context, comment, parsed.type)
+          : ''
+        disable(context, comment.loc.start, 'block', null, key)
+      }
     } else {
-      enable(context, comment.loc.start, 'block', parsed.rules)
+      if (parsed.rules.length) {
+        for (const rule of parsed.rules) {
+          enable(context, comment.loc.start, 'block', rule.ruleId)
+        }
+      } else {
+        enable(context, comment.loc.start, 'block', null)
+      }
     }
   }
 }
@@ -105,26 +164,109 @@ function processBlock(context, comment) {
  * If the comment is `eslint-disable-line` or `eslint-disable-next-line` then it reports the comment.
  * @param {RuleContext} context The rule context.
  * @param {Token} comment The comment token to process.
+ * @param {boolean} reportUnusedDisableDirectives To report unused eslint-disable comments.
  * @returns {void}
  */
-function processLine(context, comment) {
+function processLine(context, comment, reportUnusedDisableDirectives) {
   const parsed = parse(COMMENT_DIRECTIVE_L, comment.value)
   if (parsed != null && comment.loc.start.line === comment.loc.end.line) {
     const line =
       comment.loc.start.line + (parsed.type === 'eslint-disable-line' ? 0 : 1)
     const column = -1
-    disable(context, { line, column }, 'line', parsed.rules)
-    enable(context, { line: line + 1, column }, 'line', parsed.rules)
+    if (parsed.rules.length) {
+      const rules = reportUnusedDisableDirectives
+        ? reportUnusedRules(context, comment, parsed.type, parsed.rules)
+        : parsed.rules
+      for (const rule of rules) {
+        disable(context, { line, column }, 'line', rule.ruleId, rule.key || '')
+        enable(context, { line: line + 1, column }, 'line', rule.ruleId)
+      }
+    } else {
+      const key = reportUnusedDisableDirectives
+        ? reportUnused(context, comment, parsed.type)
+        : ''
+      disable(context, { line, column }, 'line', null, key)
+      enable(context, { line: line + 1, column }, 'line', null)
+    }
   }
 }
 
+/**
+ * Reports unused disable directive.
+ * Do not check the use of directives here. Filter the directives used with postprocess.
+ * @param {RuleContext} context The rule context.
+ * @param {Token} comment The comment token to report.
+ * @param {string} kind The comment directive kind.
+ * @returns {string} The report key
+ */
+function reportUnused(context, comment, kind) {
+  const loc = comment.loc
+
+  context.report({
+    loc,
+    messageId: 'unused',
+    data: { kind }
+  })
+
+  return locToKey(loc.start)
+}
+
+/**
+ * Reports unused disable directive rules.
+ * Do not check the use of directives here. Filter the directives used with postprocess.
+ * @param {RuleContext} context The rule context.
+ * @param {Token} comment The comment token to report.
+ * @param {string} kind The comment directive kind.
+ * @param {RuleAndLocation[]} rules To report rule.
+ * @returns { { ruleId: string; key: string; }[] }
+ */
+function reportUnusedRules(context, comment, kind, rules) {
+  const sourceCode = context.getSourceCode()
+  const commentStart = comment.range[0] + 4 /* <!-- */
+
+  return rules.map((rule) => {
+    const start = sourceCode.getLocFromIndex(commentStart + rule.index)
+    const end = sourceCode.getLocFromIndex(
+      commentStart + rule.index + rule.ruleId.length
+    )
+
+    context.report({
+      loc: { start, end },
+      messageId: 'unusedRule',
+      data: { rule: rule.ruleId, kind }
+    })
+
+    return {
+      ruleId: rule.ruleId,
+      key: locToKey(start)
+    }
+  })
+}
+
+/**
+ * Gets the key of location
+ * @param {Location} location The location
+ * @returns {string} The key
+ */
+function locToKey(location) {
+  return `line:${location.line},column${location.column}`
+}
+
 /**
  * Extracts the top-level elements in document fragment.
  * @param {VDocumentFragment} documentFragment The document fragment.
  * @returns {VElement[]} The top-level elements
  */
 function extractTopLevelHTMLElements(documentFragment) {
-  return documentFragment.children.filter((e) => e.type === 'VElement')
+  return documentFragment.children.filter(isVElement)
+
+  /**
+   * @param {any} e
+   * @returns {e is VElement}
+   */
+  function isVElement(e) {
+    return e.type === 'VElement'
+  }
 }
 /**
  * Extracts the top-level comments in document fragment.
@@ -155,10 +297,38 @@ module.exports = {
       categories: ['base'],
       url: 'https://eslint.vuejs.org/rules/comment-directive.html'
     },
-    schema: []
+    schema: [
+      {
+        type: 'object',
+        properties: {
+          reportUnusedDisableDirectives: {
+            type: 'boolean'
+          }
+        },
+        additionalProperties: false
+      }
+    ],
+    messages: {
+      disableBlock: '--block {{key}}',
+      enableBlock: '++block',
+      disableLine: '--line {{key}}',
+      enableLine: '++line',
+      disableBlockRule: '-block {{rule}} {{key}}',
+      enableBlockRule: '+block {{rule}}',
+      disableLineRule: '-line {{rule}} {{key}}',
+      enableLineRule: '+line {{rule}}',
+      clear: 'clear',
+
+      unused: 'Unused {{kind}} directive (no problems were reported).',
+      unusedRule:
+        "Unused {{kind}} directive (no problems were reported from '{{rule}}')."
+    }
   },
 
   create(context) {
+    const options = context.options[0] || {}
+    /** @type {boolean} */
+    const reportUnusedDisableDirectives = options.reportUnusedDisableDirectives
     const documentFragment =
       context.parserServices.getDocumentFragment &&
       context.parserServices.getDocumentFragment()
@@ -168,14 +338,14 @@ module.exports = {
         if (node.templateBody) {
           // Send directives to the post-process.
           for (const comment of node.templateBody.comments) {
-            processBlock(context, comment)
-            processLine(context, comment)
+            processBlock(context, comment, reportUnusedDisableDirectives)
+            processLine(context, comment, reportUnusedDisableDirectives)
           }
 
           // Send a clear mark to the post-process.
           context.report({
             loc: node.templateBody.loc.end,
-            message: 'clear'
+            messageId: 'clear'
           })
         }
         if (documentFragment) {
@@ -183,15 +353,15 @@ module.exports = {
           for (const comment of extractTopLevelDocumentFragmentComments(
             documentFragment
           )) {
-            processBlock(context, comment)
-            processLine(context, comment)
+            processBlock(context, comment, reportUnusedDisableDirectives)
+            processLine(context, comment, reportUnusedDisableDirectives)
           }
 
           // Send a clear mark to the post-process.
           for (const element of extractTopLevelHTMLElements(documentFragment)) {
             context.report({
               loc: element.loc.end,
-              message: 'clear'
+              messageId: 'clear'
             })
           }
         }
diff --git a/lib/rules/custom-event-name-casing.js b/lib/rules/custom-event-name-casing.js
new file mode 100644
index 00000000..cec6f55d
--- /dev/null
+++ b/lib/rules/custom-event-name-casing.js
@@ -0,0 +1,221 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+/**
+ * @typedef {import('vue-eslint-parser').AST.ESLintLiteral} Literal
+ * @typedef {import('vue-eslint-parser').AST.ESLintCallExpression} CallExpression
+ */
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const { findVariable } = require('eslint-utils')
+const utils = require('../utils')
+const { isKebabCase } = require('../utils/casing')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * Check whether the given event name is valid.
+ * @param {string} name The name to check.
+ * @returns {boolean} `true` if the given event name is valid.
+ */
+function isValidEventName(name) {
+  return isKebabCase(name) || name.startsWith('update:')
+}
+
+/**
+ * Get the name param node from the given CallExpression
+ * @param {CallExpression} node CallExpression
+ * @returns { Literal & { value: string } }
+ */
+function getNameParamNode(node) {
+  const nameLiteralNode = node.arguments[0]
+  if (
+    !nameLiteralNode ||
+    nameLiteralNode.type !== 'Literal' ||
+    typeof nameLiteralNode.value !== 'string'
+  ) {
+    // cannot check
+    return null
+  }
+
+  return nameLiteralNode
+}
+/**
+ * Get the callee member node from the given CallExpression
+ * @param {CallExpression} node CallExpression
+ */
+function getCalleeMemberNode(node) {
+  const callee = node.callee
+
+  if (callee.type === 'MemberExpression') {
+    const name = utils.getStaticPropertyName(callee)
+    if (name) {
+      return { name, member: callee }
+    }
+  }
+  return null
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'enforce custom event names always use "kebab-case"',
+      categories: ['vue3-essential', 'essential'],
+      url: 'https://eslint.vuejs.org/rules/custom-event-name-casing.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      unexpected: "Custom event name '{{name}}' must be kebab-case."
+    }
+  },
+
+  create(context) {
+    const setupContexts = new Map()
+
+    /**
+     * @param { Literal & { value: string } } nameLiteralNode
+     */
+    function verify(nameLiteralNode) {
+      const name = nameLiteralNode.value
+      if (isValidEventName(name)) {
+        return
+      }
+      context.report({
+        node: nameLiteralNode,
+        messageId: 'unexpected',
+        data: {
+          name
+        }
+      })
+    }
+
+    return utils.defineTemplateBodyVisitor(
+      context,
+      {
+        CallExpression(node) {
+          const callee = node.callee
+          const nameLiteralNode = getNameParamNode(node)
+          if (!nameLiteralNode) {
+            // cannot check
+            return
+          }
+          if (callee.type === 'Identifier' && callee.name === '$emit') {
+            verify(nameLiteralNode)
+          }
+        }
+      },
+      utils.compositingVisitors(
+        utils.defineVueVisitor(context, {
+          onSetupFunctionEnter(node, { node: vueNode }) {
+            const contextParam = node.params[1]
+            if (!contextParam) {
+              // no arguments
+              return
+            }
+            if (contextParam.type === 'RestElement') {
+              // cannot check
+              return
+            }
+            if (contextParam.type === 'ArrayPattern') {
+              // cannot check
+              return
+            }
+            const contextReferenceIds = new Set()
+            const emitReferenceIds = new Set()
+            if (contextParam.type === 'ObjectPattern') {
+              const emitProperty = contextParam.properties.find(
+                (p) =>
+                  p.type === 'Property' &&
+                  utils.getStaticPropertyName(p) === 'emit'
+              )
+              if (!emitProperty) {
+                return
+              }
+              const emitParam = emitProperty.value
+              // `setup(props, {emit})`
+              const variable = findVariable(context.getScope(), emitParam)
+              if (!variable) {
+                return
+              }
+              for (const reference of variable.references) {
+                emitReferenceIds.add(reference.identifier)
+              }
+            } else {
+              // `setup(props, context)`
+              const variable = findVariable(context.getScope(), contextParam)
+              if (!variable) {
+                return
+              }
+              for (const reference of variable.references) {
+                contextReferenceIds.add(reference.identifier)
+              }
+            }
+            setupContexts.set(vueNode, {
+              contextReferenceIds,
+              emitReferenceIds
+            })
+          },
+          CallExpression(node, { node: vueNode }) {
+            const nameLiteralNode = getNameParamNode(node)
+            if (!nameLiteralNode) {
+              // cannot check
+              return
+            }
+
+            // verify setup context
+            const setupContext = setupContexts.get(vueNode)
+            if (setupContext) {
+              const { contextReferenceIds, emitReferenceIds } = setupContext
+              if (emitReferenceIds.has(node.callee)) {
+                // verify setup(props,{emit}) {emit()}
+                verify(nameLiteralNode)
+              } else {
+                const emit = getCalleeMemberNode(node)
+                if (
+                  emit &&
+                  emit.name === 'emit' &&
+                  contextReferenceIds.has(emit.member.object)
+                ) {
+                  // verify setup(props,context) {context.emit()}
+                  verify(nameLiteralNode)
+                }
+              }
+            }
+          },
+          onVueObjectExit(node) {
+            setupContexts.delete(node)
+          }
+        }),
+        {
+          CallExpression(node) {
+            const nameLiteralNode = getNameParamNode(node)
+            if (!nameLiteralNode) {
+              // cannot check
+              return
+            }
+            const emit = getCalleeMemberNode(node)
+            // verify $emit
+            if (emit && emit.name === '$emit') {
+              // verify this.$emit()
+              verify(nameLiteralNode)
+            }
+          }
+        }
+      )
+    )
+  }
+}
diff --git a/lib/rules/html-indent.js b/lib/rules/html-indent.js
index d62f6f3a..9cbea60c 100644
--- a/lib/rules/html-indent.js
+++ b/lib/rules/html-indent.js
@@ -44,7 +44,20 @@ module.exports = {
         properties: {
           attribute: { type: 'integer', minimum: 0 },
           baseIndent: { type: 'integer', minimum: 0 },
-          closeBracket: { type: 'integer', minimum: 0 },
+          closeBracket: {
+            anyOf: [
+              { type: 'integer', minimum: 0 },
+              {
+                type: 'object',
+                properties: {
+                  startTag: { type: 'integer', minimum: 0 },
+                  endTag: { type: 'integer', minimum: 0 },
+                  selfClosingTag: { type: 'integer', minimum: 0 }
+                },
+                additionalProperties: false
+              }
+            ]
+          },
           switchCase: { type: 'integer', minimum: 0 },
           alignAttributesVertically: { type: 'boolean' },
           ignores: {
diff --git a/lib/rules/no-unused-properties.js b/lib/rules/no-unused-properties.js
index 4c9c0e41..39c6a826 100644
--- a/lib/rules/no-unused-properties.js
+++ b/lib/rules/no-unused-properties.js
@@ -433,7 +433,11 @@ module.exports = {
             node,
             new Set([GROUP_WATCHER])
           )) {
-            watcherNames.add(watcher.name)
+            let path
+            for (const seg of watcher.name.split('.')) {
+              path = path ? `${path}.${seg}` : seg
+              watcherNames.add(path)
+            }
           }
           for (const prop of utils.iterateProperties(node, groups)) {
             if (watcherNames.has(prop.name)) {
diff --git a/lib/rules/no-unused-vars.js b/lib/rules/no-unused-vars.js
index 37356c9c..e808ee7f 100644
--- a/lib/rules/no-unused-vars.js
+++ b/lib/rules/no-unused-vars.js
@@ -6,6 +6,58 @@
 
 const utils = require('../utils')
 
+/**
+ * @typedef {import('vue-eslint-parser').AST.Node} Node
+ * @typedef {import('vue-eslint-parser').AST.VElement} VElement
+ * @typedef {import('vue-eslint-parser').AST.Variable} Variable
+ */
+/**
+ * @typedef {Variable['kind']} VariableKind
+ */
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+/**
+ * Groups variables by directive kind.
+ * @param {VElement} node The element node
+ * @returns { { [kind in VariableKind]?: Variable[] } } The variables of grouped by directive kind.
+ */
+function groupingVariables(node) {
+  /** @type { { [kind in VariableKind]?: Variable[] } } */
+  const result = {}
+  for (const variable of node.variables) {
+    const vars = result[variable.kind] || (result[variable.kind] = [])
+    vars.push(variable)
+  }
+  return result
+}
+
+/**
+ * Checks if the given variable was defined by destructuring.
+ * @param {Variable} variable the given variable to check
+ * @returns {boolean} `true` if the given variable was defined by destructuring.
+ */
+function isDestructuringVar(variable) {
+  const node = variable.id
+  /** @type {Node} */
+  let parent = node.parent
+  while (parent) {
+    if (
+      parent.type === 'VForExpression' ||
+      parent.type === 'VSlotScopeExpression'
+    ) {
+      return false
+    }
+    if (parent.type === 'Property' || parent.type === 'ArrayPattern') {
+      return true
+    }
+    parent = parent.parent
+  }
+  return false
+}
+
 // ------------------------------------------------------------------------------
 // Rule Definition
 // ------------------------------------------------------------------------------
@@ -41,38 +93,52 @@ module.exports = {
       ignoreRegEx = new RegExp(ignorePattern, 'u')
     }
     return utils.defineTemplateBodyVisitor(context, {
+      /**
+       * @param {VElement} node
+       */
       VElement(node) {
-        const variables = node.variables
-        for (let i = variables.length - 1; i >= 0; i--) {
-          const variable = variables[i]
+        const vars = groupingVariables(node)
+        for (const variables of Object.values(vars)) {
+          let hasAfterUsed = false
 
-          if (variable.references.length) {
-            break
-          }
+          for (let i = variables.length - 1; i >= 0; i--) {
+            const variable = variables[i]
 
-          if (ignoreRegEx != null && ignoreRegEx.test(variable.id.name)) {
-            continue
-          }
-          context.report({
-            node: variable.id,
-            loc: variable.id.loc,
-            message: `'{{name}}' is defined but never used.`,
-            data: variable.id,
-            suggest:
-              ignorePattern === '^_'
-                ? [
-                    {
-                      desc: `Replace the ${variable.id.name} with _${variable.id.name}`,
-                      fix(fixer) {
-                        return fixer.replaceText(
-                          variable.id,
-                          `_${variable.id.name}`
-                        )
+            if (variable.references.length) {
+              hasAfterUsed = true
+              continue
+            }
+
+            if (ignoreRegEx != null && ignoreRegEx.test(variable.id.name)) {
+              continue
+            }
+
+            if (hasAfterUsed && !isDestructuringVar(variable)) {
+              // If a variable after the variable is used, it will be skipped.
+              continue
+            }
+
+            context.report({
+              node: variable.id,
+              loc: variable.id.loc,
+              message: `'{{name}}' is defined but never used.`,
+              data: variable.id,
+              suggest:
+                ignorePattern === '^_'
+                  ? [
+                      {
+                        desc: `Replace the ${variable.id.name} with _${variable.id.name}`,
+                        fix(fixer) {
+                          return fixer.replaceText(
+                            variable.id,
+                            `_${variable.id.name}`
+                          )
+                        }
                       }
-                    }
-                  ]
-                : []
-          })
+                    ]
+                  : []
+            })
+          }
         }
       }
     })
diff --git a/lib/rules/no-useless-concat.js b/lib/rules/no-useless-concat.js
new file mode 100644
index 00000000..9038b9b9
--- /dev/null
+++ b/lib/rules/no-useless-concat.js
@@ -0,0 +1,9 @@
+/**
+ * @author Yosuke Ota
+ */
+'use strict'
+
+const { wrapCoreRule } = require('../utils')
+
+// eslint-disable-next-line no-invalid-meta, no-invalid-meta-docs-categories
+module.exports = wrapCoreRule(require('eslint/lib/rules/no-useless-concat'))
diff --git a/lib/utils/indent-common.js b/lib/utils/indent-common.js
index 1c8c3838..a69d7a5d 100644
--- a/lib/utils/indent-common.js
+++ b/lib/utils/indent-common.js
@@ -21,12 +21,26 @@ const BLOCK_COMMENT_PREFIX = /^\s*\*/
 const ITERATION_OPTS = Object.freeze({ includeComments: true, filter: isNotWhitespace })
 const PREFORMATTED_ELEMENT_NAMES = ['pre', 'textarea']
 
+/**
+ * @typedef {object} IndentOptions
+ * @property { " " | "\t" } IndentOptions.indentChar
+ * @property {number} IndentOptions.indentSize
+ * @property {number} IndentOptions.baseIndent
+ * @property {number} IndentOptions.attribute
+ * @property {object} IndentOptions.closeBracket
+ * @property {number} IndentOptions.closeBracket.startTag
+ * @property {number} IndentOptions.closeBracket.endTag
+ * @property {number} IndentOptions.closeBracket.selfClosingTag
+ * @property {number} IndentOptions.switchCase
+ * @property {boolean} IndentOptions.alignAttributesVertically
+ * @property {string[]} IndentOptions.ignores
+ */
 /**
  * Normalize options.
  * @param {number|"tab"|undefined} type The type of indentation.
  * @param {Object} options Other options.
  * @param {Object} defaultOptions The default value of options.
- * @returns {{indentChar:" "|"\t",indentSize:number,baseIndent:number,attribute:number,closeBracket:number,switchCase:number,alignAttributesVertically:boolean,ignores:string[]}} Normalized options.
+ * @returns {IndentOptions} Normalized options.
  */
 function parseOptions (type, options, defaultOptions) {
   const ret = Object.assign({
@@ -34,7 +48,11 @@ function parseOptions (type, options, defaultOptions) {
     indentSize: 2,
     baseIndent: 0,
     attribute: 1,
-    closeBracket: 0,
+    closeBracket: {
+      startTag: 0,
+      endTag: 0,
+      selfClosingTag: 0
+    },
     switchCase: 0,
     alignAttributesVertically: true,
     ignores: []
@@ -54,7 +72,21 @@ function parseOptions (type, options, defaultOptions) {
     ret.attribute = options.attribute
   }
   if (Number.isSafeInteger(options.closeBracket)) {
-    ret.closeBracket = options.closeBracket
+    const num = options.closeBracket
+    ret.closeBracket = {
+      startTag: num,
+      endTag: num,
+      selfClosingTag: num
+    }
+  } else if (options.closeBracket) {
+    ret.closeBracket = Object.assign(
+      {
+        startTag: 0,
+        endTag: 0,
+        selfClosingTag: 0
+      },
+      options.closeBracket
+    )
   }
   if (Number.isSafeInteger(options.switchCase)) {
     ret.switchCase = options.switchCase
@@ -919,11 +951,12 @@ module.exports.defineVisitor = function create (context, tokenStore, defaultOpti
     },
 
     VEndTag (node) {
-      const openToken = tokenStore.getFirstToken(node)
+      const element = node.parent
+      const startTagOpenToken = tokenStore.getFirstToken(element.startTag)
       const closeToken = tokenStore.getLastToken(node)
 
       if (closeToken.type.endsWith('TagClose')) {
-        setOffset(closeToken, options.closeBracket, openToken)
+        setOffset(closeToken, options.closeBracket.endTag, startTagOpenToken)
       }
     },
 
@@ -996,7 +1029,10 @@ module.exports.defineVisitor = function create (context, tokenStore, defaultOpti
         options.alignAttributesVertically
       )
       if (closeToken != null && closeToken.type.endsWith('TagClose')) {
-        setOffset(closeToken, options.closeBracket, openToken)
+        const offset = closeToken.type !== 'HTMLSelfClosingTagClose'
+          ? options.closeBracket.startTag
+          : options.closeBracket.selfClosingTag
+        setOffset(closeToken, offset, openToken)
       }
     },
 


Test patch:
diff --git a/tests/fixtures/html-indent/close-bracket-01.vue b/tests/fixtures/html-indent/close-bracket-01.vue
new file mode 100644
index 00000000..3307b145
--- /dev/null
+++ b/tests/fixtures/html-indent/close-bracket-01.vue
@@ -0,0 +1,16 @@
+<!--{}-->
+<template>
+  <a
+    href="#"
+  >Link 1</a
+  >
+  <a
+    href="#"
+  >
+    Link 2</a
+  >
+  <div>Content</div
+  >
+  <div
+  />
+</template>
diff --git a/tests/fixtures/html-indent/close-bracket-02.vue b/tests/fixtures/html-indent/close-bracket-02.vue
new file mode 100644
index 00000000..3f171a69
--- /dev/null
+++ b/tests/fixtures/html-indent/close-bracket-02.vue
@@ -0,0 +1,16 @@
+<!--{"options":[2, { "closeBracket": 1 }]}-->
+<template>
+  <a
+    href="#"
+    >Link 1</a
+    >
+  <a
+    href="#"
+    >
+    Link 2</a
+    >
+  <div>Content</div
+    >
+  <div
+    />
+</template>
diff --git a/tests/fixtures/html-indent/close-bracket-03.vue b/tests/fixtures/html-indent/close-bracket-03.vue
new file mode 100644
index 00000000..518423c4
--- /dev/null
+++ b/tests/fixtures/html-indent/close-bracket-03.vue
@@ -0,0 +1,16 @@
+<!--{"options":[2, { "closeBracket": { "startTag": 1 } }]}-->
+<template>
+  <a
+    href="#"
+    >Link 1</a
+  >
+  <a
+    href="#"
+    >
+    Link 2</a
+  >
+  <div>Content</div
+  >
+  <div
+  />
+</template>
diff --git a/tests/fixtures/html-indent/close-bracket-04.vue b/tests/fixtures/html-indent/close-bracket-04.vue
new file mode 100644
index 00000000..f90ffc4d
--- /dev/null
+++ b/tests/fixtures/html-indent/close-bracket-04.vue
@@ -0,0 +1,16 @@
+<!--{"options":[2, { "closeBracket": { "endTag": 1 } }]}-->
+<template>
+  <a
+    href="#"
+  >Link 1</a
+    >
+  <a
+    href="#"
+  >
+    Link 2</a
+    >
+  <div>Content</div
+    >
+  <div
+  />
+</template>
diff --git a/tests/fixtures/html-indent/close-bracket-05.vue b/tests/fixtures/html-indent/close-bracket-05.vue
new file mode 100644
index 00000000..b5d48275
--- /dev/null
+++ b/tests/fixtures/html-indent/close-bracket-05.vue
@@ -0,0 +1,16 @@
+<!--{"options":[2, { "closeBracket": { "selfClosingTag": 1 } }]}-->
+<template>
+  <a
+    href="#"
+  >Link 1</a
+  >
+  <a
+    href="#"
+  >
+    Link 2</a
+  >
+  <div>Content</div
+  >
+  <div
+    />
+</template>
diff --git a/tests/fixtures/html-indent/close-bracket-06.vue b/tests/fixtures/html-indent/close-bracket-06.vue
new file mode 100644
index 00000000..d5bae8ed
--- /dev/null
+++ b/tests/fixtures/html-indent/close-bracket-06.vue
@@ -0,0 +1,16 @@
+<!--{"options":[2, { "closeBracket": {  "startTag": 1, "endTag": 2, "selfClosingTag": 3 } }]}-->
+<template>
+  <a
+    href="#"
+    >Link 1</a
+      >
+  <a
+    href="#"
+    >
+    Link 2</a
+      >
+  <div>Content</div
+      >
+  <div
+        />
+</template>
diff --git a/tests/lib/rules/comma-style.js b/tests/lib/rules/comma-style.js
index 39135a69..f02c3e6d 100644
--- a/tests/lib/rules/comma-style.js
+++ b/tests/lib/rules/comma-style.js
@@ -34,6 +34,23 @@ tester.run('comma-style', rule, {
             , data) => fn()" />
         </template>`,
       options: ['first', { exceptions: { ArrowFunctionExpression: false } }]
+    },
+    {
+      code: `
+        <template>
+          <CustomButton v-slot="a,
+            b
+            ,c" />
+        </template>`
+    },
+    {
+      code: `
+        <template>
+          <CustomButton v-slot="a,
+            b
+            ,c" />
+        </template>`,
+      options: ['first', { exceptions: { FunctionExpression: true } }]
     }
   ],
   invalid: [
@@ -119,6 +136,27 @@ tester.run('comma-style', rule, {
           // line: 3 // eslint v7.0
         }
       ]
+    },
+    {
+      code: `
+        <template>
+          <CustomButton v-slot="a,
+            b
+            ,c" />
+        </template>`,
+      options: ['last', { exceptions: { FunctionExpression: false } }],
+      output: `
+        <template>
+          <CustomButton v-slot="a,
+            b,
+            c" />
+        </template>`,
+      errors: [
+        {
+          message: "',' should be placed last."
+          // line: 3 // eslint v7.0
+        }
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/comment-directive.js b/tests/lib/rules/comment-directive.js
index f112df00..4b46a88b 100644
--- a/tests/lib/rules/comment-directive.js
+++ b/tests/lib/rules/comment-directive.js
@@ -130,6 +130,19 @@ describe('comment-directive', () => {
       assert.strictEqual(messages.length, 1)
       assert.strictEqual(messages[0].ruleId, 'no-unused-vars')
     })
+
+    it('disable specific rules if <!-- eslint-disable vue/no-duplicate-attributes ,, , vue/no-parsing-error -->', () => {
+      const code = `
+        <template>
+          <!-- eslint-disable vue/no-duplicate-attributes ,, , vue/no-parsing-error -->
+          <div id id="a">Hello</div>
+        </template>
+      `
+      const messages = linter.executeOnText(code, 'test.vue').results[0]
+        .messages
+
+      assert.deepEqual(messages.length, 0)
+    })
   })
 
   describe('eslint-disable-line', () => {
@@ -373,4 +386,169 @@ describe('comment-directive', () => {
       assert.deepEqual(messages[1].ruleId, 'vue/no-duplicate-attributes')
     })
   })
+
+  describe('reportUnusedDisableDirectives', () => {
+    const linter = new eslint.CLIEngine({
+      parser: require.resolve('vue-eslint-parser'),
+      parserOptions: {
+        ecmaVersion: 2015
+      },
+      plugins: ['vue'],
+      rules: {
+        'no-unused-vars': 'error',
+        'vue/comment-directive': [
+          'error',
+          { reportUnusedDisableDirectives: true }
+        ],
+        'vue/no-parsing-error': 'error',
+        'vue/no-duplicate-attributes': 'error'
+      },
+      useEslintrc: false
+    })
+    it('report unused <!-- eslint-disable -->', () => {
+      const code = `
+        <template>
+          <!-- eslint-disable -->
+          <div id="a">Hello</div>
+        </template>
+      `
+      const messages = linter.executeOnText(code, 'test.vue').results[0]
+        .messages
+
+      assert.deepEqual(messages.length, 1)
+      assert.deepEqual(messages[0].ruleId, 'vue/comment-directive')
+      assert.deepEqual(
+        messages[0].message,
+        'Unused eslint-disable directive (no problems were reported).'
+      )
+      assert.deepEqual(messages[0].line, 3)
+      assert.deepEqual(messages[0].column, 11)
+    })
+
+    it('dont report unused <!-- eslint-disable -->', () => {
+      const code = `
+        <template>
+          <!-- eslint-disable -->
+          <div id id="a">Hello</div>
+        </template>
+      `
+      const messages = linter.executeOnText(code, 'test.vue').results[0]
+        .messages
+
+      assert.deepEqual(messages.length, 0)
+    })
+    it('disable and report unused <!-- eslint-disable -->', () => {
+      const code = `
+        <template>
+          <!-- eslint-disable -->
+          <div id id="a">Hello</div>
+          <!-- eslint-enable -->
+          <!-- eslint-disable -->
+          <div id="b">Hello</div>
+        </template>
+      `
+      const messages = linter.executeOnText(code, 'test.vue').results[0]
+        .messages
+
+      assert.deepEqual(messages.length, 1)
+      assert.deepEqual(messages[0].ruleId, 'vue/comment-directive')
+      assert.deepEqual(
+        messages[0].message,
+        'Unused eslint-disable directive (no problems were reported).'
+      )
+      assert.deepEqual(messages[0].line, 6)
+      assert.deepEqual(messages[0].column, 11)
+    })
+
+    it('report unused <!-- eslint-disable vue/no-duplicate-attributes, vue/no-parsing-error -->', () => {
+      const code = `
+        <template>
+          <!-- eslint-disable vue/no-duplicate-attributes, vue/no-parsing-error -->
+          <div id="a">Hello</div>
+        </template>
+      `
+      const messages = linter.executeOnText(code, 'test.vue').results[0]
+        .messages
+
+      assert.deepEqual(messages.length, 2)
+
+      assert.deepEqual(messages[0].ruleId, 'vue/comment-directive')
+      assert.deepEqual(
+        messages[0].message,
+        "Unused eslint-disable directive (no problems were reported from 'vue/no-duplicate-attributes')."
+      )
+      assert.deepEqual(messages[0].line, 3)
+      assert.deepEqual(messages[0].column, 31)
+
+      assert.deepEqual(messages[1].ruleId, 'vue/comment-directive')
+      assert.deepEqual(
+        messages[1].message,
+        "Unused eslint-disable directive (no problems were reported from 'vue/no-parsing-error')."
+      )
+      assert.deepEqual(messages[1].line, 3)
+      assert.deepEqual(messages[1].column, 60)
+    })
+
+    it('report unused <!-- eslint-disable-next-line vue/no-duplicate-attributes, vue/no-parsing-error -->', () => {
+      const code = `
+        <template>
+          <!-- eslint-disable-next-line vue/no-duplicate-attributes, vue/no-parsing-error -->
+          <div id="a">Hello</div>
+          <div id id="b">Hello</div>
+        </template>
+      `
+      const messages = linter.executeOnText(code, 'test.vue').results[0]
+        .messages
+
+      assert.deepEqual(messages.length, 4)
+
+      assert.deepEqual(messages[0].ruleId, 'vue/comment-directive')
+      assert.deepEqual(
+        messages[0].message,
+        "Unused eslint-disable-next-line directive (no problems were reported from 'vue/no-duplicate-attributes')."
+      )
+      assert.deepEqual(messages[0].line, 3)
+      assert.deepEqual(messages[0].column, 41)
+
+      assert.deepEqual(messages[1].ruleId, 'vue/comment-directive')
+      assert.deepEqual(
+        messages[1].message,
+        "Unused eslint-disable-next-line directive (no problems were reported from 'vue/no-parsing-error')."
+      )
+      assert.deepEqual(messages[1].line, 3)
+      assert.deepEqual(messages[1].column, 70)
+
+      assert.deepEqual(messages[2].ruleId, 'vue/no-parsing-error')
+      assert.deepEqual(messages[2].line, 5)
+      assert.deepEqual(messages[3].ruleId, 'vue/no-duplicate-attributes')
+      assert.deepEqual(messages[3].line, 5)
+    })
+
+    it('dont report used <!-- eslint-disable-next-line vue/no-duplicate-attributes, vue/no-parsing-error -->', () => {
+      const code = `
+        <template>
+          <!-- eslint-disable-next-line vue/no-duplicate-attributes, vue/no-parsing-error -->
+          <div id id="a">Hello</div>
+        </template>
+      `
+      const messages = linter.executeOnText(code, 'test.vue').results[0]
+        .messages
+
+      assert.deepEqual(messages.length, 0)
+    })
+
+    it('dont report used, with duplicate eslint-disable', () => {
+      const code = `
+        <template>
+          <!-- eslint-disable -->
+          <!-- eslint-disable-next-line vue/no-duplicate-attributes, vue/no-parsing-error -->
+          <div id id="a">Hello</div><!-- eslint-disable-line vue/no-duplicate-attributes, vue/no-parsing-error -->
+        </template>
+      `
+      const messages = linter.executeOnText(code, 'test.vue').results[0]
+        .messages
+
+      assert.deepEqual(messages.length, 0)
+    })
+  })
 })
diff --git a/tests/lib/rules/custom-event-name-casing.js b/tests/lib/rules/custom-event-name-casing.js
new file mode 100644
index 00000000..b767223e
--- /dev/null
+++ b/tests/lib/rules/custom-event-name-casing.js
@@ -0,0 +1,221 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/custom-event-name-casing')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2019,
+    sourceType: 'module'
+  }
+})
+
+tester.run('custom-event-name-casing', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input
+          @click="$emit('foo-bar')">
+      </template>
+      <script>
+      export default {
+        setup(props, context) {
+          return {
+            onInput(value) {
+              context.emit('update:fooBar', value)
+              context.emit('foo-bar')
+            }
+          }
+        },
+        methods: {
+          onClick() {
+            this.$emit('foo-bar')
+          }
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input
+          @click="$emit('update:fooBar', value)">
+      </template>
+      <script>
+      export default {
+        setup(props, {emit}) {
+          return {
+            onInput(value) {
+              emit('update:fooBar', value)
+              emit('foo-bar')
+            }
+          }
+        },
+        methods: {
+          onClick() {
+            this.$emit('update:fooBar', value)
+          }
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input
+          @click="emit('fooBar')">
+      </template>
+      <script>
+      export default {
+        setup(context) {
+          return {
+            onInput(value) {
+              context.emit('barBaz')
+            }
+          }
+        },
+        methods: {
+          onClick() {
+            $emit('bazQux')
+          }
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input
+          @click="$emit(fooBar)">
+      </template>
+      <script>
+      export default {
+        setup(props, context) {
+          return {
+            onInput(value) {
+              context.emit(barBaz)
+            }
+          }
+        },
+        methods: {
+          onClick() {
+            this.$emit(bazQux)
+          }
+        }
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        setup(prop, ...context) {
+          return {
+            onInput(value) {
+              context.emit('barBaz')
+            }
+          }
+        },
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        setup(prop, [context]) {
+          return {
+            onInput(value) {
+              context.emit('barBaz')
+            }
+          }
+        },
+      }
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        setup(prop, {e}) {
+          return {
+            onInput(value) {
+              e('barBaz')
+            }
+          }
+        },
+      }
+      </script>
+      `
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <input
+          @click="$emit('fooBar')">
+      </template>
+      <script>
+      export default {
+        setup(props, context) {
+          return {
+            onInput(value) {
+              context.emit('barBaz')
+            }
+          }
+        },
+        methods: {
+          onClick() {
+            this.$emit('bazQux')
+          }
+        }
+      }
+      </script>
+      `,
+      errors: [
+        {
+          message: "Custom event name 'fooBar' must be kebab-case.",
+          line: 4,
+          column: 25,
+          endLine: 4,
+          endColumn: 33
+        },
+        {
+          message: "Custom event name 'barBaz' must be kebab-case.",
+          line: 11,
+          column: 28,
+          endLine: 11,
+          endColumn: 36
+        },
+        {
+          message: "Custom event name 'bazQux' must be kebab-case.",
+          line: 17,
+          column: 24,
+          endLine: 17,
+          endColumn: 32
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/no-unused-properties.js b/tests/lib/rules/no-unused-properties.js
index b9f984af..7c37da2f 100644
--- a/tests/lib/rules/no-unused-properties.js
+++ b/tests/lib/rules/no-unused-properties.js
@@ -248,6 +248,21 @@ tester.run('no-unused-properties', rule, {
       `,
       options: allOptions
     },
+    {
+      filename: 'test.vue',
+      code: `
+        <script>
+          export default {
+            props: ['foo'],
+            watch: {
+              'foo.bar'() {
+                alert('Increased!');
+              },
+            },
+          };
+        </script>
+      `
+    },
 
     // data used as a template identifier
     {
diff --git a/tests/lib/rules/no-unused-vars.js b/tests/lib/rules/no-unused-vars.js
index 0c1b85e5..81df799b 100644
--- a/tests/lib/rules/no-unused-vars.js
+++ b/tests/lib/rules/no-unused-vars.js
@@ -162,6 +162,58 @@ tester.run('no-unused-vars', rule, {
       code: '<template><div v-for="(a, _i) in foo" ></div></template>',
       options: [{ ignorePattern: '^_' }],
       errors: ["'a' is defined but never used."]
+    },
+    {
+      code:
+        '<template><my-component v-slot="a" >{{d}}</my-component></template>',
+      errors: ["'a' is defined but never used."]
+    },
+    {
+      code:
+        '<template><my-component v-for="i in foo" v-slot="a" >{{a}}</my-component></template>',
+      errors: ["'i' is defined but never used."]
+    },
+    {
+      code:
+        '<template><my-component v-for="i in foo" v-slot="a" >{{i}}</my-component></template>',
+      errors: ["'a' is defined but never used."]
+    },
+    {
+      code:
+        '<template><div v-for="({a, b}, [c, d], e, f) in foo" >{{f}}</div></template>',
+      errors: [
+        "'a' is defined but never used.",
+        "'b' is defined but never used.",
+        "'c' is defined but never used.",
+        "'d' is defined but never used."
+      ]
+    },
+    {
+      code:
+        '<template><div v-for="({a, b}, c, [d], e, f) in foo" >{{f}}</div></template>',
+      errors: [
+        "'a' is defined but never used.",
+        "'b' is defined but never used.",
+        "'d' is defined but never used."
+      ]
+    },
+    {
+      code:
+        '<template><my-component v-slot="{a, b, c, d}" >{{d}}</my-component></template>',
+      errors: [
+        "'a' is defined but never used.",
+        "'b' is defined but never used.",
+        "'c' is defined but never used."
+      ]
+    },
+    {
+      code:
+        '<template><div v-for="({a, b: bar}, c = 1, [d], e, f) in foo" >{{f}}</div></template>',
+      errors: [
+        "'a' is defined but never used.",
+        "'bar' is defined but never used.",
+        "'d' is defined but never used."
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/no-useless-concat.js b/tests/lib/rules/no-useless-concat.js
new file mode 100644
index 00000000..72e1ca55
--- /dev/null
+++ b/tests/lib/rules/no-useless-concat.js
@@ -0,0 +1,30 @@
+/**
+ * @author Yosuke Ota
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-useless-concat')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2015 }
+})
+
+tester.run('no-useless-concat', rule, {
+  valid: [
+    `<template><div :attr="'foo-bar'" /></template>`,
+    '<template><div attr="foo-bar" /></template>',
+    `<template><div :[\`foo-bar\`]="a" /></template>`
+  ],
+  invalid: [
+    {
+      code: `<template><div :attr="'foo'+'bar'" /></template>`,
+      errors: ['Unexpected string concatenation of literals.']
+    },
+    {
+      code: `<template><div :[\`foo\`+\`bar\`]="a" /></template>`,
+      errors: ['Unexpected string concatenation of literals.']
+    }
+  ]
+})