• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/**
2 * @fileoverview Rule to specify spacing of object literal keys and values
3 * @author Brandon Mills
4 */
5"use strict";
6
7//------------------------------------------------------------------------------
8// Requirements
9//------------------------------------------------------------------------------
10
11const astUtils = require("./utils/ast-utils");
12
13//------------------------------------------------------------------------------
14// Helpers
15//------------------------------------------------------------------------------
16
17/**
18 * Checks whether a string contains a line terminator as defined in
19 * http://www.ecma-international.org/ecma-262/5.1/#sec-7.3
20 * @param {string} str String to test.
21 * @returns {boolean} True if str contains a line terminator.
22 */
23function containsLineTerminator(str) {
24    return astUtils.LINEBREAK_MATCHER.test(str);
25}
26
27/**
28 * Gets the last element of an array.
29 * @param {Array} arr An array.
30 * @returns {any} Last element of arr.
31 */
32function last(arr) {
33    return arr[arr.length - 1];
34}
35
36/**
37 * Checks whether a node is contained on a single line.
38 * @param {ASTNode} node AST Node being evaluated.
39 * @returns {boolean} True if the node is a single line.
40 */
41function isSingleLine(node) {
42    return (node.loc.end.line === node.loc.start.line);
43}
44
45/**
46 * Checks whether the properties on a single line.
47 * @param {ASTNode[]} properties List of Property AST nodes.
48 * @returns {boolean} True if all properties is on a single line.
49 */
50function isSingleLineProperties(properties) {
51    const [firstProp] = properties,
52        lastProp = last(properties);
53
54    return firstProp.loc.start.line === lastProp.loc.end.line;
55}
56
57/**
58 * Initializes a single option property from the configuration with defaults for undefined values
59 * @param {Object} toOptions Object to be initialized
60 * @param {Object} fromOptions Object to be initialized from
61 * @returns {Object} The object with correctly initialized options and values
62 */
63function initOptionProperty(toOptions, fromOptions) {
64    toOptions.mode = fromOptions.mode || "strict";
65
66    // Set value of beforeColon
67    if (typeof fromOptions.beforeColon !== "undefined") {
68        toOptions.beforeColon = +fromOptions.beforeColon;
69    } else {
70        toOptions.beforeColon = 0;
71    }
72
73    // Set value of afterColon
74    if (typeof fromOptions.afterColon !== "undefined") {
75        toOptions.afterColon = +fromOptions.afterColon;
76    } else {
77        toOptions.afterColon = 1;
78    }
79
80    // Set align if exists
81    if (typeof fromOptions.align !== "undefined") {
82        if (typeof fromOptions.align === "object") {
83            toOptions.align = fromOptions.align;
84        } else { // "string"
85            toOptions.align = {
86                on: fromOptions.align,
87                mode: toOptions.mode,
88                beforeColon: toOptions.beforeColon,
89                afterColon: toOptions.afterColon
90            };
91        }
92    }
93
94    return toOptions;
95}
96
97/**
98 * Initializes all the option values (singleLine, multiLine and align) from the configuration with defaults for undefined values
99 * @param {Object} toOptions Object to be initialized
100 * @param {Object} fromOptions Object to be initialized from
101 * @returns {Object} The object with correctly initialized options and values
102 */
103function initOptions(toOptions, fromOptions) {
104    if (typeof fromOptions.align === "object") {
105
106        // Initialize the alignment configuration
107        toOptions.align = initOptionProperty({}, fromOptions.align);
108        toOptions.align.on = fromOptions.align.on || "colon";
109        toOptions.align.mode = fromOptions.align.mode || "strict";
110
111        toOptions.multiLine = initOptionProperty({}, (fromOptions.multiLine || fromOptions));
112        toOptions.singleLine = initOptionProperty({}, (fromOptions.singleLine || fromOptions));
113
114    } else { // string or undefined
115        toOptions.multiLine = initOptionProperty({}, (fromOptions.multiLine || fromOptions));
116        toOptions.singleLine = initOptionProperty({}, (fromOptions.singleLine || fromOptions));
117
118        // If alignment options are defined in multiLine, pull them out into the general align configuration
119        if (toOptions.multiLine.align) {
120            toOptions.align = {
121                on: toOptions.multiLine.align.on,
122                mode: toOptions.multiLine.align.mode || toOptions.multiLine.mode,
123                beforeColon: toOptions.multiLine.align.beforeColon,
124                afterColon: toOptions.multiLine.align.afterColon
125            };
126        }
127    }
128
129    return toOptions;
130}
131
132//------------------------------------------------------------------------------
133// Rule Definition
134//------------------------------------------------------------------------------
135
136module.exports = {
137    meta: {
138        type: "layout",
139
140        docs: {
141            description: "enforce consistent spacing between keys and values in object literal properties",
142            category: "Stylistic Issues",
143            recommended: false,
144            url: "https://eslint.org/docs/rules/key-spacing"
145        },
146
147        fixable: "whitespace",
148
149        schema: [{
150            anyOf: [
151                {
152                    type: "object",
153                    properties: {
154                        align: {
155                            anyOf: [
156                                {
157                                    enum: ["colon", "value"]
158                                },
159                                {
160                                    type: "object",
161                                    properties: {
162                                        mode: {
163                                            enum: ["strict", "minimum"]
164                                        },
165                                        on: {
166                                            enum: ["colon", "value"]
167                                        },
168                                        beforeColon: {
169                                            type: "boolean"
170                                        },
171                                        afterColon: {
172                                            type: "boolean"
173                                        }
174                                    },
175                                    additionalProperties: false
176                                }
177                            ]
178                        },
179                        mode: {
180                            enum: ["strict", "minimum"]
181                        },
182                        beforeColon: {
183                            type: "boolean"
184                        },
185                        afterColon: {
186                            type: "boolean"
187                        }
188                    },
189                    additionalProperties: false
190                },
191                {
192                    type: "object",
193                    properties: {
194                        singleLine: {
195                            type: "object",
196                            properties: {
197                                mode: {
198                                    enum: ["strict", "minimum"]
199                                },
200                                beforeColon: {
201                                    type: "boolean"
202                                },
203                                afterColon: {
204                                    type: "boolean"
205                                }
206                            },
207                            additionalProperties: false
208                        },
209                        multiLine: {
210                            type: "object",
211                            properties: {
212                                align: {
213                                    anyOf: [
214                                        {
215                                            enum: ["colon", "value"]
216                                        },
217                                        {
218                                            type: "object",
219                                            properties: {
220                                                mode: {
221                                                    enum: ["strict", "minimum"]
222                                                },
223                                                on: {
224                                                    enum: ["colon", "value"]
225                                                },
226                                                beforeColon: {
227                                                    type: "boolean"
228                                                },
229                                                afterColon: {
230                                                    type: "boolean"
231                                                }
232                                            },
233                                            additionalProperties: false
234                                        }
235                                    ]
236                                },
237                                mode: {
238                                    enum: ["strict", "minimum"]
239                                },
240                                beforeColon: {
241                                    type: "boolean"
242                                },
243                                afterColon: {
244                                    type: "boolean"
245                                }
246                            },
247                            additionalProperties: false
248                        }
249                    },
250                    additionalProperties: false
251                },
252                {
253                    type: "object",
254                    properties: {
255                        singleLine: {
256                            type: "object",
257                            properties: {
258                                mode: {
259                                    enum: ["strict", "minimum"]
260                                },
261                                beforeColon: {
262                                    type: "boolean"
263                                },
264                                afterColon: {
265                                    type: "boolean"
266                                }
267                            },
268                            additionalProperties: false
269                        },
270                        multiLine: {
271                            type: "object",
272                            properties: {
273                                mode: {
274                                    enum: ["strict", "minimum"]
275                                },
276                                beforeColon: {
277                                    type: "boolean"
278                                },
279                                afterColon: {
280                                    type: "boolean"
281                                }
282                            },
283                            additionalProperties: false
284                        },
285                        align: {
286                            type: "object",
287                            properties: {
288                                mode: {
289                                    enum: ["strict", "minimum"]
290                                },
291                                on: {
292                                    enum: ["colon", "value"]
293                                },
294                                beforeColon: {
295                                    type: "boolean"
296                                },
297                                afterColon: {
298                                    type: "boolean"
299                                }
300                            },
301                            additionalProperties: false
302                        }
303                    },
304                    additionalProperties: false
305                }
306            ]
307        }],
308        messages: {
309            extraKey: "Extra space after {{computed}}key '{{key}}'.",
310            extraValue: "Extra space before value for {{computed}}key '{{key}}'.",
311            missingKey: "Missing space after {{computed}}key '{{key}}'.",
312            missingValue: "Missing space before value for {{computed}}key '{{key}}'."
313        }
314    },
315
316    create(context) {
317
318        /**
319         * OPTIONS
320         * "key-spacing": [2, {
321         *     beforeColon: false,
322         *     afterColon: true,
323         *     align: "colon" // Optional, or "value"
324         * }
325         */
326        const options = context.options[0] || {},
327            ruleOptions = initOptions({}, options),
328            multiLineOptions = ruleOptions.multiLine,
329            singleLineOptions = ruleOptions.singleLine,
330            alignmentOptions = ruleOptions.align || null;
331
332        const sourceCode = context.getSourceCode();
333
334        /**
335         * Checks whether a property is a member of the property group it follows.
336         * @param {ASTNode} lastMember The last Property known to be in the group.
337         * @param {ASTNode} candidate The next Property that might be in the group.
338         * @returns {boolean} True if the candidate property is part of the group.
339         */
340        function continuesPropertyGroup(lastMember, candidate) {
341            const groupEndLine = lastMember.loc.start.line,
342                candidateStartLine = candidate.loc.start.line;
343
344            if (candidateStartLine - groupEndLine <= 1) {
345                return true;
346            }
347
348            /*
349             * Check that the first comment is adjacent to the end of the group, the
350             * last comment is adjacent to the candidate property, and that successive
351             * comments are adjacent to each other.
352             */
353            const leadingComments = sourceCode.getCommentsBefore(candidate);
354
355            if (
356                leadingComments.length &&
357                leadingComments[0].loc.start.line - groupEndLine <= 1 &&
358                candidateStartLine - last(leadingComments).loc.end.line <= 1
359            ) {
360                for (let i = 1; i < leadingComments.length; i++) {
361                    if (leadingComments[i].loc.start.line - leadingComments[i - 1].loc.end.line > 1) {
362                        return false;
363                    }
364                }
365                return true;
366            }
367
368            return false;
369        }
370
371        /**
372         * Determines if the given property is key-value property.
373         * @param {ASTNode} property Property node to check.
374         * @returns {boolean} Whether the property is a key-value property.
375         */
376        function isKeyValueProperty(property) {
377            return !(
378                (property.method ||
379                property.shorthand ||
380                property.kind !== "init" || property.type !== "Property") // Could be "ExperimentalSpreadProperty" or "SpreadElement"
381            );
382        }
383
384        /**
385         * Starting from the given a node (a property.key node here) looks forward
386         * until it finds the last token before a colon punctuator and returns it.
387         * @param {ASTNode} node The node to start looking from.
388         * @returns {ASTNode} The last token before a colon punctuator.
389         */
390        function getLastTokenBeforeColon(node) {
391            const colonToken = sourceCode.getTokenAfter(node, astUtils.isColonToken);
392
393            return sourceCode.getTokenBefore(colonToken);
394        }
395
396        /**
397         * Starting from the given a node (a property.key node here) looks forward
398         * until it finds the colon punctuator and returns it.
399         * @param {ASTNode} node The node to start looking from.
400         * @returns {ASTNode} The colon punctuator.
401         */
402        function getNextColon(node) {
403            return sourceCode.getTokenAfter(node, astUtils.isColonToken);
404        }
405
406        /**
407         * Gets an object literal property's key as the identifier name or string value.
408         * @param {ASTNode} property Property node whose key to retrieve.
409         * @returns {string} The property's key.
410         */
411        function getKey(property) {
412            const key = property.key;
413
414            if (property.computed) {
415                return sourceCode.getText().slice(key.range[0], key.range[1]);
416            }
417            return astUtils.getStaticPropertyName(property);
418        }
419
420        /**
421         * Reports an appropriately-formatted error if spacing is incorrect on one
422         * side of the colon.
423         * @param {ASTNode} property Key-value pair in an object literal.
424         * @param {string} side Side being verified - either "key" or "value".
425         * @param {string} whitespace Actual whitespace string.
426         * @param {int} expected Expected whitespace length.
427         * @param {string} mode Value of the mode as "strict" or "minimum"
428         * @returns {void}
429         */
430        function report(property, side, whitespace, expected, mode) {
431            const diff = whitespace.length - expected,
432                nextColon = getNextColon(property.key),
433                tokenBeforeColon = sourceCode.getTokenBefore(nextColon, { includeComments: true }),
434                tokenAfterColon = sourceCode.getTokenAfter(nextColon, { includeComments: true }),
435                isKeySide = side === "key",
436                locStart = isKeySide ? tokenBeforeColon.loc.start : tokenAfterColon.loc.start,
437                isExtra = diff > 0,
438                diffAbs = Math.abs(diff),
439                spaces = Array(diffAbs + 1).join(" ");
440
441            if ((
442                diff && mode === "strict" ||
443                diff < 0 && mode === "minimum" ||
444                diff > 0 && !expected && mode === "minimum") &&
445                !(expected && containsLineTerminator(whitespace))
446            ) {
447                let fix;
448
449                if (isExtra) {
450                    let range;
451
452                    // Remove whitespace
453                    if (isKeySide) {
454                        range = [tokenBeforeColon.range[1], tokenBeforeColon.range[1] + diffAbs];
455                    } else {
456                        range = [tokenAfterColon.range[0] - diffAbs, tokenAfterColon.range[0]];
457                    }
458                    fix = function(fixer) {
459                        return fixer.removeRange(range);
460                    };
461                } else {
462
463                    // Add whitespace
464                    if (isKeySide) {
465                        fix = function(fixer) {
466                            return fixer.insertTextAfter(tokenBeforeColon, spaces);
467                        };
468                    } else {
469                        fix = function(fixer) {
470                            return fixer.insertTextBefore(tokenAfterColon, spaces);
471                        };
472                    }
473                }
474
475                let messageId = "";
476
477                if (isExtra) {
478                    messageId = side === "key" ? "extraKey" : "extraValue";
479                } else {
480                    messageId = side === "key" ? "missingKey" : "missingValue";
481                }
482
483                context.report({
484                    node: property[side],
485                    loc: locStart,
486                    messageId,
487                    data: {
488                        computed: property.computed ? "computed " : "",
489                        key: getKey(property)
490                    },
491                    fix
492                });
493            }
494        }
495
496        /**
497         * Gets the number of characters in a key, including quotes around string
498         * keys and braces around computed property keys.
499         * @param {ASTNode} property Property of on object literal.
500         * @returns {int} Width of the key.
501         */
502        function getKeyWidth(property) {
503            const startToken = sourceCode.getFirstToken(property);
504            const endToken = getLastTokenBeforeColon(property.key);
505
506            return endToken.range[1] - startToken.range[0];
507        }
508
509        /**
510         * Gets the whitespace around the colon in an object literal property.
511         * @param {ASTNode} property Property node from an object literal.
512         * @returns {Object} Whitespace before and after the property's colon.
513         */
514        function getPropertyWhitespace(property) {
515            const whitespace = /(\s*):(\s*)/u.exec(sourceCode.getText().slice(
516                property.key.range[1], property.value.range[0]
517            ));
518
519            if (whitespace) {
520                return {
521                    beforeColon: whitespace[1],
522                    afterColon: whitespace[2]
523                };
524            }
525            return null;
526        }
527
528        /**
529         * Creates groups of properties.
530         * @param  {ASTNode} node ObjectExpression node being evaluated.
531         * @returns {Array.<ASTNode[]>} Groups of property AST node lists.
532         */
533        function createGroups(node) {
534            if (node.properties.length === 1) {
535                return [node.properties];
536            }
537
538            return node.properties.reduce((groups, property) => {
539                const currentGroup = last(groups),
540                    prev = last(currentGroup);
541
542                if (!prev || continuesPropertyGroup(prev, property)) {
543                    currentGroup.push(property);
544                } else {
545                    groups.push([property]);
546                }
547
548                return groups;
549            }, [
550                []
551            ]);
552        }
553
554        /**
555         * Verifies correct vertical alignment of a group of properties.
556         * @param {ASTNode[]} properties List of Property AST nodes.
557         * @returns {void}
558         */
559        function verifyGroupAlignment(properties) {
560            const length = properties.length,
561                widths = properties.map(getKeyWidth), // Width of keys, including quotes
562                align = alignmentOptions.on; // "value" or "colon"
563            let targetWidth = Math.max(...widths),
564                beforeColon, afterColon, mode;
565
566            if (alignmentOptions && length > 1) { // When aligning values within a group, use the alignment configuration.
567                beforeColon = alignmentOptions.beforeColon;
568                afterColon = alignmentOptions.afterColon;
569                mode = alignmentOptions.mode;
570            } else {
571                beforeColon = multiLineOptions.beforeColon;
572                afterColon = multiLineOptions.afterColon;
573                mode = alignmentOptions.mode;
574            }
575
576            // Conditionally include one space before or after colon
577            targetWidth += (align === "colon" ? beforeColon : afterColon);
578
579            for (let i = 0; i < length; i++) {
580                const property = properties[i];
581                const whitespace = getPropertyWhitespace(property);
582
583                if (whitespace) { // Object literal getters/setters lack a colon
584                    const width = widths[i];
585
586                    if (align === "value") {
587                        report(property, "key", whitespace.beforeColon, beforeColon, mode);
588                        report(property, "value", whitespace.afterColon, targetWidth - width, mode);
589                    } else { // align = "colon"
590                        report(property, "key", whitespace.beforeColon, targetWidth - width, mode);
591                        report(property, "value", whitespace.afterColon, afterColon, mode);
592                    }
593                }
594            }
595        }
596
597        /**
598         * Verifies spacing of property conforms to specified options.
599         * @param  {ASTNode} node Property node being evaluated.
600         * @param {Object} lineOptions Configured singleLine or multiLine options
601         * @returns {void}
602         */
603        function verifySpacing(node, lineOptions) {
604            const actual = getPropertyWhitespace(node);
605
606            if (actual) { // Object literal getters/setters lack colons
607                report(node, "key", actual.beforeColon, lineOptions.beforeColon, lineOptions.mode);
608                report(node, "value", actual.afterColon, lineOptions.afterColon, lineOptions.mode);
609            }
610        }
611
612        /**
613         * Verifies spacing of each property in a list.
614         * @param {ASTNode[]} properties List of Property AST nodes.
615         * @param {Object} lineOptions Configured singleLine or multiLine options
616         * @returns {void}
617         */
618        function verifyListSpacing(properties, lineOptions) {
619            const length = properties.length;
620
621            for (let i = 0; i < length; i++) {
622                verifySpacing(properties[i], lineOptions);
623            }
624        }
625
626        /**
627         * Verifies vertical alignment, taking into account groups of properties.
628         * @param  {ASTNode} node ObjectExpression node being evaluated.
629         * @returns {void}
630         */
631        function verifyAlignment(node) {
632            createGroups(node).forEach(group => {
633                const properties = group.filter(isKeyValueProperty);
634
635                if (properties.length > 0 && isSingleLineProperties(properties)) {
636                    verifyListSpacing(properties, multiLineOptions);
637                } else {
638                    verifyGroupAlignment(properties);
639                }
640            });
641        }
642
643        //--------------------------------------------------------------------------
644        // Public API
645        //--------------------------------------------------------------------------
646
647        if (alignmentOptions) { // Verify vertical alignment
648
649            return {
650                ObjectExpression(node) {
651                    if (isSingleLine(node)) {
652                        verifyListSpacing(node.properties.filter(isKeyValueProperty), singleLineOptions);
653                    } else {
654                        verifyAlignment(node);
655                    }
656                }
657            };
658
659        }
660
661        // Obey beforeColon and afterColon in each property as configured
662        return {
663            Property(node) {
664                verifySpacing(node, isSingleLine(node.parent) ? singleLineOptions : multiLineOptions);
665            }
666        };
667
668
669    }
670};
671