From d82bf0d516c8ec7f5a399f4b26b24d2f25b9a12d Mon Sep 17 00:00:00 2001
From: Nils Haberkamp <haberkamp.n@gmail.com>
Date: Sat, 22 Mar 2025 21:06:11 +0100
Subject: [PATCH] feat: add vue/no-direct-composable-in-event-handler rule

---
 docs/rules/index.md                           |   2 +
 .../no-direct-composable-in-event-handler.md  |  43 ++++++
 lib/configs/flat/vue3-essential.js            |   1 +
 lib/configs/vue3-essential.js                 |   1 +
 lib/index.js                                  |   1 +
 .../no-direct-composable-in-event-handler.js  |  66 ++++++++++
 .../no-direct-composable-in-event-handler.js  | 124 ++++++++++++++++++
 7 files changed, 238 insertions(+)
 create mode 100644 docs/rules/no-direct-composable-in-event-handler.md
 create mode 100644 lib/rules/no-direct-composable-in-event-handler.js
 create mode 100644 tests/lib/rules/no-direct-composable-in-event-handler.js

diff --git a/docs/rules/index.md b/docs/rules/index.md
index 55c5b96c9..2bef5d9f2 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -68,6 +68,7 @@ Rules in this category are enabled for all presets provided by eslint-plugin-vue
 | [vue/no-deprecated-v-on-native-modifier] | disallow using deprecated `.native` modifiers (in Vue.js 3.0.0+) |  | :three::warning: |
 | [vue/no-deprecated-v-on-number-modifiers] | disallow using deprecated number (keycode) modifiers (in Vue.js 3.0.0+) | :wrench: | :three::warning: |
 | [vue/no-deprecated-vue-config-keycodes] | disallow using deprecated `Vue.config.keyCodes` (in Vue.js 3.0.0+) |  | :three::warning: |
+| [vue/no-direct-composable-in-event-handler] | disallow direct composable usage in event handler |  | :three::hammer: |
 | [vue/no-dupe-keys] | disallow duplication of field names |  | :three::two::warning: |
 | [vue/no-dupe-v-else-if] | disallow duplicate conditions in `v-if` / `v-else-if` chains |  | :three::two::warning: |
 | [vue/no-duplicate-attributes] | disallow duplication of attributes |  | :three::two::warning: |
@@ -460,6 +461,7 @@ The following rules extend the rules provided by ESLint itself and apply them to
 [vue/no-deprecated-v-on-native-modifier]: ./no-deprecated-v-on-native-modifier.md
 [vue/no-deprecated-v-on-number-modifiers]: ./no-deprecated-v-on-number-modifiers.md
 [vue/no-deprecated-vue-config-keycodes]: ./no-deprecated-vue-config-keycodes.md
+[vue/no-direct-composable-in-event-handler]: ./no-direct-composable-in-event-handler.md
 [vue/no-dupe-keys]: ./no-dupe-keys.md
 [vue/no-dupe-v-else-if]: ./no-dupe-v-else-if.md
 [vue/no-duplicate-attr-inheritance]: ./no-duplicate-attr-inheritance.md
