summaryrefslogtreecommitdiff
path: root/tools/node_modules/eslint/lib/rules/prefer-object-spread.js
blob: d6577df7d58b13386c3df5e0001c5e133c40b8be (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
/**
 * @fileoverview Prefers object spread property over Object.assign
 * @author Sharmila Jesupaul
 * See LICENSE file in root directory for full license.
 */

"use strict";

const matchAll = require("string.prototype.matchall");

/**
 * Helper that checks if the node is an Object.assign call
 * @param {ASTNode} node - The node that the rule warns on
 * @returns {boolean} - Returns true if the node is an Object.assign call
 */
function isObjectAssign(node) {
    return (
        node.callee &&
        node.callee.type === "MemberExpression" &&
        node.callee.object.name === "Object" &&
        node.callee.property.name === "assign"
    );
}

/**
 * Helper that checks if the Object.assign call has array spread
 * @param {ASTNode} node - The node that the rule warns on
 * @returns {boolean} - Returns true if the Object.assign call has array spread
 */
function hasArraySpread(node) {
    return node.arguments.some(arg => arg.type === "SpreadElement");
}

/**
 * Helper that checks if the node needs parentheses to be valid JS.
 * The default is to wrap the node in parentheses to avoid parsing errors.
 * @param {ASTNode} node - The node that the rule warns on
 * @returns {boolean} - Returns true if the node needs parentheses
 */
function needsParens(node) {
    const parent = node.parent;

    if (!parent || !node.type) {
        return true;
    }

    switch (parent.type) {
        case "VariableDeclarator":
        case "ArrayExpression":
        case "ReturnStatement":
        case "CallExpression":
        case "Property":
            return false;
        default:
            return true;
    }
}

/**
 * Determines if an argument needs parentheses. The default is to not add parens.
 * @param {ASTNode} node - The node to be checked.
 * @returns {boolean} True if the node needs parentheses
 */
function argNeedsParens(node) {
    if (!node.type) {
        return false;
    }

    switch (node.type) {
        case "AssignmentExpression":
        case "ArrowFunctionExpression":
        case "ConditionalExpression":
            return true;
        default:
            return false;
    }
}

/**
 * Helper that adds a comma after the last non-whitespace character that is not a part of a comment.
 * @param {string} formattedArg - String of argument text
 * @param {array} comments - comments inside the argument
 * @returns {string} - argument with comma at the end of it
 */
function addComma(formattedArg, comments) {
    const nonWhitespaceCharacterRegex = /[^\s\\]/g;
    const nonWhitespaceCharacters = Array.from(matchAll(formattedArg, nonWhitespaceCharacterRegex));
    const commentRanges = comments.map(comment => comment.range);
    const validWhitespaceMatches = [];

    // Create a list of indexes where non-whitespace characters exist.
    nonWhitespaceCharacters.forEach(match => {
        const insertIndex = match.index + match[0].length;

        if (!commentRanges.length) {
            validWhitespaceMatches.push(insertIndex);
        }

        // If comment ranges are found make sure that the non whitespace characters are not part of the comment.
        commentRanges.forEach(arr => {
            const commentStart = arr[0];
            const commentEnd = arr[1];

            if (insertIndex < commentStart || insertIndex > commentEnd) {
                validWhitespaceMatches.push(insertIndex);
            }
        });
    });
    const insertPos = Math.max(...validWhitespaceMatches);
    const regex = new RegExp(`^((?:.|[^/s/S]){${insertPos}}) *`);

    return formattedArg.replace(regex, "$1, ");
}

/**
 * Helper formats an argument by either removing curlies or adding a spread operator
 * @param {ASTNode|null} arg - ast node representing argument to format
 * @param {boolean} isLast - true if on the last element of the array
 * @param {Object} sourceCode - in context sourcecode object
 * @param {array} comments - comments inside checked node
 * @returns {string} - formatted argument
 */
function formatArg(arg, isLast, sourceCode, comments) {
    const text = sourceCode.getText(arg);
    const parens = argNeedsParens(arg);
    const spread = arg.type === "SpreadElement" ? "" : "...";

    if (arg.type === "ObjectExpression" && arg.properties.length === 0) {
        return "";
    }

    if (arg.type === "ObjectExpression") {

        /**
         * This regex finds the opening curly brace and any following spaces and replaces it with whatever
         * exists before the curly brace. It also does the same for the closing curly brace. This is to avoid
         * having multiple spaces around the object expression depending on how the object properties are spaced.
         */
        const formattedObjectLiteral = text.replace(/^(.*){ */, "$1").replace(/ *}([^}]*)$/, "$1");

        return isLast ? formattedObjectLiteral : addComma(formattedObjectLiteral, comments);
    }

    if (isLast) {
        return parens ? `${spread}(${text})` : `${spread}${text}`;
    }

    return parens ? addComma(`${spread}(${text})`, comments) : `${spread}${addComma(text, comments)}`;
}

