svelte-extractor.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Language } from '../types';
  2. import { generateNodeId } from './tree-sitter-helpers';
  3. import { TreeSitterExtractor } from './tree-sitter';
  4. import { isLanguageSupported } from './grammars';
  5. /** Svelte 5 rune names — compiler builtins, not real functions */
  6. const SVELTE_RUNES = new Set([
  7. '$props', '$state', '$derived', '$effect', '$bindable',
  8. '$inspect', '$host', '$snippet',
  9. ]);
  10. /**
  11. * SvelteExtractor - Extracts code relationships from Svelte component files
  12. *
  13. * Svelte files are multi-language (script + template + style). Rather than
  14. * parsing the full Svelte grammar, we extract the <script> block content
  15. * and delegate it to the TypeScript/JavaScript TreeSitterExtractor.
  16. *
  17. * Also extracts function calls from template expressions (`{fn(...)}`) so
  18. * cross-file call edges are captured even when calls live in markup.
  19. *
  20. * Every .svelte file produces a component node (Svelte components are always importable).
  21. */
  22. export class SvelteExtractor {
  23. private filePath: string;
  24. private source: string;
  25. private nodes: Node[] = [];
  26. private edges: Edge[] = [];
  27. private unresolvedReferences: UnresolvedReference[] = [];
  28. private errors: ExtractionError[] = [];
  29. constructor(filePath: string, source: string) {
  30. this.filePath = filePath;
  31. this.source = source;
  32. }
  33. /**
  34. * Extract from Svelte source
  35. */
  36. extract(): ExtractionResult {
  37. const startTime = Date.now();
  38. try {
  39. // Create component node for the .svelte file itself
  40. const componentNode = this.createComponentNode();
  41. // Extract and process script blocks
  42. const scriptBlocks = this.extractScriptBlocks();
  43. for (const block of scriptBlocks) {
  44. this.processScriptBlock(block, componentNode.id);
  45. }
  46. // Extract function calls from template expressions ({fn(...)})
  47. this.extractTemplateCalls(componentNode.id, scriptBlocks);
  48. // Filter out Svelte rune calls ($state, $props, $derived, etc.)
  49. this.unresolvedReferences = this.unresolvedReferences.filter(
  50. ref => !SVELTE_RUNES.has(ref.referenceName)
  51. );
  52. } catch (error) {
  53. this.errors.push({
  54. message: `Svelte extraction error: ${error instanceof Error ? error.message : String(error)}`,
  55. severity: 'error',
  56. code: 'parse_error',
  57. });
  58. }
  59. return {
  60. nodes: this.nodes,
  61. edges: this.edges,
  62. unresolvedReferences: this.unresolvedReferences,
  63. errors: this.errors,
  64. durationMs: Date.now() - startTime,
  65. };
  66. }
  67. /**
  68. * Create a component node for the .svelte file
  69. */
  70. private createComponentNode(): Node {
  71. const lines = this.source.split('\n');
  72. const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath;
  73. const componentName = fileName.replace(/\.svelte$/, '');
  74. const id = generateNodeId(this.filePath, 'component', componentName, 1);
  75. const node: Node = {
  76. id,
  77. kind: 'component',
  78. name: componentName,
  79. qualifiedName: `${this.filePath}::${componentName}`,
  80. filePath: this.filePath,
  81. language: 'svelte',
  82. startLine: 1,
  83. endLine: lines.length,
  84. startColumn: 0,
  85. endColumn: lines[lines.length - 1]?.length || 0,
  86. isExported: true, // Svelte components are always importable
  87. updatedAt: Date.now(),
  88. };
  89. this.nodes.push(node);
  90. return node;
  91. }
  92. /**
  93. * Extract <script> blocks from the Svelte source
  94. */
  95. private extractScriptBlocks(): Array<{
  96. content: string;
  97. startLine: number;
  98. isModule: boolean;
  99. isTypeScript: boolean;
  100. }> {
  101. const blocks: Array<{
  102. content: string;
  103. startLine: number;
  104. isModule: boolean;
  105. isTypeScript: boolean;
  106. }> = [];
  107. const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g;
  108. let match;
  109. while ((match = scriptRegex.exec(this.source)) !== null) {
  110. const attrs = match[1] || '';
  111. const content = match.groups?.content || match[2] || '';
  112. // Detect TypeScript from lang attribute
  113. const isTypeScript = /lang\s*=\s*["'](ts|typescript)["']/.test(attrs);
  114. // Detect module script
  115. const isModule = /context\s*=\s*["']module["']/.test(attrs);
  116. // Calculate start line of the script content (line after <script>)
  117. const beforeScript = this.source.substring(0, match.index);
  118. const scriptTagLine = (beforeScript.match(/\n/g) || []).length;
  119. // The content starts on the line after the opening <script> tag
  120. const openingTag = match[0].substring(0, match[0].indexOf('>') + 1);
  121. const openingTagLines = (openingTag.match(/\n/g) || []).length;
  122. const contentStartLine = scriptTagLine + openingTagLines + 1; // 0-indexed line
  123. blocks.push({
  124. content,
  125. startLine: contentStartLine,
  126. isModule,
  127. isTypeScript,
  128. });
  129. }
  130. return blocks;
  131. }
  132. /**
  133. * Process a script block by delegating to TreeSitterExtractor
  134. */
  135. private processScriptBlock(
  136. block: { content: string; startLine: number; isModule: boolean; isTypeScript: boolean },
  137. componentNodeId: string
  138. ): void {
  139. const scriptLanguage: Language = block.isTypeScript ? 'typescript' : 'javascript';
  140. // Check if the script language parser is available
  141. if (!isLanguageSupported(scriptLanguage)) {
  142. this.errors.push({
  143. message: `Parser for ${scriptLanguage} not available, cannot parse Svelte script block`,
  144. severity: 'warning',
  145. });
  146. return;
  147. }
  148. // Delegate to TreeSitterExtractor
  149. const extractor = new TreeSitterExtractor(this.filePath, block.content, scriptLanguage);
  150. const result = extractor.extract();
  151. // Offset line numbers from script block back to .svelte file positions
  152. for (const node of result.nodes) {
  153. node.startLine += block.startLine;
  154. node.endLine += block.startLine;
  155. node.language = 'svelte'; // Mark as svelte, not TS/JS
  156. this.nodes.push(node);
  157. // Add containment edge from component to this node
  158. this.edges.push({
  159. source: componentNodeId,
  160. target: node.id,
  161. kind: 'contains',
  162. });
  163. }
  164. // Offset edges (they reference line numbers)
  165. for (const edge of result.edges) {
  166. if (edge.line) {
  167. edge.line += block.startLine;
  168. }
  169. this.edges.push(edge);
  170. }
  171. // Offset unresolved references
  172. for (const ref of result.unresolvedReferences) {
  173. ref.line += block.startLine;
  174. ref.filePath = this.filePath;
  175. ref.language = 'svelte';
  176. this.unresolvedReferences.push(ref);
  177. }
  178. // Carry over errors
  179. for (const error of result.errors) {
  180. if (error.line) {
  181. error.line += block.startLine;
  182. }
  183. this.errors.push(error);
  184. }
  185. }
  186. /**
  187. * Extract function calls from Svelte template expressions.
  188. *
  189. * In Svelte, many function calls happen in markup (e.g., `class={cn(...)}`),
  190. * not inside `<script>` blocks. We scan the template portion for `{expression}`
  191. * blocks and extract call patterns from them.
  192. */
  193. private extractTemplateCalls(
  194. componentNodeId: string,
  195. _scriptBlocks: Array<{ content: string; startLine: number }>
  196. ): void {
  197. // Build a set of line ranges covered by <script> and <style> blocks so we skip them
  198. const coveredRanges: Array<[number, number]> = [];
  199. // Find all <script>...</script> and <style>...</style> ranges
  200. const tagRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
  201. let tagMatch;
  202. while ((tagMatch = tagRegex.exec(this.source)) !== null) {
  203. const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
  204. const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
  205. coveredRanges.push([startLine, endLine]);
  206. }
  207. // Find template expressions: {...} outside of script/style blocks
  208. // Matches curly-brace expressions, excluding Svelte block syntax ({#if}, {:else}, {/if}, {@html}, {@render})
  209. const lines = this.source.split('\n');
  210. const exprRegex = /\{([^}#/:@][^}]*)\}/g;
  211. for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
  212. // Skip lines inside script/style blocks
  213. if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue;
  214. const line = lines[lineIdx]!;
  215. let exprMatch;
  216. while ((exprMatch = exprRegex.exec(line)) !== null) {
  217. const expr = exprMatch[1]!;
  218. // Extract function calls: identifiers followed by (
  219. // Matches: cn(...), buttonVariants(...), obj.method(...)
  220. const callRegex = /\b([a-zA-Z_$][\w$.]*)\s*\(/g;
  221. let callMatch;
  222. while ((callMatch = callRegex.exec(expr)) !== null) {
  223. const calleeName = callMatch[1]!;
  224. // Skip Svelte runes, control flow keywords, and common non-function patterns
  225. if (SVELTE_RUNES.has(calleeName)) continue;
  226. if (calleeName === 'if' || calleeName === 'else' || calleeName === 'each' || calleeName === 'await') continue;
  227. this.unresolvedReferences.push({
  228. fromNodeId: componentNodeId,
  229. referenceName: calleeName,
  230. referenceKind: 'calls',
  231. line: lineIdx + 1, // 1-indexed
  232. column: exprMatch.index + callMatch.index,
  233. filePath: this.filePath,
  234. language: 'svelte',
  235. });
  236. }
  237. }
  238. }
  239. }
  240. }