diff --git a/CHANGELOG.md b/CHANGELOG.md index 740a0a6dd..039731ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- TanStack Start server routes now link to their HTTP handlers while keeping API-only files out of the page map, including routes introduced after initial indexing. + - React Router framework-mode pages now appear with their page components and navigation, including nested routes and pathless layouts. - Endpoint discovery now recognizes literal routes in Hono, Elysia, Fastify, Hyper-Express, Koa router, H3, Bun, Effect v4 and option-free Vixeny builders, and correctly reads Nuxt 4 page groups and server route methods after re-indexing. diff --git a/__tests__/tanstack-start.test.ts b/__tests__/tanstack-start.test.ts new file mode 100644 index 000000000..631a4a273 --- /dev/null +++ b/__tests__/tanstack-start.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tanstackRouterResolver } from '../src/resolution/frameworks/tanstack-router'; +import { routeRoots } from '../src/ui-server/api/route-roots'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); +}); +const wrap = ( + options: string, + factory = 'createFileRoute', +) => `import {createFileRoute${factory === 'createFileRoute' ? '' : ' as ' + factory}} from '@tanstack/react-router'; +export const Route = ${factory}('/api/$id')(${options});`; +const extract = (source: string) => + tanstackRouterResolver.extract!('src/routes/api.$id.tsx', source); + +describe('TanStack Start server declarations', () => { + it('emits method-qualified routes without a phantom page', () => { + const result = extract(wrap('{server:{handlers:{GET:getItem,POST:saveItem,ANY:fallback}}}')); + expect(result.nodes.map((n) => n.name)).toEqual([ + 'GET /api/:id', + 'POST /api/:id', + 'ANY /api/:id', + ]); + expect(result.references.map((r) => [r.referenceName, r.referenceKind])).toEqual([ + ['getItem', 'references'], + ['saveItem', 'references'], + ['fallback', 'references'], + ]); + }); + it('retains a mixed page/API route and imported factory alias', () => { + const result = extract(wrap('{component:Page,server:{handlers:{GET:load}}}', 'fileRoute')); + expect(result.nodes.map((n) => n.name)).toEqual(['GET /api/:id', '/api/:id']); + expect(result.references.map((r) => r.referenceName)).toEqual(['load', 'Page']); + }); + it('reads the official createHandlers form and excludes middleware', () => { + // TanStack/router@a58e01c604e2d189ef8c8c1ad6ac8747e03aa88c, + // docs/start/framework/react/guide/server-routes.md:172–186. + const result = extract( + wrap(`{server:{handlers:({createHandlers})=>createHandlers({ +GET:{middleware:[loggerMiddleware],handler:({request})=>respond(request)},POST:save})}}`), + ); + expect(result.nodes.map((n) => n.name)).toEqual(['GET /api/:id', 'POST /api/:id']); + expect(result.references.map((r) => [r.referenceName, r.referenceKind])).toEqual([ + ['respond', 'calls'], + ['save', 'references'], + ]); + }); + it('supports destructured helper aliases, block returns, and method shorthand', () => { + const result = extract( + wrap( + `{server:{handlers:({createHandlers: make})=>{return make({GET(){return respond()},POST:{handler:save}})}}}`, + ), + ); + expect(result.nodes.map((n) => n.name)).toEqual(['GET /api/:id', 'POST /api/:id']); + expect(result.references.map((r) => r.referenceName)).toEqual(['respond', 'save']); + }); + it.each([ + '{server:{handlers:dynamic}}', + '{server}', + '{server:{handlers:{GET:load,...extra}}}', + '{server:{handlers:{[verb]:load}}}', + '{server:{handlers:{GET:{...options,handler:load}}}}', + '{server:{handlers:({createHandlers})=>{const createHandlers=other;return createHandlers({GET:load})}}}', + '{server:{handlers:()=>createHandlers({GET:load})}}', + '{server:{handlers:{GET:object.handler}}}', + '{server:{handlers:{GET:load}},...options}', + ])('does not fabricate an endpoint or page from unsupported options %s', (options) => { + expect(extract(wrap(options)).nodes).toEqual([]); + }); + it('ignores unregistered, shadowed, type-only and unrelated factories', () => { + for (const source of [ + `import {createFileRoute} from 'other';export const Route=createFileRoute('/x')({server:{handlers:{GET:load}}});`, + `import {type createFileRoute} from '@tanstack/react-router';export const Route=createFileRoute('/x')({server:{handlers:{GET:load}}});`, + `import type {createFileRoute} from '@tanstack/react-router';export const Route=createFileRoute('/x')({server:{handlers:{GET:load}}});`, + `import {createFileRoute} from '@tanstack/react-router';function fn(createFileRoute){const Route=createFileRoute('/x')({server:{handlers:{GET:load}}});}`, + `import {createFileRoute} from '@tanstack/react-router';const unused=createFileRoute('/x')({server:{handlers:{GET:load}}});`, + ]) + expect(extract(source).nodes).toEqual([]); + }); + it.each(['undefined', 'null', 'false'])( + 'does not turn component: %s into a page', + (component) => { + expect( + extract(wrap(`{component:${component},server:{handlers:{GET:load}}}`)).nodes.map( + (n) => n.name, + ), + ).toEqual(['GET /api/:id']); + }, + ); + it('does not turn RPC functions into public endpoints or callback locals into call targets', () => { + expect( + extract( + `import {createServerFn} from '@tanstack/react-start';export const rpc=createServerFn({method:'POST'}).handler(load);`, + ).nodes, + ).toEqual([]); + expect( + extract( + wrap('{server:{handlers:{GET:(load)=>load(),POST:()=>{const save=other;return save()}}}}'), + ).references, + ).toEqual([]); + }); + it('normalizes pathless/group routes but still finds their server methods', () => { + expect( + extract( + wrap('{server:{handlers:{GET:load}}}').replace('/api/$id', '/(_group)/_auth/items_/$id'), + ).nodes.map((n) => n.name), + ).toEqual(['GET /items/:id']); + }); +}); + +describe('Start routes through indexing and sync', () => { + let cg: CodeGraph | undefined; + let dir: string; + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + const write = (file: string, source: string) => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), source); + }; + it('binds imported handlers and inline calls, preserves page roots, and tracks edits/deletion', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-start-')); + write( + 'package.json', + JSON.stringify({ + dependencies: { '@tanstack/react-start': '*', '@tanstack/react-router': '*' }, + }), + ); + write( + 'src/handlers.ts', + 'export function load(){return 1;} export function respond(){return 2;}', + ); + write( + 'src/routes/api.$id.tsx', + `import {load,respond} from '../handlers'; +${wrap('{component:Page,server:{middleware:[ignored],handlers:({createHandlers})=>createHandlers({GET:load,POST:{handler:()=>respond()}})}}')} +function Page(){return
;}`, + ); + write( + 'src/nav.tsx', + `import {redirect} from '@tanstack/react-router';export function go(){return redirect({to:'/api/$id'});}`, + ); + cg = await CodeGraph.init(dir, { index: true }); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name).sort()).toEqual(['/api/:id', 'GET /api/:id', 'POST /api/:id']); + const roots = routeRoots(cg, routes); + expect(roots.get(routes.find((n) => n.name === 'GET /api/:id')!.id)?.node.name).toBe('load'); + expect(roots.get(routes.find((n) => n.name === '/api/:id')!.id)?.node.name).toBe('Page'); + const go = cg.getNodesByKind('function').find((n) => n.name === 'go')!; + expect(cg.getOutgoingEdges(go.id)).toContainEqual( + expect.objectContaining({ + kind: 'navigates', + target: routes.find((n) => n.name === '/api/:id')!.id, + }), + ); + const post = routes.find((n) => n.name === 'POST /api/:id')!; + const respond = cg.getNodesByKind('function').find((n) => n.name === 'respond')!; + expect(cg.getOutgoingEdges(post.id)).toContainEqual( + expect.objectContaining({ kind: 'calls', target: respond.id }), + ); + write( + 'src/routes/api.$id.tsx', + `import {load} from '../handlers';\n${wrap('{server:{handlers:{DELETE:load}}}')}`, + ); + await cg.sync(); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['DELETE /api/:id']); + fs.unlinkSync(path.join(dir, 'src/routes/api.$id.tsx')); + await cg.sync(); + expect(cg.getNodesByKind('route')).toEqual([]); + }); + it.each([false, true])('discovers Start after initial indexing (scoped=%s)', async (scoped) => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-start-new-')); + write('package.json', '{}'); + write('src/handler.ts', 'export function load(){return 1;}'); + cg = await CodeGraph.init(dir, { index: true }); + write( + 'package.json', + JSON.stringify({ + dependencies: { '@tanstack/react-start': '*', '@tanstack/react-router': '*' }, + }), + ); + write( + 'src/routes/api.$id.ts', + `import {load} from '../handler';\n${wrap('{server:{handlers:{GET:load}}}')}`, + ); + await cg.sync(scoped ? { paths: ['src/routes/api.$id.ts'] } : {}); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name)).toEqual(['GET /api/:id']); + expect(routeRoots(cg, routes).get(routes[0].id)?.node.name).toBe('load'); + }); +}); diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md index 84ff1c1a9..792a9071a 100644 --- a/docs/design/PLAN-application-router-coverage.md +++ b/docs/design/PLAN-application-router-coverage.md @@ -1,7 +1,7 @@ -Status: 1/13 — all steps approved; step 1 verified, preparing its PR +Status: 2/13 — step 2 verified, preparing its PR; step 1 published as PR #3 - [x] 1 React Router framework mode — seven official-fixture pages bind exact components; nested index/navigation, config/module sync and fresh compiled workers verified; build passes, 112 WASM focused/control tests pass, full native suite 4,353 pass / 46 skip; independent review clear. -- [ ] 2 TanStack Start server routes — method-qualified endpoints from literal `server.handlers` tables and documented `createHandlers` callback forms. Keep page and endpoint nodes when both exist; omit phantom pages for server-only files. Gate: shared proof, mixed page/API fixtures, middleware exclusion, and handler call edges. +- [x] 2 TanStack Start server routes — literal method tables and `createHandlers` bind handlers/calls; page/API coexistence and full/scoped sync verified; build passes, 62 WASM focused/control tests pass, full native suite 4,375 pass / 46 skip; independent review clear. - [ ] 3 Remix and React Router file conventions — support the pinned default file convention, including index, nested, parameter, pathless, and splat cases; require evidence that the convention is enabled. Gate: shared proof, explicit-config/file-route coexistence, and layout-only controls. - [ ] 4 Astro route completion — connect existing page routes to components and API method exports, then navigation where the source declares it. Gate: shared proof, page/API distinction, underscore exclusions, and cross-file handler resolution. - [ ] 5 RedwoodSDK — imported `rwsdk/router` declarations, method tables, and statically resolvable registration/prefix context. Gate: shared proof, `defineApp`/`render` composition, and interrupters excluded from page roots. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index d1ac8fbed..8a3d318fc 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -39,12 +39,14 @@ guessed. | Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — | | Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | | React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts`, `react-router-framework.test.ts` | proshop; pinned official framework config (7 pages) | -| TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend | +| TanStack Router / Start | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts`, `tanstack-start.test.ts` | TanStack examples, fastapi-template frontend; pinned Start server-handler syntax | | Vue Router / Nuxt | `frameworks/vue-router.ts` | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges) | | SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts` | sveltekit-realworld (31 edges) | React Router framework mode reads default exported literal arrays in `app/routes.ts` or `app/routes.js`, using imported `route`, `index`, `layout`, and spread `prefix` helpers. Module paths bind named default components; nested index pages take precedence over their parent. [Official source fixture](https://github.com/remix-run/react-router/blob/7aea711dd1ae2bc5a076d13ff17291829690fa74/docs/start/framework/routing.md#L28): seven expected pages, verified through indexing and navigation. Tests also cover module/config sync and a fresh compiled process using parse/resolver workers. Custom app directories, computed arrays, `relative`, anonymous defaults, and re-exports remain unsupported. +TanStack Start reads imported `createFileRoute` calls assigned to exported `const Route`: literal `server.handlers` tables, including `ANY`, and the destructured `createHandlers` callback form. Named handlers and direct inline calls bind through the existing HTTP reader. Page/API combinations retain both nodes; server-only routes do not become pages. [Pinned official handler syntax](https://github.com/TanStack/router/blob/a58e01c604e2d189ef8c8c1ad6ac8747e03aa88c/docs/start/framework/react/guide/server-routes.md#L172), [executable middleware fixture](https://github.com/TanStack/router/blob/a58e01c604e2d189ef8c8c1ad6ac8747e03aa88c/e2e/react-start/server-routes/src/routes/api/middleware-context.ts). Computed/spread tables, member handlers, custom factories and server `update` chains remain unresolved. `createServerFn` has no declared public route and gets no fabricated endpoint. + Shared machinery all six use, in `frameworks/expo-router.ts`: `RouteTable` / `RootedRouteTable`, `routesForFile`, `addRouteTo`, `matchRoute`, `appRootFor`, `parseHrefExpression`, `readHrefViaLocal`, `nthArgumentText`, `readStringAt`, diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md index 475b90340..5d11e6c29 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -32,6 +32,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Vapor** | `app.get("x", use: handler)` | | **React Router** | JSX/data-router pages; literal framework-mode `app/routes.ts` arrays with `route`, `index`, `layout`, and `prefix`, linked to named default components | | **SvelteKit** | Route component nodes | +| **TanStack Router / Start** | Page routes plus literal `server.handlers` method tables and `createHandlers` callbacks on exported file routes; middleware is excluded from handler links | | **Next.js** | App Router and Pages Router pages; `app/api/**/route.ts` method exports and `pages/api/**` default handlers | | **Vue Router** / **Nuxt** | Vue route tables; `.vue` pages in `pages/` or Nuxt 4 `app/pages/`, dynamic/optional/catch-all segments and route groups; `server/api/` and `server/routes/` with method suffixes; route middleware | | **Astro** | `src/pages/` file-based routes (`.astro` pages + `.ts` endpoints, `[param]`/`[...rest]` syntax) | @@ -42,4 +43,6 @@ React Router framework mode assumes the default `app/` directory. Computed array The JavaScript HTTP readers require a recognized package import (ES modules or CommonJS), except for the global `Bun.serve`. They follow immutable local router bindings and literal declarations, without executing your application. Named handlers produce references; direct calls inside inline handlers produce call edges. Static responses have an endpoint without an invented handler. Member handlers remain unresolved by this reader. +TanStack Start server routes support imported `createFileRoute` calls assigned to exported `const Route`. Computed/spread tables, custom factories, member handlers, and `update` chains remain unsupported. RPC functions created with `createServerFn` are not presented as public route URLs. + Computed paths, spread configuration, cross-file mounts, plugin factories, mutable router aliases, and runtime method replacement are outside this static reading. Imports and captured router bindings must precede their use in source. Vixeny builders with options are omitted because their effective paths depend on the terminal operation. Nuxt custom route configuration, page metadata overrides, non-Vue page extensions, and custom server handler wrappers are not interpreted. Re-index after upgrading to add the new endpoints to an existing graph. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 93be48352..5c026c740 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -2873,6 +2873,11 @@ export class ExtractionOrchestrator { // Load only grammars needed for changed files if (filesToIndex.length > 0) { + const previous = this.detectedFrameworkNames ?? []; + this.detectedFrameworkNames = null; + const detected = this.ensureDetectedFrameworks(currentFiles); + // A watcher scope sees only changed files; retain other packages' frameworks. + if (scopedPaths?.length) this.detectedFrameworkNames = [...new Set([...previous, ...detected])]; const overrides = loadExtensionOverrides(this.rootDir); const neededLanguages = [...new Set(filesToIndex.map((f) => detectLanguage(f, undefined, overrides)))]; // .h files default to 'c' but may be C++ — ensure cpp grammar is loaded diff --git a/src/index.ts b/src/index.ts index edd60fcb4..617d6bfba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -833,6 +833,7 @@ export class CodeGraph { // to controllers in unchanged files. The pass is idempotent and cheap // (regex over *.module.ts only). if (result.filesAdded > 0 || result.filesModified > 0) { + this.resolver.initialize(); if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:react-router:'))) await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); this.resolver.runPostExtract(); diff --git a/src/resolution/frameworks/tanstack-router.ts b/src/resolution/frameworks/tanstack-router.ts index 844eefeff..e5d953cf7 100644 --- a/src/resolution/frameworks/tanstack-router.ts +++ b/src/resolution/frameworks/tanstack-router.ts @@ -38,6 +38,9 @@ */ import type { Language, Node } from '../../types'; +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getParser } from '../../extraction/grammars'; +import { httpHandlerReferences } from './http-routing'; import type { FrameworkExtractionResult, FrameworkResolver, @@ -65,6 +68,185 @@ import { destinationsForHref } from './nextjs'; const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx']; +/** Start's exported file route can contain an HTTP table, a page, or both. */ +export function extractTanstackServerRoutes(filePath: string, content: string) { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const pages: TanstackRouteEntry[] = []; + const lines = new Set(); + const result = { nodes, references, pages, lines }; + if (!content.includes('server') || !/@tanstack\/(?:react|solid)-router/.test(content)) + return result; + const language = languageForFile(filePath); + const parser = getParser(language); + if (!parser) throw new Error(`TanStack server extraction requires the ${language} grammar`); + const tree = parser.parse(content); + if (!tree) return result; + const field = (n: SyntaxNode, name: string) => n.childForFieldName(name); + const unwrap = (n: SyntaxNode | null): SyntaxNode | null => { + while ( + n && + ['as_expression', 'satisfies_expression', 'parenthesized_expression'].includes(n.type) + ) + n = n.namedChildren[0] ?? null; + return n; + }; + const literal = (n: SyntaxNode | null) => + n?.type === 'string' && !n.text.includes('\\') ? n.text.slice(1, -1) : null; + const key = (n: SyntaxNode | null) => + n && ['property_identifier', 'identifier', 'shorthand_property_identifier'].includes(n.type) + ? n.text + : literal(n); + const properties = (n: SyntaxNode | null): Map | null => { + n = unwrap(n); + if (n?.type !== 'object') return null; + const out = new Map(); + for (const child of n.namedChildren) { + if (child.type === 'comment') continue; + const name = key(field(child, 'key') ?? field(child, 'name') ?? child); + if (!name || child.type === 'spread_element') return null; + out.set(name, child.type === 'pair' ? field(child, 'value')! : child); + } + return out; + }; + const argumentsOf = (n: SyntaxNode) => + field(n, 'arguments')?.namedChildren.filter((c) => c.type !== 'comment') ?? []; + try { + const factories = new Set(); + for (const statement of tree.rootNode.namedChildren) { + if ( + statement.type !== 'import_statement' || + /^import\s+type\b/.test(statement.text) || + !['@tanstack/react-router', '@tanstack/solid-router'].includes( + literal(field(statement, 'source')) ?? '', + ) + ) + continue; + for (const spec of statement.descendantsOfType('import_specifier')) { + if (field(spec, 'name')?.text === 'createFileRoute' && !spec.text.startsWith('type ')) + factories.add(field(spec, 'alias')?.text ?? 'createFileRoute'); + } + } + for (const statement of tree.rootNode.namedChildren) { + if (statement.type !== 'export_statement') continue; + const declaration = field(statement, 'declaration'); + if (declaration?.type !== 'lexical_declaration' || !declaration.text.startsWith('const ')) + continue; + for (const variable of declaration.namedChildren) { + if (field(variable, 'name')?.text !== 'Route') continue; + const call = unwrap(field(variable, 'value')); + if (call?.type !== 'call_expression') continue; + const factory = field(call, 'function'); + if ( + factory?.type !== 'call_expression' || + !factories.has(field(factory, 'function')?.text ?? '') + ) + continue; + const rawPath = literal(argumentsOf(factory)[0] ?? null); + const optionsNode = unwrap(argumentsOf(call)[0] ?? null); + if (!optionsNode?.namedChildren.some((n) => key(field(n, 'key') ?? n) === 'server')) + continue; + const line = call.startPosition.row + 1; + lines.add(line); + const options = properties(optionsNode); + const routePath = rawPath === null ? null : tanstackPath(rawPath); + if (!options || routePath === null || rawPath === null) continue; + const component = options.get('component'); + if ( + component && + [ + 'identifier', + 'arrow_function', + 'function_expression', + 'call_expression', + 'member_expression', + ].includes(component.type) && + component.text !== 'undefined' && + !isPathlessLayout(rawPath) && + !isLayoutFile(filePath, content) + ) { + pages.push({ + path: routePath, + component: component.type === 'identifier' ? component.text : null, + index: rawPath.length > 1 && rawPath.endsWith('/'), + fileBased: true, + line, + }); + } + let tableNode = unwrap(properties(options.get('server') ?? null)?.get('handlers') ?? null); + if (tableNode && ['arrow_function', 'function_expression'].includes(tableNode.type)) { + const params = field(tableNode, 'parameters'); + const pattern = params?.descendantsOfType('object_pattern')[0]; + let helper: string | undefined; + for (const binding of pattern?.namedChildren ?? []) { + if ( + binding.type === 'shorthand_property_identifier_pattern' && + binding.text === 'createHandlers' + ) + helper = binding.text; + if ( + binding.type === 'pair_pattern' && + key(field(binding, 'key')) === 'createHandlers' && + field(binding, 'value')?.type === 'identifier' + ) + helper = field(binding, 'value')!.text; + } + let body = unwrap(field(tableNode, 'body')); + if (body?.type === 'statement_block') { + const statements = body.namedChildren.filter((n) => n.type !== 'comment'); + body = + statements.length === 1 && statements[0]!.type === 'return_statement' + ? unwrap(statements[0]!.namedChildren[0] ?? null) + : null; + } + tableNode = + body?.type === 'call_expression' && helper && field(body, 'function')?.text === helper + ? unwrap(argumentsOf(body)[0] ?? null) + : null; + } + const table = properties(tableNode); + if (!table) continue; + for (const [method, value] of table) { + if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ANY'].includes(method)) + continue; + const handler = + value.type === 'object' ? (properties(value)?.get('handler') ?? null) : value; + if ( + !handler || + ![ + 'identifier', + 'shorthand_property_identifier', + 'arrow_function', + 'function_expression', + 'method_definition', + ].includes(handler.type) + ) + continue; + const name = `${method} ${routePath}`; + const node: Node = { + id: `route:${filePath}:${value.startIndex}:${name}:tanstack-server`, + kind: 'route', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language, + startLine: value.startPosition.row + 1, + endLine: value.endPosition.row + 1, + startColumn: value.startPosition.column, + endColumn: value.endPosition.column, + updatedAt: Date.now(), + }; + nodes.push(node); + references.push(...httpHandlerReferences(node, handler)); + } + } + } + return result; + } finally { + tree.delete(); + } +} + // ============================================================================= // Paths // ============================================================================= @@ -181,9 +363,18 @@ export function parseTanstackRoutes(content: string): TanstackRouteEntry[] { if (raw === null) continue; const path = tanstackPath(raw); if (path === null || isPathlessLayout(raw)) continue; + const chain = chainAfter(safe, close + 1); + const component = componentIn(chain); + const brace = chain.indexOf('{'); + if ( + !component && + brace >= 0 && + readFields(chain, brace, matchBracket(chain, brace)).has('server') + ) + continue; out.push({ path, - component: componentIn(chainAfter(safe, close + 1)), + component, // `createFileRoute('/dashboard/')` is the index page AT `/dashboard`; // `createFileRoute('/dashboard')` is the layout around it. index: raw.length > 1 && raw.endsWith('/'), @@ -193,8 +384,18 @@ export function parseTanstackRoutes(content: string): TanstackRouteEntry[] { } // ---- 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; + const decls = new Map< + string, + { + path: string | null; + parent: string | null; + component: string | null; + root: boolean; + index: number; + } + >(); + 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); @@ -204,7 +405,9 @@ export function parseTanstackRoutes(content: string): TanstackRouteEntry[] { 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 parent = parentField + ? (/=>\s*([A-Za-z_$][\w$]*)/.exec(parentField.text)?.[1] ?? null) + : null; const componentField = fields.get('component'); decls.set(d[1]!, { path, @@ -218,7 +421,7 @@ export function parseTanstackRoutes(content: string): TanstackRouteEntry[] { // 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!) + [...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 @@ -231,7 +434,13 @@ export function parseTanstackRoutes(content: string): TanstackRouteEntry[] { 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) }); + out.push({ + path, + component: decl.component, + index: decl.path === '/', + fileBased: false, + line: lineOf(decl.index), + }); } return out; } @@ -239,7 +448,7 @@ export function parseTanstackRoutes(content: string): TanstackRouteEntry[] { /** The address a code-based route sits at, following `getParentRoute` up. */ function composePath( name: string, - decls: Map + decls: Map, ): string | null { const segs: string[] = []; let cur: string | null = name; @@ -281,7 +490,11 @@ function chainAfter(s: string, at: number): string { 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; + 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. */ @@ -371,7 +584,7 @@ export const tanstackRouterResolver: FrameworkResolver = { '@tanstack/solid-router', '@tanstack/router', '@tanstack/react-start', - '@tanstack/start' + '@tanstack/start', ); }, @@ -385,12 +598,17 @@ export const tanstackRouterResolver: FrameworkResolver = { // 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 server = extractTanstackServerRoutes(filePath, content); + const entries = [ + ...parseTanstackRoutes(content).filter( + (e) => !(e.fileBased && (layout || server.lines.has(e.line))), + ), + ...server.pages, + ]; const language = languageForFile(filePath); const now = Date.now(); - const nodes: Node[] = []; - const references: UnresolvedRef[] = []; + const nodes: Node[] = server.nodes; + const references: UnresolvedRef[] = server.references; // 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(); @@ -438,7 +656,10 @@ export const tanstackRouterResolver: FrameworkResolver = { 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; + 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); @@ -446,7 +667,10 @@ export const tanstackRouterResolver: FrameworkResolver = { 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); + 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; @@ -459,7 +683,12 @@ export const tanstackRouterResolver: FrameworkResolver = { 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 } })) } + ? { + alsoTargets: targets.slice(1).map((t) => ({ + targetNodeId: t.node.id, + metadata: { href: t.href.display, navMethod: verb }, + })), + } : {}), confidence: 0.95, resolvedBy: 'framework', diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 0c112b161..8011fb67f 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -307,8 +307,8 @@ export class ReferenceResolver { * Initialize the resolver (detect frameworks, etc.) */ initialize(): void { - this.frameworks = detectFrameworks(this.context); this.clearCaches(); + this.frameworks = detectFrameworks(this.context); } /**