You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

442 lines
14 KiB

/**
* @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.<ASTNode[]>} 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
}
]
}];