eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
343 lines (285 loc) • 8.07 kB
JavaScript
import {
isBigIntLiteral,
isInDirectivePrologue,
isRegexLiteral,
isTaggedTemplateLiteral,
} from './ast/index.js';
import {
escapeString,
getCallArgumentText,
hasCommentInRange,
isEscapedCharacter,
isTypeScriptFile,
} from './utils/index.js';
import escapeTemplateElementRaw from './utils/escape-template-element-raw.js';
import hasTemplateIncompatibleEscape from './utils/has-template-incompatible-escape.js';
const MESSAGE_ID = 'no-useless-template-literals';
const MESSAGE_ID_SUGGESTION = 'no-useless-template-literals/suggestion';
const messages = {
[MESSAGE_ID]: 'Do not use unnecessary template literal expressions.',
[MESSAGE_ID_SUGGESTION]: 'Use `{{replacement}}` instead.',
};
function getStaticUnaryStringValue(node) {
if (
node.type !== 'UnaryExpression'
|| !['+', '-'].includes(node.operator)
) {
return;
}
const {argument} = node;
let cooked;
if (typeof argument.value === 'number') {
cooked = String(node.operator === '-' ? -argument.value : argument.value);
} else if (
node.operator === '-'
&& isBigIntLiteral(argument)
) {
cooked = String(-BigInt(argument.bigint));
} else {
return;
}
return {
cooked,
raw: cooked,
};
}
const endsWithUnescapedDollarSign = string => string.endsWith('$') && !isEscapedCharacter(string, string.length - 1);
const endsWithNulEscape = string => string.endsWith('0') && isEscapedCharacter(string, string.length - 1);
function appendTemplateRaw(raw, addition) {
if (
/^\d/v.test(addition)
&& endsWithNulEscape(raw)
) {
return;
}
if (
addition.startsWith('{')
&& endsWithUnescapedDollarSign(raw)
) {
raw = String.raw`${raw.slice(0, -1)}\$`;
}
return raw + addition;
}
function getStaticInterpolationValue(node, sourceCode) {
const staticUnaryStringValue = getStaticUnaryStringValue(node);
if (staticUnaryStringValue) {
return staticUnaryStringValue;
}
if (node.type === 'Literal') {
if (isRegexLiteral(node)) {
return;
}
if (typeof node.value === 'string') {
const raw = node.raw.slice(1, -1);
return {
cooked: node.value,
raw: escapeTemplateElementRaw(raw),
hasTemplateIncompatibleEscape: hasTemplateIncompatibleEscape(raw),
};
}
// The remaining literals are numbers, booleans, `null`, and bigints
const cooked = isBigIntLiteral(node) ? node.bigint : String(node.value);
return {
cooked,
raw: cooked,
};
}
if (
node.type === 'TemplateLiteral'
&& node.expressions.length === 0
) {
return {
cooked: node.quasis[0].value.cooked,
raw: sourceCode.getText(node).slice(1, -1),
};
}
}
// The range of the `${…}` interpolation at `index`
function getInterpolationRange(node, sourceCode, index) {
const [, quasiEnd] = sourceCode.getRange(node.quasis[index]);
const [nextQuasiStart] = sourceCode.getRange(node.quasis[index + 1]);
return [quasiEnd - 2, nextQuasiStart + 1];
}
function getInterpolationText(node, sourceCode, index) {
return sourceCode.getText().slice(...getInterpolationRange(node, sourceCode, index));
}
function isExpressionOnlyTemplate(node) {
return (
node.expressions.length === 1
&& node.quasis[0].value.raw === ''
&& node.quasis[1].value.raw === ''
);
}
function isWrappedInConstAssertion(node) {
const {parent} = node;
return (
(
parent.type === 'TSAsExpression'
|| parent.type === 'TSTypeAssertion'
)
&& parent.expression === node
&& parent.typeAnnotation.type === 'TSTypeReference'
&& parent.typeAnnotation.typeName.type === 'Identifier'
&& parent.typeAnnotation.typeName.name === 'const'
);
}
function getStaticInterpolationProblem(node, context, index) {
const staticInterpolationValue = getStaticInterpolationValue(node.expressions[index], context.sourceCode);
if (!staticInterpolationValue) {
return;
}
return {
...staticInterpolationValue,
fixable: !hasCommentInRange(context, getInterpolationRange(node, context.sourceCode, index)),
};
}
function getReplacement(node, sourceCode, problems) {
const hasDynamicExpressions = problems.includes(undefined);
const shouldUseStringLiteral = !hasDynamicExpressions && node.quasis.every(quasi => quasi.value.raw === '');
if (!shouldUseStringLiteral) {
if (problems.some(problem => problem?.hasTemplateIncompatibleEscape)) {
return;
}
let raw = '';
for (const [index, quasi] of node.quasis.entries()) {
raw = appendTemplateRaw(raw, quasi.value.raw);
if (raw === undefined) {
return;
}
if (index === node.expressions.length) {
break;
}
const problem = problems[index];
raw = problem
? appendTemplateRaw(raw, problem.raw)
: raw + getInterpolationText(node, sourceCode, index);
if (raw === undefined) {
return;
}
}
return `\`${raw}\``;
}
let cooked = '';
// `cooked` is only `null` for an invalid escape, which is a syntax error outside a tagged template
for (const [index, quasi] of node.quasis.entries()) {
cooked += quasi.value.cooked;
if (index < problems.length) {
cooked += problems[index].cooked;
}
}
return escapeString(cooked);
}
/**
@param {import('eslint').Rule.RuleContext} context
*/
const create = context => {
const {sourceCode} = context;
const isTypeScript = isTypeScriptFile(context.physicalFilename);
const {parserServices} = sourceCode;
const typeChecker = typeof parserServices?.getTypeAtLocation === 'function'
? parserServices.program?.getTypeChecker()
: undefined;
const shouldIgnoreTypeScriptTemplate = node => {
if (!isTypeScript) {
return false;
}
if (!typeChecker) {
return true;
}
try {
const typeScriptNode = parserServices.esTreeNodeToTSNodeMap.get(node);
const type = typeChecker.getContextualType(typeScriptNode) ?? typeChecker.getTypeAtLocation(typeScriptNode);
return !type || !typeChecker.isTypeAssignableTo(typeChecker.getStringType(), type);
// Defensive: the TypeScript checker can throw on unusual nodes or types, and no known input does
/* node:coverage ignore next 3 */
} catch {
return true;
}
};
context.on('TemplateLiteral', node => {
if (isTaggedTemplateLiteral(node)) {
return;
}
if (isExpressionOnlyTemplate(node)) {
const [expression] = node.expressions;
const problem = getStaticInterpolationProblem(node, context, 0);
if (problem) {
const isDirectivePrologue = isInDirectivePrologue(node);
return {
node,
messageId: MESSAGE_ID,
fix(fixer) {
if (!problem.fixable || isDirectivePrologue) {
return;
}
return fixer.replaceText(node, escapeString(problem.cooked));
},
};
}
if (isWrappedInConstAssertion(node) || shouldIgnoreTypeScriptTemplate(node)) {
return;
}
const hasComments = hasCommentInRange(context, getInterpolationRange(node, sourceCode, 0));
const replacement = `String(${getCallArgumentText(expression, context)})`;
return {
node,
messageId: MESSAGE_ID,
...(!hasComments && {
suggest: [
{
messageId: MESSAGE_ID_SUGGESTION,
data: {
replacement,
},
fix: fixer => fixer.replaceText(node, replacement),
},
],
}),
};
}
const problems = node.expressions.map((_, index) => getStaticInterpolationProblem(node, context, index));
if (problems.every(problem => problem === undefined)) {
return;
}
return {
node,
messageId: MESSAGE_ID,
fix(fixer) {
if (problems.some(problem => problem && !problem.fixable)) {
return;
}
const replacement = getReplacement(node, sourceCode, problems);
if (
replacement === undefined
|| (
isInDirectivePrologue(node)
&& !replacement.startsWith('`')
)
) {
return;
}
return fixer.replaceText(node, replacement);
},
};
});
};
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow useless template literal expressions.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
messages,
languages: [
'js/js',
],
},
};
export default config;