diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e050a04..aad2556a0 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 +- SolidStart 2 default file routes now link to exact page and HTTP handlers, including page/API coexistence, nested layouts, parameters and GET-to-HEAD fallback. + - Solid Router registered JSX and configuration trees now link to exact components, including static lazy imports, nested paths and router bases, without duplicate React routes. - Analog default file pages now link to their component classes, preserving its directory layouts, dotted paths and dynamic segments when the platform plugin and file router are registered. diff --git a/__tests__/solid-start.test.ts b/__tests__/solid-start.test.ts new file mode 100644 index 000000000..003d31b1e --- /dev/null +++ b/__tests__/solid-start.test.ts @@ -0,0 +1,286 @@ +import { afterEach, 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 { routeRoots } from '../src/ui-server/api/route-roots'; + +describe('SolidStart 2 default routes', () => { + 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); + }; + const config = `import {defineConfig} from 'vite'; import {solidStart as start} from '@solidjs/start/config'; export default defineConfig({plugins:[start()]});`; + const app = `import {Router as AppRouter} from '@solidjs/router'; import {FileRoutes as Pages} from '@solidjs/start/router'; export default function App(){return
{props.children}
}>
}`; + const setup = () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-solid-start-')); + write( + 'package.json', + JSON.stringify({ dependencies: { '@solidjs/start': '2.0.4', '@solidjs/router': '0.16.3' } }), + ); + write('vite.config.ts', config); + write('src/app.tsx', app); + }; + const page = (file: string, name: string) => + write(`src/routes/${file}.tsx`, `export default function ${name}(){return
}`); + const routes = () => + cg!.getNodesByKind('route').filter((n) => n.id.startsWith('route:solid-start:')); + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('indexes official page and API source fixtures with exact targets and calls', async () => { + setup(); + // solidjs/solid-start@5d23efbcbb47997a70978be8b0e468df50d774a8: + // apps/fixtures/basic/src/routes/about.tsx; experiments/src/routes/api/hello/[name].ts. + // These extracted examples share a minimal app; the workspace config import uses the public package. + write( + 'src/routes/about.tsx', + 'import { Title } from "@solidjs/meta";\n\nexport default function About() {\n return (\n
\n About\n

About

\n
\n );\n}\n', + ); + write( + 'src/routes/api/hello/[name].ts', + 'import type { APIHandler } from "@solidjs/start/server";\n\nexport const GET: APIHandler = async ({ params }) => {\n return `Hello ${params.name}!`;\n};\n', + ); + write( + 'src/routes/both.tsx', + 'function load(){return "ok"}\nexport default function Both(){return

}\nexport function GET(){return load()}\nexport const POST=()=>load();', + ); + cg = await CodeGraph.init(dir, { index: true }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual([ + '/about', + '/both', + 'GET /api/hello/:name', + 'GET /both', + 'HEAD /api/hello/:name', + 'HEAD /both', + 'POST /both', + ]); + const roots = routeRoots(cg, routes()); + expect(roots.get(routes().find((n) => n.name === '/about')!.id)!.node.name).toBe('About'); + for (const method of ['GET', 'HEAD']) { + const endpoint = routes().find((n) => n.name === `${method} /api/hello/:name`)!; + const target = cg.getOutgoingEdges(endpoint.id).find((e) => e.kind === 'references')!; + expect([cg.getNode(target.target)?.name, cg.getNode(target.target)?.filePath]).toEqual([ + 'GET', + 'src/routes/api/hello/[name].ts', + ]); + } + const get = cg + .getNodesByKind('function') + .find((n) => n.name === 'GET' && n.filePath === 'src/routes/both.tsx')!; + expect( + cg + .getOutgoingEdges(get.id) + .some((e) => e.kind === 'calls' && cg!.getNode(e.target)?.name === 'load'), + ).toBe(true); + }); + it('links multiline function values to their actual symbol positions', async () => { + setup(); + write( + 'src/routes/index.tsx', + 'const Home =\n () =>

;\nexport default Home;\nexport const GET =\n () => 1;', + ); + cg = await CodeGraph.init(dir, { index: true }); + const roots = routeRoots(cg, routes()); + expect( + routes() + .map((route) => [route.name, roots.get(route.id)?.node.name]) + .sort(), + ).toEqual([ + ['/', 'Home'], + ['GET /', 'GET'], + ['HEAD /', 'GET'], + ]); + }); + it('composes parameters, groups, literal dots and page-only hierarchy', async () => { + setup(); + for (const [file, name] of [ + ['index', 'Home'], + ['admin', 'Admin'], + ['admin/index', 'AdminIndex'], + ['admin/users/[id]', 'User'], + ['test(named)/child', 'TestChild'], + ['test', 'Test'], + ['(auth)/login', 'Login'], + ['[[id]]', 'Optional'], + ['[...slug]', 'CatchAll'], + ['_private', 'Private'], + ['foo.bar', 'Dot'], + ['api', 'ApiPage'], + ]) + page(file!, name!); + write('src/routes/api/child.ts', 'export function GET(){return 1}'); + cg = await CodeGraph.init(dir, { index: true }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual([ + '/', + '/*slug', + '/:id?', + '/_private', + '/admin', + '/admin/users/:id', + '/api', + '/foo.bar', + '/login', + '/test', + '/test/child', + 'GET /api/child', + 'HEAD /api/child', + ]); + }); + it('keeps explicit HEAD and skips optional APIs, route overrides and OPTIONS-only modules', async () => { + setup(); + write( + 'src/routes/explicit.ts', + 'export function GET(){return 1}\nexport function HEAD(){return 2}\nexport function OPTIONS(){return 3}', + ); + write('src/routes/options.ts', 'export function OPTIONS(){return 1}'); + write('src/routes/type-only.ts', 'function GET(){}; export type {GET};'); + write('src/routes/reassigned.ts', 'export function GET(){}; GET=other;'); + page('config/child', 'ChildOfOverride'); + write('src/routes/[[id]].ts', 'export function GET(){return 1}'); + write( + 'src/routes/config.tsx', + 'export const route={path:"other"}; export default function Config(){return

}', + ); + write( + 'src/routes/fake.tsx', + 'function Decoy(){return

} export default 1; export const GET=1;', + ); + cg = await CodeGraph.init(dir, { index: true }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual(['GET /explicit', 'HEAD /explicit', 'OPTIONS /explicit']); + const head = routes().find((n) => n.name === 'HEAD /explicit')!; + expect( + cg + .getOutgoingEdges(head.id) + .filter((e) => e.kind === 'references') + .map((e) => cg!.getNode(e.target)?.name), + ).toEqual(['HEAD']); + }); + it.each([ + [ + 'src/app.tsx', + `import {Router} from '@solidjs/router';import {FileRoutes} from '@solidjs/start/router';export default function App(){return false && }`, + ], + [ + 'src/app.tsx', + `import {Router} from '@solidjs/router';import {FileRoutes} from '@solidjs/start/router';export default Router => `, + ], + [ + 'src/app.tsx', + `import {Router} from '@solidjs/router';import {FileRoutes} from '@solidjs/start/router';export default ({Router}) => `, + ], + [ + 'src/app.tsx', + `import {Router} from '@solidjs/router';import {FileRoutes} from '@solidjs/start/router';const Unused=()=> ; export default ()=>

