yannickcr/eslint-plugin-react

View on GitHub
lib/rules/jsx-curly-brace-presence.js

Summary

Maintainability
F
4 days
Test Coverage
/**
 * @fileoverview Enforce curly braces or disallow unnecessary curly brace in JSX
 * @author Jacky Ho
 * @author Simon Lydell
 */

'use strict';

const arrayIncludes = require('array-includes');

const docsUrl = require('../util/docsUrl');
const jsxUtil = require('../util/jsx');
const report = require('../util/report');

// ------------------------------------------------------------------------------
// Constants
// ------------------------------------------------------------------------------

const OPTION_ALWAYS = 'always';
const OPTION_NEVER = 'never';
const OPTION_IGNORE = 'ignore';

const OPTION_VALUES = [
  OPTION_ALWAYS,
  OPTION_NEVER,
  OPTION_IGNORE,
];
const DEFAULT_CONFIG = { props: OPTION_NEVER, children: OPTION_NEVER, propElementValues: OPTION_IGNORE };

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

const messages = {
  unnecessaryCurly: 'Curly braces are unnecessary here.',
  missingCurly: 'Need to wrap this literal in a JSX expression.',
};

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    docs: {
      description: 'Disallow unnecessary JSX expressions when literals alone are sufficient or enforce JSX expressions on literals in JSX children or attributes',
      category: 'Stylistic Issues',
      recommended: false,
      url: docsUrl('jsx-curly-brace-presence'),
    },
    fixable: 'code',

    messages,

    schema: [
      {
        anyOf: [
          {
            type: 'object',
            properties: {
              props: { enum: OPTION_VALUES },
              children: { enum: OPTION_VALUES },
              propElementValues: { enum: OPTION_VALUES },
            },
            additionalProperties: false,
          },
          {
            enum: OPTION_VALUES,
          },
        ],
      },
    ],
  },

  create(context) {
    const HTML_ENTITY_REGEX = () => /&[A-Za-z\d#]+;/g;
    const ruleOptions = context.options[0];
    const userConfig = typeof ruleOptions === 'string'
      ? { props: ruleOptions, children: ruleOptions, propElementValues: OPTION_IGNORE }
      : Object.assign({}, DEFAULT_CONFIG, ruleOptions);

    function containsLineTerminators(rawStringValue) {
      return /[\n\r\u2028\u2029]/.test(rawStringValue);
    }

    function containsBackslash(rawStringValue) {
      return arrayIncludes(rawStringValue, '\\');
    }

    function containsHTMLEntity(rawStringValue) {
      return HTML_ENTITY_REGEX().test(rawStringValue);
    }

    function containsOnlyHtmlEntities(rawStringValue) {
      return rawStringValue.replace(HTML_ENTITY_REGEX(), '').trim() === '';
    }

    function containsDisallowedJSXTextChars(rawStringValue) {
      return /[{<>}]/.test(rawStringValue);
    }

    function containsQuoteCharacters(value) {
      return /['"]/.test(value);
    }

    function containsMultilineComment(value) {
      return /\/\*/.test(value);
    }

    function escapeDoubleQuotes(rawStringValue) {
      return rawStringValue.replace(/\\"/g, '"').replace(/"/g, '\\"');
    }

    function escapeBackslashes(rawStringValue) {
      return rawStringValue.replace(/\\/g, '\\\\');
    }

    function needToEscapeCharacterForJSX(raw, node) {
      return (
        containsBackslash(raw)
        || containsHTMLEntity(raw)
        || (node.parent.type !== 'JSXAttribute' && containsDisallowedJSXTextChars(raw))
      );
    }

    function containsWhitespaceExpression(child) {
      if (child.type === 'JSXExpressionContainer') {
        const value = child.expression.value;
        return value ? jsxUtil.isWhiteSpaces(value) : false;
      }
      return false;
    }

    function isLineBreak(text) {
      return containsLineTerminators(text) && text.trim() === '';
    }

    function wrapNonHTMLEntities(text) {
      const HTML_ENTITY = '<HTML_ENTITY>';
      const withCurlyBraces = text.split(HTML_ENTITY_REGEX()).map((word) => (
        word === '' ? '' : `{${JSON.stringify(word)}}`
      )).join(HTML_ENTITY);

      const htmlEntities = text.match(HTML_ENTITY_REGEX());
      return htmlEntities.reduce((acc, htmlEntity) => (
        acc.replace(HTML_ENTITY, htmlEntity)
      ), withCurlyBraces);
    }

    function wrapWithCurlyBraces(rawText) {
      if (!containsLineTerminators(rawText)) {
        return `{${JSON.stringify(rawText)}}`;
      }

      return rawText.split('\n').map((line) => {
        if (line.trim() === '') {
          return line;
        }
        const firstCharIndex = line.search(/[^\s]/);
        const leftWhitespace = line.slice(0, firstCharIndex);
        const text = line.slice(firstCharIndex);

        if (containsHTMLEntity(line)) {
          return `${leftWhitespace}${wrapNonHTMLEntities(text)}`;
        }
        return `${leftWhitespace}{${JSON.stringify(text)}}`;
      }).join('\n');
    }

    /**
     * Report and fix an unnecessary curly brace violation on a node
     * @param {ASTNode} JSXExpressionNode - The AST node with an unnecessary JSX expression
     */
    function reportUnnecessaryCurly(JSXExpressionNode) {
      report(context, messages.unnecessaryCurly, 'unnecessaryCurly', {
        node: JSXExpressionNode,
        fix(fixer) {
          const expression = JSXExpressionNode.expression;

          let textToReplace;
          if (jsxUtil.isJSX(expression)) {
            const sourceCode = context.getSourceCode();
            textToReplace = sourceCode.getText(expression);
          } else {
            const expressionType = expression && expression.type;
            const parentType = JSXExpressionNode.parent.type;

            if (parentType === 'JSXAttribute') {
              textToReplace = `"${expressionType === 'TemplateLiteral'
                ? expression.quasis[0].value.raw
                : expression.raw.slice(1, -1)
              }"`;
            } else if (jsxUtil.isJSX(expression)) {
              const sourceCode = context.getSourceCode();

              textToReplace = sourceCode.getText(expression);
            } else {
              textToReplace = expressionType === 'TemplateLiteral'
                ? expression.quasis[0].value.cooked : expression.value;
            }
          }

          return fixer.replaceText(JSXExpressionNode, textToReplace);
        },
      });
    }

    function reportMissingCurly(literalNode) {
      report(context, messages.missingCurly, 'missingCurly', {
        node: literalNode,
        fix(fixer) {
          if (jsxUtil.isJSX(literalNode)) {
            return fixer.replaceText(literalNode, `{${context.getSourceCode().getText(literalNode)}}`);
          }

          // If a HTML entity name is found, bail out because it can be fixed
          // by either using the real character or the unicode equivalent.
          // If it contains any line terminator character, bail out as well.
          if (
            containsOnlyHtmlEntities(literalNode.raw)
            || (literalNode.parent.type === 'JSXAttribute' && containsLineTerminators(literalNode.raw))
            || isLineBreak(literalNode.raw)
          ) {
            return null;
          }

          const expression = literalNode.parent.type === 'JSXAttribute'
            ? `{"${escapeDoubleQuotes(escapeBackslashes(
              literalNode.raw.slice(1, -1)
            ))}"}`
            : wrapWithCurlyBraces(literalNode.raw);

          return fixer.replaceText(literalNode, expression);
        },
      });
    }

    function isWhiteSpaceLiteral(node) {
      return node.type && node.type === 'Literal' && node.value && jsxUtil.isWhiteSpaces(node.value);
    }

    function isStringWithTrailingWhiteSpaces(value) {
      return /^\s|\s$/.test(value);
    }

    function isLiteralWithTrailingWhiteSpaces(node) {
      return node.type && node.type === 'Literal' && node.value && isStringWithTrailingWhiteSpaces(node.value);
    }

    // Bail out if there is any character that needs to be escaped in JSX
    // because escaping decreases readability and the original code may be more
    // readable anyway or intentional for other specific reasons
    function lintUnnecessaryCurly(JSXExpressionNode) {
      const expression = JSXExpressionNode.expression;
      const expressionType = expression.type;

      const sourceCode = context.getSourceCode();
      // Curly braces containing comments are necessary
      if (sourceCode.getCommentsInside && sourceCode.getCommentsInside(JSXExpressionNode).length > 0) {
        return;
      }

      if (
        (expressionType === 'Literal' || expressionType === 'JSXText')
          && typeof expression.value === 'string'
          && (
            (JSXExpressionNode.parent.type === 'JSXAttribute' && !isWhiteSpaceLiteral(expression))
            || !isLiteralWithTrailingWhiteSpaces(expression)
          )
          && !containsMultilineComment(expression.value)
          && !needToEscapeCharacterForJSX(expression.raw, JSXExpressionNode) && (
          jsxUtil.isJSX(JSXExpressionNode.parent)
          || !containsQuoteCharacters(expression.value)
        )
      ) {
        reportUnnecessaryCurly(JSXExpressionNode);
      } else if (
        expressionType === 'TemplateLiteral'
        && expression.expressions.length === 0
        && expression.quasis[0].value.raw.indexOf('\n') === -1
        && !isStringWithTrailingWhiteSpaces(expression.quasis[0].value.raw)
        && !needToEscapeCharacterForJSX(expression.quasis[0].value.raw, JSXExpressionNode)
        && !containsQuoteCharacters(expression.quasis[0].value.cooked)
      ) {
        reportUnnecessaryCurly(JSXExpressionNode);
      } else if (jsxUtil.isJSX(expression)) {
        reportUnnecessaryCurly(JSXExpressionNode);
      }
    }

    function areRuleConditionsSatisfied(parent, config, ruleCondition) {
      return (
        parent.type === 'JSXAttribute'
          && typeof config.props === 'string'
          && config.props === ruleCondition
      ) || (
        jsxUtil.isJSX(parent)
          && typeof config.children === 'string'
          && config.children === ruleCondition
      );
    }

    function getAdjacentSiblings(node, children) {
      for (let i = 1; i < children.length - 1; i++) {
        const child = children[i];
        if (node === child) {
          return [children[i - 1], children[i + 1]];
        }
      }
      if (node === children[0] && children[1]) {
        return [children[1]];
      }
      if (node === children[children.length - 1] && children[children.length - 2]) {
        return [children[children.length - 2]];
      }
      return [];
    }

    function hasAdjacentJsxExpressionContainers(node, children) {
      if (!children) {
        return false;
      }
      const childrenExcludingWhitespaceLiteral = children.filter((child) => !isWhiteSpaceLiteral(child));
      const adjSiblings = getAdjacentSiblings(node, childrenExcludingWhitespaceLiteral);

      return adjSiblings.some((x) => x.type && x.type === 'JSXExpressionContainer');
    }
    function hasAdjacentJsx(node, children) {
      if (!children) {
        return false;
      }
      const childrenExcludingWhitespaceLiteral = children.filter((child) => !isWhiteSpaceLiteral(child));
      const adjSiblings = getAdjacentSiblings(node, childrenExcludingWhitespaceLiteral);

      return adjSiblings.some((x) => x.type && arrayIncludes(['JSXExpressionContainer', 'JSXElement'], x.type));
    }
    function shouldCheckForUnnecessaryCurly(node, config) {
      const parent = node.parent;
      // Bail out if the parent is a JSXAttribute & its contents aren't
      // StringLiteral or TemplateLiteral since e.g
      // <App prop1={<CustomEl />} prop2={<CustomEl>...</CustomEl>} />

      if (
        parent.type && parent.type === 'JSXAttribute'
        && (node.expression && node.expression.type
          && node.expression.type !== 'Literal'
          && node.expression.type !== 'StringLiteral'
          && node.expression.type !== 'TemplateLiteral')
      ) {
        return false;
      }

      // If there are adjacent `JsxExpressionContainer` then there is no need,
      // to check for unnecessary curly braces.
      if (jsxUtil.isJSX(parent) && hasAdjacentJsxExpressionContainers(node, parent.children)) {
        return false;
      }
      if (containsWhitespaceExpression(node) && hasAdjacentJsx(node, parent.children)) {
        return false;
      }
      if (
        parent.children
        && parent.children.length === 1
        && containsWhitespaceExpression(node)
      ) {
        return false;
      }

      return areRuleConditionsSatisfied(parent, config, OPTION_NEVER);
    }

    function shouldCheckForMissingCurly(node, config) {
      if (jsxUtil.isJSX(node)) {
        return config.propElementValues !== OPTION_IGNORE;
      }
      if (
        isLineBreak(node.raw)
        || containsOnlyHtmlEntities(node.raw)
      ) {
        return false;
      }
      const parent = node.parent;
      if (
        parent.children
        && parent.children.length === 1
        && containsWhitespaceExpression(parent.children[0])
      ) {
        return false;
      }

      return areRuleConditionsSatisfied(parent, config, OPTION_ALWAYS);
    }

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

    return {
      'JSXAttribute > JSXExpressionContainer > JSXElement'(node) {
        if (userConfig.propElementValues === OPTION_NEVER) {
          reportUnnecessaryCurly(node.parent);
        }
      },

      JSXExpressionContainer(node) {
        if (shouldCheckForUnnecessaryCurly(node, userConfig)) {
          lintUnnecessaryCurly(node);
        }
      },

      'JSXAttribute > JSXElement, Literal, JSXText'(node) {
        if (shouldCheckForMissingCurly(node, userConfig)) {
          reportMissingCurly(node);
        }
      },
    };
  },
};