From d277d0529573a38edf090f3dab048f8072dd0e76 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Mon, 7 Sep 2026 00:39:59 -0600 Subject: [PATCH] feat: discover registered RedwoodSDK routes and handlers --- CHANGELOG.md | 2 + __tests__/redwood-routes.test.ts | 205 +++++++++++ .../PLAN-application-router-coverage.md | 4 +- docs/design/framework-coverage.md | 3 + .../content/docs/guides/framework-routes.md | 3 + src/index.ts | 8 +- src/resolution/frameworks/index.ts | 2 + src/resolution/frameworks/redwood.ts | 334 ++++++++++++++++++ 8 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 __tests__/redwood-routes.test.ts create mode 100644 src/resolution/frameworks/redwood.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eade051e..2e6382ab5 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 +- RedwoodSDK registered routes now link to their page or API handlers, preserving prefixes and method tables while excluding middleware from page roots. + - Astro pages now link to their exact components and source-declared navigation; exported HTTP methods in `.ts`/`.js` endpoints link to their handlers, while type-only declarations and `.mjs` files are excluded. - Remix default file routes and registered React Router `flatRoutes()` pages now link to their components and navigation, including optional segments and configuration-only sync changes. diff --git a/__tests__/redwood-routes.test.ts b/__tests__/redwood-routes.test.ts new file mode 100644 index 000000000..a568cec13 --- /dev/null +++ b/__tests__/redwood-routes.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { CodeGraph } from '../src'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { extractRedwoodRoutes } from '../src/resolution/frameworks/redwood'; +import { routeRoots } from '../src/ui-server/api/route-roots'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); +}); +const imports = `import {route, index, render, layout, prefix} from 'rwsdk/router'; +import {defineApp} from 'rwsdk/worker';\n`; +const extract = (body: string) => extractRedwoodRoutes('src/worker.tsx', imports + body); + +describe('registered RedwoodSDK routes', () => { + it('composes aliases, constant arrays, prefixes, index and layouts', () => { + const result = extractRedwoodRoutes( + 'src/worker.tsx', + `import {route as r, render as page, prefix as under, index, layout} from 'rwsdk/router'; +import {defineApp as app} from 'rwsdk/worker'; +const children = [index(Home), r('users/:id', [auth, User])]; +const worker = app([page(Document, [under('/admin', layout(Layout, children))])]); +export default {fetch: worker.fetch};`, + ); + expect(result.nodes.map((n) => n.name)).toEqual(['ANY /admin', 'ANY /admin/users/:id']); + expect(result.references.map((r) => r.referenceName)).toEqual(['Home', 'User']); + }); + it('reads standard method tables without config or interrupter handlers', () => { + const result = extract( + `export default defineApp([route('/api', {get: [auth, list], post: create, head: head, config: {disableOptions: true}})]);`, + ); + expect(result.nodes.map((n) => n.name)).toEqual(['GET /api', 'POST /api', 'HEAD /api']); + expect(result.references.map((r) => r.referenceName)).toEqual(['list', 'create', 'head']); + }); + it('keeps inline JSX pages and response endpoints distinct', () => { + const result = extract( + `export default defineApp([render(Document, [route('/page', () => ), route('/health', () => new Response('ok')), route('/nested', () => {const unused = () => ; return new Response('ok')})])]);`, + ); + expect(result.nodes.map((n) => n.name)).toEqual(['/page', 'ANY /health', 'ANY /nested']); + expect(result.references.map((r) => r.referenceName)).toEqual(['Home']); + }); + it('reads shorthand handlers and ordinary method definitions', () => { + const result = extract( + `export default defineApp([route('/api', {get, post(){return save()}})]);`, + ); + expect(result.nodes.map((n) => n.name)).toEqual(['GET /api', 'POST /api']); + expect(result.references.map((r) => r.referenceName)).toEqual(['get', 'save']); + }); + it.each([ + `const unused = route('/orphan', Home); export default defineApp([]);`, + `function wrapper(route){return [route('/shadow', Home)]} export default defineApp(wrapper(other));`, + `export default defineApp([prefix(dynamic, [route('/child', Home)])]);`, + `export default defineApp([route(path, Home)]);`, + `export default defineApp([() => route('/middleware', Home)]);`, + `export default defineApp([route('/api', {...methods, get: list})]);`, + `export default defineApp([route('/api', {[method]: list})]);`, + `export default defineApp([route('/api', {get: list, get: other})]);`, + `export default defineApp([route('/api', {get get(){return list}})]);`, + `export default defineApp([route('/api', {get: list, [method](){return other()}})]);`, + `export default defineApp([route('/api', dynamic())]);`, + `export default defineApp([route('/api', [...handlers])]);`, + `const example = "route('/fake', Home)"; export default defineApp([]);`, + `const app = defineApp([route('/fake', Home)]); export default {fetch: app.fetch, fetch: other};`, + `const app = defineApp([route('/fake', Home)]); export default {fetch: app.fetch, ...other};`, + ])('ignores unsupported/unregistered declarations: %s', (body) => + expect(extract(body).nodes).toEqual([]), + ); + it('requires runtime framework helper imports', () => { + for (const source of [ + `import {route} from 'other'; import {defineApp} from 'rwsdk/worker'; export default defineApp([route('/x', Home)]);`, + `import type {route} from 'rwsdk/router'; import {defineApp} from 'rwsdk/worker'; export default defineApp([route('/x', Home)]);`, + `import {route} from 'rwsdk/router'; import type {defineApp} from 'rwsdk/worker'; export default defineApp([route('/x', Home)]);`, + ]) + expect(extractRedwoodRoutes('src/worker.tsx', source).nodes).toEqual([]); + }); +}); + +describe('RedwoodSDK through indexing', () => { + let cg: CodeGraph | undefined; + let dir: string; + const write = (file: string, source: string) => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), source); + }; + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + // redwoodjs/sdk@39da7118f712bd86450e493cb2c213815b1893bb (1.7.3), + // playground/typed-routes/src/worker.tsx: expected route/handler pairs. + it('binds the pinned worker pages, API handlers and incremental changes', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-redwood-')); + write('package.json', JSON.stringify({ dependencies: { rwsdk: '1.7.3' } })); + for (const page of ['Home', 'UserProfile', 'FileViewer', 'BlogPost']) + write(`src/pages/${page}.tsx`, `export function ${page}(){return
}`); + write('src/other/Home.tsx', 'export function Home(){return
}'); + write( + 'src/api.ts', + `export function list(){return new Response('list')} +export function create(){return new Response('created')} +export function Misleading(){return new Response('not a page')}`, + ); + const worker = + imports + + `import {Home} from './pages/Home'; import {UserProfile} from './pages/UserProfile'; +import {FileViewer} from './pages/FileViewer'; import {BlogPost} from './pages/BlogPost'; +import {list,create,Misleading} from './api'; +function auth(){return undefined} +export const app = defineApp([render(Document, [route('/old', () => new Response(null,{status:301})), route('/',Home), route('/users/:id',[auth,UserProfile]), route('/files/*',FileViewer), route('/blog/:year/:slug',BlogPost), route('/misleading',Misleading)]), route('/api',{get:list,post:create})]); +export default {fetch: app.fetch};`; + write('src/worker.tsx', worker); + cg = await CodeGraph.init(dir, { index: true }); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name).sort()).toEqual( + [ + 'ANY /old', + '/', + '/users/:id', + '/files/*', + '/blog/:year/:slug', + 'ANY /misleading', + 'GET /api', + 'POST /api', + ].sort(), + ); + const roots = routeRoots(cg, routes); + for (const [url, handler, file] of [ + ['/', 'Home', 'src/pages/Home.tsx'], + ['/users/:id', 'UserProfile', 'src/pages/UserProfile.tsx'], + ['/files/*', 'FileViewer', 'src/pages/FileViewer.tsx'], + ['/blog/:year/:slug', 'BlogPost', 'src/pages/BlogPost.tsx'], + ['GET /api', 'list', 'src/api.ts'], + ['POST /api', 'create', 'src/api.ts'], + ]) { + const root = roots.get(routes.find((n) => n.name === url)!.id)!.node; + expect([root.name, root.filePath]).toEqual([handler, file]); + } + const userRoute = routes.find((n) => n.name === '/users/:id')!; + expect(cg.getOutgoingEdges(userRoute.id).map((e) => cg!.getNode(e.target)?.name)).not.toContain( + 'auth', + ); + write( + 'src/pages/Home.tsx', + `export function Home(){const unused = () =>
; return new Response('now an endpoint')}`, + ); + await cg.sync({ paths: ['src/pages/Home.tsx'] }); + expect(cg.getNodesByKind('route').some((n) => n.name === 'ANY /')).toBe(true); + fs.unlinkSync(path.join(dir, 'src/pages/UserProfile.tsx')); + await cg.sync(); + expect(cg.getNodesByKind('route').some((n) => n.name === 'ANY /users/:id')).toBe(true); + write( + 'src/worker.tsx', + imports + `export default defineApp([route('/new', () => new Response('ok'))]);`, + ); + await cg.sync(); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['ANY /new']); + fs.unlinkSync(path.join(dir, 'src/worker.tsx')); + await cg.sync(); + expect(cg.getNodesByKind('route')).toEqual([]); + }); + it.each([false, true])('discovers a newly added SDK registration (scoped=%s)', async (scoped) => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-redwood-new-')); + write('package.json', '{}'); + write('src/plain.ts', 'export function plain(){}'); + cg = await CodeGraph.init(dir, { index: true }); + write('package.json', JSON.stringify({ dependencies: { rwsdk: '1.7.3' } })); + write( + 'src/worker.tsx', + imports + `export default defineApp([route('/new', () => new Response('ok'))]);`, + ); + await cg.sync(scoped ? { paths: ['src/worker.tsx'] } : undefined); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['ANY /new']); + }); + it.runIf(fs.existsSync(path.resolve('dist/index.js')))( + 'classifies pages in fresh compiled workers', + () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-redwood-workers-')); + write('package.json', JSON.stringify({ dependencies: { rwsdk: '1.7.3' } })); + write('src/Home.tsx', 'export function Home(){return
}'); + write( + 'src/worker.tsx', + imports + `import {Home} from './Home'; export default defineApp([route('/',Home)]);`, + ); + const script = `const {CodeGraph}=require(${JSON.stringify(path.resolve('dist/index.js'))}); +(async()=>{const cg=await CodeGraph.init(${JSON.stringify(dir)},{index:true});const r=cg.getNodesByKind('route')[0]; +console.log(JSON.stringify([r.name,cg.getOutgoingEdges(r.id).filter(e=>e.kind==='references').map(e=>cg.getNode(e.target)?.name)]));cg.close();})().catch(e=>{console.error(e);process.exit(1)});`; + const output = execFileSync(process.execPath, ['-e', script], { + encoding: 'utf8', + timeout: 60000, + env: { + ...process.env, + CODEGRAPH_PARALLEL_RESOLVE_MIN: '1', + CODEGRAPH_RESOLVE_WORKERS: '2', + CODEGRAPH_PARSE_WORKERS: '2', + }, + }); + expect(JSON.parse(output.trim().split('\n').at(-1)!)).toEqual(['/', ['Home']]); + }, + ); +}); diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md index 45a8ddaca..234d34b8f 100644 --- a/docs/design/PLAN-application-router-coverage.md +++ b/docs/design/PLAN-application-router-coverage.md @@ -1,10 +1,10 @@ -Status: 4/13 — preparing step 4 PR (Astro route completion); steps 1–3 published as PRs #3–5 +Status: 5/13 — preparing step 5 PR (RedwoodSDK); steps 1–4 published as PRs #3–6 - [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. - [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. - [x] 3 Remix and React Router file conventions — registered default conventions bind exact page components, optional navigation and explicit config coexistence; layout/resource controls and config-only full/scoped/reopened sync pass; build passes, 102 WASM tests pass, full native suite 4,415 pass / 46 skip; independent review clear. - [x] 4 Astro route completion — exact page components, declared method handlers and local source navigation; false-anchor/type/export controls and full/scoped/new-framework sync pass; build passes, 195 WASM tests pass, full native suite 4,435 pass / 46 skip; independent review clear. -- [ ] 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. +- [x] 5 RedwoodSDK — registered literal trees, prefixes and method tables bind exact handlers; JSX evidence classifies pages, interrupters/ambiguous declarations excluded; handler edit/delete and new-framework sync pass; build passes, 91 WASM tests pass, full native suite 4,459 pass / 46 skip; independent review clear. - [ ] 6 Angular Router — registered literal route tables, nested children, component references, and statically resolvable lazy modules/components. Gate: shared proof, router registration and nesting, plus unregistered objects and custom matcher negatives. - [ ] 7 Analog — default file routes and page components using Analog-specific index, dot, parameter, and layout conventions. Gate: shared proof and fixtures that distinguish parent layouts from matching pages. - [ ] 8 Solid Router — imported JSX/config declarations, nested path composition, and router base. Gate: shared proof, nested matching semantics, component links, and unrelated JSX negatives. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 1691a0cd4..40a9accc1 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -43,6 +43,9 @@ guessed. | 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) | | Astro | `frameworks/astro.ts` | — | `astro-routes.test.ts` | pinned endpoint fixture; exact page components and source navigation | +| RedwoodSDK | `frameworks/redwood.ts` | — | `redwood-routes.test.ts` | pinned 1.7.3 typed-routes worker; exact page and API roots | + +RedwoodSDK follows imported `defineApp` registrations with literal/local-constant arrays, `route`, `index`, `render`, `layout` and `prefix`. Handler arrays bind only their last handler. Standard method tables remain method-qualified; ordinary handlers remain `ANY` unless the handler returns JSX. [Pinned worker fixture](https://github.com/redwoodjs/sdk/blob/39da7118f712bd86450e493cb2c213815b1893bb/playground/typed-routes/src/worker.tsx), [router semantics](https://github.com/redwoodjs/sdk/blob/39da7118f712bd86450e493cb2c213815b1893bb/sdk/src/runtime/lib/router.ts#L682). Tests cover handler classification changes/deletion and fresh workers. Cross-file route arrays, custom methods, mutated builders, dynamic paths, duplicate/computed/spread method tables and wrapped/anonymous exported components remain unsupported; no navigation is inferred. Remix default `app/routes/` conventions and React Router configs registering an imported, option-free `flatRoutes()` call support JS/TS pages and immediate `folder/route` modules. [Pinned filename parser](https://github.com/remix-run/react-router/blob/7aea711dd1ae2bc5a076d13ff17291829690fa74/packages/react-router-fs-routes/flatRoutes.ts#L351): dot nesting, index/pathless segments, parameters, optional segments, splats and bracket escapes. Resource-only and direct `Outlet`-only defaults are excluded. Config-only full/scoped sync and reopening an index refresh existing pages. Custom configuration, folder `index` fallback, Markdown/MDX, anonymous defaults and re-exports remain unsupported. diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md index c9546e46c..40760614c 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -36,9 +36,12 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **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/` `.astro` pages linked to components; `.ts`/`.js` HTTP-method exports linked to handlers; anchors and `Astro.redirect` link to local pages | +| **RedwoodSDK** | Registered `defineApp` trees with `route`, `index`, `render`, `layout`, `prefix` and standard method tables; exact handlers and JSX page classification | Route resolution is automatic — there's nothing to configure. If a framework file is recognized, its routes appear in the graph after the next index or sync. +RedwoodSDK handler arrays use the final handler as the route root. A route stays `ANY /path` until its handler is shown to return JSX. Cross-file route arrays, custom methods, mutations, dynamic paths, ambiguous method tables and wrapped/anonymous exported components remain unsupported. + Astro supports default roots, `[param]`/`[...rest]` filenames and exported handlers, including `ALL` as `ANY`. Type-only exports, underscore-prefixed paths and `.mjs` endpoints are excluded. Custom routing configuration, Markdown/MDX, cross-file re-exports, client transition calls and navigation to rest routes remain unsupported. React Router framework mode assumes the default `app/` directory. Computed arrays, custom app directories, `relative` helpers, anonymous defaults, and re-exports remain unsupported. Layout helpers contribute nesting without creating extra pages; an index page takes precedence over its parent layout. diff --git a/src/index.ts b/src/index.ts index 70eb4d330..dda95a202 100644 --- a/src/index.ts +++ b/src/index.ts @@ -579,7 +579,7 @@ export class CodeGraph { if (result.success && result.filesIndexed > 0) { const tReinit = Date.now(); this.resolver.initialize(); - if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:react-router:'))) + if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood):/.test(n.id))) await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); // Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs // before resolution so updated names show up in subsequent reads. @@ -834,7 +834,7 @@ export class CodeGraph { // (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:'))) + if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood):/.test(n.id))) await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); this.resolver.runPostExtract(); } else if (result.filesRemoved > 0) { @@ -846,6 +846,10 @@ export class CodeGraph { // sees the post-removal state. (runPostExtract above clears caches // itself, so the changed-files branch is already covered.) this.resolver.clearCaches(); + if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:redwood:'))) { + await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); + this.resolver.runPostExtract(); + } } // Resolve references if files were updated diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 5c168188a..11c2fb1e8 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -20,6 +20,7 @@ import { svelteKitRouterResolver } from './sveltekit-router'; import { svelteResolver } from './svelte'; import { vueResolver } from './vue'; import { astroResolver } from './astro'; +import { redwoodResolver } from './redwood'; import { djangoResolver, flaskResolver, fastapiResolver } from './python'; import { railsResolver } from './ruby'; import { springResolver } from './java'; @@ -63,6 +64,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ // Vue Router — `createRouter({ routes })` → route nodes; `router.push({ name })` / `router.push('/x')` → navigates edges vueRouterResolver, astroResolver, + redwoodResolver, // Python djangoResolver, flaskResolver, diff --git a/src/resolution/frameworks/redwood.ts b/src/resolution/frameworks/redwood.ts new file mode 100644 index 000000000..1b845c575 --- /dev/null +++ b/src/resolution/frameworks/redwood.ts @@ -0,0 +1,334 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { Node } from '../../types'; +import type { FrameworkResolver, FrameworkExtractionResult, ResolutionContext } from '../types'; +import { detectLanguage, getParser } from '../../extraction/grammars'; +import { resolveViaImport } from '../import-resolver'; +import { dependsOn } from './package-deps'; +import { httpHandlerReferences } from './http-routing'; + +const FUNCTIONS = new Set([ + 'arrow_function', + 'function_expression', + 'function_declaration', + 'method_definition', +]); +const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head']); +const unwrap = (raw: SyntaxNode | null | undefined): SyntaxNode | null => { + let node = raw ?? null; + while ( + node && + ['parenthesized_expression', 'as_expression', 'satisfies_expression'].includes(node.type) + ) + node = node.namedChildren[0] ?? null; + return node; +}; +const literal = (node: SyntaxNode | null | undefined): string | null => + node?.type === 'string' && !node.text.includes('\\') ? node.text.slice(1, -1) : null; +function returnedJsx(handler: SyntaxNode): SyntaxNode[] { + const found: SyntaxNode[] = []; + const expression = (node: SyntaxNode): void => { + if (FUNCTIONS.has(node.type)) return; + if (['jsx_element', 'jsx_self_closing_element', 'jsx_fragment'].includes(node.type)) + found.push(node); + else for (const child of node.namedChildren) expression(child); + }; + const statements = (node: SyntaxNode): void => { + if (FUNCTIONS.has(node.type)) return; + if (node.type === 'return_statement') { + for (const child of node.namedChildren) expression(child); + } else for (const child of node.namedChildren) statements(child); + }; + const body = handler.childForFieldName('body'); + if (body?.type === 'statement_block') statements(body); + else if (body) expression(body); + return found; +} + +/** Only a registered defineApp tree contributes routes; middleware bodies are opaque. */ +export function extractRedwoodRoutes(filePath: string, content: string): FrameworkExtractionResult { + const result: FrameworkExtractionResult = { nodes: [], references: [] }; + if (!content.includes('rwsdk/')) return result; + const language = detectLanguage(filePath)!; + if (!['typescript', 'javascript', 'tsx', 'jsx'].includes(language)) return result; + const parser = getParser(language); + if (!parser) throw new Error(`RedwoodSDK extraction requires the ${language} grammar`); + const tree = parser.parse(content); + if (!tree) return result; + try { + const helpers = new Map(); + const bindings = new Map(); + for (const statement of tree.rootNode.namedChildren) { + if ( + statement.type === 'import_statement' && + !statement.children.some((n) => n.type === 'type') + ) { + const source = literal(statement.childForFieldName('source')); + for (const spec of statement.descendantsOfType('import_specifier')) { + if (spec.children.some((n) => n.type === 'type')) continue; + const name = spec.childForFieldName('name')?.text; + const alias = spec.childForFieldName('alias')?.text ?? name; + if ( + name && + alias && + ((source === 'rwsdk/worker' && name === 'defineApp') || + (source === 'rwsdk/router' && + ['route', 'index', 'render', 'layout', 'prefix'].includes(name))) + ) + helpers.set(alias, name); + } + } + const declaration = + statement.type === 'export_statement' + ? statement.childForFieldName('declaration') + : statement; + if ( + declaration?.type === 'lexical_declaration' && + declaration.children.some((n) => n.type === 'const') + ) { + for (const variable of declaration.namedChildren) { + const name = variable.childForFieldName('name'); + const value = variable.childForFieldName('value'); + if (name?.type === 'identifier' && value) bindings.set(name.text, value); + } + } + } + const resolve = ( + raw: SyntaxNode | null | undefined, + seen = new Set(), + ): SyntaxNode | null => { + const node = unwrap(raw); + if (node?.type !== 'identifier' || !bindings.has(node.text)) return node; + if (seen.has(node.text)) return null; + seen.add(node.text); + return resolve(bindings.get(node.text), seen); + }; + const emit = ( + site: SyntaxNode, + routePath: string, + method: string, + raw: SyntaxNode | null, + ): void => { + let handler = unwrap(raw); + if (handler?.type === 'array') { + if (handler.namedChildren.some((n) => n.type === 'spread_element')) return; + handler = unwrap(handler.namedChildren.filter((n) => n.type !== 'comment').at(-1)); + } + if ( + !handler || + (!FUNCTIONS.has(handler.type) && + !['identifier', 'shorthand_property_identifier'].includes(handler.type)) + ) + return; + const jsx = method === 'ANY' && FUNCTIONS.has(handler.type) ? returnedJsx(handler) : []; + const page = jsx.length > 0; + const node: Node = { + id: `route:redwood:${filePath}:${site.startIndex}:${method}:${routePath}`, + kind: 'route', + name: page ? routePath : `${method} ${routePath}`, + qualifiedName: `${filePath}::${method} ${routePath}`, + filePath, + language, + startLine: site.startPosition.row + 1, + endLine: site.endPosition.row + 1, + startColumn: site.startPosition.column, + endColumn: site.endPosition.column, + updatedAt: Date.now(), + }; + if (result.nodes.some((n) => n.id === node.id)) return; + result.nodes.push(node); + result.references.push(...httpHandlerReferences(node, handler)); + if (page) + for (const tag of jsx.flatMap((n) => + n.descendantsOfType(['jsx_opening_element', 'jsx_self_closing_element']), + )) { + const name = tag.childForFieldName('name'); + if (name && /^[A-Z][\w$]*$/.test(name.text)) + result.references.push(...httpHandlerReferences(node, name)); + } + }; + const visit = (raw: SyntaxNode | null | undefined, prefix: string, depth = 0): void => { + if (depth > 32) return; + const node = resolve(raw); + if (!node) return; + if (node.type === 'array') { + for (const child of node.namedChildren) + visit( + child.type === 'spread_element' ? child.namedChildren[0] : child, + prefix, + depth + 1, + ); + return; + } + if (node.type !== 'call_expression') return; + const fn = node.childForFieldName('function'); + const helper = fn?.type === 'identifier' ? helpers.get(fn.text) : undefined; + const args = + node.childForFieldName('arguments')?.namedChildren.filter((n) => n.type !== 'comment') ?? + []; + if (helper === 'render' || helper === 'layout') { + visit(args[1], prefix, depth + 1); + return; + } + if (helper === 'prefix') { + const part = literal(args[0]); + if (part !== null) visit(args[1], prefix + '/' + part.replace(/^\/+|\/+$/g, ''), depth + 1); + return; + } + if (helper !== 'route' && helper !== 'index') return; + const part = helper === 'index' ? '/' : literal(args[0]); + if (part === null) return; + const routePath = (prefix + '/' + part.replace(/^\/+|\/+$/g, '')).replace(/\/$/, '') || '/'; + const rawHandler = unwrap(args[helper === 'index' ? 0 : 1]); + const resolvedHandler = resolve(rawHandler); + const handler = + resolvedHandler && ['object', 'array'].includes(resolvedHandler.type) + ? resolvedHandler + : rawHandler; + if (handler?.type !== 'object') { + emit(node, routePath, 'ANY', handler); + return; + } + if ( + handler.namedChildren.some( + (n) => + n.type === 'spread_element' || + (n.childForFieldName('key') ?? n.childForFieldName('name'))?.type === + 'computed_property_name' || + (n.type === 'method_definition' && + n.children.some((c) => c.type === 'get' || c.type === 'set')), + ) + ) + return; + const keys = handler.namedChildren + .map((n) => { + const key = + n.childForFieldName('key') ?? + n.childForFieldName('name') ?? + (n.type === 'shorthand_property_identifier' ? n : null); + return literal(key) ?? key?.text; + }) + .filter((key) => key !== undefined); + if (new Set(keys).size !== keys.length) return; + for (const pair of handler.namedChildren) { + const key = + pair.childForFieldName('key') ?? + pair.childForFieldName('name') ?? + (pair.type === 'shorthand_property_identifier' ? pair : null); + const method = literal(key) ?? key?.text; + if (method && METHODS.has(method)) + emit( + node, + routePath, + method.toUpperCase(), + pair.type === 'method_definition' || pair.type === 'shorthand_property_identifier' + ? pair + : pair.childForFieldName('value'), + ); + } + }; + const register = (raw: SyntaxNode | null | undefined): void => { + let node = resolve(raw); + if (node?.type === 'object') { + if ( + node.namedChildren.some( + (n) => + n.type === 'spread_element' || + n.childForFieldName('key')?.type === 'computed_property_name', + ) + ) + return; + const fields = node.namedChildren.filter( + (n) => + (literal(n.childForFieldName('key')) ?? n.childForFieldName('key')?.text) === 'fetch', + ); + if (fields.length !== 1) return; + const fetch = fields[0]!.childForFieldName('value'); + if ( + fetch?.type === 'member_expression' && + fetch.childForFieldName('property')?.text === 'fetch' + ) + node = resolve(fetch.childForFieldName('object')); + } + const fn = node?.childForFieldName('function'); + if ( + node?.type === 'call_expression' && + fn?.type === 'identifier' && + helpers.get(fn.text) === 'defineApp' + ) + visit(node.childForFieldName('arguments')?.namedChildren[0], ''); + }; + for (const statement of tree.rootNode.namedChildren) { + if (statement.type !== 'export_statement') continue; + if (statement.children.some((n) => n.type === 'default')) + register(statement.childForFieldName('value')); + const declaration = statement.childForFieldName('declaration'); + if (declaration?.type === 'lexical_declaration') + for (const variable of declaration.namedChildren) + register(variable.childForFieldName('value')); + } + return result; + } finally { + tree.delete(); + } +} + +/** Promote only handlers that return JSX; names and nested helpers are not evidence. */ +function finalizePages(context: ResolutionContext): Node[] { + const updates: Node[] = []; + const routes = context.getNodesByKind('route').filter((n) => n.id.startsWith('route:redwood:')); + for (const file of new Set(routes.map((n) => n.filePath))) { + const source = context.readFile(file); + if (source === null) continue; + const extracted = extractRedwoodRoutes(file, source); + for (const route of extracted.nodes) { + if (!route.name.startsWith('ANY ')) continue; + const ref = extracted.references.find( + (r) => r.fromNodeId === route.id && r.referenceKind === 'references', + ); + if (!ref) continue; + const imported = resolveViaImport(ref, context); + const target = imported + ? context.getNodeById?.(imported.targetNodeId) + : context + .getNodesInFile(file) + .find( + (n) => n.name === ref.referenceName && ['function', 'component'].includes(n.kind), + ); + let page = target?.kind === 'component'; + if (target?.kind === 'function') { + const source = context.readFile(target.filePath); + const parser = getParser(target.language); + const tree = source === null ? null : parser?.parse(source); + if (tree) + try { + const declaration = tree.rootNode.namedChildren + .map((n) => + n.type === 'export_statement' ? (n.childForFieldName('declaration') ?? n) : n, + ) + .flatMap((n) => (n.type === 'lexical_declaration' ? n.namedChildren : [n])) + .find((n) => n.childForFieldName('name')?.text === target.name); + const handler = + declaration?.type === 'variable_declarator' + ? unwrap(declaration.childForFieldName('value')) + : declaration; + page = !!handler && returnedJsx(handler).length > 0; + } finally { + tree.delete(); + } + } + const existing = routes.find((n) => n.id === route.id); + const name = page ? route.name.slice(4) : route.name; + if (existing && existing.name !== name) updates.push({ ...existing, name }); + } + } + return updates; +} + +export const redwoodResolver: FrameworkResolver = { + name: 'redwood', + languages: ['typescript', 'javascript', 'tsx', 'jsx'], + detect: (context) => dependsOn(context, 'rwsdk'), + resolve: () => null, + extract: extractRedwoodRoutes, + postExtract: finalizePages, +};