svelte.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. /**
  2. * Svelte / SvelteKit Framework Resolver
  3. *
  4. * Handles Svelte component references, Svelte 5 runes,
  5. * store auto-subscriptions, and SvelteKit route/module patterns.
  6. */
  7. import { Node } from '../../types';
  8. import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
  9. /**
  10. * Svelte 5 runes — compiler-provided, not user code
  11. */
  12. const SVELTE_RUNES = new Set([
  13. '$state',
  14. '$state.raw',
  15. '$state.snapshot',
  16. '$derived',
  17. '$derived.by',
  18. '$effect',
  19. '$effect.pre',
  20. '$effect.root',
  21. '$effect.tracking',
  22. '$props',
  23. '$bindable',
  24. '$inspect',
  25. '$host',
  26. ]);
  27. /**
  28. * SvelteKit framework-provided module prefixes
  29. */
  30. const SVELTEKIT_MODULE_PREFIXES = [
  31. '$app/navigation',
  32. '$app/stores',
  33. '$app/environment',
  34. '$app/forms',
  35. '$app/paths',
  36. '$env/static/private',
  37. '$env/static/public',
  38. '$env/dynamic/private',
  39. '$env/dynamic/public',
  40. ];
  41. export const svelteResolver: FrameworkResolver = {
  42. name: 'svelte',
  43. languages: ['svelte'],
  44. detect(context: ResolutionContext): boolean {
  45. // Check for svelte or @sveltejs/kit in package.json
  46. const packageJson = context.readFile('package.json');
  47. if (packageJson) {
  48. try {
  49. const pkg = JSON.parse(packageJson);
  50. const deps = { ...pkg.dependencies, ...pkg.devDependencies };
  51. if (deps.svelte || deps['@sveltejs/kit']) {
  52. return true;
  53. }
  54. } catch {
  55. // Invalid JSON
  56. }
  57. }
  58. // Check for .svelte files in project
  59. const allFiles = context.getAllFiles();
  60. return allFiles.some((f) => f.endsWith('.svelte'));
  61. },
  62. resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
  63. // Pattern 1: Svelte runes ($state, $derived, $effect, etc.)
  64. if (isRuneReference(ref.referenceName)) {
  65. // Runes are compiler-provided — return a high-confidence "framework" resolution
  66. // so CodeGraph doesn't waste time searching for user-defined symbols.
  67. // We use the fromNodeId as targetNodeId since runes don't have real targets.
  68. return {
  69. original: ref,
  70. targetNodeId: ref.fromNodeId,
  71. confidence: 1.0,
  72. resolvedBy: 'framework',
  73. };
  74. }
  75. // Pattern 2: Store auto-subscriptions ($storeName)
  76. if (ref.referenceName.startsWith('$') && !ref.referenceName.startsWith('$$')) {
  77. const storeName = ref.referenceName.substring(1);
  78. const storeNode = context.getNodesByName(storeName).find(
  79. (n) => n.kind === 'variable' || n.kind === 'constant'
  80. );
  81. if (storeNode) {
  82. return {
  83. original: ref,
  84. targetNodeId: storeNode.id,
  85. confidence: 0.85,
  86. resolvedBy: 'framework',
  87. };
  88. }
  89. }
  90. // Pattern 3: SvelteKit module imports ($app/*, $env/*, $lib/*)
  91. if (ref.referenceKind === 'imports' && ref.referenceName.startsWith('$')) {
  92. // $lib/* resolves to src/lib/* — try to find the target file
  93. if (ref.referenceName.startsWith('$lib/')) {
  94. const libPath = ref.referenceName.replace('$lib/', 'src/lib/');
  95. // Try common extensions
  96. for (const ext of ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js']) {
  97. const fullPath = libPath + ext;
  98. if (context.fileExists(fullPath)) {
  99. const nodes = context.getNodesInFile(fullPath);
  100. if (nodes.length > 0) {
  101. return {
  102. original: ref,
  103. targetNodeId: nodes[0]!.id,
  104. confidence: 0.9,
  105. resolvedBy: 'framework',
  106. };
  107. }
  108. }
  109. }
  110. }
  111. // $app/* and $env/* are framework-provided
  112. if (SVELTEKIT_MODULE_PREFIXES.some((prefix) => ref.referenceName.startsWith(prefix))) {
  113. return {
  114. original: ref,
  115. targetNodeId: ref.fromNodeId,
  116. confidence: 1.0,
  117. resolvedBy: 'framework',
  118. };
  119. }
  120. }
  121. // Pattern 4: Component references (PascalCase) — resolve to .svelte files
  122. if (isPascalCase(ref.referenceName) && ref.referenceKind === 'calls') {
  123. const result = resolveComponent(ref.referenceName, ref.filePath, context);
  124. if (result) {
  125. return {
  126. original: ref,
  127. targetNodeId: result,
  128. confidence: 0.8,
  129. resolvedBy: 'framework',
  130. };
  131. }
  132. }
  133. return null;
  134. },
  135. extract(filePath, _content) {
  136. const nodes: Node[] = [];
  137. const now = Date.now();
  138. // Detect SvelteKit route files
  139. const fileName = filePath.split(/[/\\]/).pop() || '';
  140. const routeMatch = getSvelteKitRouteInfo(fileName);
  141. if (routeMatch) {
  142. // Extract route path from directory structure
  143. // e.g., src/routes/blog/[slug]/+page.svelte -> /blog/:slug
  144. const routePath = filePathToSvelteKitRoute(filePath);
  145. if (routePath) {
  146. nodes.push({
  147. id: `route:${filePath}:${routePath}:1`,
  148. kind: 'route',
  149. name: routePath,
  150. qualifiedName: `${filePath}::route:${routePath}`,
  151. filePath,
  152. startLine: 1,
  153. endLine: 1,
  154. startColumn: 0,
  155. endColumn: 0,
  156. language: filePath.endsWith('.svelte') ? 'svelte' : 'typescript',
  157. updatedAt: now,
  158. });
  159. }
  160. }
  161. return { nodes, references: [] };
  162. },
  163. };
  164. /**
  165. * Check if a reference name is a Svelte rune
  166. */
  167. function isRuneReference(name: string): boolean {
  168. // Direct match (e.g. $state, $derived)
  169. if (SVELTE_RUNES.has(name)) return true;
  170. // Rune method calls come through as the base rune name
  171. // e.g. $state.raw -> the call is to "$state" with ".raw" accessed as property
  172. // Check if it's a base rune that has sub-methods
  173. if (name === '$state' || name === '$derived' || name === '$effect') return true;
  174. return false;
  175. }
  176. /**
  177. * Check if string is PascalCase
  178. */
  179. function isPascalCase(str: string): boolean {
  180. return /^[A-Z][a-zA-Z0-9]*$/.test(str);
  181. }
  182. /**
  183. * Resolve a Svelte component reference using name-based lookup
  184. */
  185. function resolveComponent(
  186. name: string,
  187. fromFile: string,
  188. context: ResolutionContext
  189. ): string | null {
  190. // Look for component nodes by name
  191. const candidates = context.getNodesByName(name);
  192. const components = candidates.filter((n) => n.kind === 'component');
  193. if (components.length === 0) return null;
  194. // Prefer same directory
  195. const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/'));
  196. const sameDir = components.filter((n) => n.filePath.startsWith(fromDir));
  197. if (sameDir.length > 0) return sameDir[0]!.id;
  198. // No positional signal: only an UNAMBIGUOUS name may resolve — picking
  199. // components[0] chose an arbitrary same-named component in a multi-app
  200. // monorepo (#764). Ambiguity falls through to the name-matcher, whose
  201. // proximity scoring decides.
  202. return components.length === 1 ? components[0]!.id : null;
  203. }
  204. /**
  205. * SvelteKit route file patterns
  206. */
  207. const SVELTEKIT_ROUTE_FILES: Record<string, string> = {
  208. '+page.svelte': 'page',
  209. '+page.ts': 'page-load',
  210. '+page.js': 'page-load',
  211. '+page.server.ts': 'page-server-load',
  212. '+page.server.js': 'page-server-load',
  213. '+layout.svelte': 'layout',
  214. '+layout.ts': 'layout-load',
  215. '+layout.js': 'layout-load',
  216. '+layout.server.ts': 'layout-server-load',
  217. '+layout.server.js': 'layout-server-load',
  218. '+server.ts': 'api-endpoint',
  219. '+server.js': 'api-endpoint',
  220. '+error.svelte': 'error-page',
  221. };
  222. /**
  223. * Check if filename is a SvelteKit route file
  224. */
  225. function getSvelteKitRouteInfo(fileName: string): string | null {
  226. return SVELTEKIT_ROUTE_FILES[fileName] || null;
  227. }
  228. /**
  229. * Convert a file path to a SvelteKit route path
  230. */
  231. function filePathToSvelteKitRoute(filePath: string): string | null {
  232. // Normalize to forward slashes
  233. const normalized = filePath.replace(/\\/g, '/');
  234. // Find the routes directory
  235. const routesIndex = normalized.indexOf('/routes/');
  236. if (routesIndex === -1) return null;
  237. // Extract the path after routes/
  238. const afterRoutes = normalized.substring(routesIndex + '/routes/'.length);
  239. // Remove the file name
  240. const lastSlash = afterRoutes.lastIndexOf('/');
  241. const dirPath = lastSlash === -1 ? '' : afterRoutes.substring(0, lastSlash);
  242. // Convert SvelteKit param syntax [param] to :param
  243. let route = '/' + dirPath
  244. .replace(/\[\.\.\.([^\]]+)\]/g, '*$1') // [...rest] -> *rest
  245. .replace(/\[{2}([^\]]+)\]{2}/g, ':$1?') // [[optional]] -> :optional?
  246. .replace(/\[([^\]]+)\]/g, ':$1'); // [param] -> :param
  247. if (route === '/') return '/';
  248. // Remove trailing slash
  249. return route.replace(/\/$/, '');
  250. }