/** * TanStack Router — the path is a literal, and so is the destination. * * Routes are declared two ways, and this reads both: * * // file-based (the plugin's default): the full path is the argument * export const Route = createFileRoute('/dashboard/invoices/$invoiceId')({ * component: InvoiceComponent, * }) * * // code-based: a path per route, composed through its parent * const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'posts' }) * const postRoute = createRoute({ getParentRoute: () => postsRoute, path: '$postId' }) * * Three things are TanStack's own, and each one decides whether the picture is * right: * * 1. **A parameter is `$id`, not `:id`.** Route names are normalised to the * `:id` every other framework here uses, so one matcher serves them all. * 2. **`to` is the route PATTERN, not a filled URL.** `` names the route and passes the values beside it — * where React Router would write `/posts/5`. So a destination is normalised * the same way a route name is, and then matches it exactly. * 3. **A destination is an object.** `navigate({ to: '/' })`, * `throw redirect({ to: '/login' })` — the path is under a `to` key, and a * `navigate({ search: … })` with no `to` stays on the page it is on. * * Not every route file is a page. A segment written `_auth` is a pathless * layout — it does not appear in the URL, and the file that declares it renders * an outlet rather than a screen; a `(group)` segment is likewise invisible; a * `dashboard.route.tsx` is the layout for the `/dashboard` subtree while * `dashboard.index.tsx` — whose literal carries a trailing slash — is the page * AT `/dashboard`. Drawing both would put one address on the map twice. * * Left unresolved rather than guessed: a computed `to`, a path no route serves, * and a code-based route whose parent is declared in another file (the chain is * composed within a file, which is where a route tree is written). */ import type { Language, Node } from '../../types'; import type { FrameworkExtractionResult, FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef, } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; import { dependsOn } from './package-deps'; import { matchBracket, readFields } from './object-literal'; import { addRouteTo, appRootFor, firstArgumentText, parseHrefExpression, readHrefViaLocal, readStringAt, routesForFile, toHref, type HrefLiteral, type RootedRouteTable, type RouteTable, } from './expo-router'; import { destinationsForHref } from './nextjs'; const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx']; // ============================================================================= // Paths // ============================================================================= /** * A TanStack path in the form every other framework's routes take. * * `$invoiceId` is `:invoiceId` and a bare `$` is a splat; a `_auth` segment is * a pathless layout and a `(group)` segment is a route group, neither of which * appears in the URL; a trailing `_` un-nests without changing the segment. * Returns null for a path that names no address at all. */ export function tanstackPath(raw: string): string | null { if (!raw.startsWith('/')) return null; const segs: string[] = []; for (const seg of raw.split('/')) { if (seg.length === 0) continue; if (seg.startsWith('_')) continue; // pathless layout if (seg.startsWith('(') && seg.endsWith(')')) continue; // route group const bare = seg.endsWith('_') ? seg.slice(0, -1) : seg; if (bare === '$') { segs.push(':splat*'); continue; } segs.push(bare.startsWith('$') ? ':' + bare.slice(1) : bare); } return '/' + segs.join('/'); } /** * True when the literal names a pathless layout rather than a page. * * `'/_auth'` is the layout file itself — it renders an outlet, at no address * of its own. `'/_auth/'` is the INDEX route inside that layout, and its * address is whatever the layout sits at: `_layout/index.tsx` is a project's * home page, and reading it as a layout dropped `/` from the map entirely. */ function isPathlessLayout(raw: string): boolean { if (raw.length > 1 && raw.endsWith('/')) return false; // an index route, not the layout const segs = raw.split('/').filter((s) => s.length > 0); const last = segs[segs.length - 1]; return last !== undefined && last.startsWith('_'); } /** * True for a file that wraps a subtree rather than rendering a page at its own * address. * * `` is where children render, so a route file that has one is the * layout AROUND an address and the index route beside it is the page AT it — * `_auth.invoices.tsx` and `_auth.invoices.index.tsx` both say `/invoices`, * and drawing both puts one address on the map twice. The name `route.tsx` * declares the same thing by convention, whether or not it draws an outlet. * * This is per-file on purpose: the alternative — a path that is a prefix of * another route's — is only knowable once every file has been read, and by * then the extra screen is already in the index. */ function isLayoutFile(filePath: string, content: string): boolean { const base = filePath.slice(filePath.lastIndexOf('/') + 1); if (/(?:^|\.)route\.(?:tsx|ts|jsx|js)$/.test(base)) return true; return / parent` is followed to compose the full * address — within the file, which is where a route tree is written. A route * that is another route's parent is the layout for that subtree, and the * address belongs to the index route under it. */ export function parseTanstackRoutes(content: string): TanstackRouteEntry[] { if (!ROUTE_FACTORY.test(content)) return []; const safe = stripCommentsForRegex(content, 'typescript'); const out: TanstackRouteEntry[] = []; const lineOf = (index: number): number => safe.slice(0, index).split('\n').length; // ---- file-based: the path is the first argument, the options follow ---- const fileRoutes = /\bcreate(?:Lazy)?FileRoute\s*\(/g; let f: RegExpExecArray | null; while ((f = fileRoutes.exec(safe)) !== null) { const open = f.index + f[0].length - 1; const close = matchBracket(safe, open); if (close < 0) continue; const raw = readStringAt(safe.slice(open + 1, close).trimStart(), 0); if (raw === null) continue; const path = tanstackPath(raw); if (path === null || isPathlessLayout(raw)) continue; out.push({ path, component: componentIn(chainAfter(safe, close + 1)), // `createFileRoute('/dashboard/')` is the index page AT `/dashboard`; // `createFileRoute('/dashboard')` is the layout around it. index: raw.length > 1 && raw.endsWith('/'), fileBased: true, line: lineOf(f.index), }); } // ---- code-based: a fragment per route, composed through its parent ---- const decls = new Map(); const named = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]*?)?=\s*create(Root)?Route\s*\(\s*\{/g; let d: RegExpExecArray | null; while ((d = named.exec(safe)) !== null) { const brace = safe.indexOf('{', d.index + d[0].length - 1); const end = matchBracket(safe, brace); if (end < 0) continue; const fields = readFields(safe, brace, end); const pathField = fields.get('path'); const path = d[2] ? '/' : pathField ? readStringAt(pathField.text.trimStart(), 0) : null; const parentField = fields.get('getParentRoute'); const parent = parentField ? (/=>\s*([A-Za-z_$][\w$]*)/.exec(parentField.text)?.[1] ?? null) : null; const componentField = fields.get('component'); decls.set(d[1]!, { path, parent, component: componentField ? componentIn(componentField.text) : null, root: d[2] !== undefined, index: d.index, }); } // A route with an index child is the LAYOUT around that address; the child // with `path: '/'` is what renders there. A parent with no index child still // is the page at its own address — its outlet is simply empty. const wrapsAnIndex = new Set( [...decls.values()].filter((r) => r.path === '/' && r.parent !== null).map((r) => r.parent!) ); for (const [name, decl] of decls) { if (decl.path === null) continue; // a pathless layout contributes no address // `createRootRoute` is the outermost layout — every page renders inside it, // and the index route beside it is what renders at `/`. A `__root.tsx` that // counted as a page put a second `/` on every file-based project's map. if (decl.root) continue; if (wrapsAnIndex.has(name)) continue; const full = composePath(name, decls); if (full === null) continue; const path = tanstackPath(full); if (path === null) continue; out.push({ path, component: decl.component, index: decl.path === '/', fileBased: false, line: lineOf(decl.index) }); } return out; } /** The address a code-based route sits at, following `getParentRoute` up. */ function composePath( name: string, decls: Map ): string | null { const segs: string[] = []; let cur: string | null = name; for (let hops = 0; cur !== null && hops < 24; hops++) { const decl: { path: string | null; parent: string | null } | undefined = decls.get(cur); if (!decl) return null; // a parent declared in another file — not composed if (decl.path !== null) { const own = decl.path.split('/').filter((s) => s.length > 0); segs.unshift(...own); } cur = decl.parent; } return '/' + segs.join('/'); } /** * The text of the call chain starting at `at` — `({ … })`, and any `.update({ … })` * or `.lazy(…)` after it, which is where a route's component may be written. */ function chainAfter(s: string, at: number): string { let i = at; const start = i; for (let steps = 0; steps < 8; steps++) { while (i < s.length && /\s/.test(s[i]!)) i++; if (s[i] === '.') { i++; while (i < s.length && /[\w$]/.test(s[i]!)) i++; while (i < s.length && /\s/.test(s[i]!)) i++; } if (s[i] !== '(') break; const close = matchBracket(s, i); if (close < 0) break; i = close + 1; } return s.slice(start, i); } /** The component a route names: an identifier, or the file a lazy import names. */ function componentIn(text: string): string | null { const lazy = /\bimport\s*\(\s*['"`]([^'"`]+)['"`]/.exec(text); if (lazy) return (lazy[1]!.split('/').pop() ?? '').replace(/\.\w+$/, '') || null; return /(?:^|[^\w$])component\s*:\s*([A-Z][A-Za-z0-9_]*)/.exec(text)?.[1] ?? /^\s*([A-Z][A-Za-z0-9_]*)\s*$/.exec(text)?.[1] ?? null; } /** The id a TanStack route carries — a verbatim reconstruction, so the table can recognise its own. */ function routeId(filePath: string, line: number, path: string): string { return `route:${filePath}:${line}:${path}:tanstack`; } // ============================================================================= // Route table // ============================================================================= export type TanstackTable = RootedRouteTable; /** True for a route node this resolver emitted, and no other. */ function isTanstackRoute(node: Node): boolean { return node.id === routeId(node.filePath, node.startLine, node.name); } const tables = new WeakMap(); export function tanstackTable(context: ResolutionContext): TanstackTable { const all = context.getNodesByKind('route'); const cached = tables.get(context); if (cached && cached.source === all) return cached; const byRoot = new Map(); for (const node of all) { if (!isTanstackRoute(node)) continue; const root = appRootFor(node.filePath); let t = byRoot.get(root); if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [] })); addRouteTo(t, node.name, node); } const table: TanstackTable = { source: all, byRoot }; tables.set(context, table); return table; } // ============================================================================= // Navigation calls // ============================================================================= /** `navigate({ to })` from `useNavigate`, `router.navigate({ to })`, and a thrown `redirect({ to })`. */ const NAV_CALL = /^(?:navigate|redirect)$|^(?:router|Route)\.navigate$/; /** The verb a navigation call name stands for, or null. */ export function tanstackNavVerb(name: string): string | null { if (!NAV_CALL.test(name)) return null; const dot = name.lastIndexOf('.'); return dot < 0 ? name : name.slice(dot + 1); } /** * The destination in a TanStack navigation: `{ to: '/posts/$postId' }`. * * `to` is the route pattern, so it is normalised exactly as a route name is * and then names that route. A `navigate({ search: … })` with no `to` is a * change of search parameters on the page the user is already on. */ export function tanstackDestination(expr: string): HrefLiteral | null { const args = expr.trim(); const literal = args[0] === '{' ? toKeyOf(args) : readStringAt(args, 0); if (literal === null) return null; const path = tanstackPath(literal); return path === null ? parseHrefExpression(args) : toHref(path); } /** The `to:` value of an object destination, or null when it has none or it is computed. */ function toKeyOf(args: string): string | null { const end = matchBracket(args, 0); if (end < 0) return null; const field = readFields(args, 0, end).get('to'); return field ? readStringAt(field.text.trimStart(), 0) : null; } // ============================================================================= // The resolver // ============================================================================= export const tanstackRouterResolver: FrameworkResolver = { name: 'tanstack-router', languages: [...ROUTE_LANGUAGES], detect(context: ResolutionContext): boolean { return dependsOn( context, '@tanstack/react-router', '@tanstack/solid-router', '@tanstack/router', '@tanstack/react-start', '@tanstack/start' ); }, claimsReference(name: string): boolean { return NAV_CALL.test(name); }, navigation: { tails: ['navigate', 'redirect'], // A call matches against the table of the app its file is in. scope: (route) => (isTanstackRoute(route) ? [appRootFor(route.filePath)] : null), }, extract(filePath: string, content: string): FrameworkExtractionResult { // A file-based route file describes ONE route, so the file's own shape says // whether that route is a page. A file holding a code-based route TREE // describes many, and its root component draws the outlet they render into // — judging that file by the same rule would drop every route in it. const layout = isLayoutFile(filePath, content); const entries = parseTanstackRoutes(content).filter((e) => !(e.fileBased && layout)); if (entries.length === 0) return { nodes: [], references: [] }; const language = languageForFile(filePath); const now = Date.now(); const nodes: Node[] = []; const references: UnresolvedRef[] = []; // An index route is the page AT its address; a layout at the same address // wraps it. One address, one screen — the index wins it. const byPath = new Map(); for (const entry of entries) { const held = byPath.get(entry.path); if (!held || (entry.index && !held.index)) byPath.set(entry.path, entry); } for (const entry of byPath.values()) { const node: Node = { id: routeId(filePath, entry.line, entry.path), kind: 'route', name: entry.path, qualifiedName: `${filePath}::route:${entry.path}`, filePath, startLine: entry.line, endLine: entry.line, startColumn: 0, endColumn: 0, language, updatedAt: now, }; nodes.push(node); if (entry.component) { // `calls`, as every component-backed screen binds: a `references` // candidate list is filtered to the ref's own language family. references.push({ fromNodeId: node.id, referenceName: entry.component, referenceKind: 'calls', line: entry.line, column: 0, filePath, language, candidates: [entry.component], }); } } return { nodes, references }; }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { if (ref.referenceKind !== 'calls') return null; const verb = tanstackNavVerb(ref.referenceName); if (!verb) return null; if (!ROUTE_LANGUAGES.includes(ref.language)) return null; const routes = routesForFile(tanstackTable(context), ref.filePath); if (!routes || routes.exact.size === 0) return null; const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null; if (!lines) return null; const arg = firstArgumentText(lines, ref.line, ref.column, verb); if (arg === null) return null; let href = tanstackDestination(arg); if (!href) { const enclosing = context.getNodeById?.(ref.fromNodeId); const start = enclosing && enclosing.filePath === ref.filePath ? enclosing.startLine : Math.max(1, ref.line - 40); href = readHrefViaLocal(lines, ref.line, ref.column, verb, start); } if (!href) return null; // Every arm of a conditional destination is somewhere this call goes; the // first is this reference's resolution and the rest ride as `alsoTargets`. const targets = destinationsForHref(href, routes); const target = targets[0]; if (!target) return null; return { original: ref, targetNodeId: target.node.id, ...(targets.length > 1 ? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: verb } })) } : {}), confidence: 0.95, resolvedBy: 'framework', edgeKind: 'navigates', metadata: { href: target.href.display, navMethod: verb }, }; }, };