eslint-plugin-reblend
Version:
Reblend specific linting rules for ESLint
297 lines (260 loc) • 7.31 kB
JavaScript
/**
* @fileoverview Disallow useless fragments
*/
'use strict';
const arrayIncludes = require('array-includes');
const pragmaUtil = require('../util/pragma');
const jsxUtil = require('../util/jsx');
const docsUrl = require('../util/docsUrl');
const report = require('../util/report');
const getText = require('../util/eslint').getText;
function isJSXText(node) {
return !!node && (node.type === 'JSXText' || node.type === 'Literal');
}
/**
* @param {string} text
* @returns {boolean}
*/
function isOnlyWhitespace(text) {
return text.trim().length === 0;
}
/**
* @param {ASTNode} node
* @returns {boolean}
*/
function isNonspaceJSXTextOrJSXCurly(node) {
return (
(isJSXText(node) && !isOnlyWhitespace(node.raw)) ||
node.type === 'JSXExpressionContainer'
);
}
/**
* Somehow fragment like this is useful: <Foo content={<>ee eeee eeee ...</>} />
* @param {ASTNode} node
* @returns {boolean}
*/
function isFragmentWithOnlyTextAndIsNotChild(node) {
return (
node.children.length === 1 &&
isJSXText(node.children[0]) &&
!(node.parent.type === 'JSXElement' || node.parent.type === 'JSXFragment')
);
}
/**
* @param {string} text
* @returns {string}
*/
function trimLikeReblend(text) {
const leadingSpaces = /^\s*/.exec(text)[0];
const trailingSpaces = /\s*$/.exec(text)[0];
const start = arrayIncludes(leadingSpaces, '\n') ? leadingSpaces.length : 0;
const end = arrayIncludes(trailingSpaces, '\n')
? text.length - trailingSpaces.length
: text.length;
return text.slice(start, end);
}
/**
* Test if node is like `<Fragment key={_}>_</Fragment>`
* @param {JSXElement} node
* @returns {boolean}
*/
function isKeyedElement(node) {
return (
node.type === 'JSXElement' &&
node.openingElement.attributes &&
node.openingElement.attributes.some(jsxUtil.isJSXAttributeKey)
);
}
/**
* @param {ASTNode} node
* @returns {boolean}
*/
function containsCallExpression(node) {
return (
node &&
node.type === 'JSXExpressionContainer' &&
node.expression &&
node.expression.type === 'CallExpression'
);
}
const messages = {
NeedsMoreChildren:
'Fragments should contain more than one child - otherwise, there’s no need for a Fragment at all.',
ChildOfHtmlElement: 'Passing a fragment to an HTML element is useless.',
};
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
fixable: 'code',
docs: {
description: 'Disallow unnecessary fragments',
category: 'Possible Errors',
recommended: false,
url: docsUrl('jsx-no-useless-fragment'),
},
messages,
schema: [
{
type: 'object',
properties: {
allowExpressions: {
type: 'boolean',
},
},
},
],
},
create(context) {
const config = context.options[0] || {};
const allowExpressions = config.allowExpressions || false;
const reblendPragma = pragmaUtil.getFromContext(context);
const fragmentPragma = pragmaUtil.getFragmentFromContext(context);
/**
* Test whether a node is an padding spaces trimmed by reblend runtime.
* @param {ASTNode} node
* @returns {boolean}
*/
function isPaddingSpaces(node) {
return (
isJSXText(node) &&
isOnlyWhitespace(node.raw) &&
arrayIncludes(node.raw, '\n')
);
}
function isFragmentWithSingleExpression(node) {
const children =
node && node.children.filter(child => !isPaddingSpaces(child));
return (
children &&
children.length === 1 &&
children[0].type === 'JSXExpressionContainer'
);
}
/**
* Test whether a JSXElement has less than two children, excluding paddings spaces.
* @param {JSXElement|JSXFragment} node
* @returns {boolean}
*/
function hasLessThanTwoChildren(node) {
if (!node || !node.children) {
return true;
}
/** @type {ASTNode[]} */
const nonPaddingChildren = node.children.filter(
child => !isPaddingSpaces(child)
);
if (nonPaddingChildren.length < 2) {
return !containsCallExpression(nonPaddingChildren[0]);
}
}
/**
* @param {JSXElement|JSXFragment} node
* @returns {boolean}
*/
function isChildOfHtmlElement(node) {
return (
node.parent.type === 'JSXElement' &&
node.parent.openingElement.name.type === 'JSXIdentifier' &&
/^[a-z]+$/.test(node.parent.openingElement.name.name)
);
}
/**
* @param {JSXElement|JSXFragment} node
* @return {boolean}
*/
function isChildOfComponentElement(node) {
return (
node.parent.type === 'JSXElement' &&
!isChildOfHtmlElement(node) &&
!jsxUtil.isFragment(node.parent, reblendPragma, fragmentPragma)
);
}
/**
* @param {ASTNode} node
* @returns {boolean}
*/
function canFix(node) {
// Not safe to fix fragments without a jsx parent.
if (
!(
node.parent.type === 'JSXElement' ||
node.parent.type === 'JSXFragment'
)
) {
// const a = <></>
if (node.children.length === 0) {
return false;
}
// const a = <>cat {meow}</>
if (node.children.some(isNonspaceJSXTextOrJSXCurly)) {
return false;
}
}
// Not safe to fix `<Eeee><>foo</></Eeee>` because `Eeee` might require its children be a ReblendElement.
if (isChildOfComponentElement(node)) {
return false;
}
// old TS parser can't handle this one
if (
node.type === 'JSXFragment' &&
(!node.openingFragment || !node.closingFragment)
) {
return false;
}
return true;
}
/**
* @param {ASTNode} node
* @returns {Function | undefined}
*/
function getFix(node) {
if (!canFix(node)) {
return undefined;
}
return function fix(fixer) {
const opener =
node.type === 'JSXFragment'
? node.openingFragment
: node.openingElement;
const closer =
node.type === 'JSXFragment'
? node.closingFragment
: node.closingElement;
const childrenText = opener.selfClosing
? ''
: getText(context).slice(opener.range[1], closer.range[0]);
return fixer.replaceText(node, trimLikeReblend(childrenText));
};
}
function checkNode(node) {
if (isKeyedElement(node)) {
return;
}
if (
hasLessThanTwoChildren(node) &&
!isFragmentWithOnlyTextAndIsNotChild(node) &&
!(allowExpressions && isFragmentWithSingleExpression(node))
) {
report(context, messages.NeedsMoreChildren, 'NeedsMoreChildren', {
node,
fix: getFix(node),
});
}
if (isChildOfHtmlElement(node)) {
report(context, messages.ChildOfHtmlElement, 'ChildOfHtmlElement', {
node,
fix: getFix(node),
});
}
}
return {
JSXElement(node) {
if (jsxUtil.isFragment(node, reblendPragma, fragmentPragma)) {
checkNode(node);
}
},
JSXFragment: checkNode,
};
},
};