Skip to content

fix(eslint-plugin): [no-unnecessary-template] report on types #10207

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
571be36
fix(eslint-plugin): [no-unnecessary-template] report on template types
Oct 25, 2024
d90e5fa
wrong format
Oct 25, 2024
3133d51
format again?
Oct 25, 2024
a557d8f
Merge branch 'main' into fix-issue-9971-no-unnecessary-template--repo…
omril1 Nov 10, 2024
179b4c6
chore: add .git-blame-ignore-revs for eslint-plugin-perfectionist PRs…
JoshuaKGoldberg Nov 10, 2024
081f9df
chore(deps): update dependency webpack to v5.96.1 (#10306)
renovate[bot] Nov 10, 2024
95c18d4
chore(deps): update dependency knip to v5.36.2 (#10303)
renovate[bot] Nov 10, 2024
a63e78d
chore(deps): update dependency ts-api-utils to v1.4.0 (#10297)
renovate[bot] Nov 10, 2024
05ea288
chore(deps): update dependency mocha to v10.8.2 (#10292)
renovate[bot] Nov 10, 2024
2b8663f
fix(deps): update dependency eslint to v9.14.0 (#10309)
renovate[bot] Nov 11, 2024
2f68cfa
chore(deps): update dependency @swc/core to v1.8.0 (#10317)
renovate[bot] Nov 11, 2024
410f723
fix(deps): update docusaurus monorepo to v3.6.0 (#10319)
renovate[bot] Nov 11, 2024
fd8e8f0
chore(deps): update dependency globals to v15.12.0 (#10320)
renovate[bot] Nov 11, 2024
74737e5
chore(release): publish 8.14.0
typescript-eslint[bot] Nov 11, 2024
7bc5d48
feat(eslint-plugin): added related-getter-setter-pairs rule (#10192)
JoshuaKGoldberg Nov 11, 2024
e4fad3b
test: fix jest plugin usage in integration test (#10328)
yeonjuan Nov 14, 2024
a693340
fix(eslint-plugin): [consistent-indexed-object-style] handle circular…
JavaScriptBach Nov 14, 2024
ea3e06d
docs: include Bluesky profile in social links (#10296)
gyumong Nov 14, 2024
1feb829
feat(eslint-plugin): new rule `no-unsafe-type-assertion` (#10051)
ronami Nov 14, 2024
573e199
chore: update sponsors (#10332)
typescript-eslint[bot] Nov 15, 2024
23af0af
add the valid examples without expressions and tags
Nov 16, 2024
d24e6dc
don't report on union
Nov 16, 2024
4c7c2c1
Merge remote-tracking branch 'upstream/main' into fix-issue-9971-no-u…
Nov 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 252 additions & 46 deletions packages/eslint-plugin/src/rules/no-unnecessary-template-expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,54 +46,55 @@ export default createRule<[], MessageId>({
create(context) {
const services = getParserServices(context);

function isUnderlyingTypeString(
expression: TSESTree.Expression,
): expression is TSESTree.Identifier | TSESTree.StringLiteral {
const type = getConstrainedTypeAtLocation(services, expression);

const isString = (t: ts.Type): boolean => {
return isTypeFlagSet(t, ts.TypeFlags.StringLike);
};

if (type.isUnion()) {
return type.types.every(isString);
}

if (type.isIntersection()) {
return type.types.some(isString);
}

return isString(type);
}

function isLiteral(
expression: TSESTree.Expression,
): expression is TSESTree.Literal {
return expression.type === AST_NODE_TYPES.Literal;
}

function isTemplateLiteral(
expression: TSESTree.Expression,
): expression is TSESTree.TemplateLiteral {
return expression.type === AST_NODE_TYPES.TemplateLiteral;
}

function isInfinityIdentifier(expression: TSESTree.Expression): boolean {
return (
expression.type === AST_NODE_TYPES.Identifier &&
expression.name === 'Infinity'
);
}

function isNaNIdentifier(expression: TSESTree.Expression): boolean {
return (
expression.type === AST_NODE_TYPES.Identifier &&
expression.name === 'NaN'
);
}

return {
TemplateLiteral(node: TSESTree.TemplateLiteral): void {
function isUnderlyingTypeString(
expression: TSESTree.Expression,
): expression is TSESTree.Identifier | TSESTree.StringLiteral {
const type = getConstrainedTypeAtLocation(services, expression);

const isString = (t: ts.Type): boolean => {
return isTypeFlagSet(t, ts.TypeFlags.StringLike);
};

if (type.isUnion()) {
return type.types.every(isString);
}

if (type.isIntersection()) {
return type.types.some(isString);
}

return isString(type);
}

function isLiteral(
expression: TSESTree.Expression,
): expression is TSESTree.Literal {
return expression.type === AST_NODE_TYPES.Literal;
}

function isTemplateLiteral(
expression: TSESTree.Expression,
): expression is TSESTree.TemplateLiteral {
return expression.type === AST_NODE_TYPES.TemplateLiteral;
}

function isInfinityIdentifier(
expression: TSESTree.Expression,
): boolean {
return (
expression.type === AST_NODE_TYPES.Identifier &&
expression.name === 'Infinity'
);
}

function isNaNIdentifier(expression: TSESTree.Expression): boolean {
return (
expression.type === AST_NODE_TYPES.Identifier &&
expression.name === 'NaN'
);
}
if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) {
return;
}
Expand Down Expand Up @@ -262,6 +263,211 @@ export default createRule<[], MessageId>({
fixer.removeRange([warnLocStart, expression.range[0]]),
fixer.removeRange([expression.range[1], warnLocEnd]),

...fixers.flatMap(cb => cb(fixer)),
];
},
});
}
},
TSTemplateLiteralType(node): void {
function isUnderlyingTypeString(
expression: TSESTree.TypeNode,
): expression is TSESTree.TSLiteralType {
const type = getConstrainedTypeAtLocation(services, expression);

const isString = (t: ts.Type): boolean => {
return isTypeFlagSet(t, ts.TypeFlags.StringLike);
};

if (type.isUnion()) {
return false;
}

return isString(type);
}

function isLiteral(
typeNode: TSESTree.TypeNode,
): typeNode is { literal: TSESTree.Literal } & TSESTree.TSLiteralType {
return (
typeNode.type === AST_NODE_TYPES.TSLiteralType &&
typeNode.literal.type === AST_NODE_TYPES.Literal
);
}

function isTemplateLiteral(typeNode: TSESTree.TypeNode): typeNode is {
literal: TSESTree.TemplateLiteral;
} & TSESTree.TSLiteralType {
return (
typeNode.type === AST_NODE_TYPES.TSLiteralType &&
typeNode.literal.type === AST_NODE_TYPES.TemplateLiteral
);
}

function isUndefinedType(
typeNode: TSESTree.TypeNode,
): typeNode is TSESTree.TSUndefinedKeyword {
return typeNode.type === AST_NODE_TYPES.TSUndefinedKeyword;
}

const hasSingleStringVariable =
node.quasis.length === 2 &&
node.quasis[0].value.raw === '' &&
node.quasis[1].value.raw === '' &&
node.types.length === 1 &&
isUnderlyingTypeString(node.types[0]);

if (hasSingleStringVariable) {
context.report({
loc: rangeToLoc(context.sourceCode, [
node.types[0].range[0] - 2,
node.types[0].range[1] + 1,
]),
messageId: 'noUnnecessaryTemplateExpression',
fix(fixer): TSESLint.RuleFix | null {
const wrappingCode = getMovedNodeCode({
destinationNode: node,
nodeToMove: node.types[0],
sourceCode: context.sourceCode,
});

return fixer.replaceText(node, wrappingCode);
},
});

return;
}

const fixableTypes = node.types
.filter(
types =>
isLiteral(types) ||
isTemplateLiteral(types) ||
isUndefinedType(types),
)
.reverse();

let nextCharacterIsOpeningCurlyBrace = false;

for (const type of fixableTypes) {
const fixers: ((fixer: TSESLint.RuleFixer) => TSESLint.RuleFix[])[] =
[];
const index = node.types.indexOf(type);
const prevQuasi = node.quasis[index];
const nextQuasi = node.quasis[index + 1];

if (nextQuasi.value.raw.length !== 0) {
nextCharacterIsOpeningCurlyBrace =
nextQuasi.value.raw.startsWith('{');
}

if (isLiteral(type)) {
let escapedValue = (
typeof type.literal.value === 'string'
? // The value is already a string, so we're removing quotes:
// "'va`lue'" -> "va`lue"
type.literal.raw.slice(1, -1)
: // The value may be one of number | bigint | boolean | RegExp | null.
// In regular expressions, we escape every backslash
String(type.literal.value).replaceAll('\\', '\\\\')
)
// The string or RegExp may contain ` or ${.
// We want both of these to be escaped in the final template expression.
//
// A pair of backslashes means "escaped backslash", so backslashes
// from this pair won't escape ` or ${. Therefore, to escape these
// sequences in the resulting template expression, we need to escape
// all sequences that are preceded by an even number of backslashes.
//
// This RegExp does the following transformations:
// \` -> \`
// \\` -> \\\`
// \${ -> \${
// \\${ -> \\\${
.replaceAll(
new RegExp(
`${String(evenNumOfBackslashesRegExp.source)}(\`|\\\${)`,
'g',
),
'\\$1',
);

// `...${'...$'}{...`
// ^^^^
if (
nextCharacterIsOpeningCurlyBrace &&
endsWithUnescapedDollarSign(escapedValue)
) {
escapedValue = escapedValue.replaceAll(/\$$/g, '\\$');
}

if (escapedValue.length !== 0) {
nextCharacterIsOpeningCurlyBrace = escapedValue.startsWith('{');
}

fixers.push(fixer => [fixer.replaceText(type, escapedValue)]);
} else if (isTemplateLiteral(type)) {
// Since we iterate from the last expression to the first,
// a subsequent expression can tell the current expression
// that it starts with {.
//
// `... ${`... $`}${'{...'} ...`
// ^ ^ subsequent expression starts with {
// current expression ends with a dollar sign,
// so '$' + '{' === '${' (bad news for us).
// Let's escape the dollar sign at the end.
const quasis = type.literal.quasis;
if (
nextCharacterIsOpeningCurlyBrace &&
endsWithUnescapedDollarSign(quasis[quasis.length - 1].value.raw)
) {
fixers.push(fixer => [
fixer.replaceTextRange(
[type.range[1] - 2, type.range[1] - 2],
'\\',
),
]);
}
if (quasis.length === 1 && quasis[0].value.raw.length !== 0) {
nextCharacterIsOpeningCurlyBrace =
quasis[0].value.raw.startsWith('{');
}

// Remove the beginning and trailing backtick characters.
fixers.push(fixer => [
fixer.removeRange([type.range[0], type.range[0] + 1]),
fixer.removeRange([type.range[1] - 1, type.range[1]]),
]);
} else {
nextCharacterIsOpeningCurlyBrace = false;
}

// `... $${'{...'} ...`
// ^^^^^
if (
nextCharacterIsOpeningCurlyBrace &&
endsWithUnescapedDollarSign(prevQuasi.value.raw)
) {
fixers.push(fixer => [
fixer.replaceTextRange(
[prevQuasi.range[1] - 3, prevQuasi.range[1] - 2],
'\\$',
),
]);
}

const warnLocStart = prevQuasi.range[1] - 2;
const warnLocEnd = nextQuasi.range[0] + 1;

context.report({
loc: rangeToLoc(context.sourceCode, [warnLocStart, warnLocEnd]),
messageId: 'noUnnecessaryTemplateExpression',
fix(fixer): TSESLint.RuleFix[] {
return [
// Remove the quasis' parts that are related to the current expression.
fixer.removeRange([warnLocStart, type.range[0]]),
fixer.removeRange([type.range[1], warnLocEnd]),

...fixers.flatMap(cb => cb(fixer)),
];
},
Expand Down
Loading
Loading