diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f3e8d0f..45e050a04 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 +- 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. - Angular Router registered route arrays now link to exact component classes, including nested children and static lazy imports; imported route edits refresh their registrations during sync. diff --git a/__tests__/solid-router.test.ts b/__tests__/solid-router.test.ts new file mode 100644 index 000000000..789e9b303 --- /dev/null +++ b/__tests__/solid-router.test.ts @@ -0,0 +1,210 @@ +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 { extractSolidRoutes } from '../src/resolution/frameworks/solid-router'; +import { routeRoots } from '../src/ui-server/api/route-roots'; + +describe('Solid Router registered declarations', () => { + let cg: CodeGraph | undefined; + let dir: string; + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['tsx']); + }); + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + const imports = `import {Router, Route} from '@solidjs/router';`; + const names = (source: string) => + extractSolidRoutes('src/app.tsx', imports + source) + .nodes.map((n) => n.name) + .sort(); + it('composes nested paths and base while excluding layout components', () => { + expect( + names( + ``, + ), + ).toEqual(['/app/users', '/app/users/:id']); + }); + it('accepts pathless parents, arrays and registered local config', () => { + expect( + names( + `const routes = [{children:[{path:['/one','/two'],component:Page}]}]; export const App = () => {routes};`, + ), + ).toEqual(['/one', '/two']); + }); + it('removes a parent splat before composing children', () => { + expect( + names( + ``, + ), + ).toEqual(['/docs/child']); + }); + it('recognizes imported aliases', () => { + expect( + extractSolidRoutes( + 'src/app.tsx', + `import {Router as R, Route as P} from '@solidjs/router'; const App=()=>

;`, + ).nodes.map((n) => n.name), + ).toEqual(['/a']); + }); + it('recognizes static lazy imports in identifiers and inline config', () => { + const result = extractSolidRoutes( + 'src/app.tsx', + imports + + `import {lazy} from 'solid-js'; const Page=lazy(()=>import('./page')); const routes=[{path:'/inline',component:lazy(()=>import('./other'))}]; const App=()=> {routes};`, + ); + expect(result.nodes.map((n) => n.name)).toEqual(['/named', '/inline']); + expect(result.references.map((n) => n.referenceName)).toEqual([ + 'solid-lazy:./page', + 'solid-lazy:./other', + ]); + }); + it.each([ + ``, + `const unused = [{path:'/orphan',component:Page}];`, + `function App(Router) {return }`, + `function App(Route) {return }`, + ``, + ``, + ``, + ``, + `{[{path:'/x',...props,component:Page}]}`, + `const routes=[{path:'/x',component:Page}]; routes.push(other); {routes}`, + `const routes=[{path:'/x',component:Page}]; const alias=routes; alias[0].path='/other'; {routes}`, + `const Page=foreign(()=>import('./page')); `, + `function App(){const {Route}=other;return }`, + `const children=[]; children.push({path:'child',component:Page}); const routes=[{path:'/parent',component:Page,children:children}]; {routes}`, + ])('does not invent routes for unsupported declarations: %s', (source) => { + expect(names(source)).toEqual([]); + }); + it('indexes exact imported components without duplicate React routes and syncs edits/deletions', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-solid-')); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ dependencies: { '@solidjs/router': '0.16.3', 'solid-js': '1.9.9' } }), + ); + fs.writeFileSync( + path.join(dir, 'src/page.tsx'), + 'export default function Page(){return

}', + ); + fs.writeFileSync( + path.join(dir, 'src/other.tsx'), + 'export default function Page(){return
}', + ); + const app = path.join(dir, 'src/app.tsx'); + fs.writeFileSync( + app, + imports + + `import Page from './page'; export const App=()=> ;`, + ); + cg = await CodeGraph.init(dir, { index: true }); + let routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name)).toEqual(['/first']); + expect(routeRoots(cg, routes).get(routes[0]!.id)!.node.filePath).toBe('src/page.tsx'); + fs.writeFileSync( + app, + imports + + `import Page from './page'; export const App=()=> ;`, + ); + await cg.sync(); + routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name)).toEqual(['/second']); + fs.unlinkSync(app); + await cg.sync(); + expect(cg.getNodesByKind('route')).toEqual([]); + }); + it('indexes the pinned upstream README lazy example through exact defaults', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-solid-real-')); + fs.mkdirSync(path.join(dir, 'pages')); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ dependencies: { '@solidjs/router': '0.16.3', 'solid-js': '1.9.9' } }), + ); + // solidjs/solid-router@e8d3a7f719020ef01f8879a0110d2123b8597caa README.md, lazy-load example. + fs.writeFileSync( + path.join(dir, 'app.tsx'), + `import { lazy } from "solid-js"; +import { render } from "solid-js/web"; +import { Router, Route } from "@solidjs/router"; +const Users = lazy(() => import("./pages/Users")); +const Home = lazy(() => import("./pages/Home")); +const App = (props) => (<>

