From 8845b942fd798a4377d708550552b7ebc4abb57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Tue, 9 Sep 2025 15:38:09 +0200 Subject: [PATCH 1/9] chore: added an AST search helper that prevents infinite loops --- .changeset/wicked-windows-listen.md | 5 ++++ .../src/utils/ast-search-helper.ts | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 .changeset/wicked-windows-listen.md create mode 100644 packages/eslint-plugin-svelte/src/utils/ast-search-helper.ts diff --git a/.changeset/wicked-windows-listen.md b/.changeset/wicked-windows-listen.md new file mode 100644 index 000000000..064f6d184 --- /dev/null +++ b/.changeset/wicked-windows-listen.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-svelte': patch +--- + +fix: preventing infinite loops in multiple rules diff --git a/packages/eslint-plugin-svelte/src/utils/ast-search-helper.ts b/packages/eslint-plugin-svelte/src/utils/ast-search-helper.ts new file mode 100644 index 000000000..0a6e86a23 --- /dev/null +++ b/packages/eslint-plugin-svelte/src/utils/ast-search-helper.ts @@ -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 = { + [K in ASTNode['type']]?: ( + node: ASTNode & { type: K }, + searchAnotherNode: (node: ASTNode) => T | null + ) => T | null; +}; + +export function ASTSearchHelper(startNode: ASTNode, nodeResolvers: NodeResolvers): T | null { + const visitedNodes = new Set(); + + 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); +} From 189cef57dafdfc9ca5b5da549b7dae31916eb741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Tue, 9 Sep 2025 15:41:38 +0200 Subject: [PATCH 2/9] fix(consistent-selector-style): fixed an infinite loop --- .../src/utils/expression-affixes.ts | 180 +++++++----------- .../recursive-loop01-input.svelte | 6 + 2 files changed, 70 insertions(+), 116 deletions(-) create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/recursive-loop01-input.svelte diff --git a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts index 53b6562bc..77b81919f 100644 --- a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts +++ b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts @@ -2,6 +2,7 @@ import type { TSESTree } from '@typescript-eslint/types'; import { findVariable } from './ast-utils.js'; import type { RuleContext } from '../types.js'; import type { AST } from 'svelte-eslint-parser'; +import { ASTSearchHelper } from './ast-search-helper.js'; // Variable prefix extraction @@ -76,134 +77,81 @@ function extractTemplateLiteralPrefixVariable( return null; } -// Literal prefix extraction - export function extractExpressionPrefixLiteral( context: RuleContext, expression: AST.SvelteLiteral | TSESTree.Node ): string | null { - switch (expression.type) { - case 'BinaryExpression': - return extractBinaryExpressionPrefixLiteral(context, expression); - case 'Identifier': - return extractVariablePrefixLiteral(context, expression); - case 'Literal': - return typeof expression.value === 'string' ? expression.value : null; - case 'SvelteLiteral': - return expression.value; - case 'TemplateLiteral': - return extractTemplateLiteralPrefixLiteral(context, expression); - default: - return null; - } -} - -function extractBinaryExpressionPrefixLiteral( - context: RuleContext, - expression: TSESTree.BinaryExpression -): string | null { - return expression.left.type !== 'PrivateIdentifier' - ? extractExpressionPrefixLiteral(context, expression.left) - : null; -} - -function extractVariablePrefixLiteral( - context: RuleContext, - expression: TSESTree.Identifier -): string | null { - const variable = findVariable(context, expression); - if ( - variable === null || - variable.identifiers.length !== 1 || - variable.identifiers[0].parent.type !== 'VariableDeclarator' || - variable.identifiers[0].parent.init === null - ) { - return null; - } - return extractExpressionPrefixLiteral(context, variable.identifiers[0].parent.init); -} - -function extractTemplateLiteralPrefixLiteral( - context: RuleContext, - expression: TSESTree.TemplateLiteral -): string | null { - const literalParts = [...expression.expressions, ...expression.quasis].sort((a, b) => - a.range[0] < b.range[0] ? -1 : 1 - ); - for (const part of literalParts) { - if (part.type === 'TemplateElement') { - if (part.value.raw === '') { - // Skip empty quasi - continue; + return ASTSearchHelper(expression, { + BinaryExpression: (node, searchAnotherNode) => + node.left.type !== 'PrivateIdentifier' ? searchAnotherNode(node.left) : null, + Identifier: (node, searchAnotherNode) => { + const variable = findVariable(context, node); + if ( + variable === null || + variable.identifiers.length !== 1 || + variable.identifiers[0].parent.type !== 'VariableDeclarator' || + variable.identifiers[0].parent.init === null + ) { + return null; + } + return searchAnotherNode(variable.identifiers[0].parent.init); + }, + Literal: (node) => (typeof node.value === 'string' ? node.value : null), + SvelteLiteral: (node) => node.value, + TemplateLiteral: (node, searchAnotherNode) => { + const literalParts = [...node.expressions, ...node.quasis].sort((a, b) => + a.range[0] < b.range[0] ? -1 : 1 + ); + for (const part of literalParts) { + if (part.type === 'TemplateElement') { + if (part.value.raw === '') { + // Skip empty quasi + continue; + } + return part.value.raw; + } + return searchAnotherNode(part); } - return part.value.raw; + return null; } - return extractExpressionPrefixLiteral(context, part); - } - return null; + }); } -// Literal suffix extraction - export function extractExpressionSuffixLiteral( context: RuleContext, expression: AST.SvelteLiteral | TSESTree.Node ): string | null { - switch (expression.type) { - case 'BinaryExpression': - return extractBinaryExpressionSuffixLiteral(context, expression); - case 'Identifier': - return extractVariableSuffixLiteral(context, expression); - case 'Literal': - return typeof expression.value === 'string' ? expression.value : null; - case 'SvelteLiteral': - return expression.value; - case 'TemplateLiteral': - return extractTemplateLiteralSuffixLiteral(context, expression); - default: - return null; - } -} - -function extractBinaryExpressionSuffixLiteral( - context: RuleContext, - expression: TSESTree.BinaryExpression -): string | null { - return extractExpressionSuffixLiteral(context, expression.right); -} - -function extractVariableSuffixLiteral( - context: RuleContext, - expression: TSESTree.Identifier -): string | null { - const variable = findVariable(context, expression); - if ( - variable === null || - variable.identifiers.length !== 1 || - variable.identifiers[0].parent.type !== 'VariableDeclarator' || - variable.identifiers[0].parent.init === null - ) { - return null; - } - return extractExpressionSuffixLiteral(context, variable.identifiers[0].parent.init); -} - -function extractTemplateLiteralSuffixLiteral( - context: RuleContext, - expression: TSESTree.TemplateLiteral -): string | null { - const literalParts = [...expression.expressions, ...expression.quasis].sort((a, b) => - a.range[0] < b.range[0] ? -1 : 1 - ); - for (const part of literalParts.reverse()) { - if (part.type === 'TemplateElement') { - if (part.value.raw === '') { - // Skip empty quasi - continue; + return ASTSearchHelper(expression, { + BinaryExpression: (node, searchAnotherNode) => searchAnotherNode(node.right), + Identifier: (node, searchAnotherNode) => { + const variable = findVariable(context, node); + if ( + variable === null || + variable.identifiers.length !== 1 || + variable.identifiers[0].parent.type !== 'VariableDeclarator' || + variable.identifiers[0].parent.init === null + ) { + return null; + } + return searchAnotherNode(variable.identifiers[0].parent.init); + }, + Literal: (node) => (typeof node.value === 'string' ? node.value : null), + SvelteLiteral: (node) => node.value, + TemplateLiteral: (node, searchAnotherNode) => { + const literalParts = [...node.expressions, ...node.quasis].sort((a, b) => + a.range[0] < b.range[0] ? -1 : 1 + ); + for (const part of literalParts.reverse()) { + if (part.type === 'TemplateElement') { + if (part.value.raw === '') { + // Skip empty quasi + continue; + } + return part.value.raw; + } + return searchAnotherNode(part); } - return part.value.raw; + return null; } - return extractExpressionSuffixLiteral(context, part); - } - return null; + }); } diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/recursive-loop01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/recursive-loop01-input.svelte new file mode 100644 index 000000000..1a866e48d --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/consistent-selector-style/valid/type-id-class/recursive-loop01-input.svelte @@ -0,0 +1,6 @@ + + +Click me! From b02ec93242f8f7dd8080a0363cf77042fbf877f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Tue, 9 Sep 2025 16:02:08 +0200 Subject: [PATCH 3/9] fix(no-dynamic-slot-name): fixed an infinite loop --- .../src/rules/no-dynamic-slot-name.ts | 39 +++++++++---------- .../invalid/recursive-loop01-errors.yaml | 4 ++ .../invalid/recursive-loop01-input.svelte | 6 +++ .../invalid/recursive-loop01-output.svelte | 6 +++ 4 files changed, 35 insertions(+), 20 deletions(-) create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-input.svelte create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-output.svelte diff --git a/packages/eslint-plugin-svelte/src/rules/no-dynamic-slot-name.ts b/packages/eslint-plugin-svelte/src/rules/no-dynamic-slot-name.ts index 928766713..fbe0f6145 100644 --- a/packages/eslint-plugin-svelte/src/rules/no-dynamic-slot-name.ts +++ b/packages/eslint-plugin-svelte/src/rules/no-dynamic-slot-name.ts @@ -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: { @@ -72,26 +73,24 @@ export default createRule('no-dynamic-slot-name', { } /** Find data expression */ - function findRootExpression( - node: TSESTree.Expression, - already = new Set() - ): 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 + ); } } }); diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-errors.yaml new file mode 100644 index 000000000..a625cd83b --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-errors.yaml @@ -0,0 +1,4 @@ +- message: '`` name cannot be dynamic.' + line: 6 + column: 12 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-input.svelte new file mode 100644 index 000000000..106d42c44 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-input.svelte @@ -0,0 +1,6 @@ + + + diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-output.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-output.svelte new file mode 100644 index 000000000..106d42c44 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-dynamic-slot-name/invalid/recursive-loop01-output.svelte @@ -0,0 +1,6 @@ + + + From 9da2648e1261795a184294cc8fc4ff709b229980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Tue, 9 Sep 2025 18:29:52 +0200 Subject: [PATCH 4/9] chore: migrated custom code in getStringIfConstant to ASTSearchHelper --- .../src/utils/ast-utils.ts | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/packages/eslint-plugin-svelte/src/utils/ast-utils.ts b/packages/eslint-plugin-svelte/src/utils/ast-utils.ts index e36a9cf75..2fec4d3b0 100644 --- a/packages/eslint-plugin-svelte/src/utils/ast-utils.ts +++ b/packages/eslint-plugin-svelte/src/utils/ast-utils.ts @@ -4,6 +4,7 @@ import type { Scope, Variable } from '@typescript-eslint/scope-manager'; import type { AST as SvAST } from 'svelte-eslint-parser'; import * as eslintUtils from '@eslint-community/eslint-utils'; import { voidElements, svgElements, mathmlElements } from './element-types.js'; +import { ASTSearchHelper } from './ast-search-helper.js'; /** * Checks whether or not the tokens of two given nodes are same. @@ -34,39 +35,37 @@ export function equalTokens(left: ASTNode, right: ASTNode, sourceCode: SourceCod export function getStringIfConstant( node: TSESTree.Expression | TSESTree.PrivateIdentifier ): string | null { - if (node.type === 'Literal') { - if (typeof node.value === 'string') return node.value; - } else if (node.type === 'TemplateLiteral') { - let str = ''; - const quasis = [...node.quasis]; - const expressions = [...node.expressions]; - let quasi: TSESTree.TemplateElement | undefined, expr: TSESTree.Expression | undefined; - while ((quasi = quasis.shift())) { - str += quasi.value.cooked; - expr = expressions.shift(); - if (expr) { - const exprStr = getStringIfConstant(expr); - if (exprStr == null) { - return null; - } - str += exprStr; + return ASTSearchHelper(node, { + BinaryExpression: (node, searchAnotherNode) => { + if (node.operator !== '+') { + return null; } - } - return str; - } else if (node.type === 'BinaryExpression') { - if (node.operator === '+') { - const left = getStringIfConstant(node.left); - if (left == null) { + const left = searchAnotherNode(node.left); + if (left === null) { return null; } - const right = getStringIfConstant(node.right); - if (right == null) { + const right = searchAnotherNode(node.right); + if (right === null) { return null; } return left + right; + }, + Literal: (node) => (typeof node.value === 'string' ? node.value : null), + TemplateLiteral: (node, searchAnotherNode) => { + let str = ''; + for (const quasi of node.quasis) { + str += quasi.value.cooked; + if (node.expressions[0]) { + const exprStr = searchAnotherNode(node.expressions[0]); + if (exprStr === null) { + return null; + } + str += exprStr; + } + } + return str; } - } - return null; + }); } /** From c2d7877972a9811f8d23acc030198ffc8e618dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Tue, 9 Sep 2025 18:30:41 +0200 Subject: [PATCH 5/9] fix(no-navigation-without-base): fixed an infinite loop --- .../src/utils/expression-affixes.ts | 96 ++++++------------- 1 file changed, 31 insertions(+), 65 deletions(-) diff --git a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts index 77b81919f..3b9ee283a 100644 --- a/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts +++ b/packages/eslint-plugin-svelte/src/utils/expression-affixes.ts @@ -4,77 +4,43 @@ import type { RuleContext } from '../types.js'; import type { AST } from 'svelte-eslint-parser'; import { ASTSearchHelper } from './ast-search-helper.js'; -// Variable prefix extraction - export function extractExpressionPrefixVariable( context: RuleContext, expression: TSESTree.Expression ): TSESTree.Identifier | null { - switch (expression.type) { - case 'BinaryExpression': - return extractBinaryExpressionPrefixVariable(context, expression); - case 'Identifier': - return extractVariablePrefixVariable(context, expression); - case 'MemberExpression': - return extractMemberExpressionPrefixVariable(expression); - case 'TemplateLiteral': - return extractTemplateLiteralPrefixVariable(context, expression); - default: + return ASTSearchHelper(expression, { + BinaryExpression: (node, searchAnotherNode) => + node.left.type !== 'PrivateIdentifier' ? searchAnotherNode(node.left) : null, + Identifier: (node, searchAnotherNode) => { + const variable = findVariable(context, node); + if ( + variable === null || + variable.identifiers.length !== 1 || + variable.identifiers[0].parent.type !== 'VariableDeclarator' || + variable.identifiers[0].parent.init === null + ) { + return node; + } + return searchAnotherNode(variable.identifiers[0].parent.init) ?? node; + }, + MemberExpression: (node) => (node.property.type === 'Identifier' ? node.property : null), + TemplateLiteral: (node, searchAnotherNode) => { + const literalParts = [...node.expressions, ...node.quasis].sort((a, b) => + a.range[0] < b.range[0] ? -1 : 1 + ); + for (const part of literalParts) { + if (part.type === 'TemplateElement' && part.value.raw === '') { + // Skip empty quasi in the begining + continue; + } + if (part.type !== 'TemplateElement') { + return searchAnotherNode(part); + } + return null; + } return null; - } -} - -function extractBinaryExpressionPrefixVariable( - context: RuleContext, - expression: TSESTree.BinaryExpression -): TSESTree.Identifier | null { - return expression.left.type !== 'PrivateIdentifier' - ? extractExpressionPrefixVariable(context, expression.left) - : null; -} - -function extractVariablePrefixVariable( - context: RuleContext, - expression: TSESTree.Identifier -): TSESTree.Identifier | null { - const variable = findVariable(context, expression); - if ( - variable === null || - variable.identifiers.length !== 1 || - variable.identifiers[0].parent.type !== 'VariableDeclarator' || - variable.identifiers[0].parent.init === null - ) { - return expression; - } - return ( - extractExpressionPrefixVariable(context, variable.identifiers[0].parent.init) ?? expression - ); -} - -function extractMemberExpressionPrefixVariable( - expression: TSESTree.MemberExpression -): TSESTree.Identifier | null { - return expression.property.type === 'Identifier' ? expression.property : null; -} - -function extractTemplateLiteralPrefixVariable( - context: RuleContext, - expression: TSESTree.TemplateLiteral -): TSESTree.Identifier | null { - const literalParts = [...expression.expressions, ...expression.quasis].sort((a, b) => - a.range[0] < b.range[0] ? -1 : 1 - ); - for (const part of literalParts) { - if (part.type === 'TemplateElement' && part.value.raw === '') { - // Skip empty quasi in the begining - continue; } - if (part.type !== 'TemplateElement') { - return extractExpressionPrefixVariable(context, part); - } - return null; - } - return null; + }); } export function extractExpressionPrefixLiteral( From 41f9b02e672b9efc68af071f3a260f4af948c2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Tue, 9 Sep 2025 18:42:44 +0200 Subject: [PATCH 6/9] chore: extracted URL utils and fixed an infinite loop --- .../src/rules/no-navigation-without-base.ts | 71 +----------------- .../rules/no-navigation-without-resolve.ts | 73 ++----------------- .../src/utils/url-utils.ts | 40 ++++++++++ 3 files changed, 49 insertions(+), 135 deletions(-) create mode 100644 packages/eslint-plugin-svelte/src/utils/url-utils.ts diff --git a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts index e3df10139..138546bca 100644 --- a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts +++ b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-base.ts @@ -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: { @@ -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' }); } @@ -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('#'); -} diff --git a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts index 98c34b9c3..12b4ed7aa 100644 --- a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts +++ b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts @@ -3,7 +3,7 @@ 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'; export default createRule('no-navigation-without-resolve', { meta: { @@ -96,11 +96,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' }); @@ -251,66 +251,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('#'); -} diff --git a/packages/eslint-plugin-svelte/src/utils/url-utils.ts b/packages/eslint-plugin-svelte/src/utils/url-utils.ts new file mode 100644 index 000000000..80a48aafe --- /dev/null +++ b/packages/eslint-plugin-svelte/src/utils/url-utils.ts @@ -0,0 +1,40 @@ +import type { TSESTree } from '@typescript-eslint/types'; +import type { AST } from 'svelte-eslint-parser'; +import { ASTSearchHelper } from './ast-search-helper.js'; + +export function isAbsoluteURL(url: AST.SvelteLiteral | TSESTree.Expression): boolean { + return ( + ASTSearchHelper(url, { + BinaryExpression: (node, searchAnotherNode) => + (node.left.type !== 'PrivateIdentifier' && searchAnotherNode(node.left)) || + searchAnotherNode(node.right), + Literal: (node) => typeof node.value === 'string' && urlValueIsAbsolute(node.value), + SvelteLiteral: (node) => urlValueIsAbsolute(node.value), + TemplateLiteral: (node, searchAnotherNode) => + node.expressions.some(searchAnotherNode) || + node.quasis.some((quasi) => urlValueIsAbsolute(quasi.value.raw)) + }) ?? false + ); +} + +function urlValueIsAbsolute(url: string): boolean { + return /^[+a-z]*:/i.test(url); +} + +export function isFragmentURL(url: AST.SvelteLiteral | TSESTree.Expression): boolean { + return ( + ASTSearchHelper(url, { + BinaryExpression: (node, searchAnotherNode) => + node.left.type !== 'PrivateIdentifier' && searchAnotherNode(node.left), + Literal: (node) => typeof node.value === 'string' && urlValueIsFragment(node.value), + SvelteLiteral: (node) => urlValueIsFragment(node.value), + TemplateLiteral: (node, searchAnotherNode) => + (node.expressions.length >= 1 && searchAnotherNode(node.expressions[0])) || + (node.quasis.length >= 1 && urlValueIsFragment(node.quasis[0].value.raw)) + }) ?? false + ); +} + +function urlValueIsFragment(url: string): boolean { + return url.startsWith('#'); +} From fd830ee6db8ed446b57e50459613c8b6ede5e7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Tue, 9 Sep 2025 18:56:20 +0200 Subject: [PATCH 7/9] fix(no-navigation-without-resolve): fixed an infinite loop --- .../rules/no-navigation-without-resolve.ts | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts index 12b4ed7aa..efeda7364 100644 --- a/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts +++ b/packages/eslint-plugin-svelte/src/rules/no-navigation-without-resolve.ts @@ -4,6 +4,7 @@ import { ReferenceTracker } from '@eslint-community/eslint-utils'; import { findVariable } from '../utils/ast-utils.js'; import type { RuleContext } from '../types.js'; import { isAbsoluteURL, isFragmentURL } from '../utils/url-utils.js'; +import { ASTSearchHelper } from '../utils/ast-search-helper.js'; export default createRule('no-navigation-without-resolve', { meta: { @@ -218,28 +219,25 @@ function isResolveCall( node: TSESTree.CallExpressionArgument, resolveReferences: Set ): 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 { From cd346c08784170d0ad6ddf80b86040fc7da9ba42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Tue, 9 Sep 2025 19:16:06 +0200 Subject: [PATCH 8/9] test(no-navigation-without-resolve): testing for infinite recursion --- .../invalid/recursive-loop01-errors.yaml | 4 ++++ .../invalid/recursive-loop01-input.svelte | 5 +++++ 2 files changed, 9 insertions(+) create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/recursive-loop01-errors.yaml create mode 100644 packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/recursive-loop01-input.svelte diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/recursive-loop01-errors.yaml b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/recursive-loop01-errors.yaml new file mode 100644 index 000000000..41969717a --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/recursive-loop01-errors.yaml @@ -0,0 +1,4 @@ +- message: Found a link with a url that isn't resolved. + line: 5 + column: 9 + suggestions: null diff --git a/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/recursive-loop01-input.svelte b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/recursive-loop01-input.svelte new file mode 100644 index 000000000..543e64f02 --- /dev/null +++ b/packages/eslint-plugin-svelte/tests/fixtures/rules/no-navigation-without-resolve/invalid/recursive-loop01-input.svelte @@ -0,0 +1,5 @@ + + +Click me! From 407a1c862e7fe4049adb5576802cf07caba95159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20D=C4=9Bdi=C4=8D?= Date: Tue, 9 Sep 2025 19:01:49 +0200 Subject: [PATCH 9/9] chore: migrated custom code in getSimpleNameFromNode to ASTSearchHelper --- .../src/utils/ast-utils.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/eslint-plugin-svelte/src/utils/ast-utils.ts b/packages/eslint-plugin-svelte/src/utils/ast-utils.ts index 2fec4d3b0..55cbdfdcb 100644 --- a/packages/eslint-plugin-svelte/src/utils/ast-utils.ts +++ b/packages/eslint-plugin-svelte/src/utils/ast-utils.ts @@ -666,17 +666,18 @@ function getSimpleNameFromNode( | TSESTree.Expression, context: RuleContext | undefined ): string { - if (node.type === 'Identifier' || node.type === 'SvelteName') { - return node.name; - } - if ( - node.type === 'SvelteMemberExpressionName' || - (node.type === 'MemberExpression' && !node.computed) - ) { - return `${getSimpleNameFromNode(node.object, context!)}.${getSimpleNameFromNode( - node.property, - context! - )}`; + const name = ASTSearchHelper(node, { + Identifier: (node) => node.name, + MemberExpression: (node, searchAnotherNode) => + !node.computed + ? `${searchAnotherNode(node.object)}.${searchAnotherNode(node.property)}` + : null, + SvelteName: (node) => node.name, + SvelteMemberExpressionName: (node, searchAnotherNode) => + `${searchAnotherNode(node.object)}.${searchAnotherNode(node.property)}` + }); + if (name !== null) { + return name; } // No nodes other than those listed above are currently expected to be used in names.