Repository: vuejs/eslint-plugin-vue
Pull request #2164 (2023-05-13 02:17:33)

Add support for `defineOptions` to `vue/padding-lines-in-component-definition` rule


Related to https://github.com/vuejs/eslint-plugin-vue/issues/2118


Patch:
diff --git a/docs/rules/no-unsupported-features.md b/docs/rules/no-unsupported-features.md
index cd6c3dcf..2e7d6750 100644
--- a/docs/rules/no-unsupported-features.md
+++ b/docs/rules/no-unsupported-features.md
@@ -29,6 +29,9 @@ This rule reports unsupported Vue.js syntax on the specified version.
 - `version` ... The `version` option accepts [the valid version range of `node-semver`](https://github.com/npm/node-semver#range-grammar). Set the version of Vue.js you are using. This option is required.
 - `ignores` ... You can use this `ignores` option to ignore the given features.
 The `"ignores"` option accepts an array of the following strings.
+  - Vue.js 3.3.0+
+    - `"define-slots"` ... `defineSlots()` macro.
+    - `"define-options"` ... `defineOptions()` macro.
   - Vue.js 3.2.0+
     - `"v-memo"` ... [v-memo](https://vuejs.org/api/built-in-directives.html#v-memo) directive.
     - `"v-bind-prop-modifier-shorthand"` ... `v-bind` with `.prop` modifier shorthand.
@@ -100,6 +103,8 @@ The `"ignores"` option accepts an array of the following strings.
 
 ## :books: Further Reading
 
+- [API - defineOptions()](https://vuejs.org/api/sfc-script-setup.html#defineoptions)
+- [API - defineSlots()](https://vuejs.org/api/sfc-script-setup.html#defineslots)
 - [API - v-memo](https://vuejs.org/api/built-in-directives.html#v-memo)
 - [API - v-is](https://v3.vuejs.org/api/directives.html#v-is)
 - [API - v-is (Old)](https://github.com/vuejs/docs-next/blob/008613756c3d781128d96b64a2d27f7598f8f548/src/api/directives.md#v-is)
diff --git a/docs/rules/valid-v-is.md b/docs/rules/valid-v-is.md
index 6eda3d99..0ef75aac 100644
--- a/docs/rules/valid-v-is.md
+++ b/docs/rules/valid-v-is.md
@@ -59,7 +59,7 @@ Nothing.
 
 ## :books: Further Reading
 
-- [API - v-is](https://vuejs.org/api/built-in-directives.html#v-is)
+- [API - v-is (Recent)](https://github.com/vuejs/docs/blob/8b4f11a4e94d01c7f1c91a60ceaa5b89d6b6de9f/src/api/built-in-directives.md#v-is-)
 - [API - v-is (Old)](https://github.com/vuejs/docs-next/blob/008613756c3d781128d96b64a2d27f7598f8f548/src/api/directives.md#v-is)
 
 ## :rocket: Version
diff --git a/lib/rules/component-definition-name-casing.js b/lib/rules/component-definition-name-casing.js
index e227d4b9..c3a5cbc3 100644
--- a/lib/rules/component-definition-name-casing.js
+++ b/lib/rules/component-definition-name-casing.js
@@ -77,8 +77,7 @@ module.exports = {
       }
     }
 
-    return Object.assign(
-      {},
+    return utils.compositingVisitors(
       utils.executeOnCallVueComponent(context, (node) => {
         if (node.arguments.length === 2) {
           const argument = node.arguments[0]
@@ -94,6 +93,17 @@ module.exports = {
         if (!node) return
         if (!canConvert(node.value)) return
         convertName(node.value)
+      }),
+      utils.defineScriptSetupVisitor(context, {
+        onDefineOptionsEnter(node) {
+          if (node.arguments.length === 0) return
+          const define = node.arguments[0]
+          if (define.type !== 'ObjectExpression') return
+          const nameNode = utils.findProperty(define, 'name')
+          if (!nameNode) return
+          if (!canConvert(nameNode.value)) return
+          convertName(nameNode.value)
+        }
       })
     )
   }
diff --git a/lib/rules/match-component-file-name.js b/lib/rules/match-component-file-name.js
index 70c34009..f30a6b50 100644
--- a/lib/rules/match-component-file-name.js
+++ b/lib/rules/match-component-file-name.js
@@ -119,8 +119,7 @@ module.exports = {
       }
     }
 
-    return Object.assign(
-      {},
+    return utils.compositingVisitors(
       utils.executeOnCallVueComponent(context, (node) => {
         if (node.arguments.length === 2) {
           const argument = node.arguments[0]
@@ -139,6 +138,18 @@ module.exports = {
         if (!canVerify(node.value)) return
         verifyName(node.value)
       }),
+      utils.defineScriptSetupVisitor(context, {
+        onDefineOptionsEnter(node) {
+          componentCount++
+          if (node.arguments.length === 0) return
+          const define = node.arguments[0]
+          if (define.type !== 'ObjectExpression') return
+          const nameNode = utils.findProperty(define, 'name')
+          if (!nameNode) return
+          if (!canVerify(nameNode.value)) return
+          verifyName(nameNode.value)
+        }
+      }),
       {
         'Program:exit'() {
           if (componentCount > 1) return
diff --git a/lib/rules/no-restricted-component-options.js b/lib/rules/no-restricted-component-options.js
index f50f091d..5af68053 100644
--- a/lib/rules/no-restricted-component-options.js
+++ b/lib/rules/no-restricted-component-options.js
@@ -173,13 +173,25 @@ module.exports = {
     /** @type {ParsedOption[]} */
     const options = context.options.map(parseOption)
 
-    return utils.defineVueVisitor(context, {
-      onVueObjectEnter(node) {
-        for (const option of options) {
-          verify(node, option.test, option.message)
+    return utils.compositingVisitors(
+      utils.defineVueVisitor(context, {
+        onVueObjectEnter(node) {
+          for (const option of options) {
+            verify(node, option.test, option.message)
+          }
         }
-      }
-    })
+      }),
+      utils.defineScriptSetupVisitor(context, {
+        onDefineOptionsEnter(node) {
+          if (node.arguments.length === 0) return
+          const define = node.arguments[0]
+          if (define.type !== 'ObjectExpression') return
+          for (const option of options) {
+            verify(define, option.test, option.message)
+          }
+        }
+      })
+    )
 
     /**
      * @param {ObjectExpression} node
diff --git a/lib/rules/no-unsupported-features.js b/lib/rules/no-unsupported-features.js
index 803a62ca..0bcd61a5 100644
--- a/lib/rules/no-unsupported-features.js
+++ b/lib/rules/no-unsupported-features.js
@@ -32,7 +32,10 @@ const FEATURES = {
   // Vue.js 3.2.0+
   'v-memo': require('./syntaxes/v-memo'),
   'v-bind-prop-modifier-shorthand': require('./syntaxes/v-bind-prop-modifier-shorthand'),
-  'v-bind-attr-modifier': require('./syntaxes/v-bind-attr-modifier')
+  'v-bind-attr-modifier': require('./syntaxes/v-bind-attr-modifier'),
+  // Vue.js 3.3.0+
+  'define-options': require('./syntaxes/define-options'),
+  'define-slots': require('./syntaxes/define-slots')
 }
 
 const SYNTAX_NAMES = /** @type {(keyof FEATURES)[]} */ (Object.keys(FEATURES))
@@ -115,7 +118,12 @@ module.exports = {
       forbiddenVBindPropModifierShorthand:
         '`.prop` shorthand are not supported until Vue.js "3.2.0".',
       forbiddenVBindAttrModifier:
-        '`.attr` modifiers on `v-bind` are not supported until Vue.js "3.2.0".'
+        '`.attr` modifiers on `v-bind` are not supported until Vue.js "3.2.0".',
+      // Vue.js 3.3.0+
+      forbiddenDefineOptions:
+        '`defineOptions()` macros are not supported until Vue.js "3.3.0".',
+      forbiddenDefineSlots:
+        '`defineSlots()` macros are not supported until Vue.js "3.3.0".'
     }
   },
   /** @param {RuleContext} context */
diff --git a/lib/rules/order-in-components.js b/lib/rules/order-in-components.js
index cf9dba9d..50e27062 100644
--- a/lib/rules/order-in-components.js
+++ b/lib/rules/order-in-components.js
@@ -334,8 +334,18 @@ module.exports = {
       }
     }
 
-    return utils.executeOnVue(context, (obj) => {
-      checkOrder(obj.properties)
-    })
+    return utils.compositingVisitors(
+      utils.executeOnVue(context, (obj) => {
+        checkOrder(obj.properties)
+      }),
+      utils.defineScriptSetupVisitor(context, {
+        onDefineOptionsEnter(node) {
+          if (node.arguments.length === 0) return
+          const define = node.arguments[0]
+          if (define.type !== 'ObjectExpression') return
+          checkOrder(define.properties)
+        }
+      })
+    )
   }
 }
diff --git a/lib/rules/padding-lines-in-component-definition.js b/lib/rules/padding-lines-in-component-definition.js
index 3f63050b..b2f1a54d 100644
--- a/lib/rules/padding-lines-in-component-definition.js
+++ b/lib/rules/padding-lines-in-component-definition.js
@@ -352,6 +352,16 @@ module.exports = {
             parseOption(emitsOption, OptionKeys.BetweenItems),
             parseOption(emitsOption, OptionKeys.WithinEach)
           )
+        },
+        onDefineOptionsEnter(node) {
+          if (node.arguments.length === 0) return
+          const define = node.arguments[0]
+          if (define.type !== 'ObjectExpression') return
+          verify(
+            define.properties,
+            parseOption(options, OptionKeys.BetweenOptions),
+            parseOption(options, OptionKeys.WithinOption)
+          )
         }
       })
     )
diff --git a/lib/rules/syntaxes/define-options.js b/lib/rules/syntaxes/define-options.js
new file mode 100644
index 00000000..0ef008ca
--- /dev/null
+++ b/lib/rules/syntaxes/define-options.js
@@ -0,0 +1,74 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../../utils/index')
+
+module.exports = {
+  supported: '>=3.3.0',
+  /** @param {RuleContext} context @returns {RuleListener} */
+  createScriptVisitor(context) {
+    const sourceCode = context.getSourceCode()
+    return utils.defineScriptSetupVisitor(context, {
+      onDefineOptionsEnter(node) {
+        context.report({
+          node,
+          messageId: 'forbiddenDefineOptions',
+          fix(fixer) {
+            return fix(fixer, node)
+          }
+        })
+      }
+    })
+
+    /**
+     * @param {RuleFixer} fixer
+     * @param {CallExpression} node defineOptions() node
+     */
+    function fix(fixer, node) {
+      if (node.arguments.length === 0) return null
+      const scriptSetup = utils.getScriptSetupElement(context)
+      if (!scriptSetup) return null
+      if (
+        scriptSetup.parent.children
+          .filter(utils.isVElement)
+          .some(
+            (node) =>
+              node.name === 'script' && !utils.hasAttribute(node, 'setup')
+          )
+      ) {
+        // has `<script>`
+        return null
+      }
+
+      // Find defineOptions statement
+      /** @type {ASTNode} */
+      let statement = node
+      while (statement.parent && statement.parent.type !== 'Program') {
+        statement = statement.parent
+      }
+      // Calc remove range
+      /** @type {Range} */
+      const removeRange = [...statement.range]
+      if (
+        sourceCode.lines[statement.loc.start.line - 1]
+          .slice(0, statement.loc.start.column)
+          .trim() === ''
+      ) {
+        removeRange[0] -= statement.loc.start.column
+      }
+
+      return [
+        fixer.insertTextBefore(
+          scriptSetup,
+          `<script>\nexport default ${sourceCode.getText(
+            node.arguments[0]
+          )}\n</script>\n`
+        ),
+        fixer.removeRange(removeRange)
+      ]
+    }
+  }
+}
diff --git a/lib/rules/syntaxes/define-slots.js b/lib/rules/syntaxes/define-slots.js
new file mode 100644
index 00000000..450ec3e8
--- /dev/null
+++ b/lib/rules/syntaxes/define-slots.js
@@ -0,0 +1,22 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../../utils/index')
+
+module.exports = {
+  supported: '>=3.3.0',
+  /** @param {RuleContext} context @returns {RuleListener} */
+  createScriptVisitor(context) {
+    return utils.defineScriptSetupVisitor(context, {
+      onDefineSlotsEnter(node) {
+        context.report({
+          node,
+          messageId: 'forbiddenDefineSlots'
+        })
+      }
+    })
+  }
+}


Test patch:
diff --git a/tests/lib/rules/component-definition-name-casing.js b/tests/lib/rules/component-definition-name-casing.js
index ef9cbde8..2349f5d3 100644
--- a/tests/lib/rules/component-definition-name-casing.js
+++ b/tests/lib/rules/component-definition-name-casing.js
@@ -145,6 +145,26 @@ ruleTester.run('component-definition-name-casing', rule, {
       filename: 'test.js',
       code: `fn1(component.data)`,
       parserOptions
+    },
+    {
+      filename: 'test.vue',
+      code: `<script setup> defineOptions({}) </script>`,
+      parser: require.resolve('vue-eslint-parser'),
+      parserOptions
+    },
+    {
+      filename: 'test.vue',
+      code: `<script setup> defineOptions({name: 'FooBar'}) </script>`,
+      options: ['PascalCase'],
+      parser: require.resolve('vue-eslint-parser'),
+      parserOptions
+    },
+    {
+      filename: 'test.vue',
+      code: `<script setup> defineOptions({name: 'foo-bar'}) </script>`,
+      options: ['kebab-case'],
+      parser: require.resolve('vue-eslint-parser'),
+      parserOptions
     }
   ],
 
@@ -393,6 +413,34 @@ ruleTester.run('component-definition-name-casing', rule, {
           line: 1
         }
       ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<script setup> defineOptions({name: 'foo-bar'}) </script>`,
+      output: `<script setup> defineOptions({name: 'FooBar'}) </script>`,
+      options: ['PascalCase'],
+      parser: require.resolve('vue-eslint-parser'),
+      parserOptions,
+      errors: [
+        {
+          message: 'Property name "foo-bar" is not PascalCase.',
+          line: 1
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<script setup> defineOptions({name: 'FooBar'}) </script>`,
+      output: `<script setup> defineOptions({name: 'foo-bar'}) </script>`,
+      options: ['kebab-case'],
+      parser: require.resolve('vue-eslint-parser'),
+      parserOptions,
+      errors: [
+        {
+          message: 'Property name "FooBar" is not kebab-case.',
+          line: 1
+        }
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/match-component-file-name.js b/tests/lib/rules/match-component-file-name.js
index cbd35713..82c75ccc 100644
--- a/tests/lib/rules/match-component-file-name.js
+++ b/tests/lib/rules/match-component-file-name.js
@@ -539,6 +539,13 @@ ruleTester.run('match-component-file-name', rule, {
       filename: 'test.jsx',
       code: `fn1(component.data)`,
       parserOptions
+    },
+    {
+      filename: 'MyComponent.vue',
+      code: `<script setup> defineOptions({name: 'MyComponent'}) </script>`,
+      options: [{ extensions: ['vue'] }],
+      parser: require.resolve('vue-eslint-parser'),
+      parserOptions
     }
   ],
 
@@ -1081,6 +1088,25 @@ ruleTester.run('match-component-file-name', rule, {
           ]
         }
       ]
+    },
+    {
+      filename: 'MyComponent.vue',
+      code: `<script setup> defineOptions({name: 'CoolComponent'}) </script>`,
+      options: [{ extensions: ['vue'] }],
+      parser: require.resolve('vue-eslint-parser'),
+      parserOptions,
+      errors: [
+        {
+          message:
+            'Component name `CoolComponent` should match file name `MyComponent`.',
+          suggestions: [
+            {
+              desc: 'Rename component to match file name.',
+              output: `<script setup> defineOptions({name: 'MyComponent'}) </script>`
+            }
+          ]
+        }
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/no-restricted-component-options.js b/tests/lib/rules/no-restricted-component-options.js
index 87ff4e41..484f6700 100644
--- a/tests/lib/rules/no-restricted-component-options.js
+++ b/tests/lib/rules/no-restricted-component-options.js
@@ -101,6 +101,11 @@ tester.run('no-restricted-component-options', rule, {
       </script>
       `,
       options: [['foo', 'bar']]
+    },
+    {
+      filename: 'test.vue',
+      code: `<script setup> defineOptions({ name: 'Foo' }) </script>`,
+      options: ['Foo']
     }
   ],
   invalid: [
@@ -304,6 +309,17 @@ tester.run('no-restricted-component-options', rule, {
           line: 5
         }
       ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<script setup> defineOptions({ Foo: 'Foo' }) </script>`,
+      options: ['Foo'],
+      errors: [
+        {
+          message: 'Using `Foo` is not allowed.',
+          line: 1
+        }
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/no-unsupported-features/define-options.js b/tests/lib/rules/no-unsupported-features/define-options.js
new file mode 100644
index 00000000..10b2069b
--- /dev/null
+++ b/tests/lib/rules/no-unsupported-features/define-options.js
@@ -0,0 +1,88 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../../lib/rules/no-unsupported-features')
+const utils = require('./utils')
+
+const buildOptions = utils.optionsBuilder('define-options', '^3.2.0')
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2019,
+    sourceType: 'module'
+  }
+})
+
+tester.run('no-unsupported-features/define-options', rule, {
+  valid: [
+    {
+      code: `
+      <script setup>
+        defineOptions({})
+      </script>`,
+      options: buildOptions({ version: '^3.3.0' })
+    },
+    {
+      code: `
+      <script setup>
+        defineProps({})
+      </script>`,
+      options: buildOptions()
+    },
+    {
+      code: `
+      <script setup>
+        defineOptions({})
+      </script>`,
+      options: buildOptions({ version: '^3.0.0', ignores: ['define-options'] })
+    }
+  ],
+  invalid: [
+    {
+      code: `
+      <script setup>
+        defineOptions({ name: 'Foo' })
+      </script>`,
+      options: buildOptions(),
+      output: `
+      <script>
+export default { name: 'Foo' }
+</script>
+<script setup>
+
+      </script>`,
+      errors: [
+        {
+          message:
+            '`defineOptions()` macros are not supported until Vue.js "3.3.0".',
+          line: 3
+        }
+      ]
+    },
+    {
+      code: `
+      <script setup>
+        defineOptions({});
+      </script>`,
+      options: buildOptions(),
+      output: `
+      <script>
+export default {}
+</script>
+<script setup>
+
+      </script>`,
+      errors: [
+        {
+          message:
+            '`defineOptions()` macros are not supported until Vue.js "3.3.0".',
+          line: 3
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/no-unsupported-features/define-slots.js b/tests/lib/rules/no-unsupported-features/define-slots.js
new file mode 100644
index 00000000..2fef117c
--- /dev/null
+++ b/tests/lib/rules/no-unsupported-features/define-slots.js
@@ -0,0 +1,59 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../../lib/rules/no-unsupported-features')
+const utils = require('./utils')
+
+const buildOptions = utils.optionsBuilder('define-slots', '^3.2.0')
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2019
+  }
+})
+
+tester.run('no-unsupported-features/define-slots', rule, {
+  valid: [
+    {
+      code: `
+      <script setup>
+        const slots = defineSlots()
+      </script>`,
+      options: buildOptions({ version: '^3.3.0' })
+    },
+    {
+      code: `
+      <script setup>
+        defineProps({})
+      </script>`,
+      options: buildOptions()
+    },
+    {
+      code: `
+      <script setup>
+        const slots = defineSlots()
+      </script>`,
+      options: buildOptions({ version: '^3.0.0', ignores: ['define-slots'] })
+    }
+  ],
+  invalid: [
+    {
+      code: `
+      <script setup>
+        const slots = defineSlots()
+      </script>`,
+      options: buildOptions(),
+      errors: [
+        {
+          message:
+            '`defineSlots()` macros are not supported until Vue.js "3.3.0".',
+          line: 3
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/order-in-components.js b/tests/lib/rules/order-in-components.js
index 873df3b0..b12c41a3 100644
--- a/tests/lib/rules/order-in-components.js
+++ b/tests/lib/rules/order-in-components.js
@@ -165,6 +165,19 @@ ruleTester.run('order-in-components', rule, {
         new Vue()
       `,
       parserOptions: { ecmaVersion: 6 }
+    },
+    {
+      filename: 'example.vue',
+      code: `
+      <script setup>
+        defineOptions({
+          name: 'Foo',
+          inheritAttrs: true,
+        })
+      </script>
+      `,
+      parser: require.resolve('vue-eslint-parser'),
+      parserOptions
     }
   ],
 
@@ -943,6 +956,34 @@ ruleTester.run('order-in-components', rule, {
           line: 5
         }
       ]
+    },
+    {
+      filename: 'example.vue',
+      code: `
+      <script setup>
+        defineOptions({
+          inheritAttrs: true,
+          name: 'Foo',
+        })
+      </script>
+      `,
+      parser: require.resolve('vue-eslint-parser'),
+      parserOptions,
+      output: `
+      <script setup>
+        defineOptions({
+          name: 'Foo',
+          inheritAttrs: true,
+        })
+      </script>
+      `,
+      errors: [
+        {
+          message:
+            'The "name" property should be above the "inheritAttrs" property on line 4.',
+          line: 5
+        }
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/padding-lines-in-component-definition.js b/tests/lib/rules/padding-lines-in-component-definition.js
index a4ed4d3c..58647654 100644
--- a/tests/lib/rules/padding-lines-in-component-definition.js
+++ b/tests/lib/rules/padding-lines-in-component-definition.js
@@ -385,6 +385,19 @@ tester.run('padding-lines-in-component-definition', rule, {
         </script>
       `,
       options: ['always']
+    },
+    {
+      filename: 'MyComment.vue',
+      code: `
+        <script setup>
+        defineOptions({
+            name: 'MyComment',
+
+            inheritAttrs: false,
+        })
+        </script>
+      `,
+      options: [{ betweenOptions: 'always', groupSingleLineProperties: false }]
     }
   ],
   invalid: [
@@ -1235,6 +1248,33 @@ tester.run('padding-lines-in-component-definition', rule, {
           line: 16
         }
       ]
+    },
+    {
+      filename: 'MyComment.vue',
+      code: `
+        <script setup>
+        defineOptions({
+            name: 'MyComment',
+            inheritAttrs: false,
+        })
+        </script>
+      `,
+      output: `
+        <script setup>
+        defineOptions({
+            name: 'MyComment',
+
+            inheritAttrs: false,
+        })
+        </script>
+      `,
+      options: [{ betweenOptions: 'always', groupSingleLineProperties: false }],
+      errors: [
+        {
+          message: 'Expected blank line before this definition.',
+          line: 5
+        }
+      ]
     }
   ]
 })