diff --git a/packages/eslint-plugin-router/src/__tests__/route-param-names.rule.test.ts b/packages/eslint-plugin-router/src/__tests__/route-param-names.rule.test.ts new file mode 100644 index 00000000000..e66847e363b --- /dev/null +++ b/packages/eslint-plugin-router/src/__tests__/route-param-names.rule.test.ts @@ -0,0 +1,271 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' + +import { name, rule } from '../rules/route-param-names/route-param-names.rule' + +const ruleTester = new RuleTester() + +ruleTester.run(name, rule, { + valid: [ + // Valid param names - simple $param format + { + name: 'valid simple param: $userId', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/$userId')({}) + `, + }, + { + name: 'valid simple param: $id', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/posts/$id')({}) + `, + }, + { + name: 'valid simple param: $_id (underscore prefix)', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/items/$_id')({}) + `, + }, + { + name: 'valid simple param: $$var (dollar prefix)', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/data/$$var')({}) + `, + }, + { + name: 'valid param with numbers: $user123', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/$user123')({}) + `, + }, + + // Valid param names - braces format {$param} + { + name: 'valid braces param: {$userName}', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/{$userName}')({}) + `, + }, + { + name: 'valid braces param with prefix/suffix: prefix{$id}suffix', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/items/item-{$id}-details')({}) + `, + }, + + // Valid optional params - {-$param} + { + name: 'valid optional param: {-$optional}', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/search/{-$query}')({}) + `, + }, + { + name: 'valid optional param with prefix/suffix: prefix{-$opt}suffix', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/filter/by-{-$category}-items')({}) + `, + }, + + // Wildcards - should be skipped (no validation) + { + name: 'wildcard: $ alone', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/files/$')({}) + `, + }, + { + name: 'wildcard: {$}', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/catch/{$}')({}) + `, + }, + + // Multiple valid params + { + name: 'multiple valid params in path', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/$userId/posts/$postId')({}) + `, + }, + + // createRoute with path property + { + name: 'createRoute with valid param in path property', + code: ` + import { createRoute } from '@tanstack/react-router' + const Route = createRoute({ path: '/users/$userId' }) + `, + }, + + // createLazyFileRoute + { + name: 'createLazyFileRoute with valid param', + code: ` + import { createLazyFileRoute } from '@tanstack/react-router' + const Route = createLazyFileRoute('/users/$userId')({}) + `, + }, + + // createLazyRoute + { + name: 'createLazyRoute with valid param', + code: ` + import { createLazyRoute } from '@tanstack/react-router' + const Route = createLazyRoute('/users/$userId')({}) + `, + }, + + // No params - should pass + { + name: 'no params in path', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/list')({}) + `, + }, + + // Not from tanstack router - should be ignored + { + name: 'non-tanstack import should be ignored', + code: ` + import { createFileRoute } from 'other-router' + const Route = createFileRoute('/users/$123invalid')({}) + `, + }, + ], + + invalid: [ + // Invalid param names - starts with number + { + name: 'invalid param starting with number: $123', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/$123')({}) + `, + errors: [{ messageId: 'invalidParamName', data: { paramName: '123' } }], + }, + { + name: 'invalid param starting with number: $1user', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/$1user')({}) + `, + errors: [{ messageId: 'invalidParamName', data: { paramName: '1user' } }], + }, + + // Invalid param names - contains hyphen + { + name: 'invalid param with hyphen: $user-name', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/$user-name')({}) + `, + errors: [ + { messageId: 'invalidParamName', data: { paramName: 'user-name' } }, + ], + }, + + // Invalid param names - contains dot + { + name: 'invalid param with dot: {$my.param}', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/{$my.param}')({}) + `, + errors: [ + { messageId: 'invalidParamName', data: { paramName: 'my.param' } }, + ], + }, + + // Invalid param names - contains space + { + name: 'invalid param with space: {$param name}', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/{$param name}')({}) + `, + errors: [ + { messageId: 'invalidParamName', data: { paramName: 'param name' } }, + ], + }, + + // Invalid optional param + { + name: 'invalid optional param: {-$123invalid}', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/search/{-$123invalid}')({}) + `, + errors: [ + { messageId: 'invalidParamName', data: { paramName: '123invalid' } }, + ], + }, + + // Multiple invalid params + { + name: 'multiple invalid params in path', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/users/$1id/posts/$post-id')({}) + `, + errors: [ + { messageId: 'invalidParamName', data: { paramName: '1id' } }, + { messageId: 'invalidParamName', data: { paramName: 'post-id' } }, + ], + }, + + // createRoute with invalid path property + { + name: 'createRoute with invalid param in path property', + code: ` + import { createRoute } from '@tanstack/react-router' + const Route = createRoute({ path: '/users/$123' }) + `, + errors: [{ messageId: 'invalidParamName', data: { paramName: '123' } }], + }, + + // createLazyFileRoute with invalid param + { + name: 'createLazyFileRoute with invalid param', + code: ` + import { createLazyFileRoute } from '@tanstack/react-router' + const Route = createLazyFileRoute('/users/$user-id')({}) + `, + errors: [ + { messageId: 'invalidParamName', data: { paramName: 'user-id' } }, + ], + }, + + // createLazyRoute with invalid param + { + name: 'createLazyRoute with invalid param', + code: ` + import { createLazyRoute } from '@tanstack/react-router' + const Route = createLazyRoute('/users/$1abc')({}) + `, + errors: [{ messageId: 'invalidParamName', data: { paramName: '1abc' } }], + }, + + // Invalid braces param with prefix/suffix + { + name: 'invalid braces param with prefix/suffix', + code: ` + import { createFileRoute } from '@tanstack/react-router' + const Route = createFileRoute('/items/item-{$123}-details')({}) + `, + errors: [{ messageId: 'invalidParamName', data: { paramName: '123' } }], + }, + ], +}) diff --git a/packages/eslint-plugin-router/src/__tests__/route-param-names.utils.test.ts b/packages/eslint-plugin-router/src/__tests__/route-param-names.utils.test.ts new file mode 100644 index 00000000000..67d0488b99b --- /dev/null +++ b/packages/eslint-plugin-router/src/__tests__/route-param-names.utils.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' +import { + extractParamsFromPath, + extractParamsFromSegment, + getInvalidParams, + isValidParamName, +} from '../rules/route-param-names/route-param-names.utils' + +describe('isValidParamName', () => { + it('should return true for valid param names', () => { + expect(isValidParamName('userId')).toBe(true) + expect(isValidParamName('id')).toBe(true) + expect(isValidParamName('_id')).toBe(true) + expect(isValidParamName('$var')).toBe(true) + expect(isValidParamName('user123')).toBe(true) + expect(isValidParamName('_')).toBe(true) + expect(isValidParamName('$')).toBe(true) + expect(isValidParamName('ABC')).toBe(true) + expect(isValidParamName('camelCase')).toBe(true) + expect(isValidParamName('PascalCase')).toBe(true) + expect(isValidParamName('snake_case')).toBe(true) + expect(isValidParamName('$$double')).toBe(true) + expect(isValidParamName('__double')).toBe(true) + }) + + it('should return false for invalid param names', () => { + expect(isValidParamName('123')).toBe(false) + expect(isValidParamName('1user')).toBe(false) + expect(isValidParamName('user-name')).toBe(false) + expect(isValidParamName('user.name')).toBe(false) + expect(isValidParamName('user name')).toBe(false) + expect(isValidParamName('')).toBe(false) + expect(isValidParamName('user@name')).toBe(false) + expect(isValidParamName('user#name')).toBe(false) + expect(isValidParamName('-user')).toBe(false) + }) +}) + +describe('extractParamsFromSegment', () => { + it('should return empty array for segments without $', () => { + expect(extractParamsFromSegment('')).toEqual([]) + expect(extractParamsFromSegment('users')).toEqual([]) + expect(extractParamsFromSegment('static-segment')).toEqual([]) + }) + + it('should skip wildcard segments', () => { + expect(extractParamsFromSegment('$')).toEqual([]) + expect(extractParamsFromSegment('{$}')).toEqual([]) + }) + + it('should extract simple $param format', () => { + const result = extractParamsFromSegment('$userId') + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + fullParam: '$userId', + paramName: 'userId', + isOptional: false, + isValid: true, + }) + }) + + it('should extract braces {$param} format', () => { + const result = extractParamsFromSegment('{$userId}') + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + fullParam: '$userId', + paramName: 'userId', + isOptional: false, + isValid: true, + }) + }) + + it('should extract braces with prefix/suffix', () => { + const result = extractParamsFromSegment('prefix{$id}suffix') + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + fullParam: '$id', + paramName: 'id', + isOptional: false, + isValid: true, + }) + }) + + it('should extract optional {-$param} format', () => { + const result = extractParamsFromSegment('{-$optional}') + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + fullParam: '-$optional', + paramName: 'optional', + isOptional: true, + isValid: true, + }) + }) + + it('should extract optional with prefix/suffix', () => { + const result = extractParamsFromSegment('pre{-$opt}post') + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + fullParam: '-$opt', + paramName: 'opt', + isOptional: true, + isValid: true, + }) + }) + + it('should mark invalid param names', () => { + const result = extractParamsFromSegment('$123invalid') + expect(result).toHaveLength(1) + expect(result[0]?.isValid).toBe(false) + expect(result[0]?.paramName).toBe('123invalid') + }) + + it('should mark hyphenated param names as invalid', () => { + const result = extractParamsFromSegment('$user-name') + expect(result).toHaveLength(1) + expect(result[0]?.isValid).toBe(false) + expect(result[0]?.paramName).toBe('user-name') + }) +}) + +describe('extractParamsFromPath', () => { + it('should return empty array for paths without params', () => { + expect(extractParamsFromPath('')).toEqual([]) + expect(extractParamsFromPath('/')).toEqual([]) + expect(extractParamsFromPath('/users/list')).toEqual([]) + }) + + it('should extract single param from path', () => { + const result = extractParamsFromPath('/users/$userId') + expect(result).toHaveLength(1) + expect(result[0]?.paramName).toBe('userId') + }) + + it('should extract multiple params from path', () => { + const result = extractParamsFromPath('/users/$userId/posts/$postId') + expect(result).toHaveLength(2) + expect(result[0]?.paramName).toBe('userId') + expect(result[1]?.paramName).toBe('postId') + }) + + it('should extract params with various formats', () => { + const result = extractParamsFromPath( + '/a/$simple/b/{$braces}/c/{-$optional}', + ) + expect(result).toHaveLength(3) + expect(result[0]?.paramName).toBe('simple') + expect(result[0]?.isOptional).toBe(false) + expect(result[1]?.paramName).toBe('braces') + expect(result[1]?.isOptional).toBe(false) + expect(result[2]?.paramName).toBe('optional') + expect(result[2]?.isOptional).toBe(true) + }) +}) + +describe('getInvalidParams', () => { + it('should return empty array for valid params', () => { + expect(getInvalidParams('/users/$userId')).toEqual([]) + expect(getInvalidParams('/users/$_id')).toEqual([]) + expect(getInvalidParams('/users/$$var')).toEqual([]) + }) + + it('should return invalid params only', () => { + const result = getInvalidParams('/users/$123/posts/$validId') + expect(result).toHaveLength(1) + expect(result[0]?.paramName).toBe('123') + }) + + it('should return all invalid params', () => { + const result = getInvalidParams('/users/$1id/posts/$post-id') + expect(result).toHaveLength(2) + expect(result[0]?.paramName).toBe('1id') + expect(result[1]?.paramName).toBe('post-id') + }) +}) diff --git a/packages/eslint-plugin-router/src/index.ts b/packages/eslint-plugin-router/src/index.ts index f02ea5a000b..7b8855cdf28 100644 --- a/packages/eslint-plugin-router/src/index.ts +++ b/packages/eslint-plugin-router/src/index.ts @@ -26,6 +26,7 @@ Object.assign(plugin.configs, { plugins: ['@tanstack/eslint-plugin-router'], rules: { '@tanstack/router/create-route-property-order': 'warn', + '@tanstack/router/route-param-names': 'error', }, }, 'flat/recommended': [ @@ -35,6 +36,7 @@ Object.assign(plugin.configs, { }, rules: { '@tanstack/router/create-route-property-order': 'warn', + '@tanstack/router/route-param-names': 'error', }, }, ], diff --git a/packages/eslint-plugin-router/src/rules.ts b/packages/eslint-plugin-router/src/rules.ts index 73219d401ee..1042929a455 100644 --- a/packages/eslint-plugin-router/src/rules.ts +++ b/packages/eslint-plugin-router/src/rules.ts @@ -1,4 +1,5 @@ import * as createRoutePropertyOrder from './rules/create-route-property-order/create-route-property-order.rule' +import * as routeParamNames from './rules/route-param-names/route-param-names.rule' import type { ESLintUtils } from '@typescript-eslint/utils' import type { ExtraRuleDocs } from './types' @@ -12,4 +13,5 @@ export const rules: Record< > > = { [createRoutePropertyOrder.name]: createRoutePropertyOrder.rule, + [routeParamNames.name]: routeParamNames.rule, } diff --git a/packages/eslint-plugin-router/src/rules/route-param-names/constants.ts b/packages/eslint-plugin-router/src/rules/route-param-names/constants.ts new file mode 100644 index 00000000000..541374da06e --- /dev/null +++ b/packages/eslint-plugin-router/src/rules/route-param-names/constants.ts @@ -0,0 +1,36 @@ +/** + * Functions where the path is passed as the first argument (string literal) + * e.g., createFileRoute('/path/$param')(...) + */ +export const pathAsFirstArgFunctions = [ + 'createFileRoute', + 'createLazyFileRoute', + 'createLazyRoute', +] as const + +export type PathAsFirstArgFunction = (typeof pathAsFirstArgFunctions)[number] + +/** + * Functions where the path is a property in the options object + * e.g., createRoute({ path: '/path/$param' }) + */ +export const pathAsPropertyFunctions = ['createRoute'] as const + +export type PathAsPropertyFunction = (typeof pathAsPropertyFunctions)[number] + +/** + * All route functions that need param name validation + */ +export const allRouteFunctions = [ + ...pathAsFirstArgFunctions, + ...pathAsPropertyFunctions, +] as const + +export type RouteFunction = (typeof allRouteFunctions)[number] + +/** + * Regex for valid JavaScript identifier (param name) + * Must start with letter, underscore, or dollar sign + * Can contain letters, numbers, underscores, or dollar signs + */ +export const VALID_PARAM_NAME_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/ diff --git a/packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.rule.ts b/packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.rule.ts new file mode 100644 index 00000000000..ae63ab98c4b --- /dev/null +++ b/packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.rule.ts @@ -0,0 +1,127 @@ +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' + +import { getDocsUrl } from '../../utils/get-docs-url' +import { detectTanstackRouterImports } from '../../utils/detect-router-imports' +import { getInvalidParams } from './route-param-names.utils' +import { pathAsFirstArgFunctions, pathAsPropertyFunctions } from './constants' +import type { TSESTree } from '@typescript-eslint/utils' +import type { ExtraRuleDocs } from '../../types' + +const createRule = ESLintUtils.RuleCreator(getDocsUrl) + +const pathAsFirstArgSet = new Set(pathAsFirstArgFunctions) +const pathAsPropertySet = new Set(pathAsPropertyFunctions) + +export const name = 'route-param-names' + +export const rule = createRule({ + name, + meta: { + type: 'problem', + docs: { + description: 'Ensure route param names are valid JavaScript identifiers', + recommended: 'error', + }, + messages: { + invalidParamName: + 'Invalid param name "{{paramName}}" in route path. Param names must be valid JavaScript identifiers (match /[a-zA-Z_$][a-zA-Z0-9_$]*/).', + }, + schema: [], + }, + defaultOptions: [], + + create: detectTanstackRouterImports((context, _, helpers) => { + function reportInvalidParams(node: TSESTree.Node, path: string) { + const invalidParams = getInvalidParams(path) + + for (const param of invalidParams) { + context.report({ + node, + messageId: 'invalidParamName', + data: { paramName: param.paramName }, + }) + } + } + + function getStringLiteralValue(node: TSESTree.Node): string | null { + if ( + node.type === AST_NODE_TYPES.Literal && + typeof node.value === 'string' + ) { + return node.value + } + if ( + node.type === AST_NODE_TYPES.TemplateLiteral && + node.quasis.length === 1 + ) { + const cooked = node.quasis[0]?.value.cooked + if (cooked != null) { + return cooked + } + } + return null + } + + return { + CallExpression(node) { + // Handle direct function call: createRoute({ path: '...' }) + if (node.callee.type === AST_NODE_TYPES.Identifier) { + const funcName = node.callee.name + + // Skip if not imported from TanStack Router + if (!helpers.isTanstackRouterImport(node.callee)) { + return + } + + // Case: createRoute({ path: '/path/$param' }) or createRoute({ 'path': '/path/$param' }) + if (pathAsPropertySet.has(funcName)) { + const arg = node.arguments[0] + if (arg?.type === AST_NODE_TYPES.ObjectExpression) { + for (const prop of arg.properties) { + if (prop.type === AST_NODE_TYPES.Property) { + const isPathKey = + (prop.key.type === AST_NODE_TYPES.Identifier && + prop.key.name === 'path') || + (prop.key.type === AST_NODE_TYPES.Literal && + prop.key.value === 'path') + if (isPathKey) { + const pathValue = getStringLiteralValue(prop.value) + if (pathValue) { + reportInvalidParams(prop.value, pathValue) + } + } + } + } + } + return + } + } + + // Handle curried function call: createFileRoute('/path')({ ... }) + if (node.callee.type === AST_NODE_TYPES.CallExpression) { + const innerCall = node.callee + + if (innerCall.callee.type === AST_NODE_TYPES.Identifier) { + const funcName = innerCall.callee.name + + // Skip if not imported from TanStack Router + if (!helpers.isTanstackRouterImport(innerCall.callee)) { + return + } + + // Case: createFileRoute('/path/$param')(...) or similar + if (pathAsFirstArgSet.has(funcName)) { + const pathArg = innerCall.arguments[0] + if (pathArg) { + const pathValue = getStringLiteralValue(pathArg) + if (pathValue) { + reportInvalidParams(pathArg, pathValue) + } + } + } + } + } + }, + } + }), +}) diff --git a/packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.utils.ts b/packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.utils.ts new file mode 100644 index 00000000000..825c9eed963 --- /dev/null +++ b/packages/eslint-plugin-router/src/rules/route-param-names/route-param-names.utils.ts @@ -0,0 +1,122 @@ +import { VALID_PARAM_NAME_REGEX } from './constants' + +export interface ExtractedParam { + /** The full param string including $ prefix (e.g., "$userId", "-$optional") */ + fullParam: string + /** The param name without $ prefix (e.g., "userId", "optional") */ + paramName: string + /** Whether this is an optional param (prefixed with -$) */ + isOptional: boolean + /** Whether this param name is valid */ + isValid: boolean +} + +/** + * Extracts param names from a route path segment. + * + * Handles these patterns: + * - $paramName -> extract "paramName" + * - {$paramName} -> extract "paramName" + * - prefix{$paramName}suffix -> extract "paramName" + * - {-$paramName} -> extract "paramName" (optional) + * - prefix{-$paramName}suffix -> extract "paramName" (optional) + * - $ or {$} -> wildcard, skip validation + */ +export function extractParamsFromSegment( + segment: string, +): Array { + const params: Array = [] + + // Skip empty segments + if (!segment || !segment.includes('$')) { + return params + } + + // Check for wildcard ($ alone or {$}) + if (segment === '$' || segment === '{$}') { + return params // Wildcard, no param name to validate + } + + // Pattern 1: Simple $paramName (entire segment starts with $) + if (segment.startsWith('$') && !segment.includes('{')) { + const paramName = segment.slice(1) + if (paramName) { + params.push({ + fullParam: segment, + paramName, + isOptional: false, + isValid: VALID_PARAM_NAME_REGEX.test(paramName), + }) + } + return params + } + + // Pattern 2: Braces pattern {$paramName} or {-$paramName} with optional prefix/suffix + // Match patterns like: prefix{$param}suffix, {$param}, {-$param} + const bracePattern = /\{(-?\$)([^}]*)\}/g + let match + + while ((match = bracePattern.exec(segment)) !== null) { + const prefix = match[1] // "$" or "-$" + const paramName = match[2] // The param name after $ or -$ + + if (!paramName) { + // This is a wildcard {$} or {-$}, skip + continue + } + + const isOptional = prefix === '-$' + + params.push({ + fullParam: `${prefix}${paramName}`, + paramName, + isOptional, + isValid: VALID_PARAM_NAME_REGEX.test(paramName), + }) + } + + return params +} + +/** + * Extracts all params from a route path. + * + * @param path - The route path (e.g., "/users/$userId/posts/$postId") + * @returns Array of extracted params with validation info + */ +export function extractParamsFromPath(path: string): Array { + if (!path || !path.includes('$')) { + return [] + } + + const segments = path.split('/') + const allParams: Array = [] + + for (const segment of segments) { + const params = extractParamsFromSegment(segment) + allParams.push(...params) + } + + return allParams +} + +/** + * Validates a single param name. + * + * @param paramName - The param name to validate (without $ prefix) + * @returns Whether the param name is valid + */ +export function isValidParamName(paramName: string): boolean { + return VALID_PARAM_NAME_REGEX.test(paramName) +} + +/** + * Gets all invalid params from a route path. + * + * @param path - The route path + * @returns Array of invalid param info + */ +export function getInvalidParams(path: string): Array { + const params = extractParamsFromPath(path) + return params.filter((p) => !p.isValid) +}