My Site with lots of pages

{props.children}); +render(() => (),document.getElementById("app"));`, + ); + fs.writeFileSync( + path.join(dir, 'pages/Home.tsx'), + 'export default function Home(){return
}', + ); + fs.writeFileSync( + path.join(dir, 'pages/Users.tsx'), + 'export default function Users(){return
}', + ); + cg = await CodeGraph.init(dir, { index: true }); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name).sort()).toEqual(['/', '/users']); + const roots = routeRoots(cg, routes); + expect(routes.map((n) => [n.name, roots.get(n.id)!.node.name]).sort()).toEqual([ + ['/', 'Home'], + ['/users', 'Users'], + ]); + cg.close(); + cg = await CodeGraph.open(dir); + fs.writeFileSync(path.join(dir, 'pages/Home.tsx'), 'function Decoy(){}; export default 1;'); + await cg.sync(); + const home = cg.getNodesByKind('route').find((n) => n.name === '/')!; + expect(cg.getOutgoingEdges(home.id).filter((e) => e.kind === 'references')).toEqual([]); + }); + it('detects Solid introduced by scoped sync and adds new registered routes', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-solid-sync-')); + fs.writeFileSync(path.join(dir, 'package.json'), '{}'); + fs.writeFileSync(path.join(dir, 'seed.ts'), 'export const seed=1;'); + cg = await CodeGraph.init(dir, { index: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ dependencies: { '@solidjs/router': '0.16.3' } }), + ); + fs.writeFileSync(path.join(dir, 'Home.tsx'), 'export default function Home(){return
}'); + fs.writeFileSync( + path.join(dir, 'app.tsx'), + imports + + `import Home from './Home'; export const App=()=> ;`, + ); + await cg.sync({ paths: ['app.tsx', 'Home.tsx', 'package.json'] }); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name)).toEqual(['/added']); + expect(routeRoots(cg, routes).get(routes[0]!.id)!.node.name).toBe('Home'); + }); + it('extracts and resolves lazy components in fresh compiled workers', () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-solid-workers-')); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ dependencies: { '@solidjs/router': '0.16.3' } }), + ); + fs.writeFileSync(path.join(dir, 'Home.tsx'), 'export default function Home(){return
}'); + fs.writeFileSync( + path.join(dir, 'app.tsx'), + imports + + `import {lazy} from 'solid-js'; const Home=lazy(()=>import('./Home')); export const App=()=> ;`, + ); + 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,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_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 53aa61e47..d9088c0be 100644 --- a/docs/design/PLAN-application-router-coverage.md +++ b/docs/design/PLAN-application-router-coverage.md @@ -1,4 +1,4 @@ -Status: 7/13 — Analog verified; publishing step 7 before Solid Router +Status: 8/13 — Solid Router verified; publishing step 8 before SolidStart - [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. @@ -7,7 +7,7 @@ Status: 7/13 — Analog verified; publishing step 7 before Solid Router - [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. - [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. -- [ ] 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. +- [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. - [ ] 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. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index aa38d2c5e..f68aa366b 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -46,6 +46,9 @@ guessed. | RedwoodSDK | `frameworks/redwood.ts` | — | `redwood-routes.test.ts` | pinned 1.7.3 typed-routes worker; exact page and API roots | | 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 | + +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. Analog recognizes `src/app/pages/**/*.page.ts` named default classes when a root Vite config registers the platform plugin and source registers option-free `provideFileRouter()`. Directory hierarchy determines layouts before dots become URL separators; index/pathless names, parameters and catchalls follow the pinned conventions. [Official page fixture](https://github.com/analogjs/analog/blob/0896a7eaaa2acf26443ca184bc1dd9aa1a06f4d6/apps/analog-app/src/app/pages/%28auth%29/sign-up.page.ts), [route construction](https://github.com/analogjs/analog/blob/0896a7eaaa2acf26443ca184bc1dd9aa1a06f4d6/packages/router/src/lib/routes.ts). Fresh workers and config/file add/edit/delete refresh existing pages, including after reopening. Custom roots, extra route directories, `app/routes`, metadata overrides, router options, optional catchalls, Markdown, anonymous defaults and 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 cf6070ab9..168199a99 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -39,6 +39,9 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **RedwoodSDK** | Registered `defineApp` trees with `route`, `index`, `render`, `layout`, `prefix` and standard method tables; exact handlers and JSX page classification | | **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 | + +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. 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. diff --git a/src/index.ts b/src/index.ts index dda95a202..a42bfa028 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 => /^route:(react-router|redwood):/.test(n.id))) + if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood|solid):/.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 => /^route:(react-router|redwood):/.test(n.id))) + if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood|solid):/.test(n.id))) await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); this.resolver.runPostExtract(); } else if (result.filesRemoved > 0) { diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index e788eaf34..bf519658d 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -23,6 +23,7 @@ import { astroResolver } from './astro'; import { redwoodResolver } from './redwood'; import { angularResolver } from './angular'; import { analogResolver } from './analog'; +import { solidRouterResolver } from './solid-router'; import { djangoResolver, flaskResolver, fastapiResolver } from './python'; import { railsResolver } from './ruby'; import { springResolver } from './java'; @@ -69,6 +70,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ redwoodResolver, angularResolver, analogResolver, + solidRouterResolver, // Python djangoResolver, flaskResolver, diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 28776cddb..934ddf461 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -8,6 +8,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { dependsOn } from './package-deps'; +import { detectLanguage, getParser } from '../../extraction/grammars'; export const reactResolver: FrameworkResolver = { name: 'react', @@ -103,8 +104,16 @@ export const reactResolver: FrameworkResolver = { // and element={...} contains a nested `>`, so scan a window after each // + statement.type === 'import_statement' && + statement.childForFieldName('source')?.text.slice(1, -1) === '@solidjs/router' && + statement.descendantsOfType('import_specifier').some(spec => + (spec.childForFieldName('alias') ?? spec.childForFieldName('name'))?.text === 'Route')); + tree?.delete(); let routeMatch: RegExpExecArray | null; - while ((routeMatch = routeTagRegex.exec(content)) !== null) { + while (!foreignRoute && (routeMatch = routeTagRegex.exec(content)) !== null) { const window = content.slice(routeMatch.index, routeMatch.index + 400); const pathMatch = window.match(/\bpath\s*=\s*["']([^"']+)["']/); if (!pathMatch) continue; // index/layout routes without a path diff --git a/src/resolution/frameworks/solid-router.ts b/src/resolution/frameworks/solid-router.ts new file mode 100644 index 000000000..fbef52b5c --- /dev/null +++ b/src/resolution/frameworks/solid-router.ts @@ -0,0 +1,349 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { FrameworkResolver, FrameworkExtractionResult } from '../types'; +import { detectLanguage, getParser } from '../../extraction/grammars'; +import { resolveImportPath, resolveViaImport } from '../import-resolver'; +import { dependsOn } from './package-deps'; + +const unwrap = (raw: SyntaxNode | null | undefined): SyntaxNode | null => { + let node = raw ?? null; + while ( + node && + [ + 'parenthesized_expression', + 'as_expression', + 'satisfies_expression', + 'jsx_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; +const join = (base: string, path: string): string => + ('/' + base.replace(/\*.*$/, '').replace(/^\/+|\/+$/g, '') + '/' + path.replace(/^\/+|\/+$/g, '')) + .replace(/\/+/g, '/') + .replace(/\/$/, '') || '/'; + +/** Only children of an imported Router are route declarations. */ +export function extractSolidRoutes(filePath: string, content: string): FrameworkExtractionResult { + const result: FrameworkExtractionResult = { nodes: [], references: [] }; + if (!content.includes('@solidjs/router')) return result; + const language = detectLanguage(filePath)!; + const parser = getParser(language); + if (!parser) throw new Error(`Solid Router 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')) { + const name = spec.childForFieldName('name')?.text; + if ( + name && + ((source === '@solidjs/router' && ['Route', 'Router'].includes(name)) || + (source === 'solid-js' && name === 'lazy')) && + !spec.children.some((n) => n.type === 'type') + ) + helpers.set(spec.childForFieldName('alias')?.text ?? name, 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 mutated = new Set(); + for (const expression of tree.rootNode.descendantsOfType([ + 'assignment_expression', + 'augmented_assignment_expression', + 'update_expression', + 'call_expression', + ])) { + let receiver = + expression.type === 'call_expression' + ? expression.childForFieldName('function') + : (expression.childForFieldName('left') ?? expression.childForFieldName('argument')); + if ( + expression.type === 'call_expression' && + !['member_expression', 'subscript_expression'].includes(receiver?.type ?? '') + ) + continue; + while (receiver && ['member_expression', 'subscript_expression'].includes(receiver.type)) + receiver = receiver.childForFieldName('object'); + if (receiver?.type === 'identifier') mutated.add(receiver.text); + } + for (let changed = true; changed;) { + changed = false; + for (const [name, raw] of bindings) { + const value = unwrap(raw); + if (value?.type === 'identifier' && (mutated.has(name) || mutated.has(value.text))) + for (const alias of [name, value.text]) + if (!mutated.has(alias)) { + mutated.add(alias); + changed = true; + } + } + } + const shadowed = (name: string, site: SyntaxNode): boolean => { + for (let scope = site.parent; scope && scope.type !== 'program'; scope = scope.parent) { + const params = + scope.childForFieldName('parameters') ?? scope.childForFieldName('parameter'); + if ( + params && + [ + params, + ...params.descendantsOfType(['identifier', 'shorthand_property_identifier_pattern']), + ].some((n) => n.text === name) + ) + return true; + if (scope.type === 'statement_block') + for (const child of scope.namedChildren) { + if ( + ['function_declaration', 'class_declaration'].includes(child.type) && + child.childForFieldName('name')?.text === name + ) + return true; + if ( + ['lexical_declaration', 'variable_declaration'].includes(child.type) && + child.descendantsOfType('variable_declarator').some((n) => { + const pattern = n.childForFieldName('name'); + return ( + pattern && + [ + pattern, + ...pattern.descendantsOfType([ + 'identifier', + 'shorthand_property_identifier_pattern', + ]), + ].some((binding) => binding.text === name) + ); + }) + ) + return true; + } + } + return false; + }; + 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) || mutated.has(node.text) || shadowed(node.text, node)) return null; + seen.add(node.text); + return resolve(bindings.get(node.text), seen); + }; + const fields = (node: SyntaxNode): Map | null => { + const map = new Map(); + for (const child of node.namedChildren) { + if (child.type === 'comment') continue; + const key = child.childForFieldName('key'); + const name = key?.type === 'property_identifier' ? key.text : literal(key); + const value = child.childForFieldName('value'); + if (!name || !value || map.has(name)) return null; + map.set(name, value); + } + return map; + }; + const tag = (node: SyntaxNode): SyntaxNode | undefined => + node.type === 'jsx_element' + ? node.namedChildren.find((n) => n.type === 'jsx_opening_element') + : node.type === 'jsx_self_closing_element' + ? node + : undefined; + const attributes = (opening: SyntaxNode): Map | null => { + const map = new Map(); + for (const child of opening.namedChildren.slice(1)) { + if (child.type !== 'jsx_attribute') return null; + const name = child.namedChildren[0]?.text; + const value = child.namedChildren[1]; + if (!name || !value || map.has(name)) return null; + map.set(name, value); + } + return map; + }; + const children = (node: SyntaxNode): SyntaxNode[] => + node.type === 'jsx_self_closing_element' + ? [] + : node.namedChildren.filter( + (n) => + !['jsx_opening_element', 'jsx_closing_element', 'jsx_text', 'comment'].includes( + n.type, + ), + ); + const visit = (raw: SyntaxNode | null | undefined, base: string, depth = 0): void => { + if (depth > 32) return; + const node = resolve(raw); + if (!node) return; + if (node.type === 'array' || node.type === 'jsx_fragment') { + for (const child of node.type === 'array' ? node.namedChildren : children(node)) + visit(child, base, depth + 1); + return; + } + const opening = tag(node); + const name = opening?.childForFieldName('name')?.text; + if (opening && (!name || helpers.get(name) !== 'Route' || shadowed(name, opening))) return; + const props = opening ? attributes(opening) : node.type === 'object' ? fields(node) : null; + if (!props) return; + const pathNode = props.has('path') ? resolve(props.get('path')) : null; + const paths = !props.has('path') + ? [''] + : pathNode?.type === 'array' + ? pathNode.namedChildren.map(literal) + : [literal(pathNode)]; + if (paths.some((p) => p === null)) return; + const descendants = opening + ? children(node) + : props.has('children') + ? [props.get('children')!] + : []; + const nested = descendants.filter((n) => { + const value = resolve(n); + return !(value?.type === 'array' && value.namedChildren.length === 0); + }); + for (const part of paths) { + const routePath = join(base, part!); + if (nested.length) { + for (const child of nested) visit(child, routePath, depth + 1); + continue; + } + const component = unwrap(props.get('component')); + if (!component) continue; + let referenceName: string | null = + component.type === 'identifier' && + !shadowed(component.text, component) && + !mutated.has(component.text) + ? 'solid-component:' + component.text + : null; + const value = resolve(component); + if (value?.type === 'call_expression') { + referenceName = null; + const fn = value.childForFieldName('function'); + const args = value.childForFieldName('arguments')?.namedChildren; + const callback = args?.length === 1 ? args[0] : null; + const body = unwrap(callback?.childForFieldName('body')); + const target = + body?.type === 'call_expression' && + body.childForFieldName('function')?.type === 'import' + ? body.childForFieldName('arguments')?.namedChildren + : null; + const source = target?.length === 1 ? literal(target[0]) : null; + if ( + fn?.type === 'identifier' && + helpers.get(fn.text) === 'lazy' && + !shadowed(fn.text, fn) && + callback?.type === 'arrow_function' && + source?.startsWith('.') + ) + referenceName = 'solid-lazy:' + source; + } + if (!referenceName) continue; + const id = `route:solid:${filePath}:${node.startIndex}:${routePath}`; + if (result.nodes.some((n) => n.id === id)) continue; + result.nodes.push({ + id, + kind: 'route', + name: routePath, + qualifiedName: `${filePath}::${routePath}`, + filePath, + language, + startLine: node.startPosition.row + 1, + endLine: node.endPosition.row + 1, + startColumn: node.startPosition.column, + endColumn: node.endPosition.column, + updatedAt: Date.now(), + }); + result.references.push({ + fromNodeId: id, + referenceName, + referenceKind: 'references', + filePath, + language, + line: component.startPosition.row + 1, + column: component.startPosition.column, + }); + } + }; + for (const node of tree.rootNode.descendantsOfType('jsx_element')) { + const opening = tag(node)!; + const name = opening.childForFieldName('name')?.text; + if (!name || helpers.get(name) !== 'Router' || shadowed(name, opening)) continue; + const props = attributes(opening); + if (!props) continue; + const base = props.has('base') ? literal(resolve(props.get('base'))) : ''; + if (base === null) continue; + for (const child of children(node)) visit(child, base); + } + return result; + } finally { + tree.delete(); + } +} + +export const solidRouterResolver: FrameworkResolver = { + name: 'solid-router', + languages: ['typescript', 'javascript', 'tsx', 'jsx'], + detect: (context) => dependsOn(context, '@solidjs/router'), + extract: extractSolidRoutes, + claimsReference: (name) => name.startsWith('solid-component:') || name.startsWith('solid-lazy:'), + resolve(ref, context) { + if (ref.referenceName.startsWith('solid-lazy:')) { + const file = resolveImportPath( + ref.referenceName.slice('solid-lazy:'.length), + ref.filePath, + ref.language, + context, + ); + const content = file ? context.readFile(file) : null; + const tree = + file && content !== null ? getParser(detectLanguage(file))?.parse(content) : null; + if (!tree || !file) return null; + try { + const exported = tree.rootNode.namedChildren.find( + (n) => n.type === 'export_statement' && n.children.some((c) => c.type === 'default'), + ); + const declaration = exported?.childForFieldName('declaration'); + const value = exported?.childForFieldName('value'); + const name = + declaration?.childForFieldName('name')?.text ?? + (value?.type === 'identifier' ? value.text : null); + if (!name) return null; + const targets = context + .getNodesInFile(file) + .filter((n) => ['function', 'component'].includes(n.kind) && n.name === name); + return targets.length === 1 + ? { original: ref, targetNodeId: targets[0]!.id, confidence: 1, resolvedBy: 'framework' } + : null; + } finally { + tree.delete(); + } + } + if (!ref.referenceName.startsWith('solid-component:')) return null; + const name = ref.referenceName.slice('solid-component:'.length); + const imported = resolveViaImport({ ...ref, referenceName: name }, context); + if (imported) return { ...imported, original: ref }; + const targets = context + .getNodesInFile(ref.filePath) + .filter((n) => n.name === name && ['function', 'component'].includes(n.kind)); + return targets.length === 1 + ? { original: ref, targetNodeId: targets[0]!.id, confidence: 1, resolvedBy: 'framework' } + : null; + }, +};