/** * Svelte / SvelteKit Framework Resolver * * Handles Svelte component references, Svelte 5 runes, * store auto-subscriptions, and SvelteKit route/module patterns. */ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; /** * Svelte 5 runes — compiler-provided, not user code */ const SVELTE_RUNES = new Set([ '$state', '$state.raw', '$state.snapshot', '$derived', '$derived.by', '$effect', '$effect.pre', '$effect.root', '$effect.tracking', '$props', '$bindable', '$inspect', '$host', ]); /** * SvelteKit framework-provided module prefixes */ const SVELTEKIT_MODULE_PREFIXES = [ '$app/navigation', '$app/stores', '$app/environment', '$app/forms', '$app/paths', '$env/static/private', '$env/static/public', '$env/dynamic/private', '$env/dynamic/public', ]; export const svelteResolver: FrameworkResolver = { name: 'svelte', languages: ['svelte'], detect(context: ResolutionContext): boolean { // Check for svelte or @sveltejs/kit in package.json const packageJson = context.readFile('package.json'); if (packageJson) { try { const pkg = JSON.parse(packageJson); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; if (deps.svelte || deps['@sveltejs/kit']) { return true; } } catch { // Invalid JSON } } // Check for .svelte files in project const allFiles = context.getAllFiles(); return allFiles.some((f) => f.endsWith('.svelte')); }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: Svelte runes ($state, $derived, $effect, etc.) if (isRuneReference(ref.referenceName)) { // Runes are compiler-provided — return a high-confidence "framework" resolution // so CodeGraph doesn't waste time searching for user-defined symbols. // We use the fromNodeId as targetNodeId since runes don't have real targets. return { original: ref, targetNodeId: ref.fromNodeId, confidence: 1.0, resolvedBy: 'framework', }; } // Pattern 2: Store auto-subscriptions ($storeName) if (ref.referenceName.startsWith('$') && !ref.referenceName.startsWith('$$')) { const storeName = ref.referenceName.substring(1); const storeNode = context.getNodesByName(storeName).find( (n) => n.kind === 'variable' || n.kind === 'constant' ); if (storeNode) { return { original: ref, targetNodeId: storeNode.id, confidence: 0.85, resolvedBy: 'framework', }; } } // Pattern 3: SvelteKit module imports ($app/*, $env/*, $lib/*) if (ref.referenceKind === 'imports' && ref.referenceName.startsWith('$')) { // $lib/* resolves to src/lib/* — try to find the target file if (ref.referenceName.startsWith('$lib/')) { const libPath = ref.referenceName.replace('$lib/', 'src/lib/'); // Try common extensions for (const ext of ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js']) { const fullPath = libPath + ext; if (context.fileExists(fullPath)) { const nodes = context.getNodesInFile(fullPath); if (nodes.length > 0) { return { original: ref, targetNodeId: nodes[0]!.id, confidence: 0.9, resolvedBy: 'framework', }; } } } } // $app/* and $env/* are framework-provided if (SVELTEKIT_MODULE_PREFIXES.some((prefix) => ref.referenceName.startsWith(prefix))) { return { original: ref, targetNodeId: ref.fromNodeId, confidence: 1.0, resolvedBy: 'framework', }; } } // Pattern 4: Component references (PascalCase) — resolve to .svelte files if (isPascalCase(ref.referenceName) && ref.referenceKind === 'calls') { const result = resolveComponent(ref.referenceName, ref.filePath, context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework', }; } } return null; }, extract(filePath, _content) { const nodes: Node[] = []; const now = Date.now(); // Detect SvelteKit route files const fileName = filePath.split(/[/\\]/).pop() || ''; const routeMatch = getSvelteKitRouteInfo(fileName); if (routeMatch) { // Extract route path from directory structure // e.g., src/routes/blog/[slug]/+page.svelte -> /blog/:slug const routePath = filePathToSvelteKitRoute(filePath); if (routePath) { nodes.push({ id: `route:${filePath}:${routePath}:1`, kind: 'route', name: routePath, qualifiedName: `${filePath}::route:${routePath}`, filePath, startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, language: filePath.endsWith('.svelte') ? 'svelte' : 'typescript', updatedAt: now, }); } } return { nodes, references: [] }; }, }; /** * Check if a reference name is a Svelte rune */ function isRuneReference(name: string): boolean { // Direct match (e.g. $state, $derived) if (SVELTE_RUNES.has(name)) return true; // Rune method calls come through as the base rune name // e.g. $state.raw -> the call is to "$state" with ".raw" accessed as property // Check if it's a base rune that has sub-methods if (name === '$state' || name === '$derived' || name === '$effect') return true; return false; } /** * Check if string is PascalCase */ function isPascalCase(str: string): boolean { return /^[A-Z][a-zA-Z0-9]*$/.test(str); } /** * Resolve a Svelte component reference using name-based lookup */ function resolveComponent( name: string, fromFile: string, context: ResolutionContext ): string | null { // Look for component nodes by name const candidates = context.getNodesByName(name); const components = candidates.filter((n) => n.kind === 'component'); if (components.length === 0) return null; // Prefer same directory const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/')); const sameDir = components.filter((n) => n.filePath.startsWith(fromDir)); if (sameDir.length > 0) return sameDir[0]!.id; // No positional signal: only an UNAMBIGUOUS name may resolve — picking // components[0] chose an arbitrary same-named component in a multi-app // monorepo (#764). Ambiguity falls through to the name-matcher, whose // proximity scoring decides. return components.length === 1 ? components[0]!.id : null; } /** * SvelteKit route file patterns */ const SVELTEKIT_ROUTE_FILES: Record = { '+page.svelte': 'page', '+page.ts': 'page-load', '+page.js': 'page-load', '+page.server.ts': 'page-server-load', '+page.server.js': 'page-server-load', '+layout.svelte': 'layout', '+layout.ts': 'layout-load', '+layout.js': 'layout-load', '+layout.server.ts': 'layout-server-load', '+layout.server.js': 'layout-server-load', '+server.ts': 'api-endpoint', '+server.js': 'api-endpoint', '+error.svelte': 'error-page', }; /** * Check if filename is a SvelteKit route file */ function getSvelteKitRouteInfo(fileName: string): string | null { return SVELTEKIT_ROUTE_FILES[fileName] || null; } /** * Convert a file path to a SvelteKit route path */ function filePathToSvelteKitRoute(filePath: string): string | null { // Normalize to forward slashes const normalized = filePath.replace(/\\/g, '/'); // Find the routes directory const routesIndex = normalized.indexOf('/routes/'); if (routesIndex === -1) return null; // Extract the path after routes/ const afterRoutes = normalized.substring(routesIndex + '/routes/'.length); // Remove the file name const lastSlash = afterRoutes.lastIndexOf('/'); const dirPath = lastSlash === -1 ? '' : afterRoutes.substring(0, lastSlash); // Convert SvelteKit param syntax [param] to :param let route = '/' + dirPath .replace(/\[\.\.\.([^\]]+)\]/g, '*$1') // [...rest] -> *rest .replace(/\[{2}([^\]]+)\]{2}/g, ':$1?') // [[optional]] -> :optional? .replace(/\[([^\]]+)\]/g, ':$1'); // [param] -> :param if (route === '/') return '/'; // Remove trailing slash return route.replace(/\/$/, ''); }