Repository: vuejs/eslint-plugin-vue
Pull request #1381 (2020-12-17 02:05:01)

Add vue/no-restricted-call-after-await rule
This PR adds `vue/no-restricted-call-after-await` rule.

`vue/no-restricted-call-after-await` rule reports your restricted calls after the `await` expression.

You can use this rule to check if for example `useI18n()` of `vue-i18n` are called asynchronously in the `setup()` function.


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 68677d25..0d1fccd3 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -301,6 +301,7 @@ For example:
 | [vue/no-multiple-objects-in-class](./no-multiple-objects-in-class.md) | disallow to pass multiple objects into array to class |  |
 | [vue/no-potential-component-option-typo](./no-potential-component-option-typo.md) | disallow a potential typo in your component property |  |
 | [vue/no-reserved-component-names](./no-reserved-component-names.md) | disallow the use of reserved names in component definitions |  |
+| [vue/no-restricted-call-after-await](./no-restricted-call-after-await.md) | disallow asynchronously called restricted methods |  |
 | [vue/no-restricted-component-options](./no-restricted-component-options.md) | disallow specific component option |  |
 | [vue/no-restricted-custom-event](./no-restricted-custom-event.md) | disallow specific custom event |  |
 | [vue/no-restricted-props](./no-restricted-props.md) | disallow specific props |  |
