From 2e3468e4cb77cff2b89fd0a14383efdb6bb99ea2 Mon Sep 17 00:00:00 2001 From: ota Date: Fri, 12 Jun 2020 20:27:53 +0900 Subject: [PATCH] Supports Optional Chaining --- .../components/eslint-code-block.vue | 27 +-- docs/rules/valid-v-bind-sync.md | 6 +- docs/rules/valid-v-model.md | 5 +- lib/rules/custom-event-name-casing.js | 2 +- lib/rules/no-async-in-computed-properties.js | 33 ++- lib/rules/no-deprecated-events-api.js | 25 +- .../no-deprecated-vue-config-keycodes.js | 4 +- lib/rules/no-multiple-slot-args.js | 9 +- lib/rules/no-setup-props-destructure.js | 8 +- lib/rules/no-unused-properties.js | 10 +- lib/rules/no-watch-after-await.js | 6 +- lib/rules/order-in-components.js | 4 +- lib/rules/require-default-prop.js | 20 +- lib/rules/require-explicit-emits.js | 22 +- lib/rules/require-slots-as-functions.js | 15 +- lib/rules/require-valid-default-prop.js | 7 +- lib/rules/v-on-function-call.js | 4 + lib/rules/valid-v-bind-sync.js | 69 +++++- lib/rules/valid-v-model.js | 101 +++++++-- lib/utils/index.js | 135 ++++------- tests/lib/rules/custom-event-name-casing.js | 62 ++++- .../rules/no-async-in-computed-properties.js | 90 +++++++- .../no-deprecated-dollar-listeners-api.js | 26 ++- .../no-deprecated-dollar-scopedslots-api.js | 37 ++- tests/lib/rules/no-deprecated-events-api.js | 52 ++++- .../no-deprecated-vue-config-keycodes.js | 12 +- tests/lib/rules/no-lifecycle-after-await.js | 22 +- tests/lib/rules/no-multiple-slot-args.js | 86 ++++++- tests/lib/rules/no-mutating-props.js | 68 ++++++ tests/lib/rules/no-ref-as-operand.js | 16 +- tests/lib/rules/no-setup-props-destructure.js | 34 ++- .../no-side-effects-in-computed-properties.js | 23 +- tests/lib/rules/no-unused-properties.js | 35 +++ tests/lib/rules/no-watch-after-await.js | 23 +- tests/lib/rules/order-in-components.js | 4 +- tests/lib/rules/require-default-prop.js | 23 +- tests/lib/rules/require-explicit-emits.js | 39 +++- tests/lib/rules/require-slots-as-functions.js | 24 +- tests/lib/rules/require-valid-default-prop.js | 25 ++ tests/lib/rules/this-in-template.js | 17 +- tests/lib/rules/v-on-function-call.js | 7 +- tests/lib/rules/valid-v-bind-sync.js | 38 +++- tests/lib/rules/valid-v-model.js | 32 ++- tests/lib/utils/index.js | 213 ++++++++++++------ 44 files changed, 1224 insertions(+), 296 deletions(-) diff --git a/docs/.vuepress/components/eslint-code-block.vue b/docs/.vuepress/components/eslint-code-block.vue index fed685d91..391ae5490 100644 --- a/docs/.vuepress/components/eslint-code-block.vue +++ b/docs/.vuepress/components/eslint-code-block.vue @@ -32,7 +32,7 @@ export default { }, rules: { type: Object, - default () { + default() { return {} } }, @@ -46,7 +46,7 @@ export default { } }, - data () { + data() { return { linter: null, preprocess: processors['.vue'].preprocess, @@ -59,7 +59,7 @@ export default { }, computed: { - config () { + config() { return { globals: { // ES2015 globals @@ -89,7 +89,7 @@ export default { rules: this.rules, parser: 'vue-eslint-parser', parserOptions: { - ecmaVersion: 2019, + ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { jsx: true @@ -98,33 +98,30 @@ export default { } }, - code () { + code() { return `${this.computeCodeFromSlot(this.$slots.default).trim()}\n` }, - height () { + height() { const lines = this.code.split('\n').length return `${Math.max(120, 19 * lines)}px` } }, methods: { - computeCodeFromSlot (nodes) { + computeCodeFromSlot(nodes) { if (!Array.isArray(nodes)) { return '' } - return nodes.map(node => - node.text || this.computeCodeFromSlot(node.children) - ).join('') + return nodes + .map((node) => node.text || this.computeCodeFromSlot(node.children)) + .join('') } }, - async mounted () { + async mounted() { // Load linter. - const [ - { default: Linter }, - { parseForESLint } - ] = await Promise.all([ + const [{ default: Linter }, { parseForESLint }] = await Promise.all([ import('eslint4b/dist/linter'), import('espree').then(() => import('vue-eslint-parser')) ]) diff --git a/docs/rules/valid-v-bind-sync.md b/docs/rules/valid-v-bind-sync.md index 9ebd51fd1..163d8b217 100644 --- a/docs/rules/valid-v-bind-sync.md +++ b/docs/rules/valid-v-bind-sync.md @@ -15,7 +15,8 @@ This rule checks whether every `.sync` modifier on `v-bind` directives is valid. This rule reports `.sync` modifier on `v-bind` directives in the following cases: -- The `.sync` modifier does not have the attribute value which is valid as LHS. E.g. `` +- The `.sync` modifier does not have the attribute value which is valid as LHS. E.g. ``, `` +- The `.sync` modifier has potential null object property access. E.g. `` - The `.sync` modifier is on non Vue-components. E.g. `` - The `.sync` modifier's reference is iteration variables. E.g. `
` @@ -36,6 +37,9 @@ This rule reports `.sync` modifier on `v-bind` directives in the following cases + + + diff --git a/docs/rules/valid-v-model.md b/docs/rules/valid-v-model.md index f3a0afca3..345e3bf9d 100644 --- a/docs/rules/valid-v-model.md +++ b/docs/rules/valid-v-model.md @@ -18,7 +18,8 @@ This rule reports `v-model` directives in the following cases: - The directive used on HTMLElement has an argument. E.g. `` - The directive used on HTMLElement has modifiers which are not supported. E.g. `` - The directive does not have that attribute value. E.g. `` -- The directive does not have the attribute value which is valid as LHS. E.g. `` +- The directive does not have the attribute value which is valid as LHS. E.g. ``, `` +- The directive has potential null object property access. E.g. `` - The directive is on unsupported elements. E.g. `
` - The directive is on `` elements which their types are `file`. E.g. `` - The directive's reference is iteration variables. E.g. `
` @@ -44,6 +45,8 @@ This rule reports `v-model` directives in the following cases: + +
diff --git a/lib/rules/custom-event-name-casing.js b/lib/rules/custom-event-name-casing.js index cd7f73443..967cd6789 100644 --- a/lib/rules/custom-event-name-casing.js +++ b/lib/rules/custom-event-name-casing.js @@ -48,7 +48,7 @@ function getNameParamNode(node) { * @param {CallExpression} node CallExpression */ function getCalleeMemberNode(node) { - const callee = node.callee + const callee = utils.unwrapChainExpression(node.callee) if (callee.type === 'MemberExpression') { const name = utils.getStaticPropertyName(callee) diff --git a/lib/rules/no-async-in-computed-properties.js b/lib/rules/no-async-in-computed-properties.js index e3080d7d5..461a63513 100644 --- a/lib/rules/no-async-in-computed-properties.js +++ b/lib/rules/no-async-in-computed-properties.js @@ -26,16 +26,17 @@ const TIMED_FUNCTIONS = [ * @param {CallExpression} node */ function isTimedFunction(node) { + const callee = utils.unwrapChainExpression(node.callee) return ( ((node.type === 'CallExpression' && - node.callee.type === 'Identifier' && - TIMED_FUNCTIONS.indexOf(node.callee.name) !== -1) || + callee.type === 'Identifier' && + TIMED_FUNCTIONS.indexOf(callee.name) !== -1) || (node.type === 'CallExpression' && - node.callee.type === 'MemberExpression' && - node.callee.object.type === 'Identifier' && - node.callee.object.name === 'window' && - node.callee.property.type === 'Identifier' && - TIMED_FUNCTIONS.indexOf(node.callee.property.name) !== -1)) && + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'window' && + callee.property.type === 'Identifier' && + TIMED_FUNCTIONS.indexOf(callee.property.name) !== -1)) && node.arguments.length ) } @@ -44,18 +45,16 @@ function isTimedFunction(node) { * @param {CallExpression} node */ function isPromise(node) { - if ( - node.type === 'CallExpression' && - node.callee.type === 'MemberExpression' - ) { + const callee = utils.unwrapChainExpression(node.callee) + if (node.type === 'CallExpression' && callee.type === 'MemberExpression') { return ( // hello.PROMISE_FUNCTION() - (node.callee.property.type === 'Identifier' && - PROMISE_FUNCTIONS.indexOf(node.callee.property.name) !== -1) || // Promise.PROMISE_METHOD() - (node.callee.object.type === 'Identifier' && - node.callee.object.name === 'Promise' && - node.callee.property.type === 'Identifier' && - PROMISE_METHODS.indexOf(node.callee.property.name) !== -1) + (callee.property.type === 'Identifier' && + PROMISE_FUNCTIONS.indexOf(callee.property.name) !== -1) || // Promise.PROMISE_METHOD() + (callee.object.type === 'Identifier' && + callee.object.name === 'Promise' && + callee.property.type === 'Identifier' && + PROMISE_METHODS.indexOf(callee.property.name) !== -1) ) } return false diff --git a/lib/rules/no-deprecated-events-api.js b/lib/rules/no-deprecated-events-api.js index a09f36bf0..608d306d4 100644 --- a/lib/rules/no-deprecated-events-api.js +++ b/lib/rules/no-deprecated-events-api.js @@ -32,13 +32,26 @@ module.exports = { /** @param {RuleContext} context */ create(context) { return utils.defineVueVisitor(context, { - /** @param {MemberExpression & {parent: CallExpression}} node */ - 'CallExpression > MemberExpression'(node) { - const call = node.parent + /** @param {MemberExpression & ({parent: CallExpression} | {parent: ChainExpression & {parent: CallExpression}})} node */ + 'CallExpression > MemberExpression, CallExpression > ChainExpression > MemberExpression'( + node + ) { + const call = + node.parent.type === 'ChainExpression' + ? node.parent.parent + : node.parent + + if (call.optional) { + // It is OK because checking whether it is deprecated. + // e.g. `this.$on?.()` + return + } + if ( - call.callee !== node || - node.property.type !== 'Identifier' || - !['$on', '$off', '$once'].includes(node.property.name) + utils.unwrapChainExpression(call.callee) !== node || + !['$on', '$off', '$once'].includes( + utils.getStaticPropertyName(node) || '' + ) ) { return } diff --git a/lib/rules/no-deprecated-vue-config-keycodes.js b/lib/rules/no-deprecated-vue-config-keycodes.js index b30db3fed..53effb5d4 100644 --- a/lib/rules/no-deprecated-vue-config-keycodes.js +++ b/lib/rules/no-deprecated-vue-config-keycodes.js @@ -4,6 +4,8 @@ */ 'use strict' +const utils = require('../utils') + // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ @@ -31,7 +33,7 @@ module.exports = { "MemberExpression[property.type='Identifier'][property.name='keyCodes']"( node ) { - const config = node.object + const config = utils.unwrapChainExpression(node.object) if ( config.type !== 'MemberExpression' || config.property.type !== 'Identifier' || diff --git a/lib/rules/no-multiple-slot-args.js b/lib/rules/no-multiple-slot-args.js index f98e058ec..90b6755b9 100644 --- a/lib/rules/no-multiple-slot-args.js +++ b/lib/rules/no-multiple-slot-args.js @@ -100,15 +100,12 @@ module.exports = { return utils.defineVueVisitor(context, { /** @param {MemberExpression} node */ MemberExpression(node) { - const object = node.object + const object = utils.unwrapChainExpression(node.object) if (object.type !== 'MemberExpression') { return } - if ( - object.property.type !== 'Identifier' || - (object.property.name !== '$slots' && - object.property.name !== '$scopedSlots') - ) { + const name = utils.getStaticPropertyName(object) + if (!name || (name !== '$slots' && name !== '$scopedSlots')) { return } if (!utils.isThis(object.object, context)) { diff --git a/lib/rules/no-setup-props-destructure.js b/lib/rules/no-setup-props-destructure.js index 43b1856ee..5dcd4bc93 100644 --- a/lib/rules/no-setup-props-destructure.js +++ b/lib/rules/no-setup-props-destructure.js @@ -49,18 +49,18 @@ module.exports = { return } + const rightNode = utils.unwrapChainExpression(right) if ( left.type !== 'ArrayPattern' && left.type !== 'ObjectPattern' && - right.type !== 'MemberExpression' + rightNode.type !== 'MemberExpression' ) { return } - /** @type {Expression | Super} */ - let rightId = right + let rightId = rightNode while (rightId.type === 'MemberExpression') { - rightId = rightId.object + rightId = utils.unwrapChainExpression(rightId.object) } if (rightId.type === 'Identifier' && propsReferenceIds.has(rightId)) { report(left, 'getProperty') diff --git a/lib/rules/no-unused-properties.js b/lib/rules/no-unused-properties.js index 951d6a2db..f7f30dc84 100644 --- a/lib/rules/no-unused-properties.js +++ b/lib/rules/no-unused-properties.js @@ -221,7 +221,7 @@ function getObjectPatternPropertyPatternTracker(pattern) { } /** - * @param {Identifier | MemberExpression | ThisExpression} node + * @param {Identifier | MemberExpression | ChainExpression | ThisExpression} node * @param {RuleContext} context * @returns {UsedProps} */ @@ -304,6 +304,14 @@ function extractPatternOrThisProperties(node, context) { } } } + } else if (parent.type === 'ChainExpression') { + const { usedNames, unknown, calls } = extractPatternOrThisProperties( + parent, + context + ) + result.usedNames.addAll(usedNames) + result.unknown = result.unknown || unknown + result.calls.push(...calls) } return result } diff --git a/lib/rules/no-watch-after-await.js b/lib/rules/no-watch-after-await.js index d9a8c3822..8a79555dd 100644 --- a/lib/rules/no-watch-after-await.js +++ b/lib/rules/no-watch-after-await.js @@ -7,7 +7,8 @@ const { ReferenceTracker } = require('eslint-utils') const utils = require('../utils') /** - * @param {CallExpression} node + * @param {CallExpression | ChainExpression} node + * @returns {boolean} */ function isMaybeUsedStopHandle(node) { const parent = node.parent @@ -32,6 +33,9 @@ function isMaybeUsedStopHandle(node) { // [watch()] return true } + if (parent.type === 'ChainExpression') { + return isMaybeUsedStopHandle(parent) + } } return false } diff --git a/lib/rules/order-in-components.js b/lib/rules/order-in-components.js index fcff89ab2..e99f0bebd 100644 --- a/lib/rules/order-in-components.js +++ b/lib/rules/order-in-components.js @@ -185,7 +185,9 @@ function isNotSideEffectsNode(node, visitorKeys) { node.type !== 'ConditionalExpression' && // es2015 node.type !== 'SpreadElement' && - node.type !== 'TemplateLiteral' + node.type !== 'TemplateLiteral' && + // es2020 + node.type !== 'ChainExpression' ) { // Can not be sure that a node has no side effects result = false diff --git a/lib/rules/require-default-prop.js b/lib/rules/require-default-prop.js index ff014ca8f..54125fe98 100644 --- a/lib/rules/require-default-prop.js +++ b/lib/rules/require-default-prop.js @@ -81,12 +81,18 @@ module.exports = { function findPropsWithoutDefaultValue(props) { return props.filter((prop) => { if (prop.value.type !== 'ObjectExpression') { - return ( - (prop.value.type !== 'CallExpression' && - prop.value.type !== 'Identifier') || - (prop.value.type === 'Identifier' && - NATIVE_TYPES.has(prop.value.name)) - ) + if (prop.value.type === 'Identifier') { + return NATIVE_TYPES.has(prop.value.name) + } + if ( + prop.value.type === 'CallExpression' || + prop.value.type === 'MemberExpression' + ) { + // OK + return false + } + // NG + return true } return ( @@ -98,7 +104,7 @@ module.exports = { /** * Detects whether given value node is a Boolean type - * @param {Expression | Pattern} value + * @param {Expression} value * @return {Boolean} */ function isValueNodeOfBooleanType(value) { diff --git a/lib/rules/require-explicit-emits.js b/lib/rules/require-explicit-emits.js index 7dc1706bb..903210c24 100644 --- a/lib/rules/require-explicit-emits.js +++ b/lib/rules/require-explicit-emits.js @@ -261,7 +261,7 @@ module.exports = { * @param {VueObjectData} data */ 'CallExpression[arguments.0.type=Literal]'(node, { node: vueNode }) { - const callee = node.callee + const callee = utils.unwrapChainExpression(node.callee) const nameLiteralNode = node.arguments[0] if (!nameLiteralNode || typeof nameLiteralNode.value !== 'string') { // cannot check @@ -287,20 +287,22 @@ module.exports = { if (callee.type === 'Identifier' && emitReferenceIds.has(callee)) { // verify setup(props,{emit}) {emit()} verify(emitsDeclarations, nameLiteralNode, vueNode) - } else if ( - emit && - emit.name === 'emit' && - emit.member.object.type === 'Identifier' && - contextReferenceIds.has(emit.member.object) - ) { - // verify setup(props,context) {context.emit()} - verify(emitsDeclarations, nameLiteralNode, vueNode) + } else if (emit && emit.name === 'emit') { + const memObject = utils.unwrapChainExpression(emit.member.object) + if ( + memObject.type === 'Identifier' && + contextReferenceIds.has(memObject) + ) { + // verify setup(props,context) {context.emit()} + verify(emitsDeclarations, nameLiteralNode, vueNode) + } } } // verify $emit if (emit && emit.name === '$emit') { - if (utils.isThis(emit.member.object, context)) { + const memObject = utils.unwrapChainExpression(emit.member.object) + if (utils.isThis(memObject, context)) { // verify this.$emit() verify(emitsDeclarations, nameLiteralNode, vueNode) } diff --git a/lib/rules/require-slots-as-functions.js b/lib/rules/require-slots-as-functions.js index 36a3f9339..2a3180f4e 100644 --- a/lib/rules/require-slots-as-functions.js +++ b/lib/rules/require-slots-as-functions.js @@ -33,7 +33,7 @@ module.exports = { create(context) { /** * Verify the given node - * @param {MemberExpression | Identifier} node The node to verify + * @param {MemberExpression | Identifier | ChainExpression} node The node to verify * @param {Expression} reportNode The node to report */ function verify(node, reportNode) { @@ -58,6 +58,12 @@ module.exports = { return } + if (parent.type === 'ChainExpression') { + // (this.$slots?.foo).x + verify(parent, reportNode) + return + } + if ( // this.$slots.foo.xxx parent.type === 'MemberExpression' || @@ -97,14 +103,11 @@ module.exports = { return utils.defineVueVisitor(context, { /** @param {MemberExpression} node */ MemberExpression(node) { - const object = node.object + const object = utils.unwrapChainExpression(node.object) if (object.type !== 'MemberExpression') { return } - if ( - object.property.type !== 'Identifier' || - object.property.name !== '$slots' - ) { + if (utils.getStaticPropertyName(object) !== '$slots') { return } if (!utils.isThis(object.object, context)) { diff --git a/lib/rules/require-valid-default-prop.js b/lib/rules/require-valid-default-prop.js index 5003d20c7..5a211015a 100644 --- a/lib/rules/require-valid-default-prop.js +++ b/lib/rules/require-valid-default-prop.js @@ -48,7 +48,7 @@ function getPropertyNode(obj, name) { } /** - * @param {Expression | Pattern} node + * @param {Expression} node * @returns {string[]} */ function getTypes(node) { @@ -120,10 +120,11 @@ module.exports = { } /** - * @param {Expression | Pattern} node + * @param {Expression} targetNode * @returns { StandardValueType | FunctionExprValueType | FunctionValueType | null } */ - function getValueType(node) { + function getValueType(targetNode) { + const node = utils.unwrapChainExpression(targetNode) if (node.type === 'CallExpression') { // Symbol(), Number() ... if ( diff --git a/lib/rules/v-on-function-call.js b/lib/rules/v-on-function-call.js index 04eb79792..4befe9f8c 100644 --- a/lib/rules/v-on-function-call.js +++ b/lib/rules/v-on-function-call.js @@ -85,6 +85,10 @@ module.exports = { if (expression.type !== 'CallExpression' || expression.arguments.length) { return null } + if (expression.optional) { + // Allow optional chaining + return null + } const callee = expression.callee if (callee.type !== 'Identifier') { return null diff --git a/lib/rules/valid-v-bind-sync.js b/lib/rules/valid-v-bind-sync.js index 3799d0895..48452f822 100644 --- a/lib/rules/valid-v-bind-sync.js +++ b/lib/rules/valid-v-bind-sync.js @@ -32,7 +32,22 @@ function isValidElement(node) { } /** - * Check whether the given node can be LHS. + * Check whether the given node is a MemberExpression containing an optional chaining. + * e.g. + * - `a?.b` + * - `a?.b.c` + * @param {ASTNode} node The node to check. + * @returns {boolean} `true` if the node is a MemberExpression containing an optional chaining. + */ +function isOptionalChainingMemberExpression(node) { + return ( + node.type === 'ChainExpression' && + node.expression.type === 'MemberExpression' + ) +} + +/** + * Check whether the given node can be LHS (left-hand side). * @param {ASTNode} node The node to check. * @returns {boolean} `true` if the node can be LHS. */ @@ -40,6 +55,33 @@ function isLhs(node) { return node.type === 'Identifier' || node.type === 'MemberExpression' } +/** + * Check whether the given node is a MemberExpression of a possibly null object. + * e.g. + * - `(a?.b).c` + * - `(null).foo` + * @param {ASTNode} node The node to check. + * @returns {boolean} `true` if the node is a MemberExpression of a possibly null object. + */ +function maybeNullObjectMemberExpression(node) { + if (node.type !== 'MemberExpression') { + return false + } + const { object } = node + if (object.type === 'ChainExpression') { + // `(a?.b).c` + return true + } + if (object.type === 'Literal' && object.value === null && !object.bigint) { + // `(null).foo` + return true + } + if (object.type === 'MemberExpression') { + return maybeNullObjectMemberExpression(object) + } + return false +} + // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ @@ -57,8 +99,12 @@ module.exports = { messages: { unexpectedInvalidElement: "'.sync' modifiers aren't supported on <{{name}}> non Vue-components.", + unexpectedOptionalChaining: + "Optional chaining cannot appear in 'v-bind' with '.sync' modifiers.", unexpectedNonLhsExpression: "'.sync' modifiers require the attribute value which is valid as LHS.", + unexpectedNullObject: + "'.sync' modifier has potential null object property access.", unexpectedUpdateIterationVariable: "'.sync' modifiers cannot update the iteration variable '{{varName}}' itself." } @@ -83,16 +129,33 @@ module.exports = { }) } - if (!node.value || !node.value.expression) { + if (!node.value) { return } - if (!isLhs(node.value.expression)) { + const expression = node.value.expression + if (!expression) { + // Parsing error + return + } + if (isOptionalChainingMemberExpression(expression)) { + context.report({ + node, + loc: node.loc, + messageId: 'unexpectedOptionalChaining' + }) + } else if (!isLhs(expression)) { context.report({ node, loc: node.loc, messageId: 'unexpectedNonLhsExpression' }) + } else if (maybeNullObjectMemberExpression(expression)) { + context.report({ + node, + loc: node.loc, + messageId: 'unexpectedNullObject' + }) } for (const reference of node.value.references) { diff --git a/lib/rules/valid-v-model.js b/lib/rules/valid-v-model.js index 451787367..adfe9c2da 100644 --- a/lib/rules/valid-v-model.js +++ b/lib/rules/valid-v-model.js @@ -37,7 +37,22 @@ function isValidElement(node) { } /** - * Check whether the given node can be LHS. + * Check whether the given node is a MemberExpression containing an optional chaining. + * e.g. + * - `a?.b` + * - `a?.b.c` + * @param {ASTNode} node The node to check. + * @returns {boolean} `true` if the node is a MemberExpression containing an optional chaining. + */ +function isOptionalChainingMemberExpression(node) { + return ( + node.type === 'ChainExpression' && + node.expression.type === 'MemberExpression' + ) +} + +/** + * Check whether the given node can be LHS (left-hand side). * @param {ASTNode} node The node to check. * @returns {boolean} `true` if the node can be LHS. */ @@ -45,6 +60,33 @@ function isLhs(node) { return node.type === 'Identifier' || node.type === 'MemberExpression' } +/** + * Check whether the given node is a MemberExpression of a possibly null object. + * e.g. + * - `(a?.b).c` + * - `(null).foo` + * @param {ASTNode} node The node to check. + * @returns {boolean} `true` if the node is a MemberExpression of a possibly null object. + */ +function maybeNullObjectMemberExpression(node) { + if (node.type !== 'MemberExpression') { + return false + } + const { object } = node + if (object.type === 'ChainExpression') { + // `(a?.b).c` + return true + } + if (object.type === 'Literal' && object.value === null && !object.bigint) { + // `(null).foo` + return true + } + if (object.type === 'MemberExpression') { + return maybeNullObjectMemberExpression(object) + } + return false +} + /** * Get the variable by names. * @param {string} name The variable name to find. @@ -76,6 +118,7 @@ function getVariable(name, leafNode) { // Rule Definition // ------------------------------------------------------------------------------ +/** @type {RuleModule} */ module.exports = { meta: { type: 'problem', @@ -85,7 +128,25 @@ module.exports = { url: 'https://eslint.vuejs.org/rules/valid-v-model.html' }, fixable: null, - schema: [] + schema: [], + messages: { + unexpectedInvalidElement: + "'v-model' directives aren't supported on <{{name}}> elements.", + unexpectedInputFile: + "'v-model' directives don't support 'file' input type.", + unexpectedArgument: "'v-model' directives require no argument.", + unexpectedModifier: + "'v-model' directives don't support the modifier '{{name}}'.", + missingValue: "'v-model' directives require that attribute value.", + unexpectedOptionalChaining: + "Optional chaining cannot appear in 'v-model' directives.", + unexpectedNonLhsExpression: + "'v-model' directives require the attribute value which is valid as LHS.", + unexpectedNullObject: + "'v-model' directive has potential null object property access.", + unexpectedUpdateIterationVariable: + "'v-model' directives cannot update the iteration variable '{{varName}}' itself." + } }, /** @param {RuleContext} context */ create(context) { @@ -99,8 +160,7 @@ module.exports = { context.report({ node, loc: node.loc, - message: - "'v-model' directives aren't supported on <{{name}}> elements.", + messageId: 'unexpectedInvalidElement', data: { name } }) } @@ -109,7 +169,7 @@ module.exports = { context.report({ node, loc: node.loc, - message: "'v-model' directives don't support 'file' input type." + messageId: 'unexpectedInputFile' }) } @@ -118,7 +178,7 @@ module.exports = { context.report({ node, loc: node.loc, - message: "'v-model' directives require no argument." + messageId: 'unexpectedArgument' }) } @@ -127,8 +187,7 @@ module.exports = { context.report({ node, loc: node.loc, - message: - "'v-model' directives don't support the modifier '{{name}}'.", + messageId: 'unexpectedModifier', data: { name: modifier.name } }) } @@ -139,20 +198,32 @@ module.exports = { context.report({ node, loc: node.loc, - message: "'v-model' directives require that attribute value." + messageId: 'missingValue' }) return } - if (!node.value.expression) { + const expression = node.value.expression + if (!expression) { // Parsing error return } - if (!isLhs(node.value.expression)) { + if (isOptionalChainingMemberExpression(expression)) { context.report({ node, loc: node.loc, - message: - "'v-model' directives require the attribute value which is valid as LHS." + messageId: 'unexpectedOptionalChaining' + }) + } else if (!isLhs(expression)) { + context.report({ + node, + loc: node.loc, + messageId: 'unexpectedNonLhsExpression' + }) + } else if (maybeNullObjectMemberExpression(expression)) { + context.report({ + node, + loc: node.loc, + messageId: 'unexpectedNullObject' }) } @@ -167,8 +238,8 @@ module.exports = { context.report({ node, loc: node.loc, - message: - "'v-model' directives cannot update the iteration variable '{{varName}}' itself.", + messageId: 'unexpectedUpdateIterationVariable', + data: { varName: id.name } }) } diff --git a/lib/utils/index.js b/lib/utils/index.js index 4e1725efa..b27239de0 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -315,7 +315,6 @@ module.exports = { }) } }, - /** * Checks whether the given value is defined. * @template T @@ -323,18 +322,6 @@ module.exports = { * @returns {v is T} */ isDef, - /** - * Check whether the given node is the root element or not. - * @param {VElement} node The element node to check. - * @returns {boolean} `true` if the node is the root element. - */ - isRootElement(node) { - return ( - node.parent.type === 'VDocumentFragment' || - node.parent.parent.type === 'VDocumentFragment' - ) - }, - /** * Get the previous sibling element of the given element. * @param {VElement} node The element node to get the previous sibling element. @@ -648,36 +635,6 @@ module.exports = { isHtmlVoidElementName(name) { return VOID_ELEMENT_NAMES.has(name) }, - - /** - * Parse member expression node to get array with all of its parts - * @param {ESNode} node MemberExpression - * @returns {string[]} - */ - parseMemberExpression(node) { - const members = [] - - if (node.type === 'MemberExpression') { - /** @type {Expression | Super} */ - let memberExpression = node - - while (memberExpression.type === 'MemberExpression') { - if (memberExpression.property.type === 'Identifier') { - members.push(memberExpression.property.name) - } - memberExpression = memberExpression.object - } - - if (memberExpression.type === 'ThisExpression') { - members.push('this') - } else if (memberExpression.type === 'Identifier') { - members.push(memberExpression.name) - } - } - - return members.reverse() - }, - /** * Gets the property name of a given node. * @param {Property|AssignmentProperty|MethodDefinition|MemberExpression} node - The node to get. @@ -1126,7 +1083,7 @@ module.exports = { */ *iterateObjectExpression(node, groupName) { /** @type {Set | undefined} */ - let usedGetter = new Set() + let usedGetter for (const item of node.properties) { if (item.type === 'Property') { const key = item.key @@ -1313,52 +1270,15 @@ module.exports = { getMemberChaining(node) { /** @type {MemberExpression[]} */ const nodes = [] - let n = node + let n = unwrapChainExpression(node) while (n.type === 'MemberExpression') { nodes.push(n) - n = n.object + n = unwrapChainExpression(n.object) } return [n, ...nodes.reverse()] }, - /** - * Parse CallExpression or MemberExpression to get simplified version without arguments - * - * @param {ESNode} node The node to parse (MemberExpression | CallExpression) - * @return {String} eg. 'this.asd.qwe().map().filter().test.reduce()' - */ - parseMemberOrCallExpression(node) { - const parsedCallee = [] - let n = node - let isFunc - - while (n.type === 'MemberExpression' || n.type === 'CallExpression') { - if (n.type === 'CallExpression') { - n = n.callee - isFunc = true - } else { - if (n.computed) { - parsedCallee.push(`[]${isFunc ? '()' : ''}`) - } else if (n.property.type === 'Identifier') { - parsedCallee.push(n.property.name + (isFunc ? '()' : '')) - } - isFunc = false - n = n.object - } - } - - if (n.type === 'Identifier') { - parsedCallee.push(n.name) - } - - if (n.type === 'ThisExpression') { - parsedCallee.push('this') - } - - return parsedCallee.reverse().join('.').replace(/\.\[/g, '[') - }, - /** * return two string editdistance * @param {string} a string a to compare @@ -1430,6 +1350,12 @@ module.exports = { * @return { RestElement | ArrayPattern | ObjectPattern | Identifier} */ unwrapAssignmentPattern, + /** + * Unwrap ChainExpression like "(a?.b)" + * @param { Expression | Super } node + * @return { Expression | Super } + */ + unwrapChainExpression, /** * Check whether the given node is `this` or variable that stores `this`. @@ -1479,6 +1405,7 @@ module.exports = { findMutating(props) { /** @type {MemberExpression[]} */ const pathNodes = [] + /** @type {MemberExpression | Identifier | ChainExpression} */ let node = props let target = node.parent while (true) { @@ -1499,10 +1426,9 @@ module.exports = { pathNodes } } else if (target.type === 'CallExpression') { - if (node !== props && target.callee === node) { - const callName = getStaticPropertyName( - /** @type {MemberExpression} */ (node) - ) + if (pathNodes.length > 0 && target.callee === node) { + const mem = pathNodes[pathNodes.length - 1] + const callName = getStaticPropertyName(mem) if ( callName && /^push|pop|shift|unshift|reverse|splice|sort|copyWithin|fill$/u.exec( @@ -1525,6 +1451,10 @@ module.exports = { target = target.parent continue // loop } + } else if (target.type === 'ChainExpression') { + node = target + target = target.parent + continue // loop } return null @@ -1622,14 +1552,12 @@ function findProperty(node, name, filter) { * @returns {prop is Property} */ (prop) => - prop.type === 'Property' && - getStaticPropertyName(prop) === name && - filter(prop) + isProperty(prop) && getStaticPropertyName(prop) === name && filter(prop) : /** * @param {Property | SpreadElement} prop * @returns {prop is Property} */ - (prop) => prop.type === 'Property' && getStaticPropertyName(prop) === name + (prop) => isProperty(prop) && getStaticPropertyName(prop) === name return node.properties.find(predicate) || null } @@ -1647,14 +1575,15 @@ function findAssignmentProperty(node, name, filter) { * @returns {prop is AssignmentProperty} */ (prop) => - prop.type === 'Property' && + isAssignmentProperty(prop) && getStaticPropertyName(prop) === name && filter(prop) : /** * @param {AssignmentProperty | RestElement} prop * @returns {prop is AssignmentProperty} */ - (prop) => prop.type === 'Property' && getStaticPropertyName(prop) === name + (prop) => + isAssignmentProperty(prop) && getStaticPropertyName(prop) === name return node.properties.find(predicate) || null } @@ -1730,7 +1659,7 @@ function isPropertyChain(prop, node) { /** * Unwrap AssignmentPattern like "(a = 1) => ret" * @param { AssignmentPattern | RestElement | ArrayPattern | ObjectPattern | Identifier } node - * @return { RestElement | ArrayPattern | ObjectPattern | Identifier} + * @return { RestElement | ArrayPattern | ObjectPattern | Identifier } */ function unwrapAssignmentPattern(node) { if (!node) { @@ -1743,6 +1672,24 @@ function unwrapAssignmentPattern(node) { return node } +/** + * Unwrap ChainExpression like "(a?.b)" + * @template T + * @param {T} node + * @return {T} + */ +function unwrapChainExpression(node) { + if (!node) { + return node + } + // @ts-expect-error + if (node.type === 'ChainExpression') { + // @ts-expect-error + return unwrapChainExpression(node.expression) + } + return node +} + /** * Gets the property name of a given node. * @param {Property|AssignmentProperty|MethodDefinition|MemberExpression} node - The node to get. diff --git a/tests/lib/rules/custom-event-name-casing.js b/tests/lib/rules/custom-event-name-casing.js index b767223e2..934bb079f 100644 --- a/tests/lib/rules/custom-event-name-casing.js +++ b/tests/lib/rules/custom-event-name-casing.js @@ -10,7 +10,7 @@ const rule = require('../../../lib/rules/custom-event-name-casing') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), parserOptions: { - ecmaVersion: 2019, + ecmaVersion: 2020, sourceType: 'module' } }) @@ -216,6 +216,66 @@ tester.run('custom-event-name-casing', rule, { endColumn: 32 } ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + "Custom event name 'fooBar' must be kebab-case.", + "Custom event name 'barBaz' must be kebab-case.", + "Custom event name 'bazQux' must be kebab-case." + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + "Custom event name 'fooBar' must be kebab-case.", + "Custom event name 'barBaz' must be kebab-case.", + "Custom event name 'bazQux' must be kebab-case." + ] } ] }) diff --git a/tests/lib/rules/no-async-in-computed-properties.js b/tests/lib/rules/no-async-in-computed-properties.js index b7f0283eb..afdfc82d3 100644 --- a/tests/lib/rules/no-async-in-computed-properties.js +++ b/tests/lib/rules/no-async-in-computed-properties.js @@ -12,7 +12,7 @@ const rule = require('../../../lib/rules/no-async-in-computed-properties') const RuleTester = require('eslint').RuleTester const parserOptions = { - ecmaVersion: 2018, + ecmaVersion: 2020, sourceType: 'module' } @@ -294,6 +294,34 @@ ruleTester.run('no-async-in-computed-properties', rule, { } ] }, + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo: function () { + return bar?.then?.(response => {}) + } + } + } + `, + parserOptions, + errors: ['Unexpected asynchronous action in "foo" computed property.'] + }, + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo: function () { + return (bar?.then)?.(response => {}) + } + } + } + `, + parserOptions, + errors: ['Unexpected asynchronous action in "foo" computed property.'] + }, { filename: 'test.vue', code: ` @@ -543,6 +571,66 @@ ruleTester.run('no-async-in-computed-properties', rule, { line: 12 } ] + }, + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo: function () { + setTimeout?.(() => { }, 0) + window?.setTimeout?.(() => { }, 0) + setInterval(() => { }, 0) + window?.setInterval?.(() => { }, 0) + setImmediate?.(() => { }) + window?.setImmediate?.(() => { }) + requestAnimationFrame?.(() => {}) + window?.requestAnimationFrame?.(() => {}) + } + } + } + `, + parserOptions, + errors: [ + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.' + ] + }, + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo: function () { + setTimeout?.(() => { }, 0) + ;(window?.setTimeout)?.(() => { }, 0) + setInterval(() => { }, 0) + ;(window?.setInterval)?.(() => { }, 0) + setImmediate?.(() => { }) + ;(window?.setImmediate)?.(() => { }) + requestAnimationFrame?.(() => {}) + ;(window?.requestAnimationFrame)?.(() => {}) + } + } + } + `, + parserOptions, + errors: [ + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.', + 'Unexpected timed function in "foo" computed property.' + ] } ] }) diff --git a/tests/lib/rules/no-deprecated-dollar-listeners-api.js b/tests/lib/rules/no-deprecated-dollar-listeners-api.js index deaff0758..8644d13aa 100644 --- a/tests/lib/rules/no-deprecated-dollar-listeners-api.js +++ b/tests/lib/rules/no-deprecated-dollar-listeners-api.js @@ -18,7 +18,7 @@ const RuleTester = require('eslint').RuleTester const ruleTester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2018, sourceType: 'module' } + parserOptions: { ecmaVersion: 2020, sourceType: 'module' } }) ruleTester.run('no-deprecated-dollar-listeners-api', rule, { valid: [ @@ -240,6 +240,30 @@ ruleTester.run('no-deprecated-dollar-listeners-api', rule, { messageId: 'deprecated' } ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + messageId: 'deprecated' + }, + { + messageId: 'deprecated' + } + ] } ] }) diff --git a/tests/lib/rules/no-deprecated-dollar-scopedslots-api.js b/tests/lib/rules/no-deprecated-dollar-scopedslots-api.js index 303e5de99..a29211a22 100644 --- a/tests/lib/rules/no-deprecated-dollar-scopedslots-api.js +++ b/tests/lib/rules/no-deprecated-dollar-scopedslots-api.js @@ -18,7 +18,7 @@ const RuleTester = require('eslint').RuleTester const ruleTester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2018, sourceType: 'module' } + parserOptions: { ecmaVersion: 2020, sourceType: 'module' } }) ruleTester.run('no-deprecated-dollar-scopedslots-api', rule, { valid: [ @@ -283,6 +283,41 @@ ruleTester.run('no-deprecated-dollar-scopedslots-api', rule, { messageId: 'deprecated' } ] + }, + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + errors: [ + { + messageId: 'deprecated' + }, + { + messageId: 'deprecated' + } + ] } ] }) diff --git a/tests/lib/rules/no-deprecated-events-api.js b/tests/lib/rules/no-deprecated-events-api.js index b52651aff..3022690b1 100644 --- a/tests/lib/rules/no-deprecated-events-api.js +++ b/tests/lib/rules/no-deprecated-events-api.js @@ -13,7 +13,7 @@ const rule = require('../../../lib/rules/no-deprecated-events-api') const RuleTester = require('eslint').RuleTester const parserOptions = { - ecmaVersion: 2018, + ecmaVersion: 2020, sourceType: 'module' } @@ -113,6 +113,20 @@ ruleTester.run('no-deprecated-events-api', rule, { } `, parserOptions + }, + { + filename: 'test.js', + code: ` + app.component('some-comp', { + mounted () { + // It is OK because checking whether it is deprecated. + this.$on?.('start', foo) + this.$off?.('start', foo) + this.$once?.('start', foo) + } + }) + `, + parserOptions } ], @@ -195,6 +209,42 @@ ruleTester.run('no-deprecated-events-api', rule, { line: 5 } ] + }, + { + filename: 'test.js', + code: ` + app.component('some-comp', { + mounted () { + this?.$on('start') + this?.$off('start') + this?.$once('start') + } + }) + `, + parserOptions, + errors: [ + 'The Events api `$on`, `$off` `$once` is deprecated. Using external library instead, for example mitt.', + 'The Events api `$on`, `$off` `$once` is deprecated. Using external library instead, for example mitt.', + 'The Events api `$on`, `$off` `$once` is deprecated. Using external library instead, for example mitt.' + ] + }, + { + filename: 'test.js', + code: ` + app.component('some-comp', { + mounted () { + ;(this?.$on)('start') + ;(this?.$off)('start') + ;(this?.$once)('start') + } + }) + `, + parserOptions, + errors: [ + 'The Events api `$on`, `$off` `$once` is deprecated. Using external library instead, for example mitt.', + 'The Events api `$on`, `$off` `$once` is deprecated. Using external library instead, for example mitt.', + 'The Events api `$on`, `$off` `$once` is deprecated. Using external library instead, for example mitt.' + ] } ] }) diff --git a/tests/lib/rules/no-deprecated-vue-config-keycodes.js b/tests/lib/rules/no-deprecated-vue-config-keycodes.js index eebd7943c..dae0ec800 100644 --- a/tests/lib/rules/no-deprecated-vue-config-keycodes.js +++ b/tests/lib/rules/no-deprecated-vue-config-keycodes.js @@ -17,7 +17,7 @@ const RuleTester = require('eslint').RuleTester const ruleTester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2015 } + parserOptions: { ecmaVersion: 2020 } }) ruleTester.run('no-deprecated-vue-config-keycodes', rule, { @@ -51,6 +51,16 @@ ruleTester.run('no-deprecated-vue-config-keycodes', rule, { endColumn: 20 } ] + }, + { + filename: 'test.js', + code: 'Vue?.config?.keyCodes', + errors: ['`Vue.config.keyCodes` are deprecated.'] + }, + { + filename: 'test.js', + code: '(Vue?.config)?.keyCodes', + errors: ['`Vue.config.keyCodes` are deprecated.'] } ] }) diff --git a/tests/lib/rules/no-lifecycle-after-await.js b/tests/lib/rules/no-lifecycle-after-await.js index dd1660790..17dfada47 100644 --- a/tests/lib/rules/no-lifecycle-after-await.js +++ b/tests/lib/rules/no-lifecycle-after-await.js @@ -8,7 +8,7 @@ const rule = require('../../../lib/rules/no-lifecycle-after-await') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2019, sourceType: 'module' } + parserOptions: { ecmaVersion: 2020, sourceType: 'module' } }) tester.run('no-lifecycle-after-await', rule, { @@ -204,6 +204,26 @@ tester.run('no-lifecycle-after-await', rule, { line: 18 } ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + messageId: 'forbidden' + } + ] } ] }) diff --git a/tests/lib/rules/no-multiple-slot-args.js b/tests/lib/rules/no-multiple-slot-args.js index fb42767b9..0720a951f 100644 --- a/tests/lib/rules/no-multiple-slot-args.js +++ b/tests/lib/rules/no-multiple-slot-args.js @@ -18,7 +18,7 @@ const RuleTester = require('eslint').RuleTester const ruleTester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2018, sourceType: 'module' } + parserOptions: { ecmaVersion: 2020, sourceType: 'module' } }) ruleTester.run('no-multiple-slot-args', rule, { valid: [ @@ -109,6 +109,90 @@ ruleTester.run('no-multiple-slot-args', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + 'Unexpected multiple arguments.', + 'Unexpected multiple arguments.', + 'Unexpected multiple arguments.', + 'Unexpected multiple arguments.' + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + 'Unexpected multiple arguments.', + 'Unexpected multiple arguments.', + 'Unexpected multiple arguments.', + 'Unexpected multiple arguments.' + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + 'Unexpected multiple arguments.', + 'Unexpected multiple arguments.' + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + 'Unexpected multiple arguments.', + 'Unexpected multiple arguments.' + ] + }, { filename: 'test.vue', code: ` diff --git a/tests/lib/rules/no-mutating-props.js b/tests/lib/rules/no-mutating-props.js index 1e9e09737..e46ae0fd2 100644 --- a/tests/lib/rules/no-mutating-props.js +++ b/tests/lib/rules/no-mutating-props.js @@ -343,6 +343,52 @@ ruleTester.run('no-mutating-props', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + 'Unexpected mutation of "prop1" prop.', + 'Unexpected mutation of "prop5" prop.' + ] + }, + { + filename: 'test.vue', + code: ` + + + `, + errors: [ + 'Unexpected mutation of "prop1" prop.', + 'Unexpected mutation of "prop2" prop.', + 'Unexpected mutation of "prop3" prop.' + ] + }, { filename: 'test.vue', code: ` @@ -419,6 +465,28 @@ ruleTester.run('no-mutating-props', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + 'Unexpected mutation of "foo" prop.', + 'Unexpected mutation of "bar" prop.', + 'Unexpected mutation of "baz" prop.' + ] + }, { filename: 'test.vue', code: ` diff --git a/tests/lib/rules/no-ref-as-operand.js b/tests/lib/rules/no-ref-as-operand.js index 5d53a5571..035b9029b 100644 --- a/tests/lib/rules/no-ref-as-operand.js +++ b/tests/lib/rules/no-ref-as-operand.js @@ -8,7 +8,7 @@ const rule = require('../../../lib/rules/no-ref-as-operand') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2019, sourceType: 'module' } + parserOptions: { ecmaVersion: 2020, sourceType: 'module' } }) tester.run('no-ref-as-operand', rule, { @@ -433,6 +433,20 @@ tester.run('no-ref-as-operand', rule, { messageId: 'requireDotValue' } ] + }, + { + code: ` + + `, + errors: [ + { + messageId: 'requireDotValue' + } + ] } ] }) diff --git a/tests/lib/rules/no-setup-props-destructure.js b/tests/lib/rules/no-setup-props-destructure.js index e81d680fc..171b1c10f 100644 --- a/tests/lib/rules/no-setup-props-destructure.js +++ b/tests/lib/rules/no-setup-props-destructure.js @@ -8,7 +8,7 @@ const rule = require('../../../lib/rules/no-setup-props-destructure') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2019, sourceType: 'module' } + parserOptions: { ecmaVersion: 2020, sourceType: 'module' } }) tester.run('no-setup-props-destructure', rule, { @@ -336,6 +336,38 @@ tester.run('no-setup-props-destructure', rule, { } ] }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + messageId: 'getProperty', + line: 5 + }, + { + messageId: 'getProperty', + line: 6 + }, + { + messageId: 'getProperty', + line: 7 + } + ] + }, { filename: 'test.vue', code: ` diff --git a/tests/lib/rules/no-side-effects-in-computed-properties.js b/tests/lib/rules/no-side-effects-in-computed-properties.js index b2f341a18..400c32575 100644 --- a/tests/lib/rules/no-side-effects-in-computed-properties.js +++ b/tests/lib/rules/no-side-effects-in-computed-properties.js @@ -12,7 +12,7 @@ const rule = require('../../../lib/rules/no-side-effects-in-computed-properties' const RuleTester = require('eslint').RuleTester const parserOptions = { - ecmaVersion: 2018, + ecmaVersion: 2020, sourceType: 'module' } @@ -331,6 +331,27 @@ ruleTester.run('no-side-effects-in-computed-properties', rule, { message: 'Unexpected side effect in "test1" computed property.' } ] + }, + { + code: `Vue.component('test', { + computed: { + test1() { + return this?.something?.reverse?.() + }, + test2() { + return (this?.something)?.reverse?.() + }, + test3() { + return (this?.something?.reverse)?.() + }, + } + })`, + parserOptions, + errors: [ + 'Unexpected side effect in "test1" computed property.', + 'Unexpected side effect in "test2" computed property.', + 'Unexpected side effect in "test3" computed property.' + ] } ] }) diff --git a/tests/lib/rules/no-unused-properties.js b/tests/lib/rules/no-unused-properties.js index c5796e752..838c47df8 100644 --- a/tests/lib/rules/no-unused-properties.js +++ b/tests/lib/rules/no-unused-properties.js @@ -981,6 +981,41 @@ tester.run('no-unused-properties', rule, { } `, options: [{ groups: ['props', 'setup'] }] + }, + // optional chaining + { + filename: 'test.vue', + code: ` + ` + }, + { + filename: 'test.js', + code: ` + Vue.component('MyButton', { + functional: true, + props: ['foo', 'bar'], + render: function (createElement, ctx) { + const a = ctx + const b = a?.props?.foo + const c = (a?.props)?.bar + } + }) + ` } ], diff --git a/tests/lib/rules/no-watch-after-await.js b/tests/lib/rules/no-watch-after-await.js index 9d91fbb2b..5b7a0b34a 100644 --- a/tests/lib/rules/no-watch-after-await.js +++ b/tests/lib/rules/no-watch-after-await.js @@ -8,7 +8,7 @@ const rule = require('../../../lib/rules/no-watch-after-await') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2019, sourceType: 'module' } + parserOptions: { ecmaVersion: 2020, sourceType: 'module' } }) tester.run('no-watch-after-await', rule, { @@ -91,6 +91,27 @@ tester.run('no-watch-after-await', rule, { } ` + }, + { + filename: 'test.vue', + code: ` + + ` } ], invalid: [ diff --git a/tests/lib/rules/order-in-components.js b/tests/lib/rules/order-in-components.js index 5c99608fc..76d510964 100644 --- a/tests/lib/rules/order-in-components.js +++ b/tests/lib/rules/order-in-components.js @@ -879,6 +879,7 @@ ruleTester.run('order-in-components', rule, { testYield: function* () {}, testTemplate: \`a:\${a},b:\${b},c:\${c}.\`, testNullish: a ?? b, + testOptionalChaining: a?.b?.c, name: 'burger', }; `, @@ -897,13 +898,14 @@ ruleTester.run('order-in-components', rule, { testYield: function* () {}, testTemplate: \`a:\${a},b:\${b},c:\${c}.\`, testNullish: a ?? b, + testOptionalChaining: a?.b?.c, }; `, errors: [ { message: 'The "name" property should be above the "data" property on line 3.', - line: 14 + line: 15 } ] } diff --git a/tests/lib/rules/require-default-prop.js b/tests/lib/rules/require-default-prop.js index ce292774e..1b8e6e083 100644 --- a/tests/lib/rules/require-default-prop.js +++ b/tests/lib/rules/require-default-prop.js @@ -11,7 +11,7 @@ const rule = require('../../../lib/rules/require-default-prop') const RuleTester = require('eslint').RuleTester const parserOptions = { - ecmaVersion: 2018, + ecmaVersion: 2020, sourceType: 'module' } @@ -148,7 +148,8 @@ ruleTester.run('require-default-prop', rule, { props: { bar, baz: prop, - bar1: foo() + baz1: prop.foo, + bar2: foo() } } ` @@ -270,7 +271,7 @@ ruleTester.run('require-default-prop', rule, { ] }, - // computed propertys + // computed properties { filename: 'test.vue', code: ` @@ -355,6 +356,22 @@ ruleTester.run('require-default-prop', rule, { } `, errors: ["Prop 'foo' requires default value to be set."] + }, + { + filename: 'test.vue', + code: ` + export default { + props: { + bar, + baz: prop?.foo, + bar1: foo?.(), + } + } + `, + errors: [ + "Prop 'baz' requires default value to be set.", + "Prop 'bar1' requires default value to be set." + ] } ] }) diff --git a/tests/lib/rules/require-explicit-emits.js b/tests/lib/rules/require-explicit-emits.js index cef95dd75..d59d78600 100644 --- a/tests/lib/rules/require-explicit-emits.js +++ b/tests/lib/rules/require-explicit-emits.js @@ -10,7 +10,7 @@ const rule = require('../../../lib/rules/require-explicit-emits') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), parserOptions: { - ecmaVersion: 2019, + ecmaVersion: 2020, sourceType: 'module' } }) @@ -1514,6 +1514,43 @@ emits: {'foo': null} ] } ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + 'The "foo" event has been triggered but not declared on `emits` option.', + 'The "bar" event has been triggered but not declared on `emits` option.' + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + 'The "foo" event has been triggered but not declared on `emits` option.', + 'The "bar" event has been triggered but not declared on `emits` option.' + ] } ] }) diff --git a/tests/lib/rules/require-slots-as-functions.js b/tests/lib/rules/require-slots-as-functions.js index 9d79a1ec7..ea83b5696 100644 --- a/tests/lib/rules/require-slots-as-functions.js +++ b/tests/lib/rules/require-slots-as-functions.js @@ -18,7 +18,7 @@ const RuleTester = require('eslint').RuleTester const ruleTester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2018, sourceType: 'module' } + parserOptions: { ecmaVersion: 2020, sourceType: 'module' } }) ruleTester.run('require-slots-as-functions', rule, { valid: [ @@ -86,29 +86,27 @@ ruleTester.run('require-slots-as-functions', rule, { } ] }, - { filename: 'test.vue', code: ` `, errors: [ - 'Property in `$slots` should be used as function.', - 'Property in `$slots` should be used as function.', - 'Property in `$slots` should be used as function.' + { messageId: 'unexpected', line: 5 }, + { messageId: 'unexpected', line: 7 }, + { messageId: 'unexpected', line: 9 } ] } ] diff --git a/tests/lib/rules/require-valid-default-prop.js b/tests/lib/rules/require-valid-default-prop.js index 9f8ff7275..150029246 100644 --- a/tests/lib/rules/require-valid-default-prop.js +++ b/tests/lib/rules/require-valid-default-prop.js @@ -181,6 +181,18 @@ ruleTester.run('require-valid-default-prop', rule, { } }`, parserOptions + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Number, + default: Number?.() + } + } + }`, + parserOptions } ], @@ -742,6 +754,19 @@ ruleTester.run('require-valid-default-prop', rule, { line: 11 } ] + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: String, + default: Number?.() + } + } + }`, + parserOptions, + errors: errorMessage('string') } ] }) diff --git a/tests/lib/rules/this-in-template.js b/tests/lib/rules/this-in-template.js index 2d7bd46c1..5f36b570e 100644 --- a/tests/lib/rules/this-in-template.js +++ b/tests/lib/rules/this-in-template.js @@ -18,7 +18,7 @@ const RuleTester = require('eslint').RuleTester const ruleTester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2015 } + parserOptions: { ecmaVersion: 2020 } }) function createValidTests(prefix, options) { @@ -188,7 +188,8 @@ ruleTester.run('this-in-template', rule, { valid: ['', '', ''] .concat(createValidTests('', [])) .concat(createValidTests('', ['never'])) - .concat(createValidTests('this.', ['always'])), + .concat(createValidTests('this.', ['always'])) + .concat(createValidTests('this?.', ['always'])), invalid: [] .concat( createInvalidTests( @@ -196,6 +197,12 @@ ruleTester.run('this-in-template', rule, { [], "Unexpected usage of 'this'.", 'ThisExpression' + ), + createInvalidTests( + 'this?.', + [], + "Unexpected usage of 'this'.", + 'ThisExpression' ) ) .concat( @@ -204,6 +211,12 @@ ruleTester.run('this-in-template', rule, { ['never'], "Unexpected usage of 'this'.", 'ThisExpression' + ), + createInvalidTests( + 'this?.', + ['never'], + "Unexpected usage of 'this'.", + 'ThisExpression' ) ) .concat( diff --git a/tests/lib/rules/v-on-function-call.js b/tests/lib/rules/v-on-function-call.js index b78a92370..4ba6f5b00 100644 --- a/tests/lib/rules/v-on-function-call.js +++ b/tests/lib/rules/v-on-function-call.js @@ -16,7 +16,7 @@ const rule = require('../../../lib/rules/v-on-function-call') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2015 } + parserOptions: { ecmaVersion: 2020 } }) tester.run('v-on-function-call', rule, { @@ -106,6 +106,11 @@ tester.run('v-on-function-call', rule, {
`, options: ['never', { ignoreIncludesComment: true }] + }, + { + filename: 'test.vue', + code: '', + options: ['never'] } ], invalid: [ diff --git a/tests/lib/rules/valid-v-bind-sync.js b/tests/lib/rules/valid-v-bind-sync.js index ea8bdf333..dfaf161ea 100644 --- a/tests/lib/rules/valid-v-bind-sync.js +++ b/tests/lib/rules/valid-v-bind-sync.js @@ -16,7 +16,7 @@ const rule = require('../../../lib/rules/valid-v-bind-sync') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2015 } + parserOptions: { ecmaVersion: 2020 } }) tester.run('valid-v-bind-sync', rule, { @@ -350,6 +350,42 @@ tester.run('valid-v-bind-sync', rule, { errors: [ "'.sync' modifiers require the attribute value which is valid as LHS." ] + }, + { + filename: 'test.vue', + code: '', + errors: [ + "Optional chaining cannot appear in 'v-bind' with '.sync' modifiers." + ] + }, + { + filename: 'test.vue', + code: '', + errors: [ + "Optional chaining cannot appear in 'v-bind' with '.sync' modifiers." + ] + }, + { + filename: 'test.vue', + code: '', + errors: [ + "Optional chaining cannot appear in 'v-bind' with '.sync' modifiers." + ] + }, + { + filename: 'test.vue', + code: '', + errors: ["'.sync' modifier has potential null object property access."] + }, + { + filename: 'test.vue', + code: '', + errors: ["'.sync' modifier has potential null object property access."] + }, + { + filename: 'test.vue', + code: '', + errors: ["'.sync' modifier has potential null object property access."] } ] }) diff --git a/tests/lib/rules/valid-v-model.js b/tests/lib/rules/valid-v-model.js index 9e78ef47a..2369fe4df 100644 --- a/tests/lib/rules/valid-v-model.js +++ b/tests/lib/rules/valid-v-model.js @@ -18,7 +18,7 @@ const rule = require('../../../lib/rules/valid-v-model') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), - parserOptions: { ecmaVersion: 2015 } + parserOptions: { ecmaVersion: 2020 } }) tester.run('valid-v-model', rule, { @@ -232,6 +232,36 @@ tester.run('valid-v-model', rule, { filename: 'empty-value.vue', code: '', errors: ["'v-model' directives require that attribute value."] + }, + { + filename: 'test.vue', + code: '', + errors: ["Optional chaining cannot appear in 'v-model' directives."] + }, + { + filename: 'test.vue', + code: '', + errors: ["Optional chaining cannot appear in 'v-model' directives."] + }, + { + filename: 'test.vue', + code: '', + errors: ["Optional chaining cannot appear in 'v-model' directives."] + }, + { + filename: 'test.vue', + code: '', + errors: ["'v-model' directive has potential null object property access."] + }, + { + filename: 'test.vue', + code: '', + errors: ["'v-model' directive has potential null object property access."] + }, + { + filename: 'test.vue', + code: '', + errors: ["'v-model' directive has potential null object property access."] } ] }) diff --git a/tests/lib/utils/index.js b/tests/lib/utils/index.js index d1b8c076d..fe5bf77fa 100644 --- a/tests/lib/utils/index.js +++ b/tests/lib/utils/index.js @@ -1,44 +1,19 @@ 'use strict' const babelEslint = require('babel-eslint') +const espree = require('espree') const utils = require('../../../lib/utils/index') const chai = require('chai') const assert = chai.assert -describe('parseMemberExpression', () => { - let node - - const parse = function (code) { - return babelEslint.parse(code).body[0].expression - } - - it('should parse member expression', () => { - node = parse('this.some.nested.property') - assert.deepEqual(utils.parseMemberExpression(node), [ - 'this', - 'some', - 'nested', - 'property' - ]) - - node = parse('another.property') - assert.deepEqual(utils.parseMemberExpression(node), ['another', 'property']) - - node = parse('this.something') - assert.deepEqual(utils.parseMemberExpression(node), ['this', 'something']) - }) -}) - describe('getComputedProperties', () => { - let node - const parse = function (code) { return babelEslint.parse(code).body[0].declarations[0].init } it('should return empty array when there is no computed property', () => { - node = parse(`const test = { + const node = parse(`const test = { name: 'test', data() { return {} @@ -49,7 +24,7 @@ describe('getComputedProperties', () => { }) it('should return computed properties', () => { - node = parse(`const test = { + const node = parse(`const test = { name: 'test', data() { return {} @@ -93,7 +68,7 @@ describe('getComputedProperties', () => { }) it('should not collide with object spread operator', () => { - node = parse(`const test = { + const node = parse(`const test = { name: 'test', computed: { ...mapGetters(['test']), @@ -115,7 +90,7 @@ describe('getComputedProperties', () => { }) it('should not collide with object spread operator inside CP', () => { - node = parse(`const test = { + const node = parse(`const test = { name: 'test', computed: { foo: { @@ -138,83 +113,175 @@ describe('getComputedProperties', () => { }) describe('getStaticPropertyName', () => { - let node - const parse = function (code) { return babelEslint.parse(code).body[0].declarations[0].init } it('should parse property expression with identifier', () => { - node = parse(`const test = { computed: { } }`) + const node = parse(`const test = { computed: { } }`) const parsed = utils.getStaticPropertyName(node.properties[0]) assert.ok(parsed === 'computed') }) it('should parse property expression with literal', () => { - node = parse(`const test = { ['computed'] () {} }`) + const node = parse(`const test = { ['computed'] () {} }`) const parsed = utils.getStaticPropertyName(node.properties[0]) assert.ok(parsed === 'computed') }) it('should parse property expression with template literal', () => { - node = parse(`const test = { [\`computed\`] () {} }`) + const node = parse(`const test = { [\`computed\`] () {} }`) const parsed = utils.getStaticPropertyName(node.properties[0]) assert.ok(parsed === 'computed') }) - // it('should parse identifier', () => { - // node = parse(`const test = { computed: { } }`) +}) + +describe('getStringLiteralValue', () => { + const parse = function (code) { + return babelEslint.parse(code).body[0].declarations[0].init + } - // const parsed = utils.getStaticPropertyName(node.properties[0].key) - // assert.ok(parsed === 'computed') - // }) it('should parse literal', () => { - node = parse(`const test = { ['computed'] () {} }`) + const node = parse(`const test = { ['computed'] () {} }`) const parsed = utils.getStringLiteralValue(node.properties[0].key) assert.ok(parsed === 'computed') }) it('should parse template literal', () => { - node = parse(`const test = { [\`computed\`] () {} }`) + const node = parse(`const test = { [\`computed\`] () {} }`) const parsed = utils.getStringLiteralValue(node.properties[0].key) assert.ok(parsed === 'computed') }) }) -describe('parseMemberOrCallExpression', () => { - let node - +describe('getMemberChaining', () => { const parse = function (code) { - return babelEslint.parse(code).body[0].declarations[0].init + return espree.parse(code, { ecmaVersion: 2020 }).body[0].declarations[0] + .init } - it('should parse CallExpression', () => { - node = parse( - `const test = this.lorem['ipsum'].map(d => d.id).filter((a, b) => a > b).reduce((acc, d) => acc + d, 0)` + const jsonIgnoreKeys = ['expression', 'object'] + + it('should parse MemberExpression', () => { + const node = parse(`const test = this.lorem['ipsum'].foo.bar`) + const parsed = utils.getMemberChaining(node) + assert.equal( + nodeToJson(parsed, jsonIgnoreKeys), + nodeToJson([ + { + type: 'ThisExpression' + }, + { + type: 'MemberExpression', + property: { + type: 'Identifier', + name: 'lorem' + }, + computed: false, + optional: false + }, + { + type: 'MemberExpression', + property: { + type: 'Literal', + value: 'ipsum', + raw: "'ipsum'" + }, + computed: true, + optional: false + }, + { + type: 'MemberExpression', + property: { + type: 'Identifier', + name: 'foo' + }, + computed: false, + optional: false + }, + { + type: 'MemberExpression', + property: { + type: 'Identifier', + name: 'bar' + }, + computed: false, + optional: false + } + ]) ) - const parsed = utils.parseMemberOrCallExpression(node) - assert.equal(parsed, 'this.lorem[].map().filter().reduce()') }) - it('should parse MemberExpression', () => { - node = parse( - `const test = this.lorem['ipsum'][0].map(d => d.id).dolor.reduce((acc, d) => acc + d, 0).sit` + it('should parse optional Chaining ', () => { + const node = parse(`const test = (this?.lorem)['ipsum']?.[0]?.foo?.bar`) + const parsed = utils.getMemberChaining(node) + assert.equal( + nodeToJson(parsed, jsonIgnoreKeys), + nodeToJson([ + { + type: 'ThisExpression' + }, + { + type: 'MemberExpression', + property: { + type: 'Identifier', + name: 'lorem' + }, + computed: false, + optional: true + }, + { + type: 'MemberExpression', + property: { + type: 'Literal', + value: 'ipsum', + raw: "'ipsum'" + }, + computed: true, + optional: false + }, + { + type: 'MemberExpression', + property: { + type: 'Literal', + value: 0, + raw: '0' + }, + computed: true, + optional: true + }, + { + type: 'MemberExpression', + property: { + type: 'Identifier', + name: 'foo' + }, + computed: false, + optional: true + }, + { + type: 'MemberExpression', + property: { + type: 'Identifier', + name: 'bar' + }, + computed: false, + optional: true + } + ]) ) - const parsed = utils.parseMemberOrCallExpression(node) - assert.equal(parsed, 'this.lorem[][].map().dolor.reduce().sit') }) }) describe('getRegisteredComponents', () => { - let node - const parse = function (code) { return babelEslint.parse(code).body[0].declarations[0].init } it('should return empty array when there are no components registered', () => { - node = parse(`const test = { + const node = parse(`const test = { name: 'test', }`) @@ -222,7 +289,7 @@ describe('getRegisteredComponents', () => { }) it('should return an array with all registered components', () => { - node = parse(`const test = { + const node = parse(`const test = { name: 'test', components: { ...test, @@ -249,7 +316,7 @@ describe('getRegisteredComponents', () => { }) it('should return an array of only components whose names can be identified', () => { - node = parse(`const test = { + const node = parse(`const test = { name: 'test', components: { ...test, @@ -269,15 +336,13 @@ describe('getRegisteredComponents', () => { }) describe('getComponentProps', () => { - let props - const parse = function (code) { const data = babelEslint.parse(code).body[0].declarations[0].init return utils.getComponentProps(data) } it('should return empty array when there is no component props', () => { - props = parse(`const test = { + const props = parse(`const test = { name: 'test', data() { return {} @@ -288,7 +353,7 @@ describe('getComponentProps', () => { }) it('should return empty array when component props is empty array', () => { - props = parse(`const test = { + const props = parse(`const test = { name: 'test', props: [] }`) @@ -297,7 +362,7 @@ describe('getComponentProps', () => { }) it('should return empty array when component props is empty object', () => { - props = parse(`const test = { + const props = parse(`const test = { name: 'test', props: {} }`) @@ -306,7 +371,7 @@ describe('getComponentProps', () => { }) it('should return computed props', () => { - props = parse(`const test = { + const props = parse(`const test = { name: 'test', ...test, data() { @@ -341,7 +406,7 @@ describe('getComponentProps', () => { }) it('should return computed from array props', () => { - props = parse(`const test = { + const props = parse(`const test = { name: 'test', data() { return {} @@ -382,3 +447,15 @@ describe('editdistance', () => { assert.equal(editDistance('computed', 'computd'), 1) }) }) +function nodeToJson(nodes, ignores = []) { + return JSON.stringify(nodes, replacer, 2) + function replacer(key, value) { + if (key === 'parent' || key === 'start' || key === 'end') { + return undefined + } + if (ignores.includes(key)) { + return undefined + } + return value + } +}