operator-linebreak.js 10 KB
/**
 * @fileoverview Operator linebreak - enforces operator linebreak style of two types: after and before
 * @author Benoît Zugmeyer
 */

"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const astUtils = require("./utils/ast-utils");

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

module.exports = {
    meta: {
        type: "layout",

        docs: {
            description: "enforce consistent linebreak style for operators",
            category: "Stylistic Issues",
            recommended: false,
            url: "https://eslint.org/docs/rules/operator-linebreak"
        },

        schema: [
            {
                enum: ["after", "before", "none", null]
            },
            {
                type: "object",
                properties: {
                    overrides: {
                        type: "object",
                        properties: {
                            anyOf: {
                                type: "string",
                                enum: ["after", "before", "none", "ignore"]
                            }
                        }
                    }
                },
                additionalProperties: false
            }
        ],

        fixable: "code"
    },

    create(context) {

        const usedDefaultGlobal = !context.options[0];
        const globalStyle = context.options[0] || "after";
        const options = context.options[1] || {};
        const styleOverrides = options.overrides ? Object.assign({}, options.overrides) : {};

        if (usedDefaultGlobal && !styleOverrides["?"]) {
            styleOverrides["?"] = "before";
        }

        if (usedDefaultGlobal && !styleOverrides[":"]) {
            styleOverrides[":"] = "before";
        }

        const sourceCode = context.getSourceCode();

        //--------------------------------------------------------------------------
        // Helpers
        //--------------------------------------------------------------------------

        /**
         * Gets a fixer function to fix rule issues
         * @param {Token} operatorToken The operator token of an expression
         * @param {string} desiredStyle The style for the rule. One of 'before', 'after', 'none'
         * @returns {Function} A fixer function
         */
        function getFixer(operatorToken, desiredStyle) {
            return fixer => {
                const tokenBefore = sourceCode.getTokenBefore(operatorToken);
                const tokenAfter = sourceCode.getTokenAfter(operatorToken);
                const textBefore = sourceCode.text.slice(tokenBefore.range[1], operatorToken.range[0]);
                const textAfter = sourceCode.text.slice(operatorToken.range[1], tokenAfter.range[0]);
                const hasLinebreakBefore = !astUtils.isTokenOnSameLine(tokenBefore, operatorToken);
                const hasLinebreakAfter = !astUtils.isTokenOnSameLine(operatorToken, tokenAfter);
                let newTextBefore, newTextAfter;

                if (hasLinebreakBefore !== hasLinebreakAfter && desiredStyle !== "none") {

                    // If there is a comment before and after the operator, don't do a fix.
                    if (sourceCode.getTokenBefore(operatorToken, { includeComments: true }) !== tokenBefore &&
                        sourceCode.getTokenAfter(operatorToken, { includeComments: true }) !== tokenAfter) {

                        return null;
                    }

                    /*
                     * If there is only one linebreak and it's on the wrong side of the operator, swap the text before and after the operator.
                     * foo &&
                     *           bar
                     * would get fixed to
                     * foo
                     *        && bar
                     */
                    newTextBefore = textAfter;
                    newTextAfter = textBefore;
                } else {
                    const LINEBREAK_REGEX = astUtils.createGlobalLinebreakMatcher();

                    // Otherwise, if no linebreak is desired and no comments interfere, replace the linebreaks with empty strings.
                    newTextBefore = desiredStyle === "before" || textBefore.trim() ? textBefore : textBefore.replace(LINEBREAK_REGEX, "");
                    newTextAfter = desiredStyle === "after" || textAfter.trim() ? textAfter : textAfter.replace(LINEBREAK_REGEX, "");

                    // If there was no change (due to interfering comments), don't output a fix.
                    if (newTextBefore === textBefore && newTextAfter === textAfter) {
                        return null;
                    }
                }

                if (newTextAfter === "" && tokenAfter.type === "Punctuator" && "+-".includes(operatorToken.value) && tokenAfter.value === operatorToken.value) {

                    // To avoid accidentally creating a ++ or -- operator, insert a space if the operator is a +/- and the following token is a unary +/-.
                    newTextAfter += " ";
                }

                return fixer.replaceTextRange([tokenBefore.range[1], tokenAfter.range[0]], newTextBefore + operatorToken.value + newTextAfter);
            };
        }

        /**
         * Checks the operator placement
         * @param {ASTNode} node The node to check
         * @param {ASTNode} leftSide The node that comes before the operator in `node`
         * @private
         * @returns {void}
         */
        function validateNode(node, leftSide) {

            /*
             * When the left part of a binary expression is a single expression wrapped in
             * parentheses (ex: `(a) + b`), leftToken will be the last token of the expression
             * and operatorToken will be the closing parenthesis.
             * The leftToken should be the last closing parenthesis, and the operatorToken
             * should be the token right after that.
             */
            const operatorToken = sourceCode.getTokenAfter(leftSide, astUtils.isNotClosingParenToken);
            const leftToken = sourceCode.getTokenBefore(operatorToken);
            const rightToken = sourceCode.getTokenAfter(operatorToken);
            const operator = operatorToken.value;
            const operatorStyleOverride = styleOverrides[operator];
            const style = operatorStyleOverride || globalStyle;
            const fix = getFixer(operatorToken, style);

            // if single line
            if (astUtils.isTokenOnSameLine(leftToken, operatorToken) &&
                    astUtils.isTokenOnSameLine(operatorToken, rightToken)) {

                // do nothing.

            } else if (operatorStyleOverride !== "ignore" && !astUtils.isTokenOnSameLine(leftToken, operatorToken) &&
                    !astUtils.isTokenOnSameLine(operatorToken, rightToken)) {

                // lone operator
                context.report({
                    node,
                    loc: {
                        line: operatorToken.loc.end.line,
                        column: operatorToken.loc.end.column
                    },
                    message: "Bad line breaking before and after '{{operator}}'.",
                    data: {
                        operator
                    },
                    fix
                });

            } else if (style === "before" && astUtils.isTokenOnSameLine(leftToken, operatorToken)) {

                context.report({
                    node,
                    loc: {
                        line: operatorToken.loc.end.line,
                        column: operatorToken.loc.end.column
                    },
                    message: "'{{operator}}' should be placed at the beginning of the line.",
                    data: {
                        operator
                    },
                    fix
                });

            } else if (style === "after" && astUtils.isTokenOnSameLine(operatorToken, rightToken)) {

                context.report({
                    node,
                    loc: {
                        line: operatorToken.loc.end.line,
                        column: operatorToken.loc.end.column
                    },
                    message: "'{{operator}}' should be placed at the end of the line.",
                    data: {
                        operator
                    },
                    fix
                });

            } else if (style === "none") {

                context.report({
                    node,
                    loc: {
                        line: operatorToken.loc.end.line,
                        column: operatorToken.loc.end.column
                    },
                    message: "There should be no line break before or after '{{operator}}'.",
                    data: {
                        operator
                    },
                    fix
                });

            }
        }

        /**
         * Validates a binary expression using `validateNode`
         * @param {BinaryExpression|LogicalExpression|AssignmentExpression} node node to be validated
         * @returns {void}
         */
        function validateBinaryExpression(node) {
            validateNode(node, node.left);
        }

        //--------------------------------------------------------------------------
        // Public
        //--------------------------------------------------------------------------

        return {
            BinaryExpression: validateBinaryExpression,
            LogicalExpression: validateBinaryExpression,
            AssignmentExpression: validateBinaryExpression,
            VariableDeclarator(node) {
                if (node.init) {
                    validateNode(node, node.id);
                }
            },
            ConditionalExpression(node) {
                validateNode(node, node.test);
                validateNode(node, node.consequent);
            }
        };
    }
};