diff --git a/docs/rules/no-direct-composable-in-event-handler.md b/docs/rules/no-direct-composable-in-event-handler.md
new file mode 100644
index 000000000..ae6027fa2
--- /dev/null
+++ b/docs/rules/no-direct-composable-in-event-handler.md
@@ -0,0 +1,43 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-direct-composable-in-event-handler
+description: disallow direct composable usage in event handler
+since: v10.1.0
+---
+
+# vue/no-direct-composable-in-event-handler
+
+> disallow direct composable usage in event handler
+
+- :gear: This rule is included in all of `"plugin:vue/essential"`, `*.configs["flat/essential"]`, `"plugin:vue/strongly-recommended"`, `*.configs["flat/strongly-recommended"]`, `"plugin:vue/recommended"` and `*.configs["flat/recommended"]`.
+
+This rule prevents directly calling a composable function in an event handler.
+
+## :book: Rule Details
+
+This rule prevents directly calling a composable function in an event handler. If something starts with `use`, it is considered a composable function.
+
+<eslint-code-block :rules="{'vue/no-direct-composable-in-event-handler': ['error']}">
+
+```vue
+<template>
+  <!-- ✗ BAD -->
+  <button @click="useFoo">Click me</button>
+</template>
+
+<script setup>
+function useFoo() {}
+</script>
+```
+
+</eslint-code-block>
+
+## :rocket: Version
+
+This rule was introduced in eslint-plugin-vue v10.1.0
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-direct-composable-in-event-handler.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-direct-composable-in-event-handler.js)
diff --git a/lib/configs/flat/vue3-essential.js b/lib/configs/flat/vue3-essential.js
index ff8b5b4a6..36b11113f 100644
--- a/lib/configs/flat/vue3-essential.js
+++ b/lib/configs/flat/vue3-essential.js
@@ -37,6 +37,7 @@ module.exports = [
       'vue/no-deprecated-v-on-native-modifier': 'error',
       'vue/no-deprecated-v-on-number-modifiers': 'error',
       'vue/no-deprecated-vue-config-keycodes': 'error',
+      'vue/no-direct-composable-in-event-handler': 'error',
       'vue/no-dupe-keys': 'error',
       'vue/no-dupe-v-else-if': 'error',
       'vue/no-duplicate-attributes': 'error',
diff --git a/lib/configs/vue3-essential.js b/lib/configs/vue3-essential.js
index 34b3229b1..73c610a9a 100644
--- a/lib/configs/vue3-essential.js
+++ b/lib/configs/vue3-essential.js
@@ -32,6 +32,7 @@ module.exports = {
     'vue/no-deprecated-v-on-native-modifier': 'error',
     'vue/no-deprecated-v-on-number-modifiers': 'error',
     'vue/no-deprecated-vue-config-keycodes': 'error',
+    'vue/no-direct-composable-in-event-handler': 'error',
     'vue/no-dupe-keys': 'error',
     'vue/no-dupe-v-else-if': 'error',
     'vue/no-duplicate-attributes': 'error',
diff --git a/lib/index.js b/lib/index.js
index 834e5f28b..c45923d24 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -120,6 +120,7 @@ const plugin = {
     'no-deprecated-v-on-native-modifier': require('./rules/no-deprecated-v-on-native-modifier'),
     'no-deprecated-v-on-number-modifiers': require('./rules/no-deprecated-v-on-number-modifiers'),
     'no-deprecated-vue-config-keycodes': require('./rules/no-deprecated-vue-config-keycodes'),
+    'no-direct-composable-in-event-handler': require('./rules/no-direct-composable-in-event-handler'),
     'no-dupe-keys': require('./rules/no-dupe-keys'),
     'no-dupe-v-else-if': require('./rules/no-dupe-v-else-if'),
     'no-duplicate-attr-inheritance': require('./rules/no-duplicate-attr-inheritance'),
diff --git a/lib/rules/no-direct-composable-in-event-handler.js b/lib/rules/no-direct-composable-in-event-handler.js
new file mode 100644
index 000000000..61f6d4f63
--- /dev/null
+++ b/lib/rules/no-direct-composable-in-event-handler.js
@@ -0,0 +1,66 @@
+/**
+ * @author Nils Haberkamp
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../utils')
+
+/**
+ * Check if the given function name follows the composable naming convention (starts with 'use')
+ * @param {string | null | undefined} name The function name
+ * @returns {boolean} `true` if the function name starts with 'use'
+ */
+function isComposable(name) {
+  return Boolean(name && name.startsWith('use'))
+}
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'disallow direct composable usage in event handler',
+      categories: ['vue3-essential'],
+      url: 'https://eslint.vuejs.org/rules/no-direct-composable-in-event-handler.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      forbiddenComposableUsage:
+        'Direct composable usage in event handler is not allowed.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    return utils.defineTemplateBodyVisitor(context, {
+      /** @param {VDirective} node */
+      'VAttribute[directive=true][key.name.name="on"]'(node) {
+        const eventHandler = node.value
+
+        if (!eventHandler || !eventHandler.expression) {
+          return
+        }
+
+        if (
+          eventHandler.expression.type === 'Identifier' &&
+          isComposable(eventHandler.expression.name)
+        ) {
+          context.report({
+            node,
+            messageId: 'forbiddenComposableUsage',
+            loc: {
+              start: {
+                line: node.loc.start.line,
+                column: node.loc.start.column
+              },
+              end: {
+                line: node.loc.end.line,
+                column: node.loc.end.column
+              }
+            }
+          })
+        }
+      }
+    })
+  }
+}
diff --git a/tests/lib/rules/no-direct-composable-in-event-handler.js b/tests/lib/rules/no-direct-composable-in-event-handler.js
new file mode 100644
index 000000000..f3e173c3d
--- /dev/null
+++ b/tests/lib/rules/no-direct-composable-in-event-handler.js
@@ -0,0 +1,124 @@
+/**
+ * @fileoverview Disallow direct composable usage in event handler.
+ * @author Nils Haberkamp
+ */
+'use strict'
+
+const rule = require('../../../lib/rules/no-direct-composable-in-event-handler')
+const RuleTester = require('../../eslint-compat').RuleTester
+
+const ruleTester = new RuleTester({
+  languageOptions: {
+    ecmaVersion: 2018,
+    sourceType: 'module'
+  }
+})
+
+ruleTester.run('no-direct-composable-in-event-handler', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @click="foo">Click me</button>
+      </template>
+      <script setup>
+      function foo() {}
+      </script>
+      `,
+      languageOptions: { parser: require('vue-eslint-parser') }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @click="() => console.log('foo')">Click me</button>
+      </template>`,
+      languageOptions: { parser: require('vue-eslint-parser') }
+    }
+  ],
+
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @click="useFoo">Click me</button>
+      </template>
+      <script>
+      export default {
+        setup() {
+          function useFoo() {}
+
+          return { useFoo }
+        }
+      }
+      </script>
+      `,
+      languageOptions: { parser: require('vue-eslint-parser') },
+      errors: [
+        {
+          message: 'Direct composable usage in event handler is not allowed.',
+          line: 3
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @click="useFoo">Click me</button>
+      </template>
+      <script setup>
+      function useFoo() {}
+      </script>
+      `,
+      languageOptions: { parser: require('vue-eslint-parser') },
+      errors: [
+        {
+          message: 'Direct composable usage in event handler is not allowed.',
+          line: 3
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @keydown.enter="useFoo">Click me</button>
+      </template>
+      <script setup>
+      function useFoo() {}
+      </script>
+      `,
+      languageOptions: { parser: require('vue-eslint-parser') },
+      errors: [
+        {
+          message: 'Direct composable usage in event handler is not allowed.',
+          line: 3
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <button @click="useFoo">Click me</button>
+      </template>
+      <script setup lang="ts">
+      function useFoo() {}
+      </script>
+      `,
+      languageOptions: {
+        parser: require('vue-eslint-parser'),
+        parserOptions: { parser: require.resolve('@typescript-eslint/parser') }
+      },
+      errors: [
+        {
+          message: 'Direct composable usage in event handler is not allowed.',
+          line: 3
+        }
+      ]
+    }
+  ]
+})