/** * @fileoverview Rule to enforce getter and setter pairs in objects and classes. * @author Gyandeep Singh */ "use strict"; //------------------------------------------------------------------------------ // Requirements //------------------------------------------------------------------------------ const astUtils = require("./utils/ast-utils"); //------------------------------------------------------------------------------ // Typedefs //------------------------------------------------------------------------------ /** * Property name if it can be computed statically, otherwise the list of the tokens of the key node. * @typedef {string|Token[]} Key */ /** * Accessor nodes with the same key. * @typedef {Object} AccessorData * @property {Key} key Accessor's key * @property {ASTNode[]} getters List of getter nodes. * @property {ASTNode[]} setters List of setter nodes. */ //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ /** * Checks whether or not the given lists represent the equal tokens in the same order. * Tokens are compared by their properties, not by instance. * @param {Token[]} left First list of tokens. * @param {Token[]} right Second list of tokens. * @returns {boolean} `true` if the lists have same tokens. */ function areEqualTokenLists(left, right) { if (left.length !== right.length) { return false; } for (let i = 0; i < left.length; i++) { const leftToken = left[i], rightToken = right[i]; if (leftToken.type !== rightToken.type || leftToken.value !== rightToken.value) { return false; } } return true; } /** * Checks whether or not the given keys are equal. * @param {Key} left First key. * @param {Key} right Second key. * @returns {boolean} `true` if the keys are equal. */ function areEqualKeys(left, right) { if (typeof left === "string" && typeof right === "string") { // Statically computed names. return left === right; } if (Array.isArray(left) && Array.isArray(right)) { // Token lists. return areEqualTokenLists(left, right); } return false; } /** * Checks whether or not a given node is of an accessor kind ('get' or 'set'). * @param {ASTNode} node A node to check. * @returns {boolean} `true` if the node is of an accessor kind. */ function isAccessorKind(node) { return node.kind === "get" || node.kind === "set"; } /** * Checks whether or not a given node is an `Identifier` node which was named a given name. * @param {ASTNode} node A node to check. * @param {string} name An expected name of the node. * @returns {boolean} `true` if the node is an `Identifier` node which was named as expected. */ function isIdentifier(node, name) { return node.type === "Identifier" && node.name === name; } /** * Checks whether or not a given node is an argument of a specified method call. * @param {ASTNode} node A node to check. * @param {number} index An expected index of the node in arguments. * @param {string} object An expected name of the object of the method. * @param {string} property An expected name of the method. * @returns {boolean} `true` if the node is an argument of the specified method call. */ function isArgumentOfMethodCall(node, index, object, property) { const parent = node.parent; return ( parent.type === "CallExpression" && parent.callee.type === "MemberExpression" && parent.callee.computed === false && isIdentifier(parent.callee.object, object) && isIdentifier(parent.callee.property, property) && parent.arguments[index] === node ); } /** * Checks whether or not a given node is a property descriptor. * @param {ASTNode} node A node to check. * @returns {boolean} `true` if the node is a property descriptor. */ function isPropertyDescriptor(node) { // Object.defineProperty(obj, "foo", {set: ...}) if (isArgumentOfMethodCall(node, 2, "Object", "defineProperty") || isArgumentOfMethodCall(node, 2, "Reflect", "defineProperty") ) { return true; } /* * Object.defineProperties(obj, {foo: {set: ...}}) * Object.create(proto, {foo: {set: ...}}) */ const grandparent = node.parent.parent; return grandparent.type === "ObjectExpression" && ( isArgumentOfMethodCall(grandparent, 1, "Object", "create") || isArgumentOfMethodCall(grandparent, 1, "Object", "defineProperties") ); } //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ module.exports = { meta: { type: "suggestion", docs: { description: "enforce getter and setter pairs in objects and classes", category: "Best Practices", recommended: false, url: "https://eslint.org/docs/rules/accessor-pairs" }, schema: [{ type: "object", properties: { getWithoutSet: { type: "boolean", default: false }, setWithoutGet: { type: "boolean", default: true }, enforceForClassMembers: { type: "boolean", default: true } }, additionalProperties: false }], messages: { missingGetterInPropertyDescriptor: "Getter is not present in property descriptor.", missingSetterInPropertyDescriptor: "Setter is not present in property descriptor.", missingGetterInObjectLiteral: "Getter is not present for {{ name }}.", missingSetterInObjectLiteral: "Setter is not present for {{ name }}.", missingGetterInClass: "Getter is not present for class {{ name }}.", missingSetterInClass: "Setter is not present for class {{ name }}." } }, create(context) { const config = context.options[0] || {}; const checkGetWithoutSet = config.getWithoutSet === true; const checkSetWithoutGet = config.setWithoutGet !== false; const enforceForClassMembers = config.enforceForClassMembers !== false; const sourceCode = context.getSourceCode(); /** * Reports the given node. * @param {ASTNode} node The node to report. * @param {string} messageKind "missingGetter" or "missingSetter". * @returns {void} * @private */ function report(node, messageKind) { if (node.type === "Property") { context.report({ node, messageId: `${messageKind}InObjectLiteral`, loc: astUtils.getFunctionHeadLoc(node.value, sourceCode), data: { name: astUtils.getFunctionNameWithKind(node.value) } }); } else if (node.type === "MethodDefinition") { context.report({ node, messageId: `${messageKind}InClass`, loc: astUtils.getFunctionHeadLoc(node.value, sourceCode), data: { name: astUtils.getFunctionNameWithKind(node.value) } }); } else { context.report({ node, messageId: `${messageKind}InPropertyDescriptor` }); } } /** * Reports each of the nodes in the given list using the same messageId. * @param {ASTNode[]} nodes Nodes to report. * @param {string} messageKind "missingGetter" or "missingSetter". * @returns {void} * @private */ function reportList(nodes, messageKind) { for (const node of nodes) { report(node, messageKind); } } /** * Creates a new `AccessorData` object for the given getter or setter node. * @param {ASTNode} node A getter or setter node. * @returns {AccessorData} New `AccessorData` object that contains the given node. * @private */ function createAccessorData(node) { const name = astUtils.getStaticPropertyName(node); const key = (name !== null) ? name : sourceCode.getTokens(node.key); return { key, getters: node.kind === "get" ? [node] : [], setters: node.kind === "set" ? [node] : [] }; } /** * Merges the given `AccessorData` object into the given accessors list. * @param {AccessorData[]} accessors The list to merge into. * @param {AccessorData} accessorData The object to merge. * @returns {AccessorData[]} The same instance with the merged object. * @private */ function mergeAccessorData(accessors, accessorData) { const equalKeyElement = accessors.find(a => areEqualKeys(a.key, accessorData.key)); if (equalKeyElement) { equalKeyElement.getters.push(...accessorData.getters); equalKeyElement.setters.push(...accessorData.setters); } else { accessors.push(accessorData); } return accessors; } /** * Checks accessor pairs in the given list of nodes. * @param {ASTNode[]} nodes The list to check. * @returns {void} * @private */ function checkList(nodes) { const accessors = nodes .filter(isAccessorKind) .map(createAccessorData) .reduce(mergeAccessorData, []); for (const { getters, setters } of accessors) { if (checkSetWithoutGet && setters.length && !getters.length) { reportList(setters, "missingGetter"); } if (checkGetWithoutSet && getters.length && !setters.length) { reportList(getters, "missingSetter"); } } } /** * Checks accessor pairs in an object literal. * @param {ASTNode} node `ObjectExpression` node to check. * @returns {void} * @private */ function checkObjectLiteral(node) { checkList(node.properties.filter(p => p.type === "Property")); } /** * Checks accessor pairs in a property descriptor. * @param {ASTNode} node Property descriptor `ObjectExpression` node to check. * @returns {void} * @private */ function checkPropertyDescriptor(node) { const namesToCheck = node.properties .filter(p => p.type === "Property" && p.kind === "init" && !p.computed) .map(({ key }) => key.name); const hasGetter = namesToCheck.includes("get"); const hasSetter = namesToCheck.includes("set"); if (checkSetWithoutGet && hasSetter && !hasGetter) { report(node, "missingGetter"); } if (checkGetWithoutSet && hasGetter && !hasSetter) { report(node, "missingSetter"); } } /** * Checks the given object expression as an object literal and as a possible property descriptor. * @param {ASTNode} node `ObjectExpression` node to check. * @returns {void} * @private */ function checkObjectExpression(node) { checkObjectLiteral(node); if (isPropertyDescriptor(node)) { checkPropertyDescriptor(node); } } /** * Checks the given class body. * @param {ASTNode} node `ClassBody` node to check. * @returns {void} * @private */ function checkClassBody(node) { const methodDefinitions = node.body.filter(m => m.type === "MethodDefinition"); checkList(methodDefinitions.filter(m => m.static)); checkList(methodDefinitions.filter(m => !m.static)); } const listeners = {}; if (checkSetWithoutGet || checkGetWithoutSet) { listeners.ObjectExpression = checkObjectExpression; if (enforceForClassMembers) { listeners.ClassBody = checkClassBody; } } return listeners; } };