no-regex-spaces.js 5.85 KB
/**
 * @fileoverview Rule to count multiple spaces in regular expressions
 * @author Matt DuVall <http://www.mattduvall.com/>
 */

"use strict";

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

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

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

const regExpParser = new regexpp.RegExpParser();
const DOUBLE_SPACE = / {2}/u;

/**
 * Check if node is a string
 * @param {ASTNode} node node to evaluate
 * @returns {boolean} True if its a string
 * @private
 */
function isString(node) {
    return node && node.type === "Literal" && typeof node.value === "string";
}

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

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

        docs: {
            description: "disallow multiple spaces in regular expressions",
            category: "Possible Errors",
            recommended: true,
            url: "https://eslint.org/docs/rules/no-regex-spaces"
        },

        schema: [],
        fixable: "code"
    },

    create(context) {

        /**
         * Validate regular expression
         * @param {ASTNode} nodeToReport Node to report.
         * @param {string} pattern Regular expression pattern to validate.
         * @param {string} rawPattern Raw representation of the pattern in the source code.
         * @param {number} rawPatternStartRange Start range of the pattern in the source code.
         * @param {string} flags Regular expression flags.
         * @returns {void}
         * @private
         */
        function checkRegex(nodeToReport, pattern, rawPattern, rawPatternStartRange, flags) {

            // Skip if there are no consecutive spaces in the source code, to avoid reporting e.g., RegExp(' \ ').
            if (!DOUBLE_SPACE.test(rawPattern)) {
                return;
            }

            const characterClassNodes = [];
            let regExpAST;

            try {
                regExpAST = regExpParser.parsePattern(pattern, 0, pattern.length, flags.includes("u"));
            } catch (e) {

                // Ignore regular expressions with syntax errors
                return;
            }

            regexpp.visitRegExpAST(regExpAST, {
                onCharacterClassEnter(ccNode) {
                    characterClassNodes.push(ccNode);
                }
            });

            const spacesPattern = /( {2,})(?: [+*{?]|[^+*{?]|$)/gu;
            let match;

            while ((match = spacesPattern.exec(pattern))) {
                const { 1: { length }, index } = match;

                // Report only consecutive spaces that are not in character classes.
                if (
                    characterClassNodes.every(({ start, end }) => index < start || end <= index)
                ) {
                    context.report({
                        node: nodeToReport,
                        message: "Spaces are hard to count. Use {{{length}}}.",
                        data: { length },
                        fix(fixer) {
                            if (pattern !== rawPattern) {
                                return null;
                            }
                            return fixer.replaceTextRange(
                                [rawPatternStartRange + index, rawPatternStartRange + index + length],
                                ` {${length}}`
                            );
                        }
                    });

                    // Report only the first occurence of consecutive spaces
                    return;
                }
            }
        }

        /**
         * Validate regular expression literals
         * @param {ASTNode} node node to validate
         * @returns {void}
         * @private
         */
        function checkLiteral(node) {
            if (node.regex) {
                const pattern = node.regex.pattern;
                const rawPattern = node.raw.slice(1, node.raw.lastIndexOf("/"));
                const rawPatternStartRange = node.range[0] + 1;
                const flags = node.regex.flags;

                checkRegex(
                    node,
                    pattern,
                    rawPattern,
                    rawPatternStartRange,
                    flags
                );
            }
        }

        /**
         * Validate strings passed to the RegExp constructor
         * @param {ASTNode} node node to validate
         * @returns {void}
         * @private
         */
        function checkFunction(node) {
            const scope = context.getScope();
            const regExpVar = astUtils.getVariableByName(scope, "RegExp");
            const shadowed = regExpVar && regExpVar.defs.length > 0;
            const patternNode = node.arguments[0];
            const flagsNode = node.arguments[1];

            if (node.callee.type === "Identifier" && node.callee.name === "RegExp" && isString(patternNode) && !shadowed) {
                const pattern = patternNode.value;
                const rawPattern = patternNode.raw.slice(1, -1);
                const rawPatternStartRange = patternNode.range[0] + 1;
                const flags = isString(flagsNode) ? flagsNode.value : "";

                checkRegex(
                    node,
                    pattern,
                    rawPattern,
                    rawPatternStartRange,
                    flags
                );
            }
        }

        return {
            Literal: checkLiteral,
            CallExpression: checkFunction,
            NewExpression: checkFunction
        };
    }
};