/** * @fileoverview Rule to specify spacing of object literal keys and values * @author Brandon Mills * @copyright 2014 Brandon Mills. All rights reserved. */ "use strict"; //------------------------------------------------------------------------------ // 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 /[\n\r\u2028\u2029]/.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 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) { var groupEndLine = lastMember.loc.start.line, candidateStartLine = candidate.loc.start.line, comments, i; 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. comments = candidate.leadingComments; if ( comments && comments[0].loc.start.line - groupEndLine <= 1 && candidateStartLine - last(comments).loc.end.line <= 1 ) { for (i = 1; i < comments.length; i++) { if (comments[i].loc.start.line - comments[i - 1].loc.end.line > 1) { return false; } } return true; } return false; } /** * 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); } /** Sets option values from the configured options with defaults * @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) { toOptions.mode = fromOptions.mode || "strict"; // Set align if exists - multiLine case if (typeof fromOptions.align !== "undefined") { toOptions.align = fromOptions.align; } // 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; } return toOptions; } //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ var messages = { key: "{{error}} space after {{computed}}key '{{key}}'.", value: "{{error}} space before value for {{computed}}key '{{key}}'." }; module.exports = function(context) { /** * OPTIONS * "key-spacing": [2, { * beforeColon: false, * afterColon: true, * align: "colon" // Optional, or "value" * } */ var options = context.options[0] || {}, multiLineOptions = initOptions({}, (options.multiLine || options)), singleLineOptions = initOptions({}, (options.singleLine || options)); /** * 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 "SpreadProperty" ); } /** * 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) { var prevNode; while (node && (node.type !== "Punctuator" || node.value !== ":")) { prevNode = node; node = context.getTokenAfter(node); } return prevNode; } /** * 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) { while (node && (node.type !== "Punctuator" || node.value !== ":")) { node = context.getTokenAfter(node); } return node; } /** * 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) { var key = property.key; if (property.computed) { return context.getSource().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) { var diff = whitespace.length - expected, key = property.key, firstTokenAfterColon = context.getTokenAfter(getNextColon(key)), location = side === "key" ? key.loc.start : firstTokenAfterColon.loc.start; if (( diff && mode === "strict" || diff < 0 && mode === "minimum" || diff > 0 && !expected && mode === "minimum") && !(expected && containsLineTerminator(whitespace)) ) { context.report(property[side], location, messages[side], { error: diff > 0 ? "Extra" : "Missing", computed: property.computed ? "computed " : "", key: getKey(property) }); } } /** * 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) { var startToken, endToken; startToken = context.getFirstToken(property); 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) { var whitespace = /(\s*):(\s*)/.exec(context.getSource().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.} Groups of property AST node lists. */ function createGroups(node) { if (node.properties.length === 1) { return [node.properties]; } return node.properties.reduce(function(groups, property) { var 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) { var length = properties.length, widths = properties.map(getKeyWidth), // Width of keys, including quotes targetWidth = Math.max.apply(null, widths), i, property, whitespace, width, align = multiLineOptions.align, beforeColon = multiLineOptions.beforeColon, afterColon = multiLineOptions.afterColon, mode = multiLineOptions.mode; // Conditionally include one space before or after colon targetWidth += (align === "colon" ? beforeColon : afterColon); for (i = 0; i < length; i++) { property = properties[i]; whitespace = getPropertyWhitespace(property); if (whitespace) { // Object literal getters/setters lack a colon 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(function(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) { var 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) { var length = properties.length; for (var i = 0; i < length; i++) { verifySpacing(properties[i], singleLineOptions); } } //-------------------------------------------------------------------------- // Public API //-------------------------------------------------------------------------- if (multiLineOptions.align) { // Verify vertical alignment return { "ObjectExpression": function(node) { if (isSingleLine(node)) { verifyListSpacing(node.properties); } else { verifyAlignment(node); } } }; } else { // Obey beforeColon and afterColon in each property as configured return { "Property": function(node) { verifySpacing(node, isSingleLine(node) ? singleLineOptions : multiLineOptions); } }; } }; module.exports.schema = [{ "anyOf": [ { "type": "object", "properties": { "align": { "enum": ["colon", "value"] }, "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": { "enum": ["colon", "value"] }, "mode": { "enum": ["strict", "minimum"] }, "beforeColon": { "type": "boolean" }, "afterColon": { "type": "boolean" } }, "additionalProperties": false } }, "additionalProperties": false } ] }];