/**
 * Autofixes the Object.assign call to use an object spread instead.
 * @param {ASTNode|null} node - The node that the rule warns on, i.e. the Object.assign call
 * @param {string} sourceCode - sourceCode of the Object.assign call
 * @returns {Function} autofixer - replaces the Object.assign with a spread object.
 */
function autofixSpread(node, sourceCode) {
    return fixer => {
        const args = node.arguments;
        const firstArg = args[0];
        const lastArg = args[args.length - 1];
        const parens = needsParens(node);
        const comments = sourceCode.getCommentsInside(node);
        const replaceObjectAssignStart = fixer.replaceTextRange(
            [node.range[0], firstArg.range[0]],
            `${parens ? "({" : "{"}`
        );

        const handleArgs = args
            .map((arg, i, arr) => formatArg(arg, i + 1 >= arr.length, sourceCode, comments))
            .filter(arg => arg !== "," && arg !== "");

        const insertBody = fixer.replaceTextRange([firstArg.range[0], lastArg.range[1]], handleArgs.join(""));
        const replaceObjectAssignEnd = fixer.replaceTextRange([lastArg.range[1], node.range[1]], `${parens ? "})" : "}"}`);

        return [
            replaceObjectAssignStart,
            insertBody,
            replaceObjectAssignEnd
        ];
    };
}

/**
 * Autofixes the Object.assign call with a single object literal as an argument
 * @param {ASTNode|null} node - The node that the rule warns on, i.e. the Object.assign call
 * @param {string} sourceCode - sourceCode of the Object.assign call
 * @returns {Function} autofixer - replaces the Object.assign with a object literal.
 */
function autofixObjectLiteral(node, sourceCode) {
    return fixer => {
        const argument = node.arguments[0];
        const parens = needsParens(node);

        return fixer.replaceText(node, `${parens ? "(" : ""}${sourceCode.text.slice(argument.range[0], argument.range[1])}${parens ? ")" : ""}`);
    };
}

/**
 * Check if the node has modified a given variable
 * @param {ASTNode|null} node - The node that the rule warns on, i.e. the Object.assign call
 * @returns {boolean} - true if node is an assignment, variable declaration, or import statement
 */
function isModifier(node) {
    if (!node.type) {
        return false;
    }

    return node.type === "AssignmentExpression" ||
        node.type === "VariableDeclarator" ||
        node.type === "ImportDeclaration";
}

/**
 * Check if the node has modified a given variable
 * @param {array} references - list of reference nodes
 * @returns {boolean} - true if node is has been overwritten by an assignment or import
 */
function modifyingObjectReference(references) {
    return references.some(ref => (
        ref.identifier &&
        ref.identifier.parent &&
        isModifier(ref.identifier.parent)
    ));
}


module.exports = {
    meta: {
        docs: {
            description:
                "disallow using Object.assign with an object literal as the first argument and prefer the use of object spread instead.",
            category: "Stylistic Issues",
            recommended: false,
            url: "https://eslint.org/docs/rules/prefer-object-spread"
        },
        schema: [],
        fixable: "code",
        messages: {
            useSpreadMessage: "Use an object spread instead of `Object.assign` eg: `{ ...foo }`",
            useLiteralMessage: "Use an object literal instead of `Object.assign`. eg: `{ foo: bar }`"
        }
    },

    create: function rule(context) {
        const sourceCode = context.getSourceCode();
        const scope = context.getScope();
        const objectVariable = scope.variables.filter(variable => variable.name === "Object");
        const moduleReferences = scope.childScopes.filter(childScope => {
            const varNamedObject = childScope.variables.filter(variable => variable.name === "Object");

            return childScope.type === "module" && varNamedObject.length;
        });
        const references = [].concat(...objectVariable.map(variable => variable.references || []));

        return {
            CallExpression(node) {

                /*
                 * If current file is either importing Object or redefining it,
                 * we skip warning on this rule.
                 */
                if (moduleReferences.length || (references.length && modifyingObjectReference(references))) {
                    return;
                }

                /*
                 * The condition below is cases where Object.assign has a single argument and
                 * that argument is an object literal. e.g. `Object.assign({ foo: bar })`.
                 * For now, we will warn on this case and autofix it.
                 */
                if (
                    node.arguments.length === 1 &&
                    node.arguments[0].type === "ObjectExpression" &&
                    isObjectAssign(node)
                ) {
                    context.report({
                        node,
                        messageId: "useLiteralMessage",
                        fix: autofixObjectLiteral(node, sourceCode)
                    });
                }

                /*
                 * The condition below warns on `Object.assign` calls that that have
                 * an object literal as the first argument and have a second argument
                 * that can be spread. e.g `Object.assign({ foo: bar }, baz)`
                 */
                if (
                    node.arguments.length > 1 &&
                    node.arguments[0].type === "ObjectExpression" &&
                    isObjectAssign(node) &&
                    !hasArraySpread(node)
                ) {
                    context.report({
                        node,
                        messageId: "useSpreadMessage",
                        fix: autofixSpread(node, sourceCode)
                    });
                }
            }
        };
    }
};