`, + ], + ['vite.config.ts', `import {solidStart} from 'other';export default {plugins:[solidStart()]}`], + [ + 'vite.config.ts', + `import {solidStart} from '@solidjs/start/config';export default {root:'custom',plugins:[solidStart()]}`, + ], + [ + 'vite.config.ts', + `import {solidStart} from '@solidjs/start/config';export default {plugins:[solidStart({routeDir:'custom'})]}`, + ], + [ + 'vite.config.ts', + `import {solidStart} from '@solidjs/start/config';export default {plugins:[solidStart()],...other}`, + ], + [ + 'vite.config.ts', + `import {solidStart} from '@solidjs/start/config';export default {plugins:[solidStart()],plugins:[]}`, + ], + [ + 'src/app.tsx', + `import {Router} from '@solidjs/router';import {FileRoutes} from 'other';export default function App(){return }`, + ], + [ + 'src/app.tsx', + `import {Router} from '@solidjs/router';import {FileRoutes} from '@solidjs/start/router';export default function App(FileRoutes){return }`, + ], + [ + 'src/app.tsx', + `import {Router} from '@solidjs/router';import {FileRoutes} from '@solidjs/start/router';export default function App(){return }`, + ], + ])('does not guess unsupported registration %s (%s)', async (file, source) => { + setup(); + page('index', 'Home'); + write(file, source); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toEqual([]); + }); + it('updates unchanged parents and config after scoped edits and reopening', async () => { + setup(); + page('parent', 'Parent'); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes().map((n) => n.name)).toEqual(['/parent']); + cg.close(); + cg = await CodeGraph.open(dir); + page('parent/child', 'Child'); + await cg.sync({ paths: ['src/routes/parent/child.tsx'] }); + expect(routes().map((n) => n.name)).toEqual(['/parent/child']); + fs.unlinkSync(path.join(dir, 'src/routes/parent/child.tsx')); + await cg.sync(); + expect(routes().map((n) => n.name)).toEqual(['/parent']); + write('src/routes/parent.tsx', 'export default function Renamed(){return

}'); + await cg.sync(); + expect(routeRoots(cg, routes()).get(routes()[0]!.id)!.node.name).toBe('Renamed'); + write('src/app.tsx', 'export default function App(){return

}'); + await cg.sync({ paths: ['src/app.tsx'] }); + expect(routes()).toEqual([]); + write('src/app.tsx', app); + await cg.sync(); + expect(routes().map((n) => n.name)).toEqual(['/parent']); + write('vite.config.ts', 'export default {}'); + await cg.sync(); + expect(routes()).toEqual([]); + }); + it.each([false, true])('detects a newly introduced framework (scoped=%s)', async (scoped) => { + setup(); + write('package.json', '{}'); + page('index', 'Home'); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toEqual([]); + write('package.json', JSON.stringify({ dependencies: { '@solidjs/start': '2.0.4' } })); + write('src/app.tsx', app + '\n'); + await cg.sync(scoped ? { paths: ['src/app.tsx'] } : undefined); + expect(routes().map((n) => n.name)).toEqual(['/']); + }); + it.runIf(fs.existsSync(path.resolve('dist/index.js')))( + 'uses fresh compiled parse and resolution workers', + () => { + setup(); + page('index', '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').find(r=>r.id.startsWith('route:solid-start:'));console.log(JSON.stringify([r?.name,r&&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_PARSE_WORKERS: '2', + CODEGRAPH_PARALLEL_RESOLVE_MIN: '1', + CODEGRAPH_RESOLVE_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 d9088c0be..2b1538066 100644 --- a/docs/design/PLAN-application-router-coverage.md +++ b/docs/design/PLAN-application-router-coverage.md @@ -1,4 +1,4 @@ -Status: 8/13 — Solid Router verified; publishing step 8 before SolidStart +Status: 9/13 — SolidStart validated; publishing step 9 - [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. @@ -8,7 +8,7 @@ Status: 8/13 — Solid Router verified; publishing step 8 before SolidStart - [x] 6 Angular Router — registered arrays, nested children and static lazy imports bind exact classes; imported table add/edit/delete after reopening and fresh workers pass; build passes, 117 WASM focused/control tests pass, full native suite 4,484 pass / 46 skip; independent review clear. - [x] 7 Analog — registered default pages bind exact classes with directory-based layouts and dot/parameter conventions; config/file and reopened/scoped sync plus fresh workers pass; build passes, 133 WASM focused/control tests pass, full native suite 4,500 pass / 46 skip; independent review clear. - [x] 8 Solid Router — registered JSX/config and static lazy imports bind exact components; nested bases/splats, mutations, scoped introduction and fresh workers pass; build passes, 115 WASM focused/control tests pass, full native suite 4,523 pass / 46 skip; independent review clear. -- [ ] 9 SolidStart — pinned-version file routes, default page exports, and HTTP-method exports. Gate: shared proof, page/API coexistence, layouts, and dynamic parameters. +- [x] 9 SolidStart — pinned default pages and HTTP handlers bind exact targets; file hierarchy, page/API coexistence, scoped/reopened sync and fresh workers pass; build passes, 115 WASM focused/control tests pass, full native suite 4,542 pass / 46 skip; independent review clear. - [ ] 10 Qwik City — default file routes, page components, and method-specific endpoint exports. Gate: shared proof, parameters, layouts, and `onRequest`/middleware exclusions. - [ ] 11 Vike — default `+Page` conventions and literal `+route` overrides. Gate: shared proof, parameters, exact component links, and no fallback route when an unsupported override changes routing. - [ ] 12 Waku filesystem routes — default pages, parameters, and layout exclusions for a pinned version. Gate: shared proof and `_root`/`_layout`/`_slices` controls. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index f68aa366b..de8ff1966 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -47,6 +47,9 @@ guessed. | Angular Router | `frameworks/angular.ts` | — | `angular-routes.test.ts` | pinned official tutorial registration; exact class roots and imported-array sync | | Analog | `frameworks/analog.ts` | — | `analog-routes.test.ts` | pinned 2.7.1 sign-up page; filename/layout and registration sync controls | | Solid Router | `frameworks/solid-router.ts` | — | `solid-router.test.ts` | pinned 0.16.3 README lazy example; exact component roots, nested paths and fresh workers | +| SolidStart | `frameworks/solid-start.ts` | — | `solid-start.test.ts` | pinned 2.0.4 About/API fixtures; page/API coexistence, file hierarchy and config sync | + +SolidStart supports default `src/routes/**/*.{js,jsx,ts,tsx}` with option-free `solidStart()` in a literal Vite config. Page discovery additionally requires imported `FileRoutes` directly inside the imported `Router` in the default app component. Named local default functions and method exports bind exact targets. Raw file hierarchy determines page layouts before route groups are removed; API-only descendants do not turn pages into layouts. Dots stay literal, optional page parameters and named catchalls are preserved, and GET supplies HEAD unless explicitly exported. [Official page](https://github.com/solidjs/solid-start/blob/5d23efbcbb47997a70978be8b0e468df50d774a8/apps/fixtures/basic/src/routes/about.tsx), [API fixture](https://github.com/solidjs/solid-start/blob/5d23efbcbb47997a70978be8b0e468df50d774a8/apps/fixtures/experiments/src/routes/api/hello/%5Bname%5D.ts), [route construction](https://github.com/solidjs/solid-start/blob/5d23efbcbb47997a70978be8b0e468df50d774a8/packages/start/src/config/fs-router.ts). Version 2.0.4 excludes OPTIONS-only APIs and rejects optional API parameters. Custom roots, plugin options, dynamic configuration, page `route` overrides, non-default routers, anonymous/re-exported handlers and Markdown are unsupported. No navigation is inferred. Solid Router reads imported `Router`/`Route` JSX and registered literal or module-constant config trees. It composes bases and nested paths, removes parent splats, and emits only leaf pages. Components bind through imports or same-file declarations; static `solid-js` lazy imports bind named default exports. [Pinned example](https://github.com/solidjs/solid-router/blob/e8d3a7f719020ef01f8879a0110d2123b8597caa/README.md), [path composition](https://github.com/solidjs/solid-router/blob/e8d3a7f719020ef01f8879a0110d2123b8597caa/src/utils.ts). Tests cover shadowed imports, mutated tables, exact targets, edit/delete sync and fresh workers. Other router variants, cross-file config tables, local function config bindings, dynamic declarations, spreads, inline/anonymous components and lazy re-exports remain unsupported. No navigation is inferred. diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md index 168199a99..86994f2e8 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -40,6 +40,9 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Angular Router** | `provideRouter` / `RouterModule.forRoot` arrays, nested children, relative component imports and static lazy components/route arrays/NgModules | | **Analog** | Registered default `src/app/pages/**/*.page.ts` pages, linked to named default classes; directory layouts, dot paths, index/pathless segments and parameters | | **Solid Router** | Imported `Router`/`Route` JSX and registered literal configuration, nested paths, path arrays and bases; imported/local components and static lazy defaults | +| **SolidStart** | Default file pages and HTTP-method exports; exact local targets, nested layouts, groups, parameters and GET-to-HEAD fallback | + +SolidStart coverage targets version 2.0.4: option-free `solidStart()` in Vite and, for pages, `FileRoutes` directly inside the default app's `Router`. A file can supply both a page and endpoints. Named local functions and constant function exports are supported. Custom roots/options, dynamic config, page route overrides, anonymous/re-exported handlers and Markdown remain unsupported. Optional parameters apply to pages only; OPTIONS-only APIs are excluded by this version's runtime. Solid Router emits leaf routes, preserving nested path composition even when a child begins with `/`. Parent components and the router root remain layouts. Cross-file configuration, other router variants, dynamic/spread declarations, inline/anonymous components and lazy re-exports are unsupported. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 03da9ea38..23bca1ba0 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -35,6 +35,7 @@ import ignore, { Ignore } from 'ignore'; import { detectFrameworks } from '../resolution/frameworks'; import { extractAngularRoutes, isAngularRegistrationFile } from '../resolution/frameworks/angular'; import { extractAnalogRoutes, isAnalogPage } from '../resolution/frameworks/analog'; +import { extractSolidStartRoutes, isSolidStartRoute } from '../resolution/frameworks/solid-start'; import type { ResolutionContext } from '../resolution/types'; import { createYielder, type MaybeYield } from '../resolution/cooperative-yield'; @@ -2374,11 +2375,14 @@ export class ExtractionOrchestrator { const frameworks = this.ensureDetectedFrameworks(); const angular = frameworks.includes('angular') && isAngularRegistrationFile(content); const analog = frameworks.includes('analog') && isAnalogPage(filePath); - if (!angular && !analog) return result; - await loadGrammarsForLanguages(['typescript', 'javascript']); + const solidStart = frameworks.includes('solid-start') && isSolidStartRoute(filePath); + if (!angular && !analog && !solidStart) return result; + await loadGrammarsForLanguages(solidStart ? ['typescript', 'javascript', 'tsx', 'jsx'] : ['typescript', 'javascript']); result = materializeKernelResult(result, filePath, detectLanguage(filePath)!); const context = this.frameworkSourceContext!; - const extracted = angular ? extractAngularRoutes(filePath, content, context) : extractAnalogRoutes(filePath, content, context); + const extracted = angular ? extractAngularRoutes(filePath, content, context) + : analog ? extractAnalogRoutes(filePath, content, context) + : extractSolidStartRoutes(filePath, content, context); result.nodes.push(...extracted.nodes); result.unresolvedReferences.push(...extracted.references); return result; @@ -2893,6 +2897,16 @@ export class ExtractionOrchestrator { : previous.includes('react-router-files'); this.detectedFrameworkNames = null; const detected = this.ensureDetectedFrameworks(currentFiles); + if (detected.includes('solid-start') || this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:solid-start:'))) { + const scope = this.scopedSyncMatcher(); + for (const filePath of new Set([...this.queries.getAllFilePaths(), ...currentFiles])) { + if (!isSolidStartRoute(filePath) || filesToIndex.includes(filePath) || scope.ignores(filePath) || !fs.existsSync(path.join(this.rootDir, filePath))) continue; + filesToIndex.push(filePath); + this.conventionInvalidatedFiles.add(filePath); + changedFilePaths.push(filePath); + filesModified++; + } + } if (detected.includes('analog') || this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:analog:'))) { const scope = this.scopedSyncMatcher(); for (const filePath of new Set([...this.queries.getAllFilePaths(), ...currentFiles])) { diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index bf519658d..54398f807 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -24,6 +24,7 @@ import { redwoodResolver } from './redwood'; import { angularResolver } from './angular'; import { analogResolver } from './analog'; import { solidRouterResolver } from './solid-router'; +import { solidStartResolver } from './solid-start'; import { djangoResolver, flaskResolver, fastapiResolver } from './python'; import { railsResolver } from './ruby'; import { springResolver } from './java'; @@ -71,6 +72,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ angularResolver, analogResolver, solidRouterResolver, + solidStartResolver, // Python djangoResolver, flaskResolver, diff --git a/src/resolution/frameworks/solid-start.ts b/src/resolution/frameworks/solid-start.ts new file mode 100644 index 000000000..1d886484a --- /dev/null +++ b/src/resolution/frameworks/solid-start.ts @@ -0,0 +1,412 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { FrameworkResolver, FrameworkExtractionResult, ResolutionContext } from '../types'; +import { detectLanguage, getParser } from '../../extraction/grammars'; +import { dependsOn } from './package-deps'; + +const ROOT = 'src/routes/'; +const METHODS = ['HEAD', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']; +export const isSolidStartRoute = (file: string): boolean => + file.startsWith(ROOT) && + /\.[jt]sx?$/.test(file) && + !file.endsWith('.d.ts') && + !file + .slice(ROOT.length) + .split('/') + .some((part) => part.startsWith('.')); +const literal = (node: SyntaxNode | null | undefined): string | null => + node?.type === 'string' && !node.text.includes('\\') ? node.text.slice(1, -1) : null; +const rawPath = (file: string): string => file.slice(ROOT.length).replace(/\.[jt]sx?$/, ''); + +function imports(root: SyntaxNode, source: string, name: string): Set { + const names = new Set(); + for (const statement of root.namedChildren) { + if ( + statement.type !== 'import_statement' || + literal(statement.childForFieldName('source')) !== source || + statement.children.some((n) => n.type === 'type') + ) + continue; + for (const spec of statement.descendantsOfType('import_specifier')) + if ( + spec.childForFieldName('name')?.text === name && + !spec.children.some((n) => n.type === 'type') + ) + names.add(spec.childForFieldName('alias')?.text ?? name); + } + return names; +} + +function object(node: SyntaxNode | null | undefined): Map | null { + if (node?.type !== 'object') return null; + const values = new Map(); + for (const field of node.namedChildren) { + if (field.type === 'comment') continue; + const key = field.childForFieldName('key'); + const name = literal(key) ?? (key?.type === 'property_identifier' ? key.text : null); + const value = field.childForFieldName('value'); + if (field.type !== 'pair' || !name || !value || values.has(name)) return null; + values.set(name, value); + } + return values; +} + +function plugin(context: ResolutionContext): boolean { + for (const extension of ['ts', 'js', 'mts', 'mjs']) { + const file = `vite.config.${extension}`; + const content = context.readFile(file); + if (content === null) continue; + const tree = getParser(detectLanguage(file)!)?.parse(content); + if (!tree) return false; + try { + const root = tree.rootNode; + const exported = root.namedChildren.find( + (n) => n.type === 'export_statement' && n.children.some((c) => c.type === 'default'), + ); + let config = exported?.childForFieldName('value'); + if (config?.type === 'call_expression') { + if ( + !imports(root, 'vite', 'defineConfig').has( + config.childForFieldName('function')?.text ?? '', + ) + ) + return false; + config = config.childForFieldName('arguments')?.namedChildren[0]; + } + const fields = object(config); + if (!fields || fields.has('root') || fields.has('base')) return false; + const plugins = fields.get('plugins'); + if (plugins?.type !== 'array') return false; + const names = imports(root, '@solidjs/start/config', 'solidStart'); + return plugins.namedChildren.some((call) => { + if ( + call.type !== 'call_expression' || + !names.has(call.childForFieldName('function')?.text ?? '') + ) + return false; + const args = + call.childForFieldName('arguments')?.namedChildren.filter((n) => n.type !== 'comment') ?? + []; + if (!args.length) return true; + const options = object(args[0]); + return args.length === 1 && options !== null && options.size === 0; + }); + } finally { + tree.delete(); + } + } + return false; +} + +function fileRoutes(context: ResolutionContext): boolean { + for (const extension of ['tsx', 'jsx']) { + const file = `src/app.${extension}`; + const content = context.readFile(file); + if (content === null) continue; + const tree = getParser(detectLanguage(file)!)?.parse(content); + if (!tree) return false; + try { + const root = tree.rootNode; + const routers = imports(root, '@solidjs/router', 'Router'); + const routes = imports(root, '@solidjs/start/router', 'FileRoutes'); + const names = new Set([...routers, ...routes]); + const exported = root.namedChildren.find( + (n) => n.type === 'export_statement' && n.children.some((c) => c.type === 'default'), + ); + let app = exported?.childForFieldName('declaration') ?? exported?.childForFieldName('value'); + if (app?.type === 'identifier') { + const name = app.text; + app = + root.namedChildren.find( + (n) => n.type === 'function_declaration' && n.childForFieldName('name')?.text === name, + ) ?? + root.namedChildren + .filter((n) => n.type === 'lexical_declaration') + .flatMap((n) => n.namedChildren) + .find((n) => n.childForFieldName('name')?.text === name) + ?.childForFieldName('value'); + } + if ( + !app || + !['function_declaration', 'function_expression', 'arrow_function'].includes(app.type) + ) + return false; + // A local binding with an imported name makes the JSX registration ambiguous. + if ( + root + .descendantsOfType([ + 'required_parameter', + 'optional_parameter', + 'variable_declarator', + 'function_declaration', + 'arrow_function', + ]) + .some((n) => + [...names].some( + (name) => + ( + n.childForFieldName('name') ?? + n.childForFieldName('pattern') ?? + n.childForFieldName('parameter') + )?.text === name || + (n.childForFieldName('name') ?? n.childForFieldName('pattern')) + ?.descendantsOfType(['identifier', 'shorthand_property_identifier_pattern']) + .some((binding) => binding.text === name), + ), + ) + ) + return false; + for (const element of app.descendantsOfType('jsx_element')) { + let parent = element.parent; + while ( + parent && + parent.id !== app.id && + ![ + 'function_declaration', + 'function_expression', + 'arrow_function', + 'binary_expression', + 'ternary_expression', + 'if_statement', + 'switch_statement', + 'for_statement', + 'while_statement', + ].includes(parent.type) + ) + parent = parent.parent; + if (parent?.id !== app.id) continue; + const opening = element.namedChildren[0]; + if ( + !routers.has(opening?.childForFieldName('name')?.text ?? '') || + opening?.namedChildren.some( + (n) => + n.type === 'jsx_expression' || + (n.type === 'jsx_attribute' && n.namedChildren[0]?.text !== 'root'), + ) + ) + continue; + if ( + element.namedChildren.some( + (child) => + child.type === 'jsx_self_closing_element' && + routes.has(child.childForFieldName('name')?.text ?? '') && + child.namedChildren.length === 1, + ) + ) + return true; + } + } finally { + tree.delete(); + } + } + return false; +} + +type Target = { name: string; line: number; column: number; endLine: number; endColumn: number }; +type Module = { exports: Map }; +function moduleExports(file: string, content: string): Module { + const result: Module = { exports: new Map() }; + const tree = getParser(detectLanguage(file)!)?.parse(content); + if (!tree) return result; + try { + const bindings = new Map(); + const declaration = (node: SyntaxNode) => + node.type === 'export_statement' ? node.childForFieldName('declaration') : node; + for (const statement of tree.rootNode.namedChildren) { + const node = declaration(statement); + if (node?.type === 'function_declaration' && node.childForFieldName('name')) + bindings.set(node.childForFieldName('name')!.text, node); + if (node?.type === 'lexical_declaration' && node.children.some((n) => n.type === 'const')) + for (const variable of node.namedChildren) { + const name = variable.childForFieldName('name'); + const value = variable.childForFieldName('value'); + if ( + name?.type === 'identifier' && + ['arrow_function', 'function_expression'].includes(value?.type ?? '') + ) + bindings.set(name.text, variable); + } + } + for (const mutation of tree.rootNode.descendantsOfType([ + 'assignment_expression', + 'augmented_assignment_expression', + 'update_expression', + ])) { + const assigned = mutation.childForFieldName('left') ?? mutation.childForFieldName('argument'); + if (assigned?.type === 'identifier') bindings.delete(assigned.text); + for (const name of assigned?.descendantsOfType([ + 'identifier', + 'shorthand_property_identifier_pattern', + ]) ?? []) + bindings.delete(name.text); + } + const target = (name: string): Target | null => { + const binding = bindings.get(name); + const node = + binding?.type === 'variable_declarator' ? binding.childForFieldName('value') : binding; + return node + ? { + name, + line: node.startPosition.row + 1, + column: node.startPosition.column, + endLine: node.endPosition.row + 1, + endColumn: node.endPosition.column, + } + : null; + }; + for (const statement of tree.rootNode.namedChildren) { + if ( + statement.type !== 'export_statement' || + statement.children.some((n) => n.type === 'type') + ) + continue; + const node = declaration(statement); + if (statement.children.some((n) => n.type === 'default')) { + const value = statement.childForFieldName('value'); + const name = + node?.childForFieldName('name')?.text ?? (value?.type === 'identifier' ? value.text : ''); + result.exports.set('default', target(name)); + } else if (node?.type === 'function_declaration') { + const name = node.childForFieldName('name')?.text; + if (name) result.exports.set(name, target(name)); + } else if (node?.type === 'lexical_declaration') { + for (const variable of node.namedChildren) { + const name = variable.childForFieldName('name')?.text; + if (name) result.exports.set(name, target(name)); + } + } + for (const spec of statement.descendantsOfType('export_specifier')) { + if (spec.children.some((n) => n.type === 'type')) continue; + const name = spec.childForFieldName('name')?.text ?? ''; + result.exports.set( + spec.childForFieldName('alias')?.text ?? name, + statement.childForFieldName('source') || spec.children.some((n) => n.type === 'type') + ? null + : target(name), + ); + } + } + return result; + } finally { + tree.delete(); + } +} + +const states = new WeakMap< + ResolutionContext, + { active: boolean; pages: string[]; overrides: string[] } +>(); +function project(context: ResolutionContext) { + const cached = states.get(context); + if (cached) return cached; + const state = { active: plugin(context), pages: [] as string[], overrides: [] as string[] }; + if (state.active && fileRoutes(context)) + for (const file of context.getAllFiles().filter(isSolidStartRoute)) { + const content = context.readFile(file); + if (content !== null) { + const exported = moduleExports(file, content).exports; + if (exported.has('default')) { + state.pages.push(file); + if (exported.has('route')) state.overrides.push(rawPath(file)); + } + } + } + states.set(context, state); + return state; +} + +export function extractSolidStartRoutes( + filePath: string, + content: string, + context: ResolutionContext, +): FrameworkExtractionResult { + const result: FrameworkExtractionResult = { nodes: [], references: [] }; + if (!isSolidStartRoute(filePath)) return result; + const state = project(context); + if (!state.active) return result; + const exported = moduleExports(filePath, content).exports; + const raw = rawPath(filePath); + const path = + ( + '/' + + raw + .replace(/index$/, '') + .replace(/\[([^/]+)\]/g, (_, name: string) => + name.startsWith('...') + ? '*' + name.slice(3) + : name.startsWith('[') && name.endsWith(']') + ? ':' + name.slice(1, -1) + '?' + : ':' + name, + ) + .replace(/\([^)]*\)/g, '') + ) + .replace(/\/+/g, '/') + .replace(/\/$/, '') || '/'; + const add = (method: string, target: Target | null | undefined) => { + if (!target) return; + const name = method ? `${method} ${path}` : path; + const id = `route:solid-start:${filePath}:${method || 'page'}`; + const language = detectLanguage(filePath)!; + result.nodes.push({ + id, + kind: 'route', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language, + startLine: target.line, + startColumn: target.column, + endLine: target.endLine, + endColumn: target.endColumn, + updatedAt: Date.now(), + }); + result.references.push({ + fromNodeId: id, + referenceName: `solid-start-target:${target.name}`, + referenceKind: 'references', + filePath, + language, + line: target.line, + column: target.column, + }); + }; + if ( + !exported.has('route') && + !state.overrides.some((parent) => raw.startsWith(parent + '/')) && + state.pages.includes(filePath) && + !state.pages.some((file) => rawPath(file).startsWith(raw + '/')) + ) + add('', exported.get('default')); + if ( + !raw.includes('[[') && + METHODS.some((method) => method !== 'OPTIONS' && exported.has(method)) + ) { + for (const method of METHODS) add(method, exported.get(method)); + if (exported.has('GET') && !exported.has('HEAD')) add('HEAD', exported.get('GET')); + } + return result; +} + +export const solidStartResolver: FrameworkResolver = { + name: 'solid-start', + languages: ['typescript', 'javascript'], + detect: (context) => dependsOn(context, '@solidjs/start'), + claimsReference: (name) => name.startsWith('solid-start-target:'), + resolve(ref, context) { + if ( + !ref.fromNodeId.startsWith('route:solid-start:') || + !ref.referenceName.startsWith('solid-start-target:') + ) + return null; + const candidates = context + .getNodesInFile(ref.filePath) + .filter( + (n) => + n.name === ref.referenceName.slice('solid-start-target:'.length) && + ['function', 'component'].includes(n.kind) && + n.startLine === ref.line, + ); + return candidates.length === 1 + ? { original: ref, targetNodeId: candidates[0]!.id, confidence: 1, resolvedBy: 'framework' } + : null; + }, +};