| /** | |
| * @fileoverview Rule to specify spacing of object literal keys and values | |
| * @author Brandon Mills | |
| */ | |
| "use strict"; | |
| //------------------------------------------------------------------------------ | |
| // Requirements | |
| //------------------------------------------------------------------------------ | |
| const astUtils = require("./utils/ast-utils"); | |
| //------------------------------------------------------------------------------ | |
| // Helpers | |
| //------------------------------------------------------------------------------ | |
| /** | |
| * Checks whether a string contains a line terminator as defined in | |
| * http://www.ecma-international.org/ecma-262/5.1/#sec-7.3 | |
| * @param {string} str String to test. | |
| * @returns {boolean} True if str contains a line terminator. | |
| */ | |
| function containsLineTerminator(str) { | |
| return astUtils.LINEBREAK_MATCHER.test(str); | |
| } | |
| /** | |
| * Gets the last element of an array. | |
| * @param {Array} arr An array. | |
| * @returns {any} Last element of arr. | |
| */ | |
| function last(arr) { | |
| return arr[arr.length - 1]; | |
| } | |
| /** | |
| * Checks whether a node is contained on a single line. | |
| * @param {ASTNode} node AST Node being evaluated. | |
| * @returns {boolean} True if the node is a single line. | |
| */ | |
| function isSingleLine(node) { | |
| return (node.loc.end.line === node.loc.start.line); | |
| } | |
| /** | |
| * Initializes a single option property from the configuration with defaults for undefined values | |
| * @param {Object} toOptions Object to be initialized | |
| * @param {Object} fromOptions Object to be initialized from | |
| * @returns {Object} The object with correctly initialized options and values | |
| */ | |
| function initOptionProperty(toOptions, fromOptions) { | |
| toOptions.mode = fromOptions.mode || "strict"; | |
| // Set value of beforeColon | |
| if (typeof fromOptions.beforeColon !== "undefined") { | |
| toOptions.beforeColon = +fromOptions.beforeColon; | |
| } else { | |
| toOptions.beforeColon = 0; | |
| } | |
| // Set value of afterColon | |
| if (typeof fromOptions.afterColon !== "undefined") { | |
| toOptions.afterColon = +fromOptions.afterColon; | |
| } else { | |
| toOptions.afterColon = 1; | |
| } | |
| // Set align if exists | |
| if (typeof fromOptions.align !== "undefined") { | |
| if (typeof fromOptions.align === "object") { | |
| toOptions.align = fromOptions.align; | |
| } else { // "string" | |
| toOptions.align = { | |
| on: fromOptions.align, | |
| mode: toOptions.mode, | |
| beforeColon: toOptions.beforeColon, | |
| afterColon: toOptions.afterColon | |
| }; | |
| } | |
| } | |
| return toOptions; | |
| } | |
| /** | |
| * Initializes all the option values (singleLine, multiLine and align) from the configuration with defaults for undefined values | |
| * @param {Object} toOptions Object to be initialized | |
| * @param {Object} fromOptions Object to be initialized from | |
| * @returns {Object} The object with correctly initialized options and values | |
| */ | |
| function initOptions(toOptions, fromOptions) { | |
| if (typeof fromOptions.align === "object") { | |
| // Initialize the alignment configuration | |
| toOptions.align = initOptionProperty({}, fromOptions.align); | |
| toOptions.align.on = fromOptions.align.on || "colon"; | |
| toOptions.align.mode = fromOptions.align.mode || "strict"; | |
| toOptions.multiLine = initOptionProperty({}, (fromOptions.multiLine || fromOptions)); | |
| toOptions.singleLine = initOptionProperty({}, (fromOptions.singleLine || fromOptions)); | |
| } else { // string or undefined | |
| toOptions.multiLine = initOptionProperty({}, (fromOptions.multiLine || fromOptions)); | |
| toOptions.singleLine = initOptionProperty({}, (fromOptions.singleLine || fromOptions)); | |
| // If alignment options are defined in multiLine, pull them out into the general align configuration | |
| if (toOptions.multiLine.align) { | |
| toOptions.align = { | |
| on: toOptions.multiLine.align.on, | |
| mode: toOptions.multiLine.align.mode || toOptions.multiLine.mode, | |
| beforeColon: toOptions.multiLine.align.beforeColon, | |
| afterColon: toOptions.multiLine.align.afterColon | |
| }; | |
| } | |
| } | |
| return toOptions; | |
| } | |
| //------------------------------------------------------------------------------ | |
| // Rule Definition | |
| //------------------------------------------------------------------------------ | |
| module.exports = { | |
| meta: { | |
| type: "layout", | |
| docs: { | |
| description: "enforce consistent spacing between keys and values in object literal properties", | |
| category: "Stylistic Issues", | |
| recommended: false, | |
| url: "https://eslint.org/docs/rules/key-spacing" | |
| }, | |
| fixable: "whitespace", | |
| schema: [{ | |
| anyOf: [ | |
| { | |
| type: "object", | |
| properties: { | |
| align: { | |
| anyOf: [ | |
| { | |
| enum: ["colon", "value"] | |
| }, | |
| { | |
| type: "object", | |
| properties: { | |
| mode: { | |
| enum: ["strict", "minimum"] | |
| }, | |
| on: { | |
| enum: ["colon", "value"] | |
| }, | |
| beforeColon: { | |
| type: "boolean" | |
| }, | |
| afterColon: { | |
| type: "boolean" | |
| } | |
| }, | |
| additionalProperties: false | |
| } | |
| ] | |
| }, | |
| mode: { | |
| enum: ["strict", "minimum"] | |
| }, | |
| beforeColon: { | |
| type: "boolean" | |
| }, | |
| afterColon: { | |
| type: "boolean" | |
| } | |
| }, | |
| additionalProperties: false | |
| }, | |
| { | |
| type: "object", | |
| properties: { | |
| singleLine: { | |
| type: "object", | |
| properties: { | |
| mode: { | |
| enum: ["strict", "minimum"] | |
| }, | |
| beforeColon: { | |
| type: "boolean" | |
| }, | |
| afterColon: { | |
| type: "boolean" | |
| } | |
| }, | |
| additionalProperties: false | |
| }, | |
| multiLine: { | |
| type: "object", | |
| properties: { | |
| align: { | |
| anyOf: [ | |
| { | |
| enum: ["colon", "value"] | |
| }, | |
| { | |
| type: "object", | |
| properties: { | |
| mode: { | |
| enum: ["strict", "minimum"] | |
| }, | |
| on: { | |
| enum: ["colon", "value"] | |
| }, | |
| beforeColon: { | |
| type: "boolean" | |
| }, | |
| afterColon: { | |
| type: "boolean" | |
| } | |
| }, | |
| additionalProperties: false | |
| } | |
| ] | |
| }, | |
| mode: { | |
| enum: ["strict", "minimum"] | |
| }, | |
| beforeColon: { | |
| type: "boolean" | |
| }, | |
| afterColon: { | |
| type: "boolean" | |
| } | |
| }, | |
| additionalProperties: false | |
| } | |
| }, | |
| additionalProperties: false | |
| }, | |
| { | |
| type: "object", | |
| properties: { | |
| singleLine: { | |
| type: "object", | |
| properties: { | |
| mode: { | |
| enum: ["strict", "minimum"] | |
| }, | |
| beforeColon: { | |
| type: "boolean" | |
| }, | |
| afterColon: { | |
| type: "boolean" | |
| } | |
| }, | |
| additionalProperties: false | |
| }, | |
| multiLine: { | |
| type: "object", | |
| properties: { | |
| mode: { | |
| enum: ["strict", "minimum"] | |
| }, | |
| beforeColon: { | |
| type: "boolean" | |
| }, | |
| afterColon: { | |
| type: "boolean" | |
| } | |
| }, | |
| additionalProperties: false | |
| }, | |
| align: { | |
| type: "object", | |
| properties: { | |
| mode: { | |
| enum: ["strict", "minimum"] | |
| }, | |
| on: { | |
| enum: ["colon", "value"] | |
| }, | |
| beforeColon: { | |
| type: "boolean" | |
| }, | |
| afterColon: { | |
| type: "boolean" | |
| } | |
| }, | |
| additionalProperties: false | |
| } | |
| }, | |
| additionalProperties: false | |
| } | |
| ] | |
| }], | |
| messages: { | |
| extraKey: "Extra space after {{computed}}key '{{key}}'.", | |
| extraValue: "Extra space before value for {{computed}}key '{{key}}'.", | |
| missingKey: "Missing space after {{computed}}key '{{key}}'.", | |
| missingValue: "Missing space before value for {{computed}}key '{{key}}'." | |
| } | |
| }, | |
| create(context) { | |
| /** | |
| * OPTIONS | |
| * "key-spacing": [2, { | |
| * beforeColon: false, | |
| * afterColon: true, | |
| * align: "colon" // Optional, or "value" | |
| * } | |
| */ | |
| const options = context.options[0] || {}, | |
| ruleOptions = initOptions({}, options), | |
| multiLineOptions = ruleOptions.multiLine, | |
| singleLineOptions = ruleOptions.singleLine, | |
| alignmentOptions = ruleOptions.align || null; | |
| const sourceCode = context.getSourceCode(); | |
| /** | |
| * Checks whether a property is a member of the property group it follows. | |
| * @param {ASTNode} lastMember The last Property known to be in the group. | |
| * @param {ASTNode} candidate The next Property that might be in the group. | |
| * @returns {boolean} True if the candidate property is part of the group. | |
| */ | |
| function continuesPropertyGroup(lastMember, candidate) { | |
| const groupEndLine = lastMember.loc.start.line, | |
| candidateStartLine = candidate.loc.start.line; | |
| if (candidateStartLine - groupEndLine <= 1) { | |
| return true; | |
| } | |
| /* | |
| * Check that the first comment is adjacent to the end of the group, the | |
| * last comment is adjacent to the candidate property, and that successive | |
| * comments are adjacent to each other. | |
| */ | |
| const leadingComments = sourceCode.getCommentsBefore(candidate); | |
| if ( | |
| leadingComments.length && | |
| leadingComments[0].loc.start.line - groupEndLine <= 1 && | |
| candidateStartLine - last(leadingComments).loc.end.line <= 1 | |
| ) { | |
| for (let i = 1; i < leadingComments.length; i++) { | |
| if (leadingComments[i].loc.start.line - leadingComments[i - 1].loc.end.line > 1) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Determines if the given property is key-value property. | |
| * @param {ASTNode} property Property node to check. | |
| * @returns {boolean} Whether the property is a key-value property. | |
| */ | |
| function isKeyValueProperty(property) { | |
| return !( | |
| (property.method || | |
| property.shorthand || | |
| property.kind !== "init" || property.type !== "Property") // Could be "ExperimentalSpreadProperty" or "SpreadElement" | |
| ); | |
| } | |
| /** | |
| * Starting from the given a node (a property.key node here) looks forward | |
| * until it finds the last token before a colon punctuator and returns it. | |
| * @param {ASTNode} node The node to start looking from. | |
| * @returns {ASTNode} The last token before a colon punctuator. | |
| */ | |
| function getLastTokenBeforeColon(node) { | |
| const colonToken = sourceCode.getTokenAfter(node, astUtils.isColonToken); | |
| return sourceCode.getTokenBefore(colonToken); | |
| } | |
| /** | |
| * Starting from the given a node (a property.key node here) looks forward | |
| * until it finds the colon punctuator and returns it. | |
| * @param {ASTNode} node The node to start looking from. | |
| * @returns {ASTNode} The colon punctuator. | |
| */ | |
| function getNextColon(node) { | |
| return sourceCode.getTokenAfter(node, astUtils.isColonToken); | |
| } | |
| /** | |
| * Gets an object literal property's key as the identifier name or string value. | |
| * @param {ASTNode} property Property node whose key to retrieve. | |
| * @returns {string} The property's key. | |
| */ | |
| function getKey(property) { | |
| const key = property.key; | |
| if (property.computed) { | |
| return sourceCode.getText().slice(key.range[0], key.range[1]); | |
| } | |
| return property.key.name || property.key.value; | |
| } | |
| /** | |
| * Reports an appropriately-formatted error if spacing is incorrect on one | |
| * side of the colon. | |
| * @param {ASTNode} property Key-value pair in an object literal. | |
| * @param {string} side Side being verified - either "key" or "value". | |
| * @param {string} whitespace Actual whitespace string. | |
| * @param {int} expected Expected whitespace length. | |
| * @param {string} mode Value of the mode as "strict" or "minimum" | |
| * @returns {void} | |
| */ | |
| function report(property, side, whitespace, expected, mode) { | |
| const diff = whitespace.length - expected, | |
| nextColon = getNextColon(property.key), | |
| tokenBeforeColon = sourceCode.getTokenBefore(nextColon, { includeComments: true }), | |
| tokenAfterColon = sourceCode.getTokenAfter(nextColon, { includeComments: true }), | |
| isKeySide = side === "key", | |
| locStart = isKeySide ? tokenBeforeColon.loc.start : tokenAfterColon.loc.start, | |
| isExtra = diff > 0, | |
| diffAbs = Math.abs(diff), | |
| spaces = Array(diffAbs + 1).join(" "); | |
| if (( | |
| diff && mode === "strict" || | |
| diff < 0 && mode === "minimum" || | |
| diff > 0 && !expected && mode === "minimum") && | |
| !(expected && containsLineTerminator(whitespace)) | |
| ) { | |
| let fix; | |
| if (isExtra) { | |
| let range; | |
| // Remove whitespace | |
| if (isKeySide) { | |
| range = [tokenBeforeColon.range[1], tokenBeforeColon.range[1] + diffAbs]; | |
| } else { | |
| range = [tokenAfterColon.range[0] - diffAbs, tokenAfterColon.range[0]]; | |
| } | |
| fix = function(fixer) { | |
| return fixer.removeRange(range); | |
| }; | |
| } else { | |
| // Add whitespace | |
| if (isKeySide) { | |
| fix = function(fixer) { | |
| return fixer.insertTextAfter(tokenBeforeColon, spaces); | |
| }; | |
| } else { | |
| fix = function(fixer) { | |
| return fixer.insertTextBefore(tokenAfterColon, spaces); | |
| }; | |
| } | |
| } | |
| let messageId = ""; | |
| if (isExtra) { | |
| messageId = side === "key" ? "extraKey" : "extraValue"; | |
| } else { | |
| messageId = side === "key" ? "missingKey" : "missingValue"; | |
| } | |
| context.report({ | |
| node: property[side], | |
| loc: locStart, | |
| messageId, | |
| data: { | |
| computed: property.computed ? "computed " : "", | |
| key: getKey(property) | |
| }, | |
| fix | |
| }); | |
| } | |
| } | |
| /** | |
| * Gets the number of characters in a key, including quotes around string | |
| * keys and braces around computed property keys. | |
| * @param {ASTNode} property Property of on object literal. | |
| * @returns {int} Width of the key. | |
| */ | |
| function getKeyWidth(property) { | |
| const startToken = sourceCode.getFirstToken(property); | |
| const endToken = getLastTokenBeforeColon(property.key); | |
| return endToken.range[1] - startToken.range[0]; | |
| } | |
| /** | |
| * Gets the whitespace around the colon in an object literal property. | |
| * @param {ASTNode} property Property node from an object literal. | |
| * @returns {Object} Whitespace before and after the property's colon. | |
| */ | |
| function getPropertyWhitespace(property) { | |
| const whitespace = /(\s*):(\s*)/u.exec(sourceCode.getText().slice( | |
| property.key.range[1], property.value.range[0] | |
| )); | |
| if (whitespace) { | |
| return { | |
| beforeColon: whitespace[1], | |
| afterColon: whitespace[2] | |
| }; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Creates groups of properties. | |
| * @param {ASTNode} node ObjectExpression node being evaluated. | |
| * @returns {Array.<ASTNode[]>} Groups of property AST node lists. | |
| */ | |
| function createGroups(node) { | |
| if (node.properties.length === 1) { | |
| return [node.properties]; | |
| } | |
| return node.properties.reduce((groups, property) => { | |
| const currentGroup = last(groups), | |
| prev = last(currentGroup); | |
| if (!prev || continuesPropertyGroup(prev, property)) { | |
| currentGroup.push(property); | |
| } else { | |
| groups.push([property]); | |
| } | |
| return groups; | |
| }, [ | |
| [] | |
| ]); | |
| } | |
| /** | |
| * Verifies correct vertical alignment of a group of properties. | |
| * @param {ASTNode[]} properties List of Property AST nodes. | |
| * @returns {void} | |
| */ | |
| function verifyGroupAlignment(properties) { | |
| const length = properties.length, | |
| widths = properties.map(getKeyWidth), // Width of keys, including quotes | |
| align = alignmentOptions.on; // "value" or "colon" | |
| let targetWidth = Math.max(...widths), | |
| beforeColon, afterColon, mode; | |
| if (alignmentOptions && length > 1) { // When aligning values within a group, use the alignment configuration. | |
| beforeColon = alignmentOptions.beforeColon; | |
| afterColon = alignmentOptions.afterColon; | |
| mode = alignmentOptions.mode; | |
| } else { | |
| beforeColon = multiLineOptions.beforeColon; | |
| afterColon = multiLineOptions.afterColon; | |
| mode = alignmentOptions.mode; | |
| } | |
| // Conditionally include one space before or after colon | |
| targetWidth += (align === "colon" ? beforeColon : afterColon); | |
| for (let i = 0; i < length; i++) { | |
| const property = properties[i]; | |
| const whitespace = getPropertyWhitespace(property); | |
| if (whitespace) { // Object literal getters/setters lack a colon | |
| const width = widths[i]; | |
| if (align === "value") { | |
| report(property, "key", whitespace.beforeColon, beforeColon, mode); | |
| report(property, "value", whitespace.afterColon, targetWidth - width, mode); | |
| } else { // align = "colon" | |
| report(property, "key", whitespace.beforeColon, targetWidth - width, mode); | |
| report(property, "value", whitespace.afterColon, afterColon, mode); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Verifies vertical alignment, taking into account groups of properties. | |
| * @param {ASTNode} node ObjectExpression node being evaluated. | |
| * @returns {void} | |
| */ | |
| function verifyAlignment(node) { | |
| createGroups(node).forEach(group => { | |
| verifyGroupAlignment(group.filter(isKeyValueProperty)); | |
| }); | |
| } | |
| /** | |
| * Verifies spacing of property conforms to specified options. | |
| * @param {ASTNode} node Property node being evaluated. | |
| * @param {Object} lineOptions Configured singleLine or multiLine options | |
| * @returns {void} | |
| */ | |
| function verifySpacing(node, lineOptions) { | |
| const actual = getPropertyWhitespace(node); | |
| if (actual) { // Object literal getters/setters lack colons | |
| report(node, "key", actual.beforeColon, lineOptions.beforeColon, lineOptions.mode); | |
| report(node, "value", actual.afterColon, lineOptions.afterColon, lineOptions.mode); | |
| } | |
| } | |
| /** | |
| * Verifies spacing of each property in a list. | |
| * @param {ASTNode[]} properties List of Property AST nodes. | |
| * @returns {void} | |
| */ | |
| function verifyListSpacing(properties) { | |
| const length = properties.length; | |
| for (let i = 0; i < length; i++) { | |
| verifySpacing(properties[i], singleLineOptions); | |
| } | |
| } | |
| //-------------------------------------------------------------------------- | |
| // Public API | |
| //-------------------------------------------------------------------------- | |
| if (alignmentOptions) { // Verify vertical alignment | |
| return { | |
| ObjectExpression(node) { | |
| if (isSingleLine(node)) { | |
| verifyListSpacing(node.properties.filter(isKeyValueProperty)); | |
| } else { | |
| verifyAlignment(node); | |
| } | |
| } | |
| }; | |
| } | |
| // Obey beforeColon and afterColon in each property as configured | |
| return { | |
| Property(node) { | |
| verifySpacing(node, isSingleLine(node.parent) ? singleLineOptions : multiLineOptions); | |
| } | |
| }; | |
| } | |
| }; |