• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/**
2 * @fileoverview Rule to enforce concise object methods and properties.
3 * @author Jamund Ferguson
4 */
5
6"use strict";
7
8const OPTIONS = {
9    always: "always",
10    never: "never",
11    methods: "methods",
12    properties: "properties",
13    consistent: "consistent",
14    consistentAsNeeded: "consistent-as-needed"
15};
16
17//------------------------------------------------------------------------------
18// Requirements
19//------------------------------------------------------------------------------
20const astUtils = require("./utils/ast-utils");
21
22//------------------------------------------------------------------------------
23// Rule Definition
24//------------------------------------------------------------------------------
25module.exports = {
26    meta: {
27        type: "suggestion",
28
29        docs: {
30            description: "require or disallow method and property shorthand syntax for object literals",
31            category: "ECMAScript 6",
32            recommended: false,
33            url: "https://eslint.org/docs/rules/object-shorthand"
34        },
35
36        fixable: "code",
37
38        schema: {
39            anyOf: [
40                {
41                    type: "array",
42                    items: [
43                        {
44                            enum: ["always", "methods", "properties", "never", "consistent", "consistent-as-needed"]
45                        }
46                    ],
47                    minItems: 0,
48                    maxItems: 1
49                },
50                {
51                    type: "array",
52                    items: [
53                        {
54                            enum: ["always", "methods", "properties"]
55                        },
56                        {
57                            type: "object",
58                            properties: {
59                                avoidQuotes: {
60                                    type: "boolean"
61                                }
62                            },
63                            additionalProperties: false
64                        }
65                    ],
66                    minItems: 0,
67                    maxItems: 2
68                },
69                {
70                    type: "array",
71                    items: [
72                        {
73                            enum: ["always", "methods"]
74                        },
75                        {
76                            type: "object",
77                            properties: {
78                                ignoreConstructors: {
79                                    type: "boolean"
80                                },
81                                avoidQuotes: {
82                                    type: "boolean"
83                                },
84                                avoidExplicitReturnArrows: {
85                                    type: "boolean"
86                                }
87                            },
88                            additionalProperties: false
89                        }
90                    ],
91                    minItems: 0,
92                    maxItems: 2
93                }
94            ]
95        },
96
97        messages: {
98            expectedAllPropertiesShorthanded: "Expected shorthand for all properties.",
99            expectedLiteralMethodLongform: "Expected longform method syntax for string literal keys.",
100            expectedPropertyShorthand: "Expected property shorthand.",
101            expectedPropertyLongform: "Expected longform property syntax.",
102            expectedMethodShorthand: "Expected method shorthand.",
103            expectedMethodLongform: "Expected longform method syntax.",
104            unexpectedMix: "Unexpected mix of shorthand and non-shorthand properties."
105        }
106    },
107
108    create(context) {
109        const APPLY = context.options[0] || OPTIONS.always;
110        const APPLY_TO_METHODS = APPLY === OPTIONS.methods || APPLY === OPTIONS.always;
111        const APPLY_TO_PROPS = APPLY === OPTIONS.properties || APPLY === OPTIONS.always;
112        const APPLY_NEVER = APPLY === OPTIONS.never;
113        const APPLY_CONSISTENT = APPLY === OPTIONS.consistent;
114        const APPLY_CONSISTENT_AS_NEEDED = APPLY === OPTIONS.consistentAsNeeded;
115
116        const PARAMS = context.options[1] || {};
117        const IGNORE_CONSTRUCTORS = PARAMS.ignoreConstructors;
118        const AVOID_QUOTES = PARAMS.avoidQuotes;
119        const AVOID_EXPLICIT_RETURN_ARROWS = !!PARAMS.avoidExplicitReturnArrows;
120        const sourceCode = context.getSourceCode();
121
122        //--------------------------------------------------------------------------
123        // Helpers
124        //--------------------------------------------------------------------------
125
126        const CTOR_PREFIX_REGEX = /[^_$0-9]/u;
127
128        /**
129         * Determines if the first character of the name is a capital letter.
130         * @param {string} name The name of the node to evaluate.
131         * @returns {boolean} True if the first character of the property name is a capital letter, false if not.
132         * @private
133         */
134        function isConstructor(name) {
135            const match = CTOR_PREFIX_REGEX.exec(name);
136
137            // Not a constructor if name has no characters apart from '_', '$' and digits e.g. '_', '$$', '_8'
138            if (!match) {
139                return false;
140            }
141
142            const firstChar = name.charAt(match.index);
143
144            return firstChar === firstChar.toUpperCase();
145        }
146
147        /**
148         * Determines if the property can have a shorthand form.
149         * @param {ASTNode} property Property AST node
150         * @returns {boolean} True if the property can have a shorthand form
151         * @private
152         *
153         */
154        function canHaveShorthand(property) {
155            return (property.kind !== "set" && property.kind !== "get" && property.type !== "SpreadElement" && property.type !== "SpreadProperty" && property.type !== "ExperimentalSpreadProperty");
156        }
157
158        /**
159         * Checks whether a node is a string literal.
160         * @param   {ASTNode} node Any AST node.
161         * @returns {boolean} `true` if it is a string literal.
162         */
163        function isStringLiteral(node) {
164            return node.type === "Literal" && typeof node.value === "string";
165        }
166
167        /**
168         * Determines if the property is a shorthand or not.
169         * @param {ASTNode} property Property AST node
170         * @returns {boolean} True if the property is considered shorthand, false if not.
171         * @private
172         *
173         */
174        function isShorthand(property) {
175
176            // property.method is true when `{a(){}}`.
177            return (property.shorthand || property.method);
178        }
179
180        /**
181         * Determines if the property's key and method or value are named equally.
182         * @param {ASTNode} property Property AST node
183         * @returns {boolean} True if the key and value are named equally, false if not.
184         * @private
185         *
186         */
187        function isRedundant(property) {
188            const value = property.value;
189
190            if (value.type === "FunctionExpression") {
191                return !value.id; // Only anonymous should be shorthand method.
192            }
193            if (value.type === "Identifier") {
194                return astUtils.getStaticPropertyName(property) === value.name;
195            }
196
197            return false;
198        }
199
200        /**
201         * Ensures that an object's properties are consistently shorthand, or not shorthand at all.
202         * @param   {ASTNode} node Property AST node
203         * @param   {boolean} checkRedundancy Whether to check longform redundancy
204         * @returns {void}
205         *
206         */
207        function checkConsistency(node, checkRedundancy) {
208
209            // We are excluding getters/setters and spread properties as they are considered neither longform nor shorthand.
210            const properties = node.properties.filter(canHaveShorthand);
211
212            // Do we still have properties left after filtering the getters and setters?
213            if (properties.length > 0) {
214                const shorthandProperties = properties.filter(isShorthand);
215
216                /*
217                 * If we do not have an equal number of longform properties as
218                 * shorthand properties, we are using the annotations inconsistently
219                 */
220                if (shorthandProperties.length !== properties.length) {
221
222                    // We have at least 1 shorthand property
223                    if (shorthandProperties.length > 0) {
224                        context.report({ node, messageId: "unexpectedMix" });
225                    } else if (checkRedundancy) {
226
227                        /*
228                         * If all properties of the object contain a method or value with a name matching it's key,
229                         * all the keys are redundant.
230                         */
231                        const canAlwaysUseShorthand = properties.every(isRedundant);
232
233                        if (canAlwaysUseShorthand) {
234                            context.report({ node, messageId: "expectedAllPropertiesShorthanded" });
235                        }
236                    }
237                }
238            }
239        }
240
241        /**
242         * Fixes a FunctionExpression node by making it into a shorthand property.
243         * @param {SourceCodeFixer} fixer The fixer object
244         * @param {ASTNode} node A `Property` node that has a `FunctionExpression` or `ArrowFunctionExpression` as its value
245         * @returns {Object} A fix for this node
246         */
247        function makeFunctionShorthand(fixer, node) {
248            const firstKeyToken = node.computed
249                ? sourceCode.getFirstToken(node, astUtils.isOpeningBracketToken)
250                : sourceCode.getFirstToken(node.key);
251            const lastKeyToken = node.computed
252                ? sourceCode.getFirstTokenBetween(node.key, node.value, astUtils.isClosingBracketToken)
253                : sourceCode.getLastToken(node.key);
254            const keyText = sourceCode.text.slice(firstKeyToken.range[0], lastKeyToken.range[1]);
255            let keyPrefix = "";
256
257            // key: /* */ () => {}
258            if (sourceCode.commentsExistBetween(lastKeyToken, node.value)) {
259                return null;
260            }
261
262            if (node.value.async) {
263                keyPrefix += "async ";
264            }
265            if (node.value.generator) {
266                keyPrefix += "*";
267            }
268
269            const fixRange = [firstKeyToken.range[0], node.range[1]];
270            const methodPrefix = keyPrefix + keyText;
271
272            if (node.value.type === "FunctionExpression") {
273                const functionToken = sourceCode.getTokens(node.value).find(token => token.type === "Keyword" && token.value === "function");
274                const tokenBeforeParams = node.value.generator ? sourceCode.getTokenAfter(functionToken) : functionToken;
275
276                return fixer.replaceTextRange(
277                    fixRange,
278                    methodPrefix + sourceCode.text.slice(tokenBeforeParams.range[1], node.value.range[1])
279                );
280            }
281
282            const arrowToken = sourceCode.getTokenBefore(node.value.body, astUtils.isArrowToken);
283            const fnBody = sourceCode.text.slice(arrowToken.range[1], node.value.range[1]);
284
285            let shouldAddParensAroundParameters = false;
286            let tokenBeforeParams;
287
288            if (node.value.params.length === 0) {
289                tokenBeforeParams = sourceCode.getFirstToken(node.value, astUtils.isOpeningParenToken);
290            } else {
291                tokenBeforeParams = sourceCode.getTokenBefore(node.value.params[0]);
292            }
293
294            if (node.value.params.length === 1) {
295                const hasParen = astUtils.isOpeningParenToken(tokenBeforeParams);
296                const isTokenOutsideNode = tokenBeforeParams.range[0] < node.range[0];
297
298                shouldAddParensAroundParameters = !hasParen || isTokenOutsideNode;
299            }
300
301            const sliceStart = shouldAddParensAroundParameters
302                ? node.value.params[0].range[0]
303                : tokenBeforeParams.range[0];
304            const sliceEnd = sourceCode.getTokenBefore(arrowToken).range[1];
305
306            const oldParamText = sourceCode.text.slice(sliceStart, sliceEnd);
307            const newParamText = shouldAddParensAroundParameters ? `(${oldParamText})` : oldParamText;
308
309            return fixer.replaceTextRange(
310                fixRange,
311                methodPrefix + newParamText + fnBody
312            );
313
314        }
315
316        /**
317         * Fixes a FunctionExpression node by making it into a longform property.
318         * @param {SourceCodeFixer} fixer The fixer object
319         * @param {ASTNode} node A `Property` node that has a `FunctionExpression` as its value
320         * @returns {Object} A fix for this node
321         */
322        function makeFunctionLongform(fixer, node) {
323            const firstKeyToken = node.computed ? sourceCode.getTokens(node).find(token => token.value === "[") : sourceCode.getFirstToken(node.key);
324            const lastKeyToken = node.computed ? sourceCode.getTokensBetween(node.key, node.value).find(token => token.value === "]") : sourceCode.getLastToken(node.key);
325            const keyText = sourceCode.text.slice(firstKeyToken.range[0], lastKeyToken.range[1]);
326            let functionHeader = "function";
327
328            if (node.value.async) {
329                functionHeader = `async ${functionHeader}`;
330            }
331            if (node.value.generator) {
332                functionHeader = `${functionHeader}*`;
333            }
334
335            return fixer.replaceTextRange([node.range[0], lastKeyToken.range[1]], `${keyText}: ${functionHeader}`);
336        }
337
338        /*
339         * To determine whether a given arrow function has a lexical identifier (`this`, `arguments`, `super`, or `new.target`),
340         * create a stack of functions that define these identifiers (i.e. all functions except arrow functions) as the AST is
341         * traversed. Whenever a new function is encountered, create a new entry on the stack (corresponding to a different lexical
342         * scope of `this`), and whenever a function is exited, pop that entry off the stack. When an arrow function is entered,
343         * keep a reference to it on the current stack entry, and remove that reference when the arrow function is exited.
344         * When a lexical identifier is encountered, mark all the arrow functions on the current stack entry by adding them
345         * to an `arrowsWithLexicalIdentifiers` set. Any arrow function in that set will not be reported by this rule,
346         * because converting it into a method would change the value of one of the lexical identifiers.
347         */
348        const lexicalScopeStack = [];
349        const arrowsWithLexicalIdentifiers = new WeakSet();
350        const argumentsIdentifiers = new WeakSet();
351
352        /**
353         * Enters a function. This creates a new lexical identifier scope, so a new Set of arrow functions is pushed onto the stack.
354         * Also, this marks all `arguments` identifiers so that they can be detected later.
355         * @returns {void}
356         */
357        function enterFunction() {
358            lexicalScopeStack.unshift(new Set());
359            context.getScope().variables.filter(variable => variable.name === "arguments").forEach(variable => {
360                variable.references.map(ref => ref.identifier).forEach(identifier => argumentsIdentifiers.add(identifier));
361            });
362        }
363
364        /**
365         * Exits a function. This pops the current set of arrow functions off the lexical scope stack.
366         * @returns {void}
367         */
368        function exitFunction() {
369            lexicalScopeStack.shift();
370        }
371
372        /**
373         * Marks the current function as having a lexical keyword. This implies that all arrow functions
374         * in the current lexical scope contain a reference to this lexical keyword.
375         * @returns {void}
376         */
377        function reportLexicalIdentifier() {
378            lexicalScopeStack[0].forEach(arrowFunction => arrowsWithLexicalIdentifiers.add(arrowFunction));
379        }
380
381        //--------------------------------------------------------------------------
382        // Public
383        //--------------------------------------------------------------------------
384
385        return {
386            Program: enterFunction,
387            FunctionDeclaration: enterFunction,
388            FunctionExpression: enterFunction,
389            "Program:exit": exitFunction,
390            "FunctionDeclaration:exit": exitFunction,
391            "FunctionExpression:exit": exitFunction,
392
393            ArrowFunctionExpression(node) {
394                lexicalScopeStack[0].add(node);
395            },
396            "ArrowFunctionExpression:exit"(node) {
397                lexicalScopeStack[0].delete(node);
398            },
399
400            ThisExpression: reportLexicalIdentifier,
401            Super: reportLexicalIdentifier,
402            MetaProperty(node) {
403                if (node.meta.name === "new" && node.property.name === "target") {
404                    reportLexicalIdentifier();
405                }
406            },
407            Identifier(node) {
408                if (argumentsIdentifiers.has(node)) {
409                    reportLexicalIdentifier();
410                }
411            },
412
413            ObjectExpression(node) {
414                if (APPLY_CONSISTENT) {
415                    checkConsistency(node, false);
416                } else if (APPLY_CONSISTENT_AS_NEEDED) {
417                    checkConsistency(node, true);
418                }
419            },
420
421            "Property:exit"(node) {
422                const isConciseProperty = node.method || node.shorthand;
423
424                // Ignore destructuring assignment
425                if (node.parent.type === "ObjectPattern") {
426                    return;
427                }
428
429                // getters and setters are ignored
430                if (node.kind === "get" || node.kind === "set") {
431                    return;
432                }
433
434                // only computed methods can fail the following checks
435                if (node.computed && node.value.type !== "FunctionExpression" && node.value.type !== "ArrowFunctionExpression") {
436                    return;
437                }
438
439                //--------------------------------------------------------------
440                // Checks for property/method shorthand.
441                if (isConciseProperty) {
442                    if (node.method && (APPLY_NEVER || AVOID_QUOTES && isStringLiteral(node.key))) {
443                        const messageId = APPLY_NEVER ? "expectedMethodLongform" : "expectedLiteralMethodLongform";
444
445                        // { x() {} } should be written as { x: function() {} }
446                        context.report({
447                            node,
448                            messageId,
449                            fix: fixer => makeFunctionLongform(fixer, node)
450                        });
451                    } else if (APPLY_NEVER) {
452
453                        // { x } should be written as { x: x }
454                        context.report({
455                            node,
456                            messageId: "expectedPropertyLongform",
457                            fix: fixer => fixer.insertTextAfter(node.key, `: ${node.key.name}`)
458                        });
459                    }
460                } else if (APPLY_TO_METHODS && !node.value.id && (node.value.type === "FunctionExpression" || node.value.type === "ArrowFunctionExpression")) {
461                    if (IGNORE_CONSTRUCTORS && node.key.type === "Identifier" && isConstructor(node.key.name)) {
462                        return;
463                    }
464                    if (AVOID_QUOTES && isStringLiteral(node.key)) {
465                        return;
466                    }
467
468                    // {[x]: function(){}} should be written as {[x]() {}}
469                    if (node.value.type === "FunctionExpression" ||
470                        node.value.type === "ArrowFunctionExpression" &&
471                        node.value.body.type === "BlockStatement" &&
472                        AVOID_EXPLICIT_RETURN_ARROWS &&
473                        !arrowsWithLexicalIdentifiers.has(node.value)
474                    ) {
475                        context.report({
476                            node,
477                            messageId: "expectedMethodShorthand",
478                            fix: fixer => makeFunctionShorthand(fixer, node)
479                        });
480                    }
481                } else if (node.value.type === "Identifier" && node.key.name === node.value.name && APPLY_TO_PROPS) {
482
483                    // {x: x} should be written as {x}
484                    context.report({
485                        node,
486                        messageId: "expectedPropertyShorthand",
487                        fix(fixer) {
488                            return fixer.replaceText(node, node.value.name);
489                        }
490                    });
491                } else if (node.value.type === "Identifier" && node.key.type === "Literal" && node.key.value === node.value.name && APPLY_TO_PROPS) {
492                    if (AVOID_QUOTES) {
493                        return;
494                    }
495
496                    // {"x": x} should be written as {x}
497                    context.report({
498                        node,
499                        messageId: "expectedPropertyShorthand",
500                        fix(fixer) {
501                            return fixer.replaceText(node, node.value.name);
502                        }
503                    });
504                }
505            }
506        };
507    }
508};
509