diff --git a/docs/rules/no-restricted-call-after-await.md b/docs/rules/no-restricted-call-after-await.md
new file mode 100644
index 00000000..735fc87b
--- /dev/null
+++ b/docs/rules/no-restricted-call-after-await.md
@@ -0,0 +1,98 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-restricted-call-after-await
+description: disallow asynchronously called restricted methods
+---
+# vue/no-restricted-call-after-await
+
+> disallow asynchronously called restricted methods
+
+- :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 reports your restricted calls after the `await` expression.
+In `setup()` function, you need to call your restricted functions synchronously.
+
+## :wrench: Options
+
+This rule takes a list of objects, where each object specifies a restricted module name and an exported name:
+
+```json5
+{
+  "vue/no-restricted-call-after-await": ["error",
+    { "module": "vue-i18n", "path": "useI18n" },
+    { ... } // You can specify more...
+  ]
+}
+```
+
+<eslint-code-block :rules="{'vue/no-restricted-call-after-await': ['error', { module: 'vue-i18n', path: ['useI18n'] }]}">
+
+```vue
+<script>
+import { useI18n } from 'vue-i18n'
+export default {
+  async setup() {
+    /* ✓ GOOD */
+    useI18n({})
+
+    await doSomething()
+
+    /* ✗ BAD */
+    useI18n({})
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+The following properties can be specified for the object.
+
+- `module` ... Specify the module name.
+- `path` ... Specify the imported name or the path that points to the method.
+- `message` ... Specify an optional custom message.
+
+For examples:
+
+```json5
+{
+  "vue/no-restricted-call-after-await": ["error",
+    { "module": "a", "path": "foo" },
+    { "module": "b", "path": ["bar", "baz"] },
+    { "module": "c" }, // Checks the default import.
+    { "module": "d", "path": "default" }, // Checks the default import.
+  ]
+}
+```
+
+<eslint-code-block :rules="{'vue/no-restricted-call-after-await': ['error', { module: 'a', path: 'foo' }, { module: 'b', path: ['bar', 'baz'] }, { module: 'c' }, { module: 'd', path: 'default' }]}">
+
+```vue
+<script>
+import { foo as fooOfA } from 'a'
+import { bar as barOfB } from 'b'
+import defaultOfC from 'c'
+import defaultOfD from 'd'
+export default {
+  async setup() {
+    await doSomething()
+
+    /* ✗ BAD */
+    fooOfA()
+    barOfB.baz()
+    defaultOfC()
+    defaultOfD()
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-restricted-call-after-await.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-restricted-call-after-await.js)
diff --git a/lib/index.js b/lib/index.js
index 95ffd1dd..0cc2ef27 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -91,6 +91,7 @@ module.exports = {
     'no-ref-as-operand': require('./rules/no-ref-as-operand'),
     'no-reserved-component-names': require('./rules/no-reserved-component-names'),
     'no-reserved-keys': require('./rules/no-reserved-keys'),
+    'no-restricted-call-after-await': require('./rules/no-restricted-call-after-await'),
     'no-restricted-component-options': require('./rules/no-restricted-component-options'),
     'no-restricted-custom-event': require('./rules/no-restricted-custom-event'),
     'no-restricted-props': require('./rules/no-restricted-props'),
diff --git a/lib/rules/no-restricted-call-after-await.js b/lib/rules/no-restricted-call-after-await.js
new file mode 100644
index 00000000..6723d91c
--- /dev/null
+++ b/lib/rules/no-restricted-call-after-await.js
@@ -0,0 +1,227 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+const fs = require('fs')
+const path = require('path')
+const { ReferenceTracker } = require('eslint-utils')
+const utils = require('../utils')
+
+/**
+ * @typedef {import('eslint-utils').TYPES.TraceMap} TraceMap
+ * @typedef {import('eslint-utils').TYPES.TraceKind} TraceKind
+ */
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'disallow asynchronously called restricted methods',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/no-restricted-call-after-await.html'
+    },
+    fixable: null,
+    schema: {
+      type: 'array',
+      items: {
+        type: 'object',
+        properties: {
+          module: { type: 'string' },
+          path: {
+            anyOf: [
+              { type: 'string' },
+              {
+                type: 'array',
+                items: {
+                  type: 'string'
+                }
+              }
+            ]
+          },
+          message: { type: 'string', minLength: 1 }
+        },
+        required: ['module'],
+        additionalProperties: false
+      },
+      uniqueItems: true,
+      minItems: 0
+    },
+    messages: {
+      // eslint-disable-next-line eslint-plugin/report-message-format
+      restricted: '{{message}}'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    /** @type {Map<ESNode, string>} */
+    const restrictedCallNodes = new Map()
+    /** @type {Map<FunctionExpression | ArrowFunctionExpression | FunctionDeclaration, { setupProperty: Property, afterAwait: boolean }>} */
+    const setupFunctions = new Map()
+
+    /**x
+     * @typedef {object} ScopeStack
+     * @property {ScopeStack | null} upper
+     * @property {FunctionExpression | ArrowFunctionExpression | FunctionDeclaration} functionNode
+     */
+    /** @type {ScopeStack | null} */
+    let scopeStack = null
+
+    /** @type {Record<string, string[]> | null} */
+    let allLocalImports = null
+    /**
+     * @param {string} id
+     */
+    function safeRequireResolve(id) {
+      try {
+        if (fs.statSync(id).isDirectory()) {
+          return require.resolve(id)
+        }
+      } catch (_e) {
+        // ignore
+      }
+      return id
+    }
+    /**
+     * @param {Program} ast
+     */
+    function getAllLocalImports(ast) {
+      if (!allLocalImports) {
+        allLocalImports = {}
+        const dir = path.dirname(context.getFilename())
+        for (const body of ast.body) {
+          if (body.type !== 'ImportDeclaration') {
+            continue
+          }
+          const source = String(body.source.value)
+          if (!source.startsWith('.')) {
+            continue
+          }
+          const modulePath = safeRequireResolve(path.join(dir, source))
+          const list =
+            allLocalImports[modulePath] || (allLocalImports[modulePath] = [])
+          list.push(source)
+        }
+      }
+
+      return allLocalImports
+    }
+
+    function getCwd() {
+      if (context.getCwd) {
+        return context.getCwd()
+      }
+      return path.resolve('')
+    }
+
+    /**
+     * @param {string} moduleName
+     * @param {Program} ast
+     * @returns {string[]}
+     */
+    function normalizeModules(moduleName, ast) {
+      /** @type {string} */
+      let modulePath
+      if (moduleName.startsWith('.')) {
+        modulePath = safeRequireResolve(path.join(getCwd(), moduleName))
+      } else if (path.isAbsolute(moduleName)) {
+        modulePath = safeRequireResolve(moduleName)
+      } else {
+        return [moduleName]
+      }
+      return getAllLocalImports(ast)[modulePath] || []
+    }
+
+    return utils.compositingVisitors(
+      {
+        /** @param {Program} node */
+        Program(node) {
+          const tracker = new ReferenceTracker(context.getScope())
+
+          for (const option of context.options) {
+            const modules = normalizeModules(option.module, node)
+
+            for (const module of modules) {
+              /** @type {TraceMap} */
+              const traceMap = {
+                [module]: {
+                  [ReferenceTracker.ESM]: true
+                }
+              }
+
+              /** @type {TraceKind & TraceMap} */
+              const mod = traceMap[module]
+              let local = mod
+              const paths = Array.isArray(option.path)
+                ? option.path
+                : [option.path || 'default']
+              for (const path of paths) {
+                local = local[path] || (local[path] = {})
+              }
+              local[ReferenceTracker.CALL] = true
+              const message =
+                option.message ||
+                `The \`${[`import("${module}")`, ...paths].join(
+                  '.'
+                )}\` after \`await\` expression are forbidden.`
+
+              for (const { node } of tracker.iterateEsmReferences(traceMap)) {
+                restrictedCallNodes.set(node, message)
+              }
+            }
+          }
+        }
+      },
+      utils.defineVueVisitor(context, {
+        /** @param {FunctionExpression | ArrowFunctionExpression | FunctionDeclaration} node */
+        ':function'(node) {
+          scopeStack = {
+            upper: scopeStack,
+            functionNode: node
+          }
+        },
+        onSetupFunctionEnter(node) {
+          setupFunctions.set(node, {
+            setupProperty: node.parent,
+            afterAwait: false
+          })
+        },
+        AwaitExpression() {
+          if (!scopeStack) {
+            return
+          }
+          const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
+          if (!setupFunctionData) {
+            return
+          }
+          setupFunctionData.afterAwait = true
+        },
+        /** @param {CallExpression} node */
+        CallExpression(node) {
+          if (!scopeStack) {
+            return
+          }
+          const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
+          if (!setupFunctionData || !setupFunctionData.afterAwait) {
+            return
+          }
+
+          const message = restrictedCallNodes.get(node)
+          if (message) {
+            context.report({
+              node,
+              messageId: 'restricted',
+              data: { message }
+            })
+          }
+        },
+        /** @param {FunctionExpression | ArrowFunctionExpression | FunctionDeclaration} node */
+        ':function:exit'(node) {
+          scopeStack = scopeStack && scopeStack.upper
+
+          setupFunctions.delete(node)
+        }
+      })
+    )
+  }
+}
diff --git a/typings/eslint/index.d.ts b/typings/eslint/index.d.ts
index f977b45a..fdbca850 100644
--- a/typings/eslint/index.d.ts
+++ b/typings/eslint/index.d.ts
@@ -327,6 +327,9 @@ export namespace Rule {
     getSourceCode(): SourceCode
     markVariableAsUsed(name: string): boolean
     report(descriptor: ReportDescriptor): void
+
+    // eslint@6 does not have this method.
+    getCwd?: () => string
   }
 
   type ReportDescriptor =


