diff --git a/docs/rules/README.md b/docs/rules/README.md
index ad262e405..b16b4d951 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -286,6 +286,7 @@ For example:
| [vue/object-curly-spacing](./object-curly-spacing.md) | enforce consistent spacing inside braces | :wrench: |
| [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: |
| [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported | |
+| [vue/require-explicit-emits](./require-explicit-emits.md) | require `emits` option with name triggered by `$emit()` | |
| [vue/require-name-property](./require-name-property.md) | require a name property in Vue components | |
| [vue/script-indent](./script-indent.md) | enforce consistent indentation in `
+```
+
+
+
+
+
+```vue
+
+```
+
+
+
+
+
+```vue
+
+```
+
+
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further reading
+
+- [Vue RFCs - 0030-emits-option](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0030-emits-option.md)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/require-explicit-emits.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/require-explicit-emits.js)
diff --git a/lib/index.js b/lib/index.js
index cd0691de0..d5a06bb44 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -87,6 +87,7 @@ module.exports = {
'require-component-is': require('./rules/require-component-is'),
'require-default-prop': require('./rules/require-default-prop'),
'require-direct-export': require('./rules/require-direct-export'),
+ 'require-explicit-emits': require('./rules/require-explicit-emits'),
'require-name-property': require('./rules/require-name-property'),
'require-prop-type-constructor': require('./rules/require-prop-type-constructor'),
'require-prop-types': require('./rules/require-prop-types'),
diff --git a/lib/rules/no-async-in-computed-properties.js b/lib/rules/no-async-in-computed-properties.js
index 42956550d..35a785b48 100644
--- a/lib/rules/no-async-in-computed-properties.js
+++ b/lib/rules/no-async-in-computed-properties.js
@@ -72,7 +72,7 @@ module.exports = {
},
create (context) {
- const forbiddenNodes = []
+ const computedPropertiesMap = new Map()
let scopeStack = { upper: null, body: null }
const expressionTypes = {
@@ -83,13 +83,9 @@ module.exports = {
timed: 'timed function'
}
- function onFunctionEnter (node) {
+ function onFunctionEnter (node, { node: vueNode }) {
if (node.async) {
- forbiddenNodes.push({
- node: node,
- type: 'async',
- targetBody: node.body
- })
+ verify(node, node.body, 'async', computedPropertiesMap.get(vueNode))
}
scopeStack = { upper: scopeStack, body: node.body }
@@ -98,68 +94,53 @@ module.exports = {
function onFunctionExit () {
scopeStack = scopeStack.upper
}
- return Object.assign({},
- {
- ':function': onFunctionEnter,
- ':function:exit': onFunctionExit,
-
- NewExpression (node) {
- if (node.callee.name === 'Promise') {
- forbiddenNodes.push({
- node: node,
- type: 'new',
- targetBody: scopeStack.body
- })
- }
- },
-
- CallExpression (node) {
- if (isPromise(node)) {
- forbiddenNodes.push({
- node: node,
- type: 'promise',
- targetBody: scopeStack.body
- })
- } else if (isTimedFunction(node)) {
- forbiddenNodes.push({
- node: node,
- type: 'timed',
- targetBody: scopeStack.body
- })
- }
- },
-
- AwaitExpression (node) {
- forbiddenNodes.push({
+
+ function verify (node, targetBody, type, computedProperties) {
+ computedProperties.forEach(cp => {
+ if (
+ cp.value &&
+ node.loc.start.line >= cp.value.loc.start.line &&
+ node.loc.end.line <= cp.value.loc.end.line &&
+ targetBody === cp.value
+ ) {
+ context.report({
node: node,
- type: 'await',
- targetBody: scopeStack.body
- })
- }
- },
- utils.executeOnVue(context, (obj) => {
- const computedProperties = utils.getComputedProperties(obj)
-
- computedProperties.forEach(cp => {
- forbiddenNodes.forEach(el => {
- if (
- cp.value &&
- el.node.loc.start.line >= cp.value.loc.start.line &&
- el.node.loc.end.line <= cp.value.loc.end.line &&
- el.targetBody === cp.value
- ) {
- context.report({
- node: el.node,
- message: 'Unexpected {{expressionName}} in "{{propertyName}}" computed property.',
- data: {
- expressionName: expressionTypes[el.type],
- propertyName: cp.key
- }
- })
+ message: 'Unexpected {{expressionName}} in "{{propertyName}}" computed property.',
+ data: {
+ expressionName: expressionTypes[type],
+ propertyName: cp.key
}
})
- })
+ }
})
- )
+ }
+ return utils.defineVueVisitor(context, {
+ ObjectExpression (node, { node: vueNode }) {
+ if (node !== vueNode) {
+ return
+ }
+ computedPropertiesMap.set(node, utils.getComputedProperties(node))
+ },
+ ':function': onFunctionEnter,
+ ':function:exit': onFunctionExit,
+
+ NewExpression (node, { node: vueNode }) {
+ if (node.callee.name === 'Promise') {
+ verify(node, scopeStack.body, 'new', computedPropertiesMap.get(vueNode))
+ }
+ },
+
+ CallExpression (node, { node: vueNode }) {
+ if (isPromise(node)) {
+ verify(node, scopeStack.body, 'promise', computedPropertiesMap.get(vueNode))
+ } else if (isTimedFunction(node)) {
+ verify(node, scopeStack.body, 'timed', computedPropertiesMap.get(vueNode))
+ }
+ },
+
+ AwaitExpression (node, { node: vueNode }) {
+ verify(node, scopeStack.body, 'await', computedPropertiesMap.get(vueNode))
+ }
+ })
}
}
diff --git a/lib/rules/no-deprecated-events-api.js b/lib/rules/no-deprecated-events-api.js
index cc21edd40..8537469ee 100644
--- a/lib/rules/no-deprecated-events-api.js
+++ b/lib/rules/no-deprecated-events-api.js
@@ -30,28 +30,17 @@ module.exports = {
},
create (context) {
- const forbiddenNodes = []
-
- return Object.assign(
+ return utils.defineVueVisitor(context,
{
'CallExpression > MemberExpression > ThisExpression' (node) {
if (!['$on', '$off', '$once'].includes(node.parent.property.name)) return
- forbiddenNodes.push(node.parent.parent)
+
+ context.report({
+ node: node.parent.parent,
+ messageId: 'noDeprecatedEventsApi'
+ })
}
- },
- utils.executeOnVue(context, (obj) => {
- forbiddenNodes.forEach(node => {
- if (
- node.loc.start.line >= obj.loc.start.line &&
- node.loc.end.line <= obj.loc.end.line
- ) {
- context.report({
- node,
- messageId: 'noDeprecatedEventsApi'
- })
- }
- })
- })
+ }
)
}
}
diff --git a/lib/rules/no-lifecycle-after-await.js b/lib/rules/no-lifecycle-after-await.js
index c4ae51a0e..b1c3c2439 100644
--- a/lib/rules/no-lifecycle-after-await.js
+++ b/lib/rules/no-lifecycle-after-await.js
@@ -25,16 +25,6 @@ module.exports = {
create (context) {
const lifecycleHookCallNodes = new Set()
const setupFunctions = new Map()
- const forbiddenNodes = new Map()
-
- function addForbiddenNode (property, node) {
- let list = forbiddenNodes.get(property)
- if (!list) {
- list = []
- forbiddenNodes.set(property, list)
- }
- list.push(node)
- }
let scopeStack = { upper: null, functionNode: null }
@@ -56,56 +46,53 @@ module.exports = {
for (const { node } of tracker.iterateEsmReferences(traceMap)) {
lifecycleHookCallNodes.add(node)
}
- },
- 'Property[value.type=/^(Arrow)?FunctionExpression$/]' (node) {
- if (utils.getStaticPropertyName(node) !== 'setup') {
- return
- }
-
- setupFunctions.set(node.value, {
- setupProperty: node,
- afterAwait: false
- })
- },
- ':function' (node) {
- scopeStack = { upper: scopeStack, functionNode: node }
- },
- 'AwaitExpression' () {
- const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
- if (!setupFunctionData) {
- return
- }
- setupFunctionData.afterAwait = true
- },
- 'CallExpression' (node) {
- const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
- if (!setupFunctionData || !setupFunctionData.afterAwait) {
- return
- }
-
- if (lifecycleHookCallNodes.has(node)) {
- addForbiddenNode(setupFunctionData.setupProperty, node)
- }
- },
- ':function:exit' (node) {
- scopeStack = scopeStack.upper
-
- setupFunctions.delete(node)
}
},
- utils.executeOnVue(context, obj => {
- const reportsList = obj.properties
- .map(item => forbiddenNodes.get(item))
- .filter(reports => !!reports)
- for (const reports of reportsList) {
- for (const node of reports) {
- context.report({
- node,
- messageId: 'forbidden'
+ utils.defineVueVisitor(context,
+ {
+ 'Property[value.type=/^(Arrow)?FunctionExpression$/]' (node, { node: vueNode }) {
+ if (node.parent !== vueNode) {
+ return
+ }
+ if (utils.getStaticPropertyName(node) !== 'setup') {
+ return
+ }
+
+ setupFunctions.set(node.value, {
+ setupProperty: node,
+ afterAwait: false
})
+ },
+ ':function' (node) {
+ scopeStack = { upper: scopeStack, functionNode: node }
+ },
+ 'AwaitExpression' () {
+ const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
+ if (!setupFunctionData) {
+ return
+ }
+ setupFunctionData.afterAwait = true
+ },
+ 'CallExpression' (node) {
+ const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
+ if (!setupFunctionData || !setupFunctionData.afterAwait) {
+ return
+ }
+
+ if (lifecycleHookCallNodes.has(node)) {
+ context.report({
+ node,
+ messageId: 'forbidden'
+ })
+ }
+ },
+ ':function:exit' (node) {
+ scopeStack = scopeStack.upper
+
+ setupFunctions.delete(node)
}
- }
- })
+ },
+ )
)
}
}
diff --git a/lib/rules/no-setup-props-destructure.js b/lib/rules/no-setup-props-destructure.js
index 7698ab9b7..f167357da 100644
--- a/lib/rules/no-setup-props-destructure.js
+++ b/lib/rules/no-setup-props-destructure.js
@@ -22,115 +22,93 @@ module.exports = {
}
},
create (context) {
- const setupFunctions = new Map()
- const forbiddenNodes = new Map()
+ const setupScopePropsReferenceIds = new Map()
- function addForbiddenNode (property, node, messageId) {
- let list = forbiddenNodes.get(property)
- if (!list) {
- list = []
- forbiddenNodes.set(property, list)
- }
- list.push({
+ function report (node, messageId) {
+ context.report({
node,
messageId
})
}
- function verify (left, right, { propsReferenceIds, setupProperty }) {
+ function verify (left, right, propsReferenceIds) {
if (!right) {
return
}
- if (left.type === 'ArrayPattern' || left.type === 'ObjectPattern') {
- if (propsReferenceIds.has(right)) {
- addForbiddenNode(setupProperty, left, 'getProperty')
- }
- } else if (left.type === 'Identifier' && right.type === 'MemberExpression') {
- if (propsReferenceIds.has(right.object)) {
- addForbiddenNode(setupProperty, right, 'getProperty')
- }
+ if (left.type !== 'ArrayPattern' && left.type !== 'ObjectPattern' && right.type !== 'MemberExpression') {
+ return
+ }
+
+ let rightId = right
+ while (rightId.type === 'MemberExpression') {
+ rightId = rightId.object
+ }
+ if (propsReferenceIds.has(rightId)) {
+ report(left, 'getProperty')
}
}
- let scopeStack = { upper: null, functionNode: null }
+ let scopeStack = null
- return Object.assign(
- {
- 'Property[value.type=/^(Arrow)?FunctionExpression$/]' (node) {
- if (utils.getStaticPropertyName(node) !== 'setup') {
- return
- }
- const param = node.value.params[0]
- if (!param) {
- // no arguments
- return
- }
- if (param.type === 'RestElement') {
- // cannot check
- return
- }
- if (param.type === 'ArrayPattern' || param.type === 'ObjectPattern') {
- addForbiddenNode(node, param, 'destructuring')
- return
- }
- setupFunctions.set(node.value, {
- setupProperty: node,
- propsParam: param,
- propsReferenceIds: new Set()
- })
- },
- ':function' (node) {
- scopeStack = { upper: scopeStack, functionNode: node }
- },
- ':function>*' (node) {
- const setupFunctionData = setupFunctions.get(node.parent)
- if (!setupFunctionData || setupFunctionData.propsParam !== node) {
- return
- }
- const variable = findVariable(context.getScope(), node)
- if (!variable) {
- return
- }
- const { propsReferenceIds } = setupFunctionData
- for (const reference of variable.references) {
- if (!reference.isRead()) {
- continue
- }
+ return utils.defineVueVisitor(context, {
+ 'Property[value.type=/^(Arrow)?FunctionExpression$/]' (node, { node: vueNode }) {
+ if (node.parent !== vueNode) {
+ return
+ }
+ if (utils.getStaticPropertyName(node) !== 'setup') {
+ return
+ }
+ const propsParam = node.value.params[0]
+ if (!propsParam) {
+ // no arguments
+ return
+ }
+ if (propsParam.type === 'RestElement') {
+ // cannot check
+ return
+ }
+ if (propsParam.type === 'ArrayPattern' || propsParam.type === 'ObjectPattern') {
+ report(propsParam, 'destructuring')
+ return
+ }
- propsReferenceIds.add(reference.identifier)
- }
- },
- 'VariableDeclarator' (node) {
- const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
- if (!setupFunctionData) {
- return
- }
- verify(node.id, node.init, setupFunctionData)
- },
- 'AssignmentExpression' (node) {
- const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
- if (!setupFunctionData) {
- return
+ const variable = findVariable(context.getScope(), propsParam)
+ if (!variable) {
+ return
+ }
+ const propsReferenceIds = new Set()
+ for (const reference of variable.references) {
+ if (!reference.isRead()) {
+ continue
}
- verify(node.left, node.right, setupFunctionData)
- },
- ':function:exit' (node) {
- scopeStack = scopeStack.upper
- setupFunctions.delete(node)
+ propsReferenceIds.add(reference.identifier)
}
+ setupScopePropsReferenceIds.set(node.value, propsReferenceIds)
},
- utils.executeOnVue(context, obj => {
- const reportsList = obj.properties
- .map(item => forbiddenNodes.get(item))
- .filter(reports => !!reports)
- for (const reports of reportsList) {
- for (const report of reports) {
- context.report(report)
- }
+ ':function' (node) {
+ scopeStack = { upper: scopeStack, functionNode: node }
+ },
+ 'VariableDeclarator' (node) {
+ const propsReferenceIds = setupScopePropsReferenceIds.get(scopeStack.functionNode)
+ if (!propsReferenceIds) {
+ return
}
- })
- )
+ verify(node.id, node.init, propsReferenceIds)
+ },
+ 'AssignmentExpression' (node) {
+ const propsReferenceIds = setupScopePropsReferenceIds.get(scopeStack.functionNode)
+ if (!propsReferenceIds) {
+ return
+ }
+ verify(node.left, node.right, propsReferenceIds)
+ },
+ ':function:exit' (node) {
+ scopeStack = scopeStack.upper
+
+ setupScopePropsReferenceIds.delete(node)
+ }
+ })
}
}
diff --git a/lib/rules/no-side-effects-in-computed-properties.js b/lib/rules/no-side-effects-in-computed-properties.js
index a11c30326..eab6e0fae 100644
--- a/lib/rules/no-side-effects-in-computed-properties.js
+++ b/lib/rules/no-side-effects-in-computed-properties.js
@@ -23,7 +23,7 @@ module.exports = {
},
create (context) {
- const forbiddenNodes = []
+ const computedPropertiesMap = new Map()
let scopeStack = { upper: null, body: null }
function onFunctionEnter (node) {
@@ -34,63 +34,56 @@ module.exports = {
scopeStack = scopeStack.upper
}
- return Object.assign({},
- {
- ':function': onFunctionEnter,
- ':function:exit': onFunctionExit,
+ function verify (node, targetBody, computedProperties) {
+ computedProperties.forEach(cp => {
+ if (
+ cp.value &&
+ node.loc.start.line >= cp.value.loc.start.line &&
+ node.loc.end.line <= cp.value.loc.end.line &&
+ targetBody === cp.value
+ ) {
+ context.report({
+ node: node,
+ message: 'Unexpected side effect in "{{key}}" computed property.',
+ data: { key: cp.key }
+ })
+ }
+ })
+ }
- // this.xxx <=|+=|-=>
- 'AssignmentExpression' (node) {
- if (node.left.type !== 'MemberExpression') return
- if (utils.parseMemberExpression(node.left)[0] === 'this') {
- forbiddenNodes.push({
- node,
- targetBody: scopeStack.body
- })
- }
- },
- // this.xxx <++|-->
- 'UpdateExpression > MemberExpression' (node) {
- if (utils.parseMemberExpression(node)[0] === 'this') {
- forbiddenNodes.push({
- node,
- targetBody: scopeStack.body
- })
- }
- },
- // this.xxx.func()
- 'CallExpression' (node) {
- const code = utils.parseMemberOrCallExpression(node)
- const MUTATION_REGEX = /(this.)((?!(concat|slice|map|filter)\().)[^\)]*((push|pop|shift|unshift|reverse|splice|sort|copyWithin|fill)\()/g
+ return utils.defineVueVisitor(context, {
+ ObjectExpression (node, { node: vueNode }) {
+ if (node !== vueNode) {
+ return
+ }
+ computedPropertiesMap.set(node, utils.getComputedProperties(node))
+ },
+ ':function': onFunctionEnter,
+ ':function:exit': onFunctionExit,
- if (MUTATION_REGEX.test(code)) {
- forbiddenNodes.push({
- node,
- targetBody: scopeStack.body
- })
- }
+ // this.xxx <=|+=|-=>
+ 'AssignmentExpression' (node, { node: vueNode }) {
+ if (node.left.type !== 'MemberExpression') return
+ if (utils.parseMemberExpression(node.left)[0] === 'this') {
+ verify(node, scopeStack.body, computedPropertiesMap.get(vueNode))
}
},
- utils.executeOnVue(context, (obj) => {
- const computedProperties = utils.getComputedProperties(obj)
+ // this.xxx <++|-->
+ 'UpdateExpression > MemberExpression' (node, { node: vueNode }) {
+ if (utils.parseMemberExpression(node)[0] === 'this') {
+ verify(node, scopeStack.body, computedPropertiesMap.get(vueNode))
+ }
+ },
+ // this.xxx.func()
+ 'CallExpression' (node, { node: vueNode }) {
+ const code = utils.parseMemberOrCallExpression(node)
+ const MUTATION_REGEX = /(this.)((?!(concat|slice|map|filter)\().)[^\)]*((push|pop|shift|unshift|reverse|splice|sort|copyWithin|fill)\()/g
- computedProperties.forEach(cp => {
- forbiddenNodes.forEach(({ node, targetBody }) => {
- if (
- cp.value &&
- node.loc.start.line >= cp.value.loc.start.line &&
- node.loc.end.line <= cp.value.loc.end.line &&
- targetBody === cp.value
- ) {
- context.report({
- node: node,
- message: 'Unexpected side effect in "{{key}}" computed property.',
- data: { key: cp.key }
- })
- }
- })
- })
- })
+ if (MUTATION_REGEX.test(code)) {
+ verify(node, scopeStack.body, computedPropertiesMap.get(vueNode))
+ }
+ }
+ }
)
}
}
diff --git a/lib/rules/no-watch-after-await.js b/lib/rules/no-watch-after-await.js
index f65a459de..e666b8616 100644
--- a/lib/rules/no-watch-after-await.js
+++ b/lib/rules/no-watch-after-await.js
@@ -50,16 +50,6 @@ module.exports = {
create (context) {
const watchCallNodes = new Set()
const setupFunctions = new Map()
- const forbiddenNodes = new Map()
-
- function addForbiddenNode (property, node) {
- let list = forbiddenNodes.get(property)
- if (!list) {
- list = []
- forbiddenNodes.set(property, list)
- }
- list.push(node)
- }
let scopeStack = { upper: null, functionNode: null }
@@ -82,56 +72,53 @@ module.exports = {
for (const { node } of tracker.iterateEsmReferences(traceMap)) {
watchCallNodes.add(node)
}
- },
- 'Property[value.type=/^(Arrow)?FunctionExpression$/]' (node) {
- if (utils.getStaticPropertyName(node) !== 'setup') {
- return
- }
-
- setupFunctions.set(node.value, {
- setupProperty: node,
- afterAwait: false
- })
- },
- ':function' (node) {
- scopeStack = { upper: scopeStack, functionNode: node }
- },
- 'AwaitExpression' () {
- const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
- if (!setupFunctionData) {
- return
- }
- setupFunctionData.afterAwait = true
- },
- 'CallExpression' (node) {
- const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
- if (!setupFunctionData || !setupFunctionData.afterAwait) {
- return
- }
-
- if (watchCallNodes.has(node) && !isMaybeUsedStopHandle(node)) {
- addForbiddenNode(setupFunctionData.setupProperty, node)
- }
- },
- ':function:exit' (node) {
- scopeStack = scopeStack.upper
-
- setupFunctions.delete(node)
}
},
- utils.executeOnVue(context, obj => {
- const reportsList = obj.properties
- .map(item => forbiddenNodes.get(item))
- .filter(reports => !!reports)
- for (const reports of reportsList) {
- for (const node of reports) {
- context.report({
- node,
- messageId: 'forbidden'
+ utils.defineVueVisitor(context,
+ {
+ 'Property[value.type=/^(Arrow)?FunctionExpression$/]' (node, { node: vueNode }) {
+ if (node.parent !== vueNode) {
+ return
+ }
+ if (utils.getStaticPropertyName(node) !== 'setup') {
+ return
+ }
+
+ setupFunctions.set(node.value, {
+ setupProperty: node,
+ afterAwait: false
})
+ },
+ ':function' (node) {
+ scopeStack = { upper: scopeStack, functionNode: node }
+ },
+ 'AwaitExpression' () {
+ const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
+ if (!setupFunctionData) {
+ return
+ }
+ setupFunctionData.afterAwait = true
+ },
+ 'CallExpression' (node) {
+ const setupFunctionData = setupFunctions.get(scopeStack.functionNode)
+ if (!setupFunctionData || !setupFunctionData.afterAwait) {
+ return
+ }
+
+ if (watchCallNodes.has(node) && !isMaybeUsedStopHandle(node)) {
+ context.report({
+ node,
+ messageId: 'forbidden'
+ })
+ }
+ },
+ ':function:exit' (node) {
+ scopeStack = scopeStack.upper
+
+ setupFunctions.delete(node)
}
- }
- })
+ },
+ )
)
}
}
diff --git a/lib/rules/require-explicit-emits.js b/lib/rules/require-explicit-emits.js
new file mode 100644
index 000000000..2addf8471
--- /dev/null
+++ b/lib/rules/require-explicit-emits.js
@@ -0,0 +1,390 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+// @ts-check
+
+/**
+ * @typedef {import('vue-eslint-parser').AST.ESLintLiteral} Literal
+ * @typedef {import('vue-eslint-parser').AST.ESLintProperty} Property
+ * @typedef {import('vue-eslint-parser').AST.ESLintObjectExpression} ObjectExpression
+ * @typedef {import('../utils').ComponentArrayEmit} ComponentArrayEmit
+ * @typedef {import('../utils').ComponentObjectEmit} ComponentObjectEmit
+ */
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const { findVariable } = require('eslint-utils')
+const utils = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+const FIX_EMITS_AFTER_OPTIONS = [
+ 'props',
+ 'propsData',
+ 'setup',
+ 'data',
+ 'computed',
+ 'watch',
+ 'methods',
+ 'template', 'render',
+ 'renderError',
+
+ // lifecycle hooks
+ 'beforeCreate',
+ 'created',
+ 'beforeMount',
+ 'mounted',
+ 'beforeUpdate',
+ 'updated',
+ 'activated',
+ 'deactivated',
+ 'beforeDestroy',
+ 'destroyed'
+]
+
+/**
+ * Check whether the given token is a left brace.
+ * @param {Token} token The token to check.
+ * @returns {boolean} `true` if the token is a left brace.
+ */
+function isLeftBrace (token) {
+ return token != null && token.type === 'Punctuator' && token.value === '{'
+}
+
+/**
+ * Check whether the given token is a right brace.
+ * @param {Token} token The token to check.
+ * @returns {boolean} `true` if the token is a right brace.
+ */
+function isRightBrace (token) {
+ return token != null && token.type === 'Punctuator' && token.value === '}'
+}
+
+/**
+ * Check whether the given token is a left bracket.
+ * @param {Token} token The token to check.
+ * @returns {boolean} `true` if the token is a left bracket.
+ */
+function isLeftBracket (token) {
+ return token != null && token.type === 'Punctuator' && token.value === '['
+}
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'require `emits` option with name triggered by `$emit()`',
+ categories: undefined,
+ url: 'https://eslint.vuejs.org/rules/require-explicit-emits.html'
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ missing: 'The "{{name}}" event has been triggered but not declared on `emits` option.',
+ addOneOption: 'Add the "{{name}}" to `emits` option.',
+ addArrayEmitsOption: 'Add the `emits` option with array syntax and define "{{name}}" event.',
+ addObjectEmitsOption: 'Add the `emits` option with object syntax and define "{{name}}" event.'
+ }
+ },
+
+ create (context) {
+ /** @typedef { { node: Literal, name: string } } EmitCellName */
+
+ const setupContexts = new Map()
+ const vueEmitsDeclarations = new Map()
+
+ /** @type {EmitCellName[]} */
+ const templateEmitCellNames = []
+ /** @type { { type: 'export' | 'mark' | 'definition', object: ObjectExpression, emits: (ComponentArrayEmit | ComponentObjectEmit)[] } | null } */
+ let vueObjectData = null
+
+ function addTemplateEmitCellName (nameLiteralNode) {
+ templateEmitCellNames.push({
+ node: nameLiteralNode,
+ name: nameLiteralNode.value
+ })
+ }
+
+ function verify (emitsDeclarations, nameLiteralNode, vueObjectNode) {
+ const name = nameLiteralNode.value
+ if (emitsDeclarations.some(e => e.emitName === name)) {
+ return
+ }
+ context.report({
+ node: nameLiteralNode,
+ messageId: 'missing',
+ data: {
+ name
+ },
+ suggest: buildSuggest(vueObjectNode, emitsDeclarations, nameLiteralNode, context)
+ })
+ }
+
+ return utils.defineTemplateBodyVisitor(context,
+ {
+ 'CallExpression[arguments.0.type=Literal]' (node) {
+ const callee = node.callee
+ const nameLiteralNode = node.arguments[0]
+ if (!nameLiteralNode || typeof nameLiteralNode.value !== 'string') {
+ // cannot check
+ return
+ }
+ if (callee.type === 'Identifier' && callee.name === '$emit') {
+ addTemplateEmitCellName(nameLiteralNode)
+ }
+ },
+ "VElement[parent.type!='VElement']:exit" () {
+ if (!vueObjectData) {
+ return
+ }
+ const emitsDeclarationNames = new Set(vueObjectData.emits.map(e => e.emitName))
+
+ for (const { name, node } of templateEmitCellNames) {
+ if (emitsDeclarationNames.has(name)) {
+ continue
+ }
+ context.report({
+ node,
+ messageId: 'missing',
+ data: {
+ name
+ },
+ suggest: buildSuggest(vueObjectData.object, vueObjectData.emits, node, context)
+ })
+ }
+ }
+ },
+ utils.defineVueVisitor(context, {
+ ObjectExpression (node, { node: vueNode }) {
+ if (node !== vueNode) {
+ return
+ }
+ vueEmitsDeclarations.set(node, utils.getComponentEmits(node))
+
+ const setupProperty = node.properties.find(p => utils.getStaticPropertyName(p) === 'setup')
+ if (!setupProperty) {
+ return
+ }
+ if (!/^(Arrow)?FunctionExpression$/.test(setupProperty.value.type)) {
+ return
+ }
+ const contextParam = setupProperty.value.params[1]
+ if (!contextParam) {
+ // no arguments
+ return
+ }
+ if (contextParam.type === 'RestElement') {
+ // cannot check
+ return
+ }
+ if (contextParam.type === 'ArrayPattern') {
+ // cannot check
+ return
+ }
+ const contextReferenceIds = new Set()
+ const emitReferenceIds = new Set()
+ if (contextParam.type === 'ObjectPattern') {
+ const emitProperty = contextParam.properties.find(p => p.type === 'Property' && utils.getStaticPropertyName(p) === 'emit')
+ if (!emitProperty) {
+ return
+ }
+ const emitParam = emitProperty.value
+ // `setup(props, {emit})`
+ const variable = findVariable(context.getScope(), emitParam)
+ if (!variable) {
+ return
+ }
+ for (const reference of variable.references) {
+ if (!reference.isRead()) {
+ continue
+ }
+
+ emitReferenceIds.add(reference.identifier)
+ }
+ } else {
+ // `setup(props, context)`
+ const variable = findVariable(context.getScope(), contextParam)
+ if (!variable) {
+ return
+ }
+ for (const reference of variable.references) {
+ if (!reference.isRead()) {
+ continue
+ }
+
+ contextReferenceIds.add(reference.identifier)
+ }
+ }
+ setupContexts.set(node, {
+ contextReferenceIds,
+ emitReferenceIds
+ })
+ },
+ 'CallExpression[arguments.0.type=Literal]' (node, { node: vueNode }) {
+ const callee = node.callee
+ const nameLiteralNode = node.arguments[0]
+ if (!nameLiteralNode || typeof nameLiteralNode.value !== 'string') {
+ // cannot check
+ return
+ }
+ const emitsDeclarations = vueEmitsDeclarations.get(vueNode)
+
+ let emit
+ if (callee.type === 'MemberExpression') {
+ const name = utils.getStaticPropertyName(callee)
+ if (name === 'emit' || name === '$emit') {
+ emit = { name, member: callee }
+ }
+ }
+
+ // verify setup context
+ const setupContext = setupContexts.get(vueNode)
+ if (setupContext) {
+ const { contextReferenceIds, emitReferenceIds } = setupContext
+ if (emitReferenceIds.has(callee)) {
+ // verify setup(props,{emit}) {emit()}
+ verify(emitsDeclarations, nameLiteralNode, vueNode)
+ } else if (emit && emit.name === 'emit' && contextReferenceIds.has(emit.member.object)) {
+ // verify setup(props,context) {context.emit()}
+ verify(emitsDeclarations, nameLiteralNode, vueNode)
+ }
+ }
+
+ // verify $emit
+ if (emit && emit.name === '$emit') {
+ const objectType = emit.member.object.type
+ if (objectType === 'Identifier' || objectType === 'ThisExpression') {
+ // verify this.$emit()
+ verify(emitsDeclarations, nameLiteralNode, vueNode)
+ }
+ }
+ },
+ 'ObjectExpression:exit' (node, { node: vueNode, type }) {
+ if (node !== vueNode) {
+ return
+ }
+ if (!vueObjectData || vueObjectData.type !== 'export') {
+ vueObjectData = {
+ type,
+ object: node,
+ emits: vueEmitsDeclarations.get(node)
+ }
+ }
+ setupContexts.delete(node)
+ vueEmitsDeclarations.delete(node)
+ }
+ }),
+ )
+ }
+}
+
+/**
+ * @param {ObjectExpression} object
+ * @param {(ComponentArrayEmit | ComponentObjectEmit)[]} emits
+ * @param {Literal} nameNode
+ * @param {RuleContext} context
+ */
+function buildSuggest (object, emits, nameNode, context) {
+ const certainEmits = emits.filter(e => e.key)
+ if (certainEmits.length) {
+ const last = certainEmits[certainEmits.length - 1]
+ return [
+ {
+ messageId: 'addOneOption',
+ data: { name: nameNode.value },
+ fix (fixer) {
+ if (last.value === null) {
+ // Array
+ return fixer.insertTextAfter(last.node, `, '${nameNode.value}'`)
+ } else {
+ // Object
+ return fixer.insertTextAfter(last.node, `, '${nameNode.value}': null`)
+ }
+ }
+ }
+ ]
+ }
+
+ const propertyNodes = object.properties
+ .filter(p =>
+ p.type === 'Property' &&
+ p.key.type === 'Identifier'
+ )
+
+ const emitsOption = propertyNodes.find(p => utils.getStaticPropertyName(p) === 'emits')
+ if (emitsOption) {
+ const sourceCode = context.getSourceCode()
+ const emitsOptionValue = emitsOption.value
+ if (emitsOptionValue.type === 'ArrayExpression') {
+ const leftBracket = sourceCode.getFirstToken(emitsOptionValue, isLeftBracket)
+ return [
+ {
+ messageId: 'addOneOption',
+ data: { name: nameNode.value },
+ fix (fixer) {
+ return fixer.insertTextAfter(leftBracket, `'${nameNode.value}'${emitsOptionValue.elements.length ? ',' : ''}`)
+ }
+ }
+ ]
+ } else if (emitsOptionValue.type === 'ObjectExpression') {
+ const leftBrace = sourceCode.getFirstToken(emitsOptionValue, isLeftBrace)
+ return [
+ {
+ messageId: 'addOneOption',
+ data: { name: nameNode.value },
+ fix (fixer) {
+ return fixer.insertTextAfter(leftBrace, `'${nameNode.value}': null${emitsOptionValue.properties.length ? ',' : ''}`)
+ }
+ }
+ ]
+ }
+ return []
+ }
+
+ const sourceCode = context.getSourceCode()
+ const afterOptionNode = propertyNodes.find(p => FIX_EMITS_AFTER_OPTIONS.includes(utils.getStaticPropertyName(p)))
+ return [
+ {
+ messageId: 'addArrayEmitsOption',
+ data: { name: nameNode.value },
+ fix (fixer) {
+ if (afterOptionNode) {
+ return fixer.insertTextAfter(sourceCode.getTokenBefore(afterOptionNode), `\nemits: ['${nameNode.value}'],`)
+ } else if (object.properties.length) {
+ const before = propertyNodes[propertyNodes.length - 1] || object.properties[object.properties.length - 1]
+ return fixer.insertTextAfter(before, `,\nemits: ['${nameNode.value}']`)
+ } else {
+ const objectLeftBrace = sourceCode.getFirstToken(object, isLeftBrace)
+ const objectRightBrace = sourceCode.getLastToken(object, isRightBrace)
+ return fixer.insertTextAfter(objectLeftBrace, `\nemits: ['${nameNode.value}']${objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line ? '' : '\n'}`)
+ }
+ }
+ },
+ {
+ messageId: 'addObjectEmitsOption',
+ data: { name: nameNode.value },
+ fix (fixer) {
+ if (afterOptionNode) {
+ return fixer.insertTextAfter(sourceCode.getTokenBefore(afterOptionNode), `\nemits: {'${nameNode.value}': null},`)
+ } else if (object.properties.length) {
+ const before = propertyNodes[propertyNodes.length - 1] || object.properties[object.properties.length - 1]
+ return fixer.insertTextAfter(before, `,\nemits: {'${nameNode.value}': null}`)
+ } else {
+ const objectLeftBrace = sourceCode.getFirstToken(object, isLeftBrace)
+ const objectRightBrace = sourceCode.getLastToken(object, isRightBrace)
+ return fixer.insertTextAfter(objectLeftBrace, `\nemits: {'${nameNode.value}': null}${objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line ? '' : '\n'}`)
+ }
+ }
+ }
+ ]
+}
diff --git a/lib/rules/require-render-return.js b/lib/rules/require-render-return.js
index 3ae1623d2..17cfcb355 100644
--- a/lib/rules/require-render-return.js
+++ b/lib/rules/require-render-return.js
@@ -23,32 +23,36 @@ module.exports = {
},
create (context) {
- const forbiddenNodes = []
+ const renderFunctions = new Map()
// ----------------------------------------------------------------------
// Public
// ----------------------------------------------------------------------
return Object.assign({},
+ utils.defineVueVisitor(context,
+ {
+ ObjectExpression (obj, { node: vueNode }) {
+ if (obj !== vueNode) {
+ return
+ }
+ const node = obj.properties.find(item => item.type === 'Property' &&
+ utils.getStaticPropertyName(item) === 'render' &&
+ (item.value.type === 'ArrowFunctionExpression' || item.value.type === 'FunctionExpression')
+ )
+ if (!node) return
+ renderFunctions.set(node.value, node.key)
+ }
+ }
+ ),
utils.executeOnFunctionsWithoutReturn(true, node => {
- forbiddenNodes.push(node)
+ if (renderFunctions.has(node)) {
+ context.report({
+ node: renderFunctions.get(node),
+ message: 'Expected to return a value in render function.'
+ })
+ }
}),
- utils.executeOnVue(context, obj => {
- const node = obj.properties.find(item => item.type === 'Property' &&
- utils.getStaticPropertyName(item) === 'render' &&
- (item.value.type === 'ArrowFunctionExpression' || item.value.type === 'FunctionExpression')
- )
- if (!node) return
-
- forbiddenNodes.forEach(el => {
- if (node.value === el) {
- context.report({
- node: node.key,
- message: 'Expected to return a value in render function.'
- })
- }
- })
- })
)
}
}
diff --git a/lib/rules/return-in-computed-property.js b/lib/rules/return-in-computed-property.js
index 00a43a5c2..c62f59846 100644
--- a/lib/rules/return-in-computed-property.js
+++ b/lib/rules/return-in-computed-property.js
@@ -36,33 +36,38 @@ module.exports = {
const options = context.options[0] || {}
const treatUndefinedAsUnspecified = !(options.treatUndefinedAsUnspecified === false)
- const forbiddenNodes = []
+ const computedProperties = new Set()
// ----------------------------------------------------------------------
// Public
// ----------------------------------------------------------------------
return Object.assign({},
+ utils.defineVueVisitor(context,
+ {
+ ObjectExpression (obj, { node: vueNode }) {
+ if (obj !== vueNode) {
+ return
+ }
+ for (const computedProperty of utils.getComputedProperties(obj)) {
+ computedProperties.add(computedProperty)
+ }
+ }
+ }
+ ),
utils.executeOnFunctionsWithoutReturn(treatUndefinedAsUnspecified, node => {
- forbiddenNodes.push(node)
- }),
- utils.executeOnVue(context, properties => {
- const computedProperties = utils.getComputedProperties(properties)
-
computedProperties.forEach(cp => {
- forbiddenNodes.forEach(el => {
- if (cp.value && cp.value.parent === el) {
- context.report({
- node: el,
- message: 'Expected to return a value in "{{name}}" computed property.',
- data: {
- name: cp.key
- }
- })
- }
- })
+ if (cp.value && cp.value.parent === node) {
+ context.report({
+ node,
+ message: 'Expected to return a value in "{{name}}" computed property.',
+ data: {
+ name: cp.key
+ }
+ })
+ }
})
- })
+ }),
)
}
}
diff --git a/lib/utils/index.js b/lib/utils/index.js
index b302cd6cb..3e1ce4c58 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -17,10 +17,19 @@
* @typedef {import('vue-eslint-parser').AST.ESLintTemplateLiteral} TemplateLiteral
*/
+/**
+ * @typedef {import('eslint').Rule.RuleContext} RuleContext
+ * @typedef {import('vue-eslint-parser').AST.Token} Token
+ */
+
/**
* @typedef { {key: Literal | null, value: null, node: ArrayExpression['elements'][0], propName: string} } ComponentArrayProp
* @typedef { {key: Property['key'], value: Property['value'], node: Property, propName: string} } ComponentObjectProp
*/
+/**
+ * @typedef { {key: Literal | null, value: null, node: ArrayExpression['elements'][0], emitName: string} } ComponentArrayEmit
+ * @typedef { {key: Property['key'], value: Property['value'], node: Property, emitName: string} } ComponentObjectEmit
+ */
// ------------------------------------------------------------------------------
// Helpers
@@ -33,6 +42,11 @@ const assert = require('assert')
const path = require('path')
const vueEslintParser = require('vue-eslint-parser')
+/**
+ * @type { WeakMap }
+ */
+const componentComments = new WeakMap()
+
/**
* Wrap the rule context object to override methods which access to tokens (such as getTokenAfter).
* @param {RuleContext} context The rule context object.
@@ -422,10 +436,8 @@ module.exports = {
return []
}
- let props
-
if (propsNode.value.type === 'ObjectExpression') {
- props = propsNode.value.properties
+ return propsNode.value.properties
.filter(prop => prop.type === 'Property')
.map(prop => {
return {
@@ -434,15 +446,50 @@ module.exports = {
}
})
} else {
- props = propsNode.value.elements
+ return propsNode.value.elements
.filter(prop => prop)
.map(prop => {
const key = prop.type === 'Literal' && typeof prop.value === 'string' ? prop : null
return { key, value: null, node: prop, propName: key != null ? prop.value : null }
})
}
+ },
+
+ /**
+ * Get all emits by looking at all component's properties
+ * @param {ObjectExpression} componentObject Object with component definition
+ * @return {(ComponentArrayEmit | ComponentObjectEmit)[]} Array of component emits in format: [{key?: String, value?: ASTNode, node: ASTNod}]
+ */
+ getComponentEmits (componentObject) {
+ const emitsNode = componentObject.properties
+ .find(p =>
+ p.type === 'Property' &&
+ p.key.type === 'Identifier' &&
+ p.key.name === 'emits' &&
+ (p.value.type === 'ObjectExpression' || p.value.type === 'ArrayExpression')
+ )
+
+ if (!emitsNode) {
+ return []
+ }
- return props
+ if (emitsNode.value.type === 'ObjectExpression') {
+ return emitsNode.value.properties
+ .filter(prop => prop.type === 'Property')
+ .map(prop => {
+ return {
+ key: prop.key, value: unwrapTypes(prop.value), node: prop,
+ emitName: getStaticPropertyName(prop)
+ }
+ })
+ } else {
+ return emitsNode.value.elements
+ .filter(prop => prop)
+ .map(prop => {
+ const key = prop.type === 'Literal' && typeof prop.value === 'string' ? prop : null
+ return { key, value: null, node: prop, emitName: key != null ? prop.value : null }
+ })
+ }
},
/**
@@ -484,115 +531,66 @@ module.exports = {
})
},
- isVueFile (path) {
- return path.endsWith('.vue') || path.endsWith('.jsx')
- },
+ isVueFile,
/**
- * Check whether the given node is a Vue component based
- * on the filename and default export type
- * export default {} in .vue || .jsx
- * @param {ASTNode} node Node to check
- * @param {string} path File name with extension
- * @returns {boolean}
+ * Check if current file is a Vue instance or component and call callback
+ * @param {RuleContext} context The ESLint rule context object.
+ * @param {Function} cb Callback function
*/
- isVueComponentFile (node, path) {
- return this.isVueFile(path) &&
- node.type === 'ExportDefaultDeclaration' &&
- node.declaration.type === 'ObjectExpression'
+ executeOnVue (context, cb) {
+ return compositingVisitors(
+ this.executeOnVueComponent(context, cb),
+ this.executeOnVueInstance(context, cb)
+ )
},
/**
- * Check whether given node is Vue component
- * Vue.component('xxx', {}) || component('xxx', {})
- * @param {ASTNode} node Node to check
- * @returns {boolean}
+ * Define handlers to traverse the Vue Objects.
+ * @param {RuleContext} context The ESLint rule context object.
+ * @param {Object} visitor The visitor to traverse the Vue Objects.
+ * @param {Function} cb Callback function
*/
- isVueComponent (node) {
- if (node.type === 'CallExpression') {
- const callee = node.callee
-
- if (callee.type === 'MemberExpression') {
- const calleeObject = unwrapTypes(callee.object)
-
- if (calleeObject.type === 'Identifier') {
- const propName = getStaticPropertyName(callee.property)
- if (calleeObject.name === 'Vue') {
- // for Vue.js 2.x
- // Vue.component('xxx', {}) || Vue.mixin({}) || Vue.extend('xxx', {})
- const isFullVueComponentForVue2 =
- ['component', 'mixin', 'extend'].includes(propName) &&
- isObjectArgument(node)
+ defineVueVisitor (context, visitor) {
+ let vueStack = null
- return isFullVueComponentForVue2
- }
-
- // for Vue.js 3.x
- // app.component('xxx', {}) || app.mixin({})
- const isFullVueComponent =
- ['component', 'mixin'].includes(propName) &&
- isObjectArgument(node)
-
- return isFullVueComponent
- }
+ function callVisitor (key, node) {
+ if (visitor[key] && vueStack) {
+ visitor[key](node, vueStack)
}
-
- if (callee.type === 'Identifier') {
- if (callee.name === 'component') {
- // for Vue.js 2.x
- // component('xxx', {})
- const isDestructedVueComponent = isObjectArgument(node)
- return isDestructedVueComponent
- }
- if (callee.name === 'createApp') {
- // for Vue.js 3.x
- // createApp({})
- const isAppVueComponent = isObjectArgument(node)
- return isAppVueComponent
- }
- if (callee.name === 'defineComponent') {
- // for Vue.js 3.x
- // defineComponent({})
- const isDestructedVueComponent = isObjectArgument(node)
- return isDestructedVueComponent
- }
+ }
+ function objectEnter (node) {
+ const type = getVueObjectType(context, node)
+ if (type) {
+ vueStack = { node, type, parent: vueStack }
+ }
+ }
+ function objectExit (node) {
+ if (vueStack && vueStack.node === node) {
+ vueStack = vueStack.parent
}
}
- return false
+ const vueVisitor = {}
+ for (const key in visitor) {
+ vueVisitor[key] = (node) => callVisitor(key, node)
+ }
- function isObjectArgument (node) {
- return node.arguments.length > 0 &&
- unwrapTypes(node.arguments.slice(-1)[0]).type === 'ObjectExpression'
+ return {
+ ...vueVisitor,
+ ObjectExpression: vueVisitor.ObjectExpression ? (node) => {
+ objectEnter(node)
+ vueVisitor.ObjectExpression(node)
+ } : objectEnter,
+ 'ObjectExpression:exit': vueVisitor['ObjectExpression:exit'] ? (node) => {
+ vueVisitor['ObjectExpression:exit'](node)
+ objectExit(node)
+ } : objectExit
}
},
- /**
- * Check whether given node is new Vue instance
- * new Vue({})
- * @param {ASTNode} node Node to check
- * @returns {boolean}
- */
- isVueInstance (node) {
- const callee = node.callee
- return node.type === 'NewExpression' &&
- callee.type === 'Identifier' &&
- callee.name === 'Vue' &&
- node.arguments.length &&
- unwrapTypes(node.arguments[0]).type === 'ObjectExpression'
- },
-
- /**
- * Check if current file is a Vue instance or component and call callback
- * @param {RuleContext} context The ESLint rule context object.
- * @param {Function} cb Callback function
- */
- executeOnVue (context, cb) {
- return Object.assign(
- this.executeOnVueComponent(context, cb),
- this.executeOnVueInstance(context, cb)
- )
- },
+ getVueObjectType,
+ compositingVisitors,
/**
* Check if current file is a Vue instance (new Vue) and call callback
@@ -600,13 +598,11 @@ module.exports = {
* @param {Function} cb Callback function
*/
executeOnVueInstance (context, cb) {
- const _this = this
-
return {
- 'NewExpression:exit' (node) {
- // new Vue({})
- if (!_this.isVueInstance(node)) return
- cb(node.arguments[0])
+ 'ObjectExpression:exit' (node) {
+ const type = getVueObjectType(context, node)
+ if (!type || type !== 'instance') return
+ cb(node, type)
}
}
},
@@ -617,32 +613,11 @@ module.exports = {
* @param {Function} cb Callback function
*/
executeOnVueComponent (context, cb) {
- const filePath = context.getFilename()
- const sourceCode = context.getSourceCode()
- const _this = this
- const componentComments = sourceCode.getAllComments().filter(comment => /@vue\/component/g.test(comment.value))
- const foundNodes = []
-
- const isDuplicateNode = (node) => {
- if (foundNodes.some(el => el.loc.start.line === node.loc.start.line)) return true
- foundNodes.push(node)
- return false
- }
-
return {
'ObjectExpression:exit' (node) {
- if (!componentComments.some(el => el.loc.end.line === node.loc.start.line - 1) || isDuplicateNode(node)) return
- cb(node)
- },
- 'ExportDefaultDeclaration:exit' (node) {
- // export default {} in .vue || .jsx
- if (!_this.isVueComponentFile(node, filePath) || isDuplicateNode(node.declaration)) return
- cb(node.declaration)
- },
- 'CallExpression:exit' (node) {
- // Vue.component('xxx', {}) || component('xxx', {})
- if (!_this.isVueComponent(node) || isDuplicateNode(node.arguments.slice(-1)[0])) return
- cb(unwrapTypes(node.arguments.slice(-1)[0]))
+ const type = getVueObjectType(context, node)
+ if (!type || (type !== 'mark' && type !== 'export' && type !== 'definition')) return
+ cb(node, type)
}
}
},
@@ -882,14 +857,15 @@ module.exports = {
*/
unwrapTypes
}
+
/**
-* Unwrap typescript types like "X as F"
-* @template T
-* @param {T} node
-* @return {T}
-*/
+ * Unwrap typescript types like "X as F"
+ * @template T
+ * @param {T} node
+ * @return {T}
+ */
function unwrapTypes (node) {
- return node.type === 'TSAsExpression' ? node.expression : node
+ return !node ? node : node.type === 'TSAsExpression' ? unwrapTypes(node.expression) : node
}
/**
@@ -933,3 +909,174 @@ function getStaticPropertyName (node) {
return null
}
+
+function isVueFile (path) {
+ return path.endsWith('.vue') || path.endsWith('.jsx')
+}
+
+/**
+ * Check whether the given node is a Vue component based
+ * on the filename and default export type
+ * export default {} in .vue || .jsx
+ * @param {ASTNode} node Node to check
+ * @param {string} path File name with extension
+ * @returns {boolean}
+ */
+function isVueComponentFile (node, path) {
+ return isVueFile(path) &&
+ node.type === 'ExportDefaultDeclaration' &&
+ node.declaration.type === 'ObjectExpression'
+}
+
+/**
+ * Check whether given node is Vue component
+ * Vue.component('xxx', {}) || component('xxx', {})
+ * @param {ASTNode} node Node to check
+ * @returns {boolean}
+ */
+function isVueComponent (node) {
+ if (node.type === 'CallExpression') {
+ const callee = node.callee
+
+ if (callee.type === 'MemberExpression') {
+ const calleeObject = unwrapTypes(callee.object)
+
+ if (calleeObject.type === 'Identifier') {
+ const propName = getStaticPropertyName(callee.property)
+ if (calleeObject.name === 'Vue') {
+ // for Vue.js 2.x
+ // Vue.component('xxx', {}) || Vue.mixin({}) || Vue.extend('xxx', {})
+ const isFullVueComponentForVue2 =
+ ['component', 'mixin', 'extend'].includes(propName) &&
+ isObjectArgument(node)
+
+ return isFullVueComponentForVue2
+ }
+
+ // for Vue.js 3.x
+ // app.component('xxx', {}) || app.mixin({})
+ const isFullVueComponent =
+ ['component', 'mixin'].includes(propName) &&
+ isObjectArgument(node)
+
+ return isFullVueComponent
+ }
+ }
+
+ if (callee.type === 'Identifier') {
+ if (callee.name === 'component') {
+ // for Vue.js 2.x
+ // component('xxx', {})
+ const isDestructedVueComponent = isObjectArgument(node)
+ return isDestructedVueComponent
+ }
+ if (callee.name === 'createApp') {
+ // for Vue.js 3.x
+ // createApp({})
+ const isAppVueComponent = isObjectArgument(node)
+ return isAppVueComponent
+ }
+ if (callee.name === 'defineComponent') {
+ // for Vue.js 3.x
+ // defineComponent({})
+ const isDestructedVueComponent = isObjectArgument(node)
+ return isDestructedVueComponent
+ }
+ }
+ }
+
+ return false
+
+ function isObjectArgument (node) {
+ return node.arguments.length > 0 &&
+ unwrapTypes(node.arguments.slice(-1)[0]).type === 'ObjectExpression'
+ }
+}
+
+/**
+ * Check whether given node is new Vue instance
+ * new Vue({})
+ * @param {ASTNode} node Node to check
+ * @returns {boolean}
+ */
+function isVueInstance (node) {
+ const callee = node.callee
+ return node.type === 'NewExpression' &&
+ callee.type === 'Identifier' &&
+ callee.name === 'Vue' &&
+ node.arguments.length &&
+ unwrapTypes(node.arguments[0]).type === 'ObjectExpression'
+}
+
+/**
+ * If the given object is a Vue component or instance, returns the Vue definition type.
+ * @param {RuleContext} context The ESLint rule context object.
+ * @param {ObjectExpression} node Node to check
+ * @returns { 'mark' | 'export' | 'definition' | 'instance' | null } The Vue definition type.
+ */
+function getVueObjectType (context, node) {
+ if (node.type !== 'ObjectExpression') {
+ return null
+ }
+ let parent = node.parent
+ while (parent && parent.type === 'TSAsExpression') {
+ parent = parent.parent
+ }
+ if (parent) {
+ if (parent.type === 'ExportDefaultDeclaration') {
+ // export default {} in .vue || .jsx
+ const filePath = context.getFilename()
+ if (isVueComponentFile(parent, filePath) && unwrapTypes(parent.declaration) === node) {
+ return 'export'
+ }
+ } else if (parent.type === 'CallExpression') {
+ // Vue.component('xxx', {}) || component('xxx', {})
+ if (isVueComponent(parent) && unwrapTypes(parent.arguments.slice(-1)[0]) === node) {
+ return 'definition'
+ }
+ } else if (parent.type === 'NewExpression') {
+ // new Vue({})
+ if (isVueInstance(parent) && unwrapTypes(parent.arguments[0]) === node) {
+ return 'instance'
+ }
+ }
+ }
+ if (getComponentComments(context).some(el => el.loc.end.line === node.loc.start.line - 1)) {
+ return 'mark'
+ }
+ return null
+}
+
+/**
+ * Gets the component comments of a given context.
+ * @param {RuleContext} context The ESLint rule context object.
+ * @return {Token[]} The the component comments.
+ */
+function getComponentComments (context) {
+ let tokens = componentComments.get(context)
+ if (tokens) {
+ return tokens
+ }
+ const sourceCode = context.getSourceCode()
+ tokens = sourceCode.getAllComments().filter(comment => /@vue\/component/g.test(comment.value))
+ componentComments.set(context, tokens)
+ return tokens
+}
+
+function compositingVisitors (...visitors) {
+ const visitor = {}
+ for (const v of visitors) {
+ for (const key in v) {
+ if (visitor[key]) {
+ const o = visitor[key]
+ visitor[key] = (node) => {
+ o(node)
+ v[key](node)
+ }
+ } else {
+ visitor[key] = v[key]
+ }
+ }
+ }
+ return visitor
+}
diff --git a/tests/lib/rules/no-setup-props-destructure.js b/tests/lib/rules/no-setup-props-destructure.js
index 2f3bccc51..e81d680fc 100644
--- a/tests/lib/rules/no-setup-props-destructure.js
+++ b/tests/lib/rules/no-setup-props-destructure.js
@@ -135,6 +135,30 @@ tester.run('no-setup-props-destructure', rule, {
}
`
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
}
],
invalid: [
@@ -330,6 +354,42 @@ tester.run('no-setup-props-destructure', rule, {
line: 6
}
]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ messageId: 'getProperty',
+ line: 5
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ messageId: 'getProperty',
+ line: 5
+ }
+ ]
}
]
})
diff --git a/tests/lib/rules/require-explicit-emits.js b/tests/lib/rules/require-explicit-emits.js
new file mode 100644
index 000000000..0a084ab43
--- /dev/null
+++ b/tests/lib/rules/require-explicit-emits.js
@@ -0,0 +1,1433 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/require-explicit-emits')
+
+const tester = new RuleTester({
+ parser: require.resolve('vue-eslint-parser'),
+ parserOptions: {
+ ecmaVersion: 2019,
+ sourceType: 'module'
+ }
+})
+
+tester.run('require-explicit-emits', rule, {
+ valid: [
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ // unknown
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+
+
+
+
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `
+ }
+ ],
+ invalid: [
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ line: 3,
+ column: 28,
+ messageId: 'missing',
+ endLine: 3,
+ endColumn: 33,
+ suggestions: [
+ {
+ desc: 'Add the `emits` option with array syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ },
+ {
+ desc: 'Add the `emits` option with object syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ line: 3,
+ column: 28,
+ messageId: 'missing',
+ endLine: 3,
+ endColumn: 33,
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ line: 3,
+ column: 28,
+ messageId: 'missing',
+ endLine: 3,
+ endColumn: 33,
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ line: 3,
+ column: 28,
+ messageId: 'missing',
+ endLine: 3,
+ endColumn: 33,
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ line: 7,
+ column: 24,
+ messageId: 'missing',
+ endLine: 7,
+ endColumn: 29,
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ line: 8,
+ column: 22,
+ messageId: 'missing',
+ endLine: 8,
+ endColumn: 27,
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ line: 6,
+ column: 24,
+ messageId: 'missing',
+ endLine: 6,
+ endColumn: 29,
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ line: 6,
+ column: 16,
+ messageId: 'missing',
+ endLine: 6,
+ endColumn: 21,
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+ `,
+ errors: [
+ {
+ line: 6,
+ column: 16,
+ messageId: 'missing',
+ endLine: 6,
+ endColumn: 21,
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+ `
+ }
+ ]
+ },
+ {
+ line: 7,
+ column: 16,
+ messageId: 'missing',
+ endLine: 7,
+ endColumn: 21,
+ suggestions: [
+ {
+ desc: 'Add the "bar" to `emits` option.',
+ output: `
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the `emits` option with array syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ },
+ {
+ desc: 'Add the `emits` option with object syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ line: 3,
+ column: 28,
+ messageId: 'missing',
+ endLine: 3,
+ endColumn: 33,
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ },
+ {
+ message: 'The "bar" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the "bar" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ },
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ },
+ {
+ message: 'The "bar" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the "bar" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ },
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the "foo" to `emits` option.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: []
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the `emits` option with array syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ }, {
+ desc: 'Add the `emits` option with object syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the `emits` option with array syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ }, {
+ desc: 'Add the `emits` option with object syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the `emits` option with array syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ }, {
+ desc: 'Add the `emits` option with object syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ },
+ {
+ filename: 'test.vue',
+ code: `
+
+
+
+
+ `,
+ errors: [
+ {
+ message: 'The "foo" event has been triggered but not declared on `emits` option.',
+ suggestions: [
+ {
+ desc: 'Add the `emits` option with array syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ }, {
+ desc: 'Add the `emits` option with object syntax and define "foo" event.',
+ output: `
+
+
+
+
+ `
+ }
+ ]
+ }
+ ]
+ }
+ ]
+})