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

Issue:
Enforcing props destructuring
<!--
  Before proposing new rule, please make sure it hasn't been posted already.
  You can see all open propositions here:
  https://github.com/vuejs/eslint-plugin-vue/issues?q=is%3Aopen+is%3Aissue+label%3A%22new+rule+proposition%22
-->

**Please describe what the rule should do:**
The role of this rule would be to suggest [props destructuring](https://vuejs.org/guide/components/props.html#reactive-props-destructure). It could also warns on its usage if necessary.
<!-- A clear and concise description of what the rule should do. -->

**What category should the rule belong to?**
<!-- (place an "X" next to just one item) -->
[X] Enforces code style (layout)
[ ] Warns about a potential error (problem)
[ ] Suggests an alternate way of doing something (suggestion)
[ ] Other (please specify:)

**Provide 2-3 code examples that this rule should warn about:**

```vue
// vue/prefer-props-destructuring

const props = defineProps<>({...}) // 🚨

const {...} = defineProps<>({...}) // 👍
```

**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->



Pull request:
feat: add `define-props-destructuring` rule
resolve #2688

Patch:
diff --git a/docs/rules/define-props-destructuring.md b/docs/rules/define-props-destructuring.md
new file mode 100644
index 00000000..7f3bc784
--- /dev/null
+++ b/docs/rules/define-props-destructuring.md
@@ -0,0 +1,95 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/define-props-destructuring
+description: enforce consistent style for props destructuring
+---
+
+# vue/define-props-destructuring
+
+> enforce consistent style for props destructuring
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> _**This rule has not been released yet.**_ </badge>
+
+## :book: Rule Details
+
+This rule enforces a consistent style for handling Vue 3 Composition API props, allowing you to choose between requiring destructuring or prohibiting it.
+
+By default, the rule requires you to use destructuring syntax when using `defineProps` instead of storing props in a variable and warns against combining `withDefaults` with destructuring.
+
+<eslint-code-block :rules="{'vue/define-props-destructuring': ['error']}">
+
+```vue
+<script setup>
+  // ✓ GOOD
+  const { foo } = defineProps(['foo'])
+  const { bar = 'default' } = defineProps(['bar'])
+
+  // ✗ BAD
+  const props = defineProps(['foo'])
+  const propsWithDefaults = withDefaults(defineProps(['foo']), { foo: 'default' })
+
+  // ✗ BAD
+  const { baz } = withDefaults(defineProps(['baz']), { baz: 'default' })
+</script>
+```
+
+</eslint-code-block>
+
+The rule applies to both JavaScript and TypeScript props:
+
+<eslint-code-block :rules="{'vue/define-props-destructuring': ['error']}">
+
+```vue
+<script setup lang="ts">
+  // ✓ GOOD
+  const { foo } = defineProps<{ foo?: string }>()
+  const { bar = 'default' } = defineProps<{ bar?: string }>()
+
+  // ✗ BAD
+  const props = defineProps<{ foo?: string }>()
+  const propsWithDefaults = withDefaults(defineProps<{ foo?: string }>(), { foo: 'default' })
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+```js
+{
+  "vue/define-props-destructuring": ["error", {
+    "destructure": "always" | "never"
+  }]
+}
+```
+
+- `destructure` - Sets the destructuring preference for props
+  - `"always"` (default) - Requires destructuring when using `defineProps` and warns against using `withDefaults` with destructuring
+  - `"never"` - Requires using a variable to store props and prohibits destructuring
+
+### `"destructure": "never"`
+
+<eslint-code-block :rules="{'vue/define-props-destructuring': ['error', { destructure: 'never' }]}">
+
+```vue
+<script setup>
+  // ✓ GOOD
+  const props = defineProps(['foo'])
+  const propsWithDefaults = withDefaults(defineProps(['foo']), { foo: 'default' })
+
+  // ✗ BAD
+  const { foo } = defineProps(['foo'])
+</script>
+```
+
+</eslint-code-block>
+
+## :books: Further Reading
+
+- [Reactive Props Destructure](https://vuejs.org/guide/components/props.html#reactive-props-destructure)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/define-props-destructuring.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/define-props-destructuring.js)
diff --git a/docs/rules/index.md b/docs/rules/index.md
index 55c5b96c..7828b58b 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -218,6 +218,7 @@ For example:
 | [vue/define-emits-declaration] | enforce declaration style of `defineEmits` |  | :hammer: |
 | [vue/define-macros-order] | enforce order of compiler macros (`defineProps`, `defineEmits`, etc.) | :wrench::bulb: | :lipstick: |
 | [vue/define-props-declaration] | enforce declaration style of `defineProps` |  | :hammer: |
+| [vue/define-props-destructuring] | enforce consistent style for props destructuring |  | :hammer: |
 | [vue/enforce-style-attribute] | enforce or forbid the use of the `scoped` and `module` attributes in SFC top level style tags |  | :hammer: |
 | [vue/html-button-has-type] | disallow usage of button without an explicit type attribute |  | :hammer: |
 | [vue/html-comment-content-newline] | enforce unified line break in HTML comments | :wrench: | :lipstick: |
@@ -398,6 +399,7 @@ The following rules extend the rules provided by ESLint itself and apply them to
 [vue/define-emits-declaration]: ./define-emits-declaration.md
 [vue/define-macros-order]: ./define-macros-order.md
 [vue/define-props-declaration]: ./define-props-declaration.md
+[vue/define-props-destructuring]: ./define-props-destructuring.md
 [vue/dot-location]: ./dot-location.md
 [vue/dot-notation]: ./dot-notation.md
 [vue/enforce-style-attribute]: ./enforce-style-attribute.md
diff --git a/docs/rules/no-bare-strings-in-template.md b/docs/rules/no-bare-strings-in-template.md
index df1fae12..23a23c11 100644
--- a/docs/rules/no-bare-strings-in-template.md
+++ b/docs/rules/no-bare-strings-in-template.md
@@ -12,7 +12,7 @@ since: v7.0.0
 
 ## :book: Rule Details
 
-This rule disallows the use of bare strings in `<template>`.  
+This rule disallows the use of bare strings in `<template>`.
 In order to be able to internationalize your application, you will need to avoid using plain strings in your templates. Instead, you would need to use a template helper specializing in translation.
 
 This rule was inspired by [no-bare-strings rule in ember-template-lint](https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/no-bare-strings.md).
@@ -50,7 +50,7 @@ This rule was inspired by [no-bare-strings rule in ember-template-lint](https://
 </eslint-code-block>
 
 :::tip
-This rule does not check for string literals, in bindings and mustaches interpolation. This is because it looks like a conscious decision.  
+This rule does not check for string literals, in bindings and mustaches interpolation. This is because it looks like a conscious decision.
 If you want to report these string literals, enable the [vue/no-useless-v-bind] and [vue/no-useless-mustaches] rules and fix the useless string literals.
 :::
 
@@ -72,7 +72,7 @@ If you want to report these string literals, enable the [vue/no-useless-v-bind]
 }
 ```
 
-- `allowlist` ... An array of allowed strings.
+- `allowlist` ... An array of allowed strings or regular expression patterns (e.g. `/\d+/` to allow numbers).
 - `attributes` ... An object whose keys are tag name or patterns and value is an array of attributes to check for that tag name.
 - `directives` ... An array of directive names to check literal value.
 
diff --git a/lib/index.js b/lib/index.js
index 834e5f28..e511536f 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -58,6 +58,7 @@ const plugin = {
     'define-emits-declaration': require('./rules/define-emits-declaration'),
     'define-macros-order': require('./rules/define-macros-order'),
     'define-props-declaration': require('./rules/define-props-declaration'),
+    'define-props-destructuring': require('./rules/define-props-destructuring'),
     'dot-location': require('./rules/dot-location'),
     'dot-notation': require('./rules/dot-notation'),
     'enforce-style-attribute': require('./rules/enforce-style-attribute'),
diff --git a/lib/rules/define-props-destructuring.js b/lib/rules/define-props-destructuring.js
new file mode 100644
index 00000000..65ec1dcd
--- /dev/null
+++ b/lib/rules/define-props-destructuring.js
@@ -0,0 +1,79 @@
+/**
+ * @author Wayne Zhang
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../utils')
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'enforce consistent style for props destructuring',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/define-props-destructuring.html'
+    },
+    fixable: null,
+    schema: [
+      {
+        type: 'object',
+        properties: {
+          destructure: {
+            enum: ['always', 'never']
+          }
+        },
+        additionalProperties: false
+      }
+    ],
+    messages: {
+      preferDestructuring: 'Prefer destructuring from `defineProps` directly.',
+      avoidDestructuring: 'Avoid destructuring from `defineProps`.',
+      avoidWithDefaults: 'Avoid using `withDefaults` with destructuring.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const options = context.options[0] || {}
+    const destructurePreference = options.destructure || 'always'
+
+    return utils.compositingVisitors(
+      utils.defineScriptSetupVisitor(context, {
+        onDefinePropsEnter(node, props) {
+          const hasNoArgs = props.filter((prop) => prop.propName).length === 0
+          if (hasNoArgs) {
+            return
+          }
+
+          const hasDestructure = utils.isUsingPropsDestructure(node)
+          const hasWithDefaults = utils.hasWithDefaults(node)
+
+          if (destructurePreference === 'never') {
+            if (hasDestructure) {
+              context.report({
+                node,
+                messageId: 'avoidDestructuring'
+              })
+            }
+            return
+          }
+
+          if (!hasDestructure) {
+            context.report({
+              node,
+              messageId: 'preferDestructuring'
+            })
+            return
+          }
+
+          if (hasWithDefaults) {
+            context.report({
+              node: node.parent.callee,
+              messageId: 'avoidWithDefaults'
+            })
+          }
+        }
+      })
+    )
+  }
+}
diff --git a/lib/rules/no-bare-strings-in-template.js b/lib/rules/no-bare-strings-in-template.js
index 1c75f509..8e4acc96 100644
--- a/lib/rules/no-bare-strings-in-template.js
+++ b/lib/rules/no-bare-strings-in-template.js
@@ -149,17 +149,33 @@ module.exports = {
      */
     const opts = context.options[0] || {}
     /** @type {string[]} */
-    const allowlist = opts.allowlist || DEFAULT_ALLOWLIST
+    const rawAllowlist = opts.allowlist || DEFAULT_ALLOWLIST
     const attributes = parseTargetAttrs(opts.attributes || DEFAULT_ATTRIBUTES)
     const directives = opts.directives || DEFAULT_DIRECTIVES
 
-    const allowlistRe = new RegExp(
-      allowlist
-        .map((w) => regexp.escape(w))
-        .sort((a, b) => b.length - a.length)
-        .join('|'),
-      'gu'
-    )
+    /** @type {string[]} */
+    const stringAllowlist = []
+    /** @type {RegExp[]} */
+    const regexAllowlist = []
+
+    for (const item of rawAllowlist) {
+      if (regexp.isRegExp(item)) {
+        regexAllowlist.push(regexp.toRegExp(item))
+      } else {
+        stringAllowlist.push(item)
+      }
+    }
+
+    const allowlistRe =
+      stringAllowlist.length > 0
+        ? new RegExp(
+            stringAllowlist
+              .map((w) => regexp.escape(w))
+              .sort((a, b) => b.length - a.length)
+              .join('|'),
+            'gu'
+          )
+        : null
 
     /** @type {ElementStack | null} */
     let elementStack = null
@@ -168,7 +184,21 @@ module.exports = {
      * @param {string} str
      */
     function getBareString(str) {
-      return str.trim().replace(allowlistRe, '').trim()
+      let result = str.trim()
+
+      if (allowlistRe) {
+        result = result.replace(allowlistRe, '')
+      }
+
+      for (const regex of regexAllowlist) {
+        const flags = regex.flags.includes('g')
+          ? regex.flags
+          : `${regex.flags}g`
+        const globalRegex = new RegExp(regex.source, flags)
+        result = result.replace(globalRegex, '')
+      }
+
+      return result.trim()
     }
 
     /**
diff --git a/package.json b/package.json
index b9d2c00b..5eb6e811 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "eslint-plugin-vue",
-  "version": "10.0.0",
+  "version": "10.0.1",
   "description": "Official ESLint plugin for Vue.js",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",


Test patch:
diff --git a/tests/lib/rules/define-props-destructuring.js b/tests/lib/rules/define-props-destructuring.js
new file mode 100644
index 00000000..ec24b432
--- /dev/null
+++ b/tests/lib/rules/define-props-destructuring.js
@@ -0,0 +1,212 @@
+/**
+ * @author Wayne Zhang
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('../../eslint-compat').RuleTester
+const rule = require('../../../lib/rules/define-props-destructuring')
+
+const tester = new RuleTester({
+  languageOptions: {
+    parser: require('vue-eslint-parser'),
+    ecmaVersion: 2015,
+    sourceType: 'module'
+  }
+})
+
+tester.run('define-props-destructuring', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      const props = defineProps()
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      const { foo = 'default' } = defineProps(['foo'])
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+      const { foo = 'default' } = defineProps<{ foo?: string }>()
+      </script>
+      `,
+      languageOptions: {
+        parserOptions: { parser: require.resolve('@typescript-eslint/parser') }
+      }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      const props = defineProps(['foo'])
+      </script>
+      `,
+      options: [{ destructure: 'never' }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      const props = withDefaults(defineProps(['foo']), { foo: 'default' })
+      </script>
+      `,
+      options: [{ destructure: 'never' }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+      const props = defineProps<{ foo?: string }>()
+      </script>
+      `,
+      options: [{ destructure: 'never' }],
+      languageOptions: {
+        parserOptions: { parser: require.resolve('@typescript-eslint/parser') }
+      }
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      const props = defineProps(['foo'])
+      </script>
+      `,
+      errors: [
+        {
+          messageId: 'preferDestructuring',
+          line: 3,
+          column: 21
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      const props = withDefaults(defineProps(['foo']), { foo: 'default' })
+      </script>
+      `,
+      errors: [
+        {
+          messageId: 'preferDestructuring',
+          line: 3,
+          column: 34
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      const { foo } = withDefaults(defineProps(['foo']), { foo: 'default' })
+      </script>
+      `,
+      errors: [
+        {
+          messageId: 'avoidWithDefaults',
+          line: 3,
+          column: 23
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+      const props = withDefaults(defineProps<{ foo?: string }>(), { foo: 'default' })
+      </script>
+      `,
+      languageOptions: {
+        parserOptions: { parser: require.resolve('@typescript-eslint/parser') }
+      },
+      errors: [
+        {
+          messageId: 'preferDestructuring',
+          line: 3,
+          column: 34
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+      const { foo } = withDefaults(defineProps<{ foo?: string }>(), { foo: 'default' })
+      </script>
+      `,
+      languageOptions: {
+        parserOptions: { parser: require.resolve('@typescript-eslint/parser') }
+      },
+      errors: [
+        {
+          messageId: 'avoidWithDefaults',
+          line: 3,
+          column: 23
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      const { foo } = defineProps(['foo'])
+      </script>
+      `,
+      options: [{ destructure: 'never' }],
+      errors: [
+        {
+          messageId: 'avoidDestructuring',
+          line: 3,
+          column: 23
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+      const { foo } = withDefaults(defineProps(['foo']), { foo: 'default' })
+      </script>
+      `,
+      options: [{ destructure: 'never' }],
+      errors: [
+        {
+          messageId: 'avoidDestructuring',
+          line: 3,
+          column: 36
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+      const { foo } = defineProps<{ foo?: string }>()
+      </script>
+      `,
+      options: [{ destructure: 'never' }],
+      languageOptions: {
+        parserOptions: { parser: require.resolve('@typescript-eslint/parser') }
+      },
+      errors: [
+        {
+          messageId: 'avoidDestructuring',
+          line: 3,
+          column: 23
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/no-bare-strings-in-template.js b/tests/lib/rules/no-bare-strings-in-template.js
index 4c6c89c7..6706aaae 100644
--- a/tests/lib/rules/no-bare-strings-in-template.js
+++ b/tests/lib/rules/no-bare-strings-in-template.js
@@ -132,6 +132,32 @@ tester.run('no-bare-strings-in-template', rule, {
       </template>
       `,
       options: [{ allowlist: ['@@'] }]
+    },
+    // regex
+    {
+      code: `
+      <template>
+        <h1>123 321</h1>
+      </template>
+      `,
+      options: [{ allowlist: [String.raw`/\d+/g`] }]
+    },
+    {
+      code: `
+      <template>
+        <h1>$foo</h1>
+        <h1>$bar</h1>
+      </template>
+      `,
+      options: [{ allowlist: [String.raw`/\$\w+/`] }]
+    },
+    {
+      code: `
+      <template>
+        <h1>foo123foo</h1>
+      </template>
+      `,
+      options: [{ allowlist: [String.raw`/\d+/`, 'foo'] }]
     }
   ],
   invalid: [
@@ -316,6 +342,40 @@ tester.run('no-bare-strings-in-template', rule, {
           endColumn: 34
         }
       ]
+    },
+    {
+      code: `
+      <template>
+        <h1>123, foo is invalid, 321</h1>
+      </template>
+      `,
+      options: [{ allowlist: [String.raw`/^\d+$/g`] }],
+      errors: [
+        {
+          messageId: 'unexpected',
+          line: 3,
+          column: 13,
+          endLine: 3,
+          endColumn: 37
+        }
+      ]
+    },
+    {
+      code: `
+      <template>
+        <h1>foo123bar</h1>
+      </template>
+      `,
+      options: [{ allowlist: [String.raw`/\d+/`, 'foo'] }],
+      errors: [
+        {
+          messageId: 'unexpected',
+          line: 3,
+          column: 13,
+          endLine: 3,
+          endColumn: 22
+        }
+      ]
     }
   ]
 })