Test patch:
diff --git a/tests/lib/rules/no-restricted-call-after-await.js b/tests/lib/rules/no-restricted-call-after-await.js
new file mode 100644
index 00000000..530e7cd3
--- /dev/null
+++ b/tests/lib/rules/no-restricted-call-after-await.js
@@ -0,0 +1,382 @@
+/**
+ * @author Yosuke Ota
+ */
+'use strict'
+
+const path = require('path')
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-restricted-call-after-await')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2020, sourceType: 'module' }
+})
+
+tester.run('no-restricted-call-after-await', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      import { useI18n } from 'vue-i18n'
+      export default {
+        async setup() {
+          const { t } = useI18n({ })
+          return { t }
+        }
+      }
+      </script>
+      `,
+      options: [{ module: 'vue-i18n', path: 'useI18n' }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      import { useI18n } from 'vue-i18n'
+      export default {
+        async foo() {
+          await doSomething()
+          const { t } = useI18n({ })
+          return { t }
+        }
+      }
+      </script>
+      `,
+      options: [{ module: 'vue-i18n', path: 'useI18n' }]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      import { bar } from 'foo'
+      export default {
+        async setup() {
+          const qux = bar.baz({ })
+          await doSomething()
+        }
+      }
+      </script>
+      `,
+      options: [{ module: 'foo', path: ['bar', 'baz'] }]
+    },
+    {
+      filename: path.join(process.cwd(), 'test.vue'),
+      code: `
+      <script>
+      export default {
+        async setup() {
+          await doSomething()
+          const v = bar({ })
+          qux()
+          useI18n()
+        }
+      }
+      </script>
+      `,
+      options: [
+        { module: './foo', path: 'bar' },
+        { module: './baz', path: 'qux' },
+        { module: 'vue-i18n', path: 'useI18n' }
+      ]
+    },
+    {
+      filename: path.join(process.cwd(), 'test.vue'),
+      code: `
+      <script>
+      import {useI18n} from 'vue-i18n'
+      import { bar } from './foo'
+      import { qux } from './baz'
+      export default {
+        async setup() {
+          const v = bar({ })
+          qux()
+          useI18n()
+          await doSomething()
+        }
+      }
+      </script>
+      `,
+      options: [
+        { module: './foo', path: 'bar' },
+        { module: './baz', path: 'qux' },
+        { module: 'vue-i18n', path: 'useI18n' }
+      ]
+    },
+    {
+      filename: path.join(process.cwd(), 'test.vue'),
+      code: `
+      <script>
+      import {useI18n} from 'vue-i18n'
+      import { bar } from './foo'
+      import { qux } from './baz'
+      const v = bar({ })
+      qux()
+      useI18n()
+      export default {
+        async setup() {
+          await doSomething()
+        }
+      }
+      </script>
+      `,
+      options: [
+        { module: './foo', path: 'bar' },
+        { module: './baz', path: 'qux' },
+        { module: 'vue-i18n', path: 'useI18n' }
+      ]
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      import { useI18n } from 'vue-i18n'
+      export default {
+        async setup() {
+          await doSomething()
+          const { t } = useI18n({ })
+          return { t }
+        }
+      }
+      </script>
+      `,
+      options: [{ module: 'vue-i18n', path: 'useI18n' }],
+      errors: [
+        {
+          message:
+            'The `import("vue-i18n").useI18n` after `await` expression are forbidden.',
+          line: 7,
+          column: 25,
+          endLine: 7,
+          endColumn: 37
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      import { bar } from 'foo'
+      export default {
+        async setup() {
+          await doSomething()
+          const qux = bar.baz({ })
+        }
+      }
+      </script>
+      `,
+      options: [{ module: 'foo', path: ['bar', 'baz'] }],
+      errors: [
+        {
+          message:
+            'The `import("foo").bar.baz` after `await` expression are forbidden.',
+          line: 7
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      import { bar } from 'foo'
+      import { useI18n } from 'vue-i18n'
+      export default {
+        async setup() {
+          await doSomething()
+          const {t} = useI18n({ })
+          const qux = bar.baz({ })
+        }
+      }
+      </script>
+      `,
+      options: [
+        { module: 'vue-i18n', path: 'useI18n' },
+        { module: 'foo', path: ['bar', 'baz'] }
+      ],
+      errors: [
+        {
+          message:
+            'The `import("vue-i18n").useI18n` after `await` expression are forbidden.',
+          line: 8
+        },
+        {
+          message:
+            'The `import("foo").bar.baz` after `await` expression are forbidden.',
+          line: 9
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      import def from 'foo'
+      export default {
+        async setup() {
+          await doSomething()
+          const v = def({ })
+        }
+      }
+      </script>
+      `,
+      options: [{ module: 'foo', path: 'default' }],
+      errors: [
+        {
+          message:
+            'The `import("foo").default` after `await` expression are forbidden.',
+          line: 7
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      import def from 'foo'
+      export default {
+        async setup() {
+          await doSomething()
+          const v = def({ })
+        }
+      }
+      </script>
+      `,
+      options: [{ module: 'foo' }],
+      errors: [
+        {
+          message:
+            'The `import("foo").default` after `await` expression are forbidden.',
+          line: 7
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      import * as i18n from 'vue-i18n'
+      export default {
+        async setup() {
+          await doSomething()
+          const v = i18n.useI18n({ })
+        }
+      }
+      </script>
+      `,
+      options: [{ module: 'vue-i18n', path: 'useI18n' }],
+      errors: [
+        {
+          message:
+            'The `import("vue-i18n").useI18n` after `await` expression are forbidden.',
+          line: 7
+        }
+      ]
+    },
+    {
+      filename: path.join(process.cwd(), 'test.vue'),
+      code: `
+      <script>
+      import {useI18n} from 'vue-i18n'
+      import { bar } from './foo'
+      import { qux } from './baz'
+      export default {
+        async setup() {
+          useI18n()
+          await doSomething()
+          const v = bar({ })
+          qux()
+          useI18n()
+        }
+      }
+      </script>
+      `,
+      options: [
+        { module: './foo', path: 'bar' },
+        { module: './baz', path: 'qux' },
+        { module: 'vue-i18n', path: 'useI18n' }
+      ],
+      errors: [
+        {
+          message:
+            'The `import("./foo").bar` after `await` expression are forbidden.',
+          line: 10
+        },
+        {
+          message:
+            'The `import("./baz").qux` after `await` expression are forbidden.',
+          line: 11
+        },
+        {
+          message:
+            'The `import("vue-i18n").useI18n` after `await` expression are forbidden.',
+          line: 12
+        }
+      ]
+    },
+    {
+      filename: path.join(process.cwd(), 'test.vue'),
+      code: `
+      <script>
+      import {useI18n} from 'vue-i18n'
+      import { bar } from './foo'
+      import { qux } from './baz'
+      export default {
+        async setup() {
+          useI18n()
+          await doSomething()
+          const v = bar({ })
+          qux()
+          useI18n()
+        }
+      }
+      </script>
+      `,
+      options: [
+        { module: path.join(process.cwd(), './foo'), path: 'bar' },
+        { module: path.join(process.cwd(), './baz'), path: 'qux' },
+        { module: 'vue-i18n', path: 'useI18n' }
+      ],
+      errors: [
+        {
+          message:
+            'The `import("./foo").bar` after `await` expression are forbidden.',
+          line: 10
+        },
+        {
+          message:
+            'The `import("./baz").qux` after `await` expression are forbidden.',
+          line: 11
+        },
+        {
+          message:
+            'The `import("vue-i18n").useI18n` after `await` expression are forbidden.',
+          line: 12
+        }
+      ]
+    },
+    {
+      filename: path.join(__dirname, '../../../test/test.vue'),
+      code: `
+      <script>
+      import { foo } from '..'
+      export default {
+        async setup() {
+          await doSomething()
+          foo({})
+        }
+      }
+      </script>
+      `,
+      options: [{ module: require.resolve('../../..'), path: 'foo' }],
+      errors: [
+        {
+          message:
+            'The `import("..").foo` after `await` expression are forbidden.',
+          line: 7
+        }
+      ]
+    }
+  ]
+})