Skip to content
Closed
5 changes: 5 additions & 0 deletions .changeset/wicked-windows-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-svelte': patch
---

fix: preventing infinite loops in multiple rules
39 changes: 19 additions & 20 deletions packages/eslint-plugin-svelte/src/rules/no-dynamic-slot-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getAttributeValueQuoteAndRange,
getStringIfConstant
} from '../utils/ast-utils.js';
import { ASTSearchHelper } from '../utils/ast-search-helper.js';

export default createRule('no-dynamic-slot-name', {
meta: {
Expand Down Expand Up @@ -72,26 +73,24 @@ export default createRule('no-dynamic-slot-name', {
}

/** Find data expression */
function findRootExpression(
node: TSESTree.Expression,
already = new Set<TSESTree.Identifier>()
): TSESTree.Expression {
if (node.type !== 'Identifier' || already.has(node)) {
return node;
}
already.add(node);
const variable = findVariable(context, node);
if (!variable || variable.defs.length !== 1) {
return node;
}
const def = variable.defs[0];
if (def.type === 'Variable') {
if (def.parent.kind === 'const' && def.node.init) {
const init = def.node.init;
return findRootExpression(init, already);
}
}
return node;
function findRootExpression(node: TSESTree.Expression): TSESTree.Expression {
return (
ASTSearchHelper(node, {
Identifier: (node, searchAnotherNode) => {
const variable = findVariable(context, node);
if (
variable === null ||
variable.defs.length !== 1 ||
variable.defs[0].type !== 'Variable' ||
variable.defs[0].parent.kind !== 'const' ||
variable.defs[0].node.init === null
) {
return node;
}
return searchAnotherNode(variable.defs[0].node.init) ?? variable.defs[0].node.init;
}
}) ?? node
);
}
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ReferenceTracker } from '@eslint-community/eslint-utils';
import { findVariable } from '../utils/ast-utils.js';
import { extractExpressionPrefixVariable } from '../utils/expression-affixes.js';
import type { RuleContext } from '../types.js';
import type { AST } from 'svelte-eslint-parser';
import { isAbsoluteURL, isFragmentURL } from '../utils/url-utils.js';

export default createRule('no-navigation-without-base', {
meta: {
Expand Down Expand Up @@ -101,15 +101,15 @@ export default createRule('no-navigation-without-base', {
}
const hrefValue = node.value[0];
if (hrefValue.type === 'SvelteLiteral') {
if (!expressionIsAbsolute(hrefValue) && !expressionIsFragment(hrefValue)) {
if (!isAbsoluteURL(hrefValue) && !isFragmentURL(hrefValue)) {
context.report({ loc: hrefValue.loc, messageId: 'linkNotPrefixed' });
}
return;
}
if (
!expressionStartsWithBase(context, hrefValue.expression, basePathNames) &&
!expressionIsAbsolute(hrefValue.expression) &&
!expressionIsFragment(hrefValue.expression)
!isAbsoluteURL(hrefValue.expression) &&
!isFragmentURL(hrefValue.expression)
) {
context.report({ loc: hrefValue.loc, messageId: 'linkNotPrefixed' });
}
Expand Down Expand Up @@ -242,66 +242,3 @@ function expressionIsEmpty(url: TSESTree.Expression): boolean {
url.quasis[0].value.raw === '')
);
}

function expressionIsAbsolute(url: AST.SvelteLiteral | TSESTree.Expression): boolean {
switch (url.type) {
case 'BinaryExpression':
return binaryExpressionIsAbsolute(url);
case 'Literal':
return typeof url.value === 'string' && urlValueIsAbsolute(url.value);
case 'SvelteLiteral':
return urlValueIsAbsolute(url.value);
case 'TemplateLiteral':
return templateLiteralIsAbsolute(url);
default:
return false;
}
}

function binaryExpressionIsAbsolute(url: TSESTree.BinaryExpression): boolean {
return (
(url.left.type !== 'PrivateIdentifier' && expressionIsAbsolute(url.left)) ||
expressionIsAbsolute(url.right)
);
}

function templateLiteralIsAbsolute(url: TSESTree.TemplateLiteral): boolean {
return (
url.expressions.some(expressionIsAbsolute) ||
url.quasis.some((quasi) => urlValueIsAbsolute(quasi.value.raw))
);
}

function urlValueIsAbsolute(url: string): boolean {
return /^[+a-z]*:/i.test(url);
}

function expressionIsFragment(url: AST.SvelteLiteral | TSESTree.Expression): boolean {
switch (url.type) {
case 'BinaryExpression':
return binaryExpressionIsFragment(url);
case 'Literal':
return typeof url.value === 'string' && urlValueIsFragment(url.value);
case 'SvelteLiteral':
return urlValueIsFragment(url.value);
case 'TemplateLiteral':
return templateLiteralIsFragment(url);
default:
return false;
}
}

function binaryExpressionIsFragment(url: TSESTree.BinaryExpression): boolean {
return url.left.type !== 'PrivateIdentifier' && expressionIsFragment(url.left);
}

function templateLiteralIsFragment(url: TSESTree.TemplateLiteral): boolean {
return (
(url.expressions.length >= 1 && expressionIsFragment(url.expressions[0])) ||
(url.quasis.length >= 1 && urlValueIsFragment(url.quasis[0].value.raw))
);
}

function urlValueIsFragment(url: string): boolean {
return url.startsWith('#');
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { createRule } from '../utils/index.js';
import { ReferenceTracker } from '@eslint-community/eslint-utils';
import { findVariable } from '../utils/ast-utils.js';
import type { RuleContext } from '../types.js';
import type { AST } from 'svelte-eslint-parser';
import { isAbsoluteURL, isFragmentURL } from '../utils/url-utils.js';
import { ASTSearchHelper } from '../utils/ast-search-helper.js';

export default createRule('no-navigation-without-resolve', {
meta: {
Expand Down Expand Up @@ -96,11 +97,11 @@ export default createRule('no-navigation-without-resolve', {
}
if (
(node.value[0].type === 'SvelteLiteral' &&
!expressionIsAbsolute(node.value[0]) &&
!expressionIsFragment(node.value[0])) ||
!isAbsoluteURL(node.value[0]) &&
!isFragmentURL(node.value[0])) ||
(node.value[0].type === 'SvelteMustacheTag' &&
!expressionIsAbsolute(node.value[0].expression) &&
!expressionIsFragment(node.value[0].expression) &&
!isAbsoluteURL(node.value[0].expression) &&
!isFragmentURL(node.value[0].expression) &&
!isResolveCall(context, node.value[0].expression, resolveReferences))
) {
context.report({ loc: node.value[0].loc, messageId: 'linkWithoutResolve' });
Expand Down Expand Up @@ -218,28 +219,25 @@ function isResolveCall(
node: TSESTree.CallExpressionArgument,
resolveReferences: Set<TSESTree.Identifier>
): boolean {
if (
node.type === 'CallExpression' &&
((node.callee.type === 'Identifier' && resolveReferences.has(node.callee)) ||
(node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
resolveReferences.has(node.callee.property)))
) {
return true;
}
if (node.type === 'Identifier') {
const variable = findVariable(context, node);
if (
variable !== null &&
variable.identifiers.length > 0 &&
variable.identifiers[0].parent.type === 'VariableDeclarator' &&
variable.identifiers[0].parent.init !== null &&
isResolveCall(context, variable.identifiers[0].parent.init, resolveReferences)
) {
return true;
}
}
return false;
return (
ASTSearchHelper(node, {
CallExpression: (node) =>
(node.callee.type === 'Identifier' && resolveReferences.has(node.callee)) ||
(node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
resolveReferences.has(node.callee.property)),
Identifier: (node, searchAnotherNode) => {
const variable = findVariable(context, node);
return (
variable !== null &&
variable.identifiers.length > 0 &&
variable.identifiers[0].parent.type === 'VariableDeclarator' &&
variable.identifiers[0].parent.init !== null &&
searchAnotherNode(variable.identifiers[0].parent.init)
);
}
}) ?? false
);
}

function expressionIsEmpty(url: TSESTree.CallExpressionArgument): boolean {
Expand All @@ -251,66 +249,3 @@ function expressionIsEmpty(url: TSESTree.CallExpressionArgument): boolean {
url.quasis[0].value.raw === '')
);
}

function expressionIsAbsolute(url: AST.SvelteLiteral | TSESTree.Expression): boolean {
switch (url.type) {
case 'BinaryExpression':
return binaryExpressionIsAbsolute(url);
case 'Literal':
return typeof url.value === 'string' && urlValueIsAbsolute(url.value);
case 'SvelteLiteral':
return urlValueIsAbsolute(url.value);
case 'TemplateLiteral':
return templateLiteralIsAbsolute(url);
default:
return false;
}
}

function binaryExpressionIsAbsolute(url: TSESTree.BinaryExpression): boolean {
return (
(url.left.type !== 'PrivateIdentifier' && expressionIsAbsolute(url.left)) ||
expressionIsAbsolute(url.right)
);
}

function templateLiteralIsAbsolute(url: TSESTree.TemplateLiteral): boolean {
return (
url.expressions.some(expressionIsAbsolute) ||
url.quasis.some((quasi) => urlValueIsAbsolute(quasi.value.raw))
);
}

function urlValueIsAbsolute(url: string): boolean {
return /^[+a-z]*:/i.test(url);
}

function expressionIsFragment(url: AST.SvelteLiteral | TSESTree.Expression): boolean {
switch (url.type) {
case 'BinaryExpression':
return binaryExpressionIsFragment(url);
case 'Literal':
return typeof url.value === 'string' && urlValueIsFragment(url.value);
case 'SvelteLiteral':
return urlValueIsFragment(url.value);
case 'TemplateLiteral':
return templateLiteralIsFragment(url);
default:
return false;
}
}

function binaryExpressionIsFragment(url: TSESTree.BinaryExpression): boolean {
return url.left.type !== 'PrivateIdentifier' && expressionIsFragment(url.left);
}

function templateLiteralIsFragment(url: TSESTree.TemplateLiteral): boolean {
return (
(url.expressions.length >= 1 && expressionIsFragment(url.expressions[0])) ||
(url.quasis.length >= 1 && urlValueIsFragment(url.quasis[0].value.raw))
);
}

function urlValueIsFragment(url: string): boolean {
return url.startsWith('#');
}
29 changes: 29 additions & 0 deletions packages/eslint-plugin-svelte/src/utils/ast-search-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { TSESTree } from '@typescript-eslint/types';
import type { AST } from 'svelte-eslint-parser';

type ASTNode = AST.SvelteNode | TSESTree.Node;
type NodeResolvers<T> = {
[K in ASTNode['type']]?: (
node: ASTNode & { type: K },
searchAnotherNode: (node: ASTNode) => T | null
) => T | null;
};

export function ASTSearchHelper<T>(startNode: ASTNode, nodeResolvers: NodeResolvers<T>): T | null {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the use of this function? I don't understand it. Can you explain?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is the core of this PR. It abstracts away a common pattern where you search through the AST and depending on the node you return some value or look in another node (usually parent, child, but also e.g. variable definition).

As we noticed in the linked PR, there is an issue in both consistent-selector-style as well as no-navigation-without-resolve where you could get an infinite recursion in the rule code.

This function prevents infinite loops as well. The pattern seemed to repeat in multiple places in the codebase, so I created this abstraction.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: no-dynamic-slot-name - Ugh, that's not in the tests, could you add it? The playground doesn't work for me.

Since the rule is deprecated, my first choice would be to leave it as it is and remove the change to this rule, what do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function prevents infinite loops as well. The pattern seemed to repeat in multiple places in the codebase, so I created this abstraction.

So that's not working right now, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the rule is deprecated, my first choice would be to leave it as it is and remove the change to this rule, what do you think?

I think it would be a good idea to refactor the deprecation rules as well, to test whether the new functions work well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The playground doesn't work for me.

Try testing it with the following file:

<script>
  /* eslint svelte/no-dynamic-slot-name: "error" */
  const f = SLOT_NAME;
  const SLOT_NAME = f;
</script>

<!-- ✓ GOOD -->
<slot name="good" />

<!-- ✗ BAD -->
<slot name={SLOT_NAME} />

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, with the test case I was able to fix that rule as well :)

const visitedNodes = new Set<ASTNode>();

function searchNode(node: ASTNode): T | null {
if (!(node.type in nodeResolvers) || visitedNodes.has(node)) {
return null;
}
visitedNodes.add(node);
return (
nodeResolvers[node.type] as (
node: ASTNode,
searchAnotherNode: (node: ASTNode) => T | null
) => T | null
)(node, searchNode);
}

return searchNode(startNode);
}
Loading
Loading