diff --git a/docs/rules/README.md b/docs/rules/README.md index 83eda5692..23ba5a897 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -290,6 +290,7 @@ For example: | [vue/no-template-target-blank](./no-template-target-blank.md) | disallow target="_blank" attribute without rel="noopener noreferrer" | | | [vue/no-unregistered-components](./no-unregistered-components.md) | disallow using components that are not registered inside templates | | | [vue/no-unsupported-features](./no-unsupported-features.md) | disallow unsupported Vue.js syntax on the specified version | :wrench: | +| [vue/no-unused-properties](./no-unused-properties.md) | disallow unused properties | | | [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 | | diff --git a/docs/rules/no-unused-properties.md b/docs/rules/no-unused-properties.md index 4f6b56da8..438187060 100644 --- a/docs/rules/no-unused-properties.md +++ b/docs/rules/no-unused-properties.md @@ -2,22 +2,26 @@ pageClass: rule-details sidebarDepth: 0 title: vue/no-unused-properties -description: disallow unused properties, data and computed properties +description: disallow unused properties --- # vue/no-unused-properties -> disallow unused properties, data and computed properties +> disallow unused properties ## :book: Rule Details -This rule disallows any unused properties, data and computed properties. +This rule is aimed at eliminating unused properties. -```vue -/* ✓ GOOD */ +::: warning Note +This rule cannot be checked for use in other components (e.g. `mixins`, Property access via `$refs`) and use in places where the scope cannot be determined. +::: + + +```vue + - ``` -```vue -/* ✗ BAD (`count` property not used) */ + + + +```vue + - ``` -```vue -/* ✓ GOOD */ + + +## :wrench: Options + +```json +{ + "vue/no-unused-properties": ["error", { + "groups": ["props"] + }] +} +``` + +- `"groups"` (`string[]`) Array of groups to search for properties. Default is `["props"]`. The value of the array is some of the following strings: + - `"props"` + - `"data"` + - `"computed"` + - `"methods"` + - `"setup"` + +### `"groups": ["props", "data"]` + + +```vue + ``` -```vue -/* ✓ BAD (`count` data not used) */ + + + +```vue + ``` -```vue -/* ✓ GOOD */ + + +### `"groups": ["props", "computed"]` + + +```vue + - ``` -```vue -/* ✓ BAD (`reversedMessage` computed property not used) */ + + + +```vue + - ``` -## :wrench: Options - -None. + ## :mag: Implementation diff --git a/lib/index.js b/lib/index.js index 6adbbecd4..47fb57681 100644 --- a/lib/index.js +++ b/lib/index.js @@ -81,6 +81,7 @@ module.exports = { 'no-unregistered-components': require('./rules/no-unregistered-components'), 'no-unsupported-features': require('./rules/no-unsupported-features'), 'no-unused-components': require('./rules/no-unused-components'), + 'no-unused-properties': require('./rules/no-unused-properties'), 'no-unused-vars': require('./rules/no-unused-vars'), 'no-use-v-if-with-v-for': require('./rules/no-use-v-if-with-v-for'), 'no-v-html': require('./rules/no-v-html'), diff --git a/lib/rules/no-unused-properties.js b/lib/rules/no-unused-properties.js index 7897788b2..449e4dacc 100644 --- a/lib/rules/no-unused-properties.js +++ b/lib/rules/no-unused-properties.js @@ -8,8 +8,8 @@ // Requirements // ------------------------------------------------------------------------------ -const remove = require('lodash/remove') const utils = require('../utils') +const { findVariable } = require('eslint-utils') // ------------------------------------------------------------------------------ // Constants @@ -18,12 +18,16 @@ const utils = require('../utils') const GROUP_PROPERTY = 'props' const GROUP_DATA = 'data' const GROUP_COMPUTED_PROPERTY = 'computed' +const GROUP_METHODS = 'methods' +const GROUP_SETUP = 'setup' const GROUP_WATCHER = 'watch' const PROPERTY_LABEL = { [GROUP_PROPERTY]: 'property', [GROUP_DATA]: 'data', - [GROUP_COMPUTED_PROPERTY]: 'computed property' + [GROUP_COMPUTED_PROPERTY]: 'computed property', + [GROUP_METHODS]: 'method', + [GROUP_SETUP]: 'property returned from `setup()`' } // ------------------------------------------------------------------------------ @@ -34,33 +38,9 @@ const PROPERTY_LABEL = { * Extract names from references objects. */ const getReferencesNames = references => { - if (!references || !references.length) { - return [] - } - - return references.map(reference => { - if (!reference.id || !reference.id.name) { - return - } - - return reference.id.name - }) -} - -/** - * Report all unused properties. - */ -const reportUnusedProperties = (context, properties) => { - if (!properties || !properties.length) { - return - } - - properties.forEach(property => { - context.report({ - node: property.node, - message: `Unused ${PROPERTY_LABEL[property.groupName]} found: "${property.name}"` - }) - }) + return references + .filter(ref => ref.variable == null) + .map(ref => ref.id.name) } // ------------------------------------------------------------------------------ @@ -71,97 +51,242 @@ module.exports = { meta: { type: 'suggestion', docs: { - description: 'disallow unused properties, data and computed properties', - category: undefined, + description: 'disallow unused properties', + categories: undefined, url: 'https://eslint.vuejs.org/rules/no-unused-properties.html' }, fixable: null, - schema: [] + schema: [ + { + type: 'object', + properties: { + groups: { + type: 'array', + items: { + enum: [ + GROUP_PROPERTY, + GROUP_DATA, + GROUP_COMPUTED_PROPERTY, + GROUP_METHODS, + GROUP_SETUP + ] + }, + additionalItems: false, + uniqueItems: true + } + }, + additionalProperties: false + } + ], + messages: { + unused: "'{{name}}' of {{group}} found, but never used." + } }, create (context) { - let hasTemplate - let rootTemplateEnd - let unusedProperties = [] - const thisExpressionsVariablesNames = [] - - const initialize = { - Program (node) { - if (context.parserServices.getTemplateBodyTokenStore == null) { - context.report({ - loc: { line: 1, column: 0 }, - message: - 'Use the latest vue-eslint-parser. See also https://vuejs.github.io/eslint-plugin-vue/user-guide/#what-is-the-use-the-latest-vue-eslint-parser-error.' - }) + const options = context.options[0] || {} + const groups = new Set(options.groups || [GROUP_PROPERTY]) + + /** + * @typedef {import('vue-eslint-parser').AST.Node} ASTNode + * @typedef {import('vue-eslint-parser').AST.ESLintObjectPattern} ObjectPattern + * @typedef { { node: ASTNode } } VueData + * @typedef { { name: string, groupName: string, node: ASTNode } } PropertyData + * @typedef { { + * usedNames: Set, + * } } BasePropertiesContainer + * @typedef { BasePropertiesContainer } TemplatePropertiesContainer + * @typedef { BasePropertiesContainer & { + * ignore: boolean, + * properties: Array, + * usedPropsNames: Set, + * propsReferenceIds: Set, + * } } VueComponentPropertiesContainer + * @typedef { {node: ASTNode, upper: VueDataStack} } VueDataStack + */ + + /** @type {TemplatePropertiesContainer} */ + const templatePropertiesContainer = { + usedNames: new Set() + } + /** @type {Map} */ + const vueComponentPropertiesContainers = new Map() + /** + * @param {ASTNode} node + * @returns {VueComponentPropertiesContainer} + */ + function getVueComponentPropertiesContainer (node) { + const key = node + + let container = vueComponentPropertiesContainers.get(key) + if (!container) { + container = { + properties: [], + usedNames: new Set(), + usedPropsNames: new Set(), + propsReferenceIds: new Set(), + ignore: false + } + vueComponentPropertiesContainers.set(key, container) + } + return container + } + + /** + * @param {ObjectPattern} node + * @param {Set} usedNames + * @param {VueComponentPropertiesContainer} vueComponentPropertiesContainer + */ + function extractObjectPatternProperties (node, usedNames, vueComponentPropertiesContainer) { + for (const prop of node.properties) { + if (prop.type === 'Property') { + usedNames.add(utils.getStaticPropertyName(prop)) + } else { + // If use RestElement, everything is used! + vueComponentPropertiesContainer.ignore = true return } + } + } + + /** + * @param {ASTNode} node + * @param {VueComponentPropertiesContainer} vueComponentPropertiesContainer + * @returns {Set | null} + */ + function getPropertyNamesSet (node, vueComponentPropertiesContainer) { + if (utils.isThis(node, context)) { + return vueComponentPropertiesContainer.usedNames + } + if (vueComponentPropertiesContainer.propsReferenceIds.has(node)) { + return vueComponentPropertiesContainer.usedPropsNames + } + return null + } - hasTemplate = Boolean(node.templateBody) + /** + * Report all unused properties. + */ + function reportUnusedProperties () { + for (const container of vueComponentPropertiesContainers.values()) { + if (container.ignore) { + continue + } + for (const property of container.properties) { + if (container.usedNames.has(property.name) || templatePropertiesContainer.usedNames.has(property.name)) { + continue + } + if (property.groupName === 'props' && container.usedPropsNames.has(property.name)) { + continue + } + context.report({ + node: property.node, + messageId: 'unused', + data: { + group: PROPERTY_LABEL[property.groupName], + name: property.name + } + }) + } } } const scriptVisitor = Object.assign( {}, + utils.defineVueVisitor(context, { + ObjectExpression (node, vueData) { + if (node !== vueData.node) { + return + } + + const container = getVueComponentPropertiesContainer(vueData.node) + const watcherNames = new Set() + for (const watcher of utils.iterateProperties(node, new Set([GROUP_WATCHER]))) { + watcherNames.add(watcher.name) + } + for (const prop of utils.iterateProperties(node, groups)) { + if (watcherNames.has(prop.name)) { + continue + } + container.properties.push(prop) + } + }, + 'Property[value.type=/^(Arrow)?FunctionExpression$/]' (node, vueData) { + if (node.parent !== vueData.node) { + return + } + if (utils.getStaticPropertyName(node) !== 'setup') { + return + } + const container = getVueComponentPropertiesContainer(vueData.node) + const propsParam = node.value.params[0] + if (!propsParam) { + // no arguments + return + } + if (propsParam.type === 'RestElement' || propsParam.type === 'ArrayPattern') { + // cannot check + return + } + if (propsParam.type === 'ObjectPattern') { + extractObjectPatternProperties(propsParam, container.usedPropsNames, container) + return + } + const variable = findVariable(context.getScope(), propsParam) + if (!variable) { + return + } + for (const reference of variable.references) { + container.propsReferenceIds.add(reference.identifier) + } + }, + MemberExpression (node, vueData) { + const vueComponentPropertiesContainer = getVueComponentPropertiesContainer(vueData.node) + const usedNames = getPropertyNamesSet(node.object, vueComponentPropertiesContainer) + if (!usedNames) { + return + } + usedNames.add(utils.getStaticPropertyName(node)) + }, + 'VariableDeclarator > ObjectPattern' (node, vueData) { + const decl = node.parent + const vueComponentPropertiesContainer = getVueComponentPropertiesContainer(vueData.node) + const usedNames = getPropertyNamesSet(decl.init, vueComponentPropertiesContainer) + if (!usedNames) { + return + } + extractObjectPatternProperties(node, usedNames, vueComponentPropertiesContainer) + }, + 'AssignmentExpression > ObjectPattern' (node, vueData) { + const assign = node.parent + const vueComponentPropertiesContainer = getVueComponentPropertiesContainer(vueData.node) + const usedNames = getPropertyNamesSet(assign.right, vueComponentPropertiesContainer) + if (!usedNames) { + return + } + extractObjectPatternProperties(node, usedNames, vueComponentPropertiesContainer) + } + }), { - 'MemberExpression[object.type="ThisExpression"][property.type="Identifier"][property.name]' ( - node - ) { - thisExpressionsVariablesNames.push(node.property.name) + 'Program:exit' (node) { + if (!node.templateBody) { + reportUnusedProperties() + } } }, - utils.executeOnVue(context, obj => { - unusedProperties = Array.from( - utils.iterateProperties(obj, new Set([GROUP_PROPERTY, GROUP_DATA, GROUP_COMPUTED_PROPERTY])) - ) - - const watchers = Array.from(utils.iterateProperties(obj, new Set([GROUP_WATCHER]))) - const watchersNames = watchers.map(watcher => watcher.name) - - remove(unusedProperties, property => { - return ( - thisExpressionsVariablesNames.includes(property.name) || - watchersNames.includes(property.name) - ) - }) - - if (!hasTemplate && unusedProperties.length) { - reportUnusedProperties(context, unusedProperties) - } - }) ) const templateVisitor = { - 'VExpressionContainer[expression!=null][references]' (node) { - const referencesNames = getReferencesNames(node.references) - - remove(unusedProperties, property => { - return referencesNames.includes(property.name) - }) - }, - // save root template end location - just a helper to be used - // for a decision if a parser reached the end of the root template - "VElement[name='template']" (node) { - if (rootTemplateEnd) { - return + 'VExpressionContainer' (node) { + for (const name of getReferencesNames(node.references)) { + templatePropertiesContainer.usedNames.add(name) } - - rootTemplateEnd = node.loc.end }, - "VElement[name='template']:exit" (node) { - if (node.loc.end !== rootTemplateEnd) { - return - } - - if (unusedProperties.length) { - reportUnusedProperties(context, unusedProperties) - } + "VElement[parent.type!='VElement']:exit" () { + reportUnusedProperties() } } - return Object.assign( - {}, - initialize, - utils.defineTemplateBodyVisitor(context, templateVisitor, scriptVisitor) - ) + return utils.defineTemplateBodyVisitor(context, templateVisitor, scriptVisitor) } } diff --git a/tests/lib/rules/no-unused-properties.js b/tests/lib/rules/no-unused-properties.js index e80216468..4ca80dab2 100644 --- a/tests/lib/rules/no-unused-properties.js +++ b/tests/lib/rules/no-unused-properties.js @@ -8,13 +8,15 @@ const RuleTester = require('eslint').RuleTester const rule = require('../../../lib/rules/no-unused-properties') const tester = new RuleTester({ - parser: 'vue-eslint-parser', + parser: require.resolve('vue-eslint-parser'), parserOptions: { - ecmaVersion: 2018, + ecmaVersion: 2020, sourceType: 'module' } }) +const allOptions = [{ groups: ['props', 'computed', 'data', 'methods', 'setup'] }] + tester.run('no-unused-properties', rule, { valid: [ // a property used in a script expression @@ -31,6 +33,41 @@ tester.run('no-unused-properties', rule, { ` }, + // default options + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, // a property being watched { @@ -184,7 +221,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // data being watched @@ -205,7 +243,8 @@ tester.run('no-unused-properties', rule, { }, }; - ` + `, + options: allOptions }, // data used as a template identifier @@ -224,7 +263,8 @@ tester.run('no-unused-properties', rule, { } } - ` + `, + options: allOptions }, // data used in a template expression @@ -244,7 +284,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // data used in v-if @@ -263,7 +304,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // data used in v-for @@ -282,7 +324,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // data used in v-html @@ -301,7 +344,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // data used in v-model @@ -320,7 +364,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // data passed in a component @@ -339,7 +384,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // data used in v-on @@ -358,7 +404,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // computed property used in a script expression @@ -377,7 +424,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // computed property being watched @@ -398,7 +446,8 @@ tester.run('no-unused-properties', rule, { }, }; - ` + `, + options: allOptions }, // computed property used as a template identifier @@ -417,7 +466,8 @@ tester.run('no-unused-properties', rule, { } } - ` + `, + options: allOptions }, // computed properties used in a template expression @@ -439,7 +489,8 @@ tester.run('no-unused-properties', rule, { } } - ` + `, + options: allOptions }, // computed property used in v-if @@ -458,7 +509,8 @@ tester.run('no-unused-properties', rule, { } } - ` + `, + options: allOptions }, // computed property used in v-for @@ -477,7 +529,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // computed property used in v-html @@ -496,7 +549,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // computed property used in v-model @@ -528,7 +582,8 @@ tester.run('no-unused-properties', rule, { } }; - ` + `, + options: allOptions }, // computed property passed in a component @@ -547,7 +602,8 @@ tester.run('no-unused-properties', rule, { } } - ` + `, + options: allOptions }, // ignores unused data when marked with eslint-disable @@ -567,6 +623,92 @@ tester.run('no-unused-properties', rule, { } }; + `, + options: allOptions + }, + + // trace this + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + { + filename: 'test.vue', + code: ` + + ` + }, + // use rest + { + filename: 'test.vue', + code: ` + + ` } ], @@ -587,7 +729,7 @@ tester.run('no-unused-properties', rule, { `, errors: [ { - message: 'Unused property found: "count"', + message: "'count' of property found, but never used.", line: 7 } ] @@ -610,9 +752,10 @@ tester.run('no-unused-properties', rule, { }; `, + options: [{ groups: ['props', 'computed', 'data'] }], errors: [ { - message: 'Unused data found: "count"', + message: "'count' of data found, but never used.", line: 9 } ] @@ -635,12 +778,216 @@ tester.run('no-unused-properties', rule, { }; `, + options: [{ groups: ['props', 'computed', 'data'] }], errors: [ { - message: 'Unused computed property found: "count"', + message: "'count' of computed property found, but never used.", line: 8 } ] + }, + + // all options + { + filename: 'test.vue', + code: ` + + + `, + options: allOptions, + errors: [ + { + message: "'a' of property found, but never used.", + line: 7 + }, + { + message: "'b' of data found, but never used.", + line: 9 + }, + { + message: "'c' of computed property found, but never used.", + line: 12 + }, + { + message: "'d' of method found, but never used.", + line: 17 + }, + { + message: "'e' of property returned from `setup()` found, but never used.", + line: 20 + } + ] + }, + + // trace this + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: "'count' of property found, but never used.", + line: 4 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: "'count' of property found, but never used.", + line: 4 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + { + message: "'count' of property found, but never used.", + line: 4 + } + ] + }, + + // setup + { + filename: 'test.vue', + code: ` + + `, + errors: [ + "'bar' of property found, but never used." + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + "'bar' of property found, but never used." + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + "'foo' of property found, but never used.", + "'bar' of property found, but never used." + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + "'foo' of property found, but never used.", + "'bar' of property found, but never used." + ] + }, + { + filename: 'test.vue', + code: ` + + `, + errors: [ + "'foo' of property found, but never used.", + "'bar' of property found, but never used." + ] } ] })