Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/icy-mammals-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-svelte': patch
---

feat(no-navigation-without-resolve): added support for ResolvedPathname types
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { TSESTree } from '@typescript-eslint/types';

import { createRule } from '../utils/index.js';
import { ReferenceTracker } from '@eslint-community/eslint-utils';
import { FindVariableContext } from '../utils/ast-utils.js';
import { findVariable } from '../utils/ast-utils.js';
import type { RuleContext } from '../types.js';
import type { AST } from 'svelte-eslint-parser';
import { type TSTools, getTypeScriptTools } from '../utils/ts-utils/index.js';

export default createRule('no-navigation-without-resolve', {
meta: {
Expand Down Expand Up @@ -48,6 +50,8 @@ export default createRule('no-navigation-without-resolve', {
]
},
create(context) {
const tsTools = getTypeScriptTools(context);

let resolveReferences: Set<TSESTree.Identifier> = new Set<TSESTree.Identifier>();

const ignoreGoto = context.options[0]?.ignoreGoto ?? false;
Expand All @@ -66,7 +70,7 @@ export default createRule('no-navigation-without-resolve', {
} = extractFunctionCallReferences(referenceTracker);
if (!ignoreGoto) {
for (const gotoCall of gotoCalls) {
checkGotoCall(context, gotoCall, resolveReferences);
checkGotoCall(context, gotoCall, resolveReferences, tsTools);
}
}
if (!ignorePushState) {
Expand All @@ -75,6 +79,7 @@ export default createRule('no-navigation-without-resolve', {
context,
pushStateCall,
resolveReferences,
tsTools,
'pushStateWithoutResolve'
);
}
Expand All @@ -85,22 +90,24 @@ export default createRule('no-navigation-without-resolve', {
context,
replaceStateCall,
resolveReferences,
tsTools,
'replaceStateWithoutResolve'
);
}
}
},
...(!ignoreLinks && {
SvelteShorthandAttribute(node) {
checkLinkAttribute(context, node, node.value, resolveReferences);
checkLinkAttribute(context, node, node.value, resolveReferences, tsTools);
},
SvelteAttribute(node) {
if (node.value.length > 0) {
checkLinkAttribute(
context,
node,
node.value[0].type === 'SvelteMustacheTag' ? node.value[0].expression : node.value[0],
resolveReferences
resolveReferences,
tsTools
);
}
}
Expand Down Expand Up @@ -187,11 +194,18 @@ function extractFunctionCallReferences(referenceTracker: ReferenceTracker): {
function checkGotoCall(
context: RuleContext,
call: TSESTree.CallExpression,
resolveReferences: Set<TSESTree.Identifier>
resolveReferences: Set<TSESTree.Identifier>,
tsTools: TSTools | null
): void {
if (
call.arguments.length > 0 &&
!isValueAllowed(new FindVariableContext(context), call.arguments[0], resolveReferences, {})
!isValueAllowed(
new FindVariableContext(context),
call.arguments[0],
resolveReferences,
tsTools,
{}
)
) {
context.report({ loc: call.arguments[0].loc, messageId: 'gotoWithoutResolve' });
}
Expand All @@ -201,13 +215,20 @@ function checkShallowNavigationCall(
context: RuleContext,
call: TSESTree.CallExpression,
resolveReferences: Set<TSESTree.Identifier>,
tsTools: TSTools | null,
messageId: string
): void {
if (
call.arguments.length > 0 &&
!isValueAllowed(new FindVariableContext(context), call.arguments[0], resolveReferences, {
allowEmpty: true
})
!isValueAllowed(
new FindVariableContext(context),
call.arguments[0],
resolveReferences,
tsTools,
{
allowEmpty: true
}
)
) {
context.report({ loc: call.arguments[0].loc, messageId });
}
Expand All @@ -217,7 +238,8 @@ function checkLinkAttribute(
context: RuleContext,
attribute: AST.SvelteAttribute | AST.SvelteShorthandAttribute,
value: TSESTree.Expression | AST.SvelteLiteral,
resolveReferences: Set<TSESTree.Identifier>
resolveReferences: Set<TSESTree.Identifier>,
tsTools: TSTools | null
): void {
if (
attribute.parent.parent.type === 'SvelteElement' &&
Expand All @@ -226,7 +248,7 @@ function checkLinkAttribute(
attribute.parent.parent.name.name === 'a' &&
attribute.key.name === 'href' &&
!hasRelExternal(new FindVariableContext(context), attribute.parent) &&
!isValueAllowed(new FindVariableContext(context), value, resolveReferences, {
!isValueAllowed(new FindVariableContext(context), value, resolveReferences, tsTools, {
allowAbsolute: true,
allowFragment: true,
allowNullish: true
Expand Down Expand Up @@ -275,6 +297,7 @@ function isValueAllowed(
ctx: FindVariableContext,
value: TSESTree.CallExpressionArgument | AST.SvelteLiteral,
resolveReferences: Set<TSESTree.Identifier>,
tsTools: TSTools | null,
config: {
allowAbsolute?: boolean;
allowEmpty?: boolean;
Expand All @@ -287,23 +310,34 @@ function isValueAllowed(
if (
variable !== null &&
variable.identifiers.length > 0 &&
variable.identifiers[0].parent.type === 'VariableDeclarator' &&
variable.identifiers[0].parent.init !== null
variable.identifiers[0].parent.type === 'VariableDeclarator'
) {
return isValueAllowed(ctx, variable.identifiers[0].parent.init, resolveReferences, config);
if (expressionIsResolvedPathname(variable.identifiers[0], tsTools)) {
return true;
}
if (variable.identifiers[0].parent.init !== null) {
return isValueAllowed(
ctx,
variable.identifiers[0].parent.init,
resolveReferences,
tsTools,
config
);
}
}
}
if (value.type === 'ConditionalExpression') {
return (
isValueAllowed(ctx, value.consequent, resolveReferences, config) &&
isValueAllowed(ctx, value.alternate, resolveReferences, config)
isValueAllowed(ctx, value.consequent, resolveReferences, tsTools, config) &&
isValueAllowed(ctx, value.alternate, resolveReferences, tsTools, config)
);
}
if (
(config.allowAbsolute && expressionIsAbsoluteUrl(ctx, value)) ||
(config.allowEmpty && expressionIsEmpty(value)) ||
(config.allowFragment && expressionStartsWith(ctx, value, '#')) ||
(config.allowNullish && expressionIsNullish(value)) ||
expressionIsResolvedPathname(value, tsTools) ||
expressionIsResolveCall(ctx, value, resolveReferences)
) {
return true;
Expand All @@ -313,6 +347,41 @@ function isValueAllowed(

// Helper functions

function expressionIsResolvedPathname(
value: TSESTree.CallExpressionArgument | TSESTree.Expression | AST.SvelteLiteral,
tsTools: TSTools | null
): boolean {
if (tsTools === null) {
return false;
}
const checker = tsTools.service.program.getTypeChecker();

const tsNode = tsTools.service.esTreeNodeToTSNodeMap.get(value);
if (tsNode === undefined) {
return false;
}
const nodeType = checker.getTypeAtLocation(tsNode);

const appTypesModule = checker.getAmbientModules().find((m) => m.name === '"$app/types"');
if (!appTypesModule) {
return false;
}

const resolvedPathnameSymbol = checker
.getExportsOfModule(appTypesModule)
.find((e) => e.name === 'ResolvedPathname');
if (!resolvedPathnameSymbol) {
return false;
}
const resolvedPathnameType = checker.getDeclaredTypeOfSymbol(resolvedPathnameSymbol);

// getTypeAtLocation returns the resolved (structural) type without alias information, so we cannot compare aliasSymbols directly. Instead we check structural equivalence by testing assignability in both directions: this correctly rejects strict subtypes like Pathname (Pathname ⊂ ResolvedPathname, so only one direction holds).
return (
checker.isTypeAssignableTo(nodeType, resolvedPathnameType) &&
checker.isTypeAssignableTo(resolvedPathnameType, nodeType)
);
}

function expressionIsResolveCall(
ctx: FindVariableContext,
node: TSESTree.CallExpressionArgument | AST.SvelteLiteral,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected goto() call without resolve().
line: 12
column: 7
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';

type ResolvedPathname = string;

interface Props {
href: ResolvedPathname;
}

const { href }: Props = $props();

goto(href);
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected goto() call without resolve().
line: 10
column: 7
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';

interface Props {
href: string;
}

const { href }: Props = $props();

goto(href);
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected goto() call without resolve().
line: 12
column: 7
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
import type { Pathname } from '$app/types';

import { goto } from '$app/navigation';

interface Props {
href: Pathname;
}

const { href }: Props = $props();

goto(href);
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected href link without resolve().
line: 11
column: 4
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script lang="ts">
type ResolvedPathname = string;

interface Props {
href: ResolvedPathname;
}

const { href }: Props = $props();
</script>

<a {href}>Click me!</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected href link without resolve().
line: 9
column: 4
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script lang="ts">
interface Props {
href: string;
}

const { href }: Props = $props();
</script>

<a {href}>Click me!</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected href link without resolve().
line: 11
column: 4
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script lang="ts">
import type { Pathname } from '$app/types';

interface Props {
href: Pathname;
}

const { href }: Props = $props();
</script>

<a {href}>Click me!</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected pushState() call without resolve().
line: 12
column: 12
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
import { pushState } from '$app/navigation';

type ResolvedPathname = string;

interface Props {
href: ResolvedPathname;
}

const { href }: Props = $props();

pushState(href);
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected pushState() call without resolve().
line: 10
column: 12
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script lang="ts">
import { pushState } from '$app/navigation';

interface Props {
href: string;
}

const { href }: Props = $props();

pushState(href);
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected pushState() call without resolve().
line: 12
column: 12
suggestions: null
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
import type { Pathname } from '$app/types';

import { pushState } from '$app/navigation';

interface Props {
href: Pathname;
}

const { href }: Props = $props();

pushState(href);
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- message: Unexpected replaceState() call without resolve().
line: 12
column: 15
suggestions: null
Loading
Loading