Repository: vuejs/eslint-plugin-vue
Pull request #1856 (2022-04-19 11:46:03)

Add new rule: vue/define-macros-order (#1855)

edikdeisling: There are a few thoughts:
1. Maybe it would be better to have `defineEmitsFirst: true` rather than `order: ['defineEmits', 'defineProps']`? Or there are maybe some other `define*` that could come in?
2. Do we need to give an ability to specify a new line between these statements? `newLineBetween: true`

Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 3d2d113d..26ce0e59 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -312,6 +312,7 @@ For example:
 | [vue/component-name-in-template-casing](./component-name-in-template-casing.md) | enforce specific casing for the component naming style in template | :wrench: |
 | [vue/component-options-name-casing](./component-options-name-casing.md) | enforce the casing of component name in `components` options | :wrench::bulb: |
 | [vue/custom-event-name-casing](./custom-event-name-casing.md) | enforce specific casing for custom event name |  |
+| [vue/define-macros-order](./define-macros-order.md) | enforce order of `defineEmits` and `defineProps` compiler macros | :wrench: |
 | [vue/html-button-has-type](./html-button-has-type.md) | disallow usage of button without an explicit type attribute |  |
 | [vue/html-comment-content-newline](./html-comment-content-newline.md) | enforce unified line brake in HTML comments | :wrench: |
 | [vue/html-comment-content-spacing](./html-comment-content-spacing.md) | enforce unified spacing in HTML comments | :wrench: |
diff --git a/docs/rules/define-macros-order.md b/docs/rules/define-macros-order.md
new file mode 100644
index 00000000..d928e003
--- /dev/null
+++ b/docs/rules/define-macros-order.md
@@ -0,0 +1,72 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/define-macros-order
+description: enforce order of `defineEmits` and `defineProps` compiler macros
+---
+# vue/define-macros-order
+
+> enforce order of `defineEmits` and `defineProps` compiler macros
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+## :book: Rule Details
+
+This rule reports the `defineProps` and `defineEmits` compiler macros when they are not the first statements in `<script setup>` (after any potential import statements or type definitions) or when they are not in the correct order.
+
+## :wrench: Options
+
+```json
+{
+  "vue/define-macros-order": ["error", {
+    "order": ["defineProps", "defineEmits"]
+  }]
+}
+```
+
+- `order` (`string[]`) ... The order of defineEmits and defineProps macros
+
+### `{ "order": ["defineProps", "defineEmits"] }` (default)
+
+<eslint-code-block fix :rules="{'vue/define-macros-order': ['error']}">
+
+```vue
+<!-- ✓ GOOD -->
+<script setup>
+defineProps(/* ... */)
+defineEmits(/* ... */)
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/define-macros-order': ['error']}">
+
+```vue
+<!-- ✗ BAD -->
+<script setup>
+defineEmits(/* ... */)
+defineProps(/* ... */)
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/define-macros-order': ['error']}">
+
+```vue
+<!-- ✗ BAD -->
+<script setup>
+const bar = ref()
+defineProps(/* ... */)
+defineEmits(/* ... */)
+</script>
+```
+
+</eslint-code-block>
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/define-macros-order.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/define-macros-order.js)
diff --git a/lib/index.js b/lib/index.js
index d412a6bf..39f91b51 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -27,6 +27,7 @@ module.exports = {
     'component-options-name-casing': require('./rules/component-options-name-casing'),
     'component-tags-order': require('./rules/component-tags-order'),
     'custom-event-name-casing': require('./rules/custom-event-name-casing'),
+    'define-macros-order': require('./rules/define-macros-order'),
     'dot-location': require('./rules/dot-location'),
     'dot-notation': require('./rules/dot-notation'),
     eqeqeq: require('./rules/eqeqeq'),
diff --git a/lib/rules/define-macros-order.js b/lib/rules/define-macros-order.js
new file mode 100644
index 00000000..9c8ea45e
--- /dev/null
+++ b/lib/rules/define-macros-order.js
@@ -0,0 +1,288 @@
+/**
+ * @author Eduard Deisling
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const utils = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+const MACROS_EMITS = 'defineEmits'
+const MACROS_PROPS = 'defineProps'
+const ORDER = [MACROS_EMITS, MACROS_PROPS]
+const DEFAULT_ORDER = [MACROS_PROPS, MACROS_EMITS]
+
+/**
+ * @param {ASTNode} node
+ */
+function isUseStrictStatement(node) {
+  return (
+    node.type === 'ExpressionStatement' &&
+    node.expression.type === 'Literal' &&
+    node.expression.value === 'use strict'
+  )
+}
+
+/**
+ * Get an index of the first statement after imports and interfaces in order
+ * to place defineEmits and defineProps before this statement
+ * @param {Program} program
+ */
+function getTargetStatementPosition(program) {
+  const skipStatements = new Set([
+    'ImportDeclaration',
+    'TSInterfaceDeclaration',
+    'TSTypeAliasDeclaration',
+    'DebuggerStatement',
+    'EmptyStatement'
+  ])
+
+  for (const [index, item] of program.body.entries()) {
+    if (!skipStatements.has(item.type) && !isUseStrictStatement(item)) {
+      return index
+    }
+  }
+
+  return -1
+}
+
+/**
+ * We need to handle cases like "const props = defineProps(...)"
+ * Define macros must be used only on top, so we can look for "Program" type
+ * inside node.parent.type
+ * @param {CallExpression|ASTNode} node
+ * @return {ASTNode}
+ */
+function getDefineMacrosStatement(node) {
+  if (!node.parent) {
+    throw new Error('Node has no parent')
+  }
+
+  if (node.parent.type === 'Program') {
+    return node
+  }
+
+  return getDefineMacrosStatement(node.parent)
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+/** @param {RuleContext} context */
+function create(context) {
+  const scriptSetup = utils.getScriptSetupElement(context)
+
+  if (!scriptSetup) {
+    return {}
+  }
+
+  const sourceCode = context.getSourceCode()
+  const options = context.options
+  /** @type {[string, string]} */
+  const order = (options[0] && options[0].order) || DEFAULT_ORDER
+  /** @type {Map<string, ASTNode>} */
+  const macrosNodes = new Map()
+
+  return utils.compositingVisitors(
+    utils.defineScriptSetupVisitor(context, {
+      onDefinePropsExit(node) {
+        macrosNodes.set(MACROS_PROPS, getDefineMacrosStatement(node))
+      },
+      onDefineEmitsExit(node) {
+        macrosNodes.set(MACROS_EMITS, getDefineMacrosStatement(node))
+      }
+    }),
+    {
+      'Program:exit'(program) {
+        const shouldFirstNode = macrosNodes.get(order[0])
+        const shouldSecondNode = macrosNodes.get(order[1])
+        const firstStatementIndex = getTargetStatementPosition(program)
+        const firstStatement = program.body[firstStatementIndex]
+
+        // have both defineEmits and defineProps
+        if (shouldFirstNode && shouldSecondNode) {
+          const secondStatement = program.body[firstStatementIndex + 1]
+
+          // need move only first
+          if (firstStatement === shouldSecondNode) {
+            reportNotOnTop(order[0], shouldFirstNode, firstStatement)
+            return
+          }
+
+          // need move both defineEmits and defineProps
+          if (firstStatement !== shouldFirstNode) {
+            reportBothNotOnTop(
+              shouldFirstNode,
+              shouldSecondNode,
+              firstStatement
+            )
+            return
+          }
+
+          // need move only second
+          if (secondStatement !== shouldSecondNode) {
+            reportNotOnTop(order[1], shouldSecondNode, shouldFirstNode)
+          }
+
+          return
+        }
+
+        // have only first and need to move it
+        if (shouldFirstNode && firstStatement !== shouldFirstNode) {
+          reportNotOnTop(order[0], shouldFirstNode, firstStatement)
+          return
+        }
+
+        // have only second and need to move it
+        if (shouldSecondNode && firstStatement !== shouldSecondNode) {
+          reportNotOnTop(order[1], shouldSecondNode, firstStatement)
+        }
+      }
+    }
+  )
+
+  /**
+   * @param {ASTNode} shouldFirstNode
+   * @param {ASTNode} shouldSecondNode
+   * @param {ASTNode} before
+   */
+  function reportBothNotOnTop(shouldFirstNode, shouldSecondNode, before) {
+    context.report({
+      node: shouldFirstNode,
+      loc: shouldFirstNode.loc,
+      messageId: 'macrosNotOnTop',
+      data: {
+        macro: order[0]
+      },
+      fix(fixer) {
+        return [
+          ...moveNodeBefore(fixer, shouldFirstNode, before),
+          ...moveNodeBefore(fixer, shouldSecondNode, before)
+        ]
+      }
+    })
+  }
+
+  /**
+   * @param {string} macro
+   * @param {ASTNode} node
+   * @param {ASTNode} before
+   */
+  function reportNotOnTop(macro, node, before) {
+    context.report({
+      node,
+      loc: node.loc,
+      messageId: 'macrosNotOnTop',
+      data: {
+        macro
+      },
+      fix(fixer) {
+        return moveNodeBefore(fixer, node, before)
+      }
+    })
+  }
+
+  /**
+   * Move all lines of "node" with its comments to before the "target"
+   * @param {RuleFixer} fixer
+   * @param {ASTNode} node
+   * @param {ASTNode} target
+   */
+  function moveNodeBefore(fixer, node, target) {
+    // get comments under tokens(if any)
+    const beforeNodeToken = sourceCode.getTokenBefore(node)
+    const nodeComment = sourceCode.getTokenAfter(beforeNodeToken, {
+      includeComments: true
+    })
+    const nextNodeComment = sourceCode.getTokenAfter(node, {
+      includeComments: true
+    })
+    // get positions of what we need to remove
+    const cutStart = getLineStartIndex(nodeComment, beforeNodeToken)
+    const cutEnd = getLineStartIndex(nextNodeComment, node)
+    // get space before target
+    const beforeTargetToken = sourceCode.getTokenBefore(target)
+    const targetComment = sourceCode.getTokenAfter(beforeTargetToken, {
+      includeComments: true
+    })
+    const textSpace = getTextBetweenTokens(beforeTargetToken, targetComment)
+    // make insert text: comments + node + space before target
+    const textNode = sourceCode.getText(
+      node,
+      node.range[0] - nodeComment.range[0]
+    )
+    const insertText = textNode + textSpace
+
+    return [
+      fixer.insertTextBefore(targetComment, insertText),
+      fixer.removeRange([cutStart, cutEnd])
+    ]
+  }
+
+  /**
+   * @param {ASTNode} tokenBefore
+   * @param {ASTNode} tokenAfter
+   */
+  function getTextBetweenTokens(tokenBefore, tokenAfter) {
+    return sourceCode.text.slice(tokenBefore.range[1], tokenAfter.range[0])
+  }
+
+  /**
+   * Get position of the beginning of the token's line(or prevToken end if no line)
+   * @param {ASTNode} token
+   * @param {ASTNode} prevToken
+   */
+  function getLineStartIndex(token, prevToken) {
+    // if we have next token on the same line - get index right before that token
+    if (token.loc.start.line === prevToken.loc.end.line) {
+      return prevToken.range[1]
+    }
+
+    return sourceCode.getIndexFromLoc({
+      line: token.loc.start.line,
+      column: 0
+    })
+  }
+}
+
+module.exports = {
+  meta: {
+    type: 'layout',
+    docs: {
+      description:
+        'enforce order of `defineEmits` and `defineProps` compiler macros',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/define-macros-order.html'
+    },
+    fixable: 'code',
+    schema: [
+      {
+        type: 'object',
+        properties: {
+          order: {
+            type: 'array',
+            items: {
+              enum: Object.values(ORDER)
+            },
+            uniqueItems: true,
+            additionalItems: false
+          }
+        },
+        additionalProperties: false
+      }
+    ],
+    messages: {
+      macrosNotOnTop:
+        '{{macro}} should be the first statement in `<script setup>` (after any potential import statements or type definitions).'
+    }
+  },
+  create
+}
diff --git a/lib/utils/ts-ast-utils.js b/lib/utils/ts-ast-utils.js
index dcc6c7cf..f5cf9691 100644
--- a/lib/utils/ts-ast-utils.js
+++ b/lib/utils/ts-ast-utils.js
@@ -260,23 +260,21 @@ function inferRuntimeType(context, node, checked = new Set()) {
       return ['Array']
 
     case 'TSLiteralType':
-      switch (node.literal.type) {
-        //@ts-ignore ?
-        case 'StringLiteral':
-          return ['String']
-        //@ts-ignore ?
-        case 'BooleanLiteral':
-          return ['Boolean']
-        //@ts-ignore ?
-        case 'NumericLiteral':
-        //@ts-ignore ?
-        // eslint-disable-next-line no-fallthrough
-        case 'BigIntLiteral':
-          return ['Number']
-        default:
-          return [`null`]
+      if (node.literal.type === 'Literal') {
+        switch (typeof node.literal.value) {
+          case 'boolean':
+            return ['Boolean']
+          case 'string':
+            return ['String']
+          case 'number':
+          case 'bigint':
+            return ['Number']
+        }
+        if (node.literal.value instanceof RegExp) {
+          return ['RegExp']
+        }
       }
-
+      return [`null`]
     case 'TSTypeReference':
       if (node.typeName.type === 'Identifier') {
         const variable = findVariable(context.getScope(), node.typeName.name)


Test patch:
diff --git a/tests/lib/rules/define-macros-order.js b/tests/lib/rules/define-macros-order.js
new file mode 100644
index 00000000..ce5f3004
--- /dev/null
+++ b/tests/lib/rules/define-macros-order.js
@@ -0,0 +1,392 @@
+/**
+ * @author *****your name*****
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/define-macros-order')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+const optionsEmitsFirst = [
+  {
+    order: ['defineEmits', 'defineProps']
+  }
+]
+
+const optionsPropsFirst = [
+  {
+    order: ['defineProps', 'defineEmits']
+  }
+]
+
+function message(macro) {
+  return `${macro} should be the first statement in \`<script setup>\` (after any potential import statements or type definitions).`
+}
+
+tester.run('define-macros-order', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup>
+          defineProps({
+            test: Boolean
+          })
+          defineEmits(['update:test'])
+          console.log('test')
+        </script>
+      `,
+      options: optionsPropsFirst
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup lang="ts">
+          const props = withDefaults(defineProps<Props>(), {
+            msg: 'hello',
+            labels: () => ['one', 'two']
+          })
+          const emit = defineEmits<{(e: 'update:test'): void}>()
+          console.log('test')
+        </script>
+      `,
+      parserOptions: {
+        parser: require.resolve('@typescript-eslint/parser')
+      }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup lang="ts">
+          defineEmits(['update:test'])
+          console.log('test')
+        </script>
+      `,
+      options: optionsPropsFirst
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup lang="ts">
+          defineProps({
+            test: Boolean
+          })
+          console.log('test')
+        </script>
+      `,
+      options: optionsPropsFirst
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup lang="ts">
+          console.log('test')
+        </script>
+      `,
+      options: optionsPropsFirst
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup lang="ts">
+          import { bar } from 'foo'
+          defineEmits(['update:test'])
+          defineProps({
+            test: Boolean
+          })
+          console.log('test')
+        </script>
+      `,
+      options: optionsEmitsFirst
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup>
+          import { bar } from 'foo'
+          console.log('test1')
+          defineEmits(['update:test'])
+        </script>
+      `,
+      output: `
+        <script setup>
+          import { bar } from 'foo'
+          defineEmits(['update:test'])
+          console.log('test1')
+        </script>
+      `,
+      options: optionsEmitsFirst,
+      errors: [
+        {
+          message: message('defineEmits'),
+          line: 5
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup>
+          import { bar } from 'foo'
+
+          console.log('test1')
+          defineEmits(['update:test'])
+          console.log('test2')
+          defineProps({
+            test: Boolean
+          })
+          console.log('test3')
+        </script>
+      `,
+      output: `
+        <script setup>
+          import { bar } from 'foo'
+
+          defineProps({
+            test: Boolean
+          })
+
+          defineEmits(['update:test'])
+
+          console.log('test1')
+          console.log('test2')
+          console.log('test3')
+        </script>
+      `,
+      options: optionsPropsFirst,
+      errors: [
+        {
+          message: message('defineProps'),
+          line: 8
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup>
+          import { bar } from 'foo'
+
+          defineEmits(['update:test'])
+          defineProps({
+            test: Boolean
+          })
+
+          console.log('test1')
+        </script>
+      `,
+      output: `
+        <script setup>
+          import { bar } from 'foo'
+
+          defineProps({
+            test: Boolean
+          })
+
+          defineEmits(['update:test'])
+          console.log('test1')
+        </script>
+      `,
+      options: optionsPropsFirst,
+      errors: [
+        {
+          message: message('defineProps'),
+          line: 6
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup>
+          console.log('test1')
+          const props = defineProps({
+            test: Boolean
+          })
+          console.log('test2')
+          const emit = defineEmits(['update:test'])
+          console.log('test3')
+        </script>
+      `,
+      output: `
+        <script setup>
+          const emit = defineEmits(['update:test'])
+          const props = defineProps({
+            test: Boolean
+          })
+          console.log('test1')
+          console.log('test2')
+          console.log('test3')
+        </script>
+      `,
+      options: optionsEmitsFirst,
+      errors: [
+        {
+          message: message('defineEmits'),
+          line: 8
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script lang="ts" setup>
+          interface Props {
+            msg?: string
+            labels?: string[]
+          }
+
+          const props = withDefaults(defineProps<Props>(), {
+            msg: 'hello',
+            labels: () => ['one', 'two']
+          })
+          const emit = defineEmits<{(e: 'update:test'): void}>()
+        </script>
+      `,
+      output: `
+        <script lang="ts" setup>
+          interface Props {
+            msg?: string
+            labels?: string[]
+          }
+
+          const emit = defineEmits<{(e: 'update:test'): void}>()
+
+          const props = withDefaults(defineProps<Props>(), {
+            msg: 'hello',
+            labels: () => ['one', 'two']
+          })
+        </script>
+      `,
+      parserOptions: {
+        parser: require.resolve('@typescript-eslint/parser')
+      },
+      options: optionsEmitsFirst,
+      errors: [
+        {
+          message: message('defineEmits'),
+          line: 12
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script lang="ts" setup>
+          import bla from 'bla';
+          interface Foo {};
+          type Bar = {};
+          // <--- auto-fix should move \`defineProps\` here
+          const someOtherCode = '';
+          import foo from 'bar'; // not idiomatic, but allowed
+          interface SomeOtherInterface {};
+          defineProps({ test: Boolean });
+        </script>
+      `,
+      output: `
+        <script lang="ts" setup>
+          import bla from 'bla';
+          interface Foo {};
+          type Bar = {};
+          defineProps({ test: Boolean });
+          // <--- auto-fix should move \`defineProps\` here
+          const someOtherCode = '';
+          import foo from 'bar'; // not idiomatic, but allowed
+          interface SomeOtherInterface {};
+        </script>
+      `,
+      parserOptions: {
+        parser: require.resolve('@typescript-eslint/parser')
+      },
+      errors: [
+        {
+          message: message('defineProps'),
+          line: 10
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script lang="ts" setup>
+          debugger
+
+          console.log('test1')
+
+          /** Description for props */
+          const props = withDefaults(defineProps<Props>(), {
+            msg: 'hello'
+          });
+
+          console.log('test2')
+
+          // Description for emit
+          // Description for emit line 2
+          const emit = defineEmits<{(e: 'test'): void}>();
+
+          console.log('test3')
+        </script>
+      `,
+      output: `
+        <script lang="ts" setup>
+          debugger
+
+          // Description for emit
+          // Description for emit line 2
+          const emit = defineEmits<{(e: 'test'): void}>();
+
+          /** Description for props */
+          const props = withDefaults(defineProps<Props>(), {
+            msg: 'hello'
+          });
+
+          console.log('test1')
+
+          console.log('test2')
+
+          console.log('test3')
+        </script>
+      `,
+      parserOptions: {
+        parser: require.resolve('@typescript-eslint/parser')
+      },
+      options: optionsEmitsFirst,
+      errors: [
+        {
+          message: message('defineEmits'),
+          line: 16
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        <script setup>
+          const props = defineProps({ test: Boolean });defineEmits(['update:test'])
+        </script>
+      `,
+      output: `
+        <script setup>
+          defineEmits(['update:test'])
+          const props = defineProps({ test: Boolean });        </script>
+      `,
+      options: optionsEmitsFirst,
+      errors: [
+        {
+          message: message('defineEmits'),
+          line: 3
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/require-valid-default-prop.js b/tests/lib/rules/require-valid-default-prop.js
index e353edf5..c08a0403 100644
--- a/tests/lib/rules/require-valid-default-prop.js
+++ b/tests/lib/rules/require-valid-default-prop.js
@@ -252,6 +252,29 @@ ruleTester.run('require-valid-default-prop', rule, {
       parserOptions: { ecmaVersion: 6, sourceType: 'module' },
       parser: require.resolve('@typescript-eslint/parser'),
       errors: errorMessage('function')
+    },
+    {
+      // https://github.com/vuejs/eslint-plugin-vue/issues/1853
+      filename: 'test.vue',
+      code: `<script setup lang="ts">
+      export interface SomePropInterface {
+        someProp?: false | string;
+        str?: 'foo' | 'bar';
+        num?: 1 | 2;
+      }
+
+      withDefaults(defineProps<SomePropInterface>(), {
+        someProp: false,
+        str: 'foo',
+        num: 1
+      });
+      </script>`,
+      parserOptions: {
+        ecmaVersion: 6,
+        sourceType: 'module',
+        parser: require.resolve('@typescript-eslint/parser')
+      },
+      parser: require.resolve('vue-eslint-parser')
     }
   ],