key-spacing.js 10.6 KB
/**
 * @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);
}

//------------------------------------------------------------------------------
// 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] || {},
        align = options.align,
        mode = options.mode || "strict",
        beforeColon = +!!options.beforeColon, // Defaults to false
        afterColon = +!(options.afterColon === false); // Defaults to true

    /**
     * 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;
    }

    /**
     * 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.
     * @returns {void}
     */
    function report(property, side, whitespace, expected) {
        var diff = whitespace.length - expected,
            key = property.key,
            firstTokenAfterColon = context.getTokenAfter(key, 1),
            location = side === "key" ? key.loc.start : firstTokenAfterColon.loc.start;

        if ((diff && mode === "strict" || diff < 0 && 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;

        // Ignore shorthand methods and properties, as they have no colon
        if (property.method || property.shorthand) {
            return 0;
        }

        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]
            };
        }
    }

    /**
     * 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;

        // 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) {
                continue; // Object literal getters/setters lack a colon
            }

            width = widths[i];

            if (align === "value") {
                report(property, "key", whitespace.beforeColon, beforeColon);
                report(property, "value", whitespace.afterColon, targetWidth - width);
            } else { // align = "colon"
                report(property, "key", whitespace.beforeColon, targetWidth - width);
                report(property, "value", whitespace.afterColon, afterColon);
            }
        }
    }

    /**
     * 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);
        });
    }

    /**
     * Verifies spacing of property conforms to specified options.
     * @param  {ASTNode} node Property node being evaluated.
     * @returns {void}
     */
    function verifySpacing(node) {
        var whitespace = getPropertyWhitespace(node);
        if (whitespace) { // Object literal getters/setters lack colons
            report(node, "key", whitespace.beforeColon, beforeColon);
            report(node, "value", whitespace.afterColon, afterColon);
        }
    }

    /**
     * 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]);
        }
    }

    //--------------------------------------------------------------------------
    // Public API
    //--------------------------------------------------------------------------

    if (align) { // Verify vertical alignment

        return {
            "ObjectExpression": function(node) {
                if (isSingleLine(node)) {
                    verifyListSpacing(node.properties);
                } else {
                    verifyAlignment(node);
                }
            }
        };

    } else { // Strictly obey beforeColon and afterColon in each property

        return {
            "Property": function(node) {
                verifySpacing(node);
            }
        };

    }

};

module.exports.schema = [
    {
        "type": "object",
        "properties": {
            "align": {
                "enum": ["colon", "value"]
            },
            "mode": {
                "enum": ["strict", "minimum"]
            },
            "beforeColon": {
                "type": "boolean"
            },
            "afterColon": {
                "type": "boolean"
            }
        },
        "additionalProperties": false
    }
];