|
|
@@ -3,6 +3,12 @@ import { generateNodeId } from './tree-sitter-helpers';
|
|
|
import { TreeSitterExtractor } from './tree-sitter';
|
|
|
import { isLanguageSupported } from './grammars';
|
|
|
|
|
|
+/** Svelte 5 rune names — compiler builtins, not real functions */
|
|
|
+const SVELTE_RUNES = new Set([
|
|
|
+ '$props', '$state', '$derived', '$effect', '$bindable',
|
|
|
+ '$inspect', '$host', '$snippet',
|
|
|
+]);
|
|
|
+
|
|
|
/**
|
|
|
* SvelteExtractor - Extracts code relationships from Svelte component files
|
|
|
*
|
|
|
@@ -10,6 +16,9 @@ import { isLanguageSupported } from './grammars';
|
|
|
* parsing the full Svelte grammar, we extract the <script> block content
|
|
|
* and delegate it to the TypeScript/JavaScript TreeSitterExtractor.
|
|
|
*
|
|
|
+ * Also extracts function calls from template expressions (`{fn(...)}`) so
|
|
|
+ * cross-file call edges are captured even when calls live in markup.
|
|
|
+ *
|
|
|
* Every .svelte file produces a component node (Svelte components are always importable).
|
|
|
*/
|
|
|
export class SvelteExtractor {
|
|
|
@@ -41,6 +50,14 @@ export class SvelteExtractor {
|
|
|
for (const block of scriptBlocks) {
|
|
|
this.processScriptBlock(block, componentNode.id);
|
|
|
}
|
|
|
+
|
|
|
+ // Extract function calls from template expressions ({fn(...)})
|
|
|
+ this.extractTemplateCalls(componentNode.id, scriptBlocks);
|
|
|
+
|
|
|
+ // Filter out Svelte rune calls ($state, $props, $derived, etc.)
|
|
|
+ this.unresolvedReferences = this.unresolvedReferences.filter(
|
|
|
+ ref => !SVELTE_RUNES.has(ref.referenceName)
|
|
|
+ );
|
|
|
} catch (error) {
|
|
|
this.errors.push({
|
|
|
message: `Svelte extraction error: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
@@ -196,4 +213,64 @@ export class SvelteExtractor {
|
|
|
this.errors.push(error);
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Extract function calls from Svelte template expressions.
|
|
|
+ *
|
|
|
+ * In Svelte, many function calls happen in markup (e.g., `class={cn(...)}`),
|
|
|
+ * not inside `<script>` blocks. We scan the template portion for `{expression}`
|
|
|
+ * blocks and extract call patterns from them.
|
|
|
+ */
|
|
|
+ private extractTemplateCalls(
|
|
|
+ componentNodeId: string,
|
|
|
+ _scriptBlocks: Array<{ content: string; startLine: number }>
|
|
|
+ ): void {
|
|
|
+ // Build a set of line ranges covered by <script> and <style> blocks so we skip them
|
|
|
+ const coveredRanges: Array<[number, number]> = [];
|
|
|
+
|
|
|
+ // Find all <script>...</script> and <style>...</style> ranges
|
|
|
+ const tagRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
|
|
|
+ let tagMatch;
|
|
|
+ while ((tagMatch = tagRegex.exec(this.source)) !== null) {
|
|
|
+ const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
|
|
|
+ const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
|
|
|
+ coveredRanges.push([startLine, endLine]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Find template expressions: {...} outside of script/style blocks
|
|
|
+ // Matches curly-brace expressions, excluding Svelte block syntax ({#if}, {:else}, {/if}, {@html}, {@render})
|
|
|
+ const lines = this.source.split('\n');
|
|
|
+ const exprRegex = /\{([^}#/:@][^}]*)\}/g;
|
|
|
+
|
|
|
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
|
+ // Skip lines inside script/style blocks
|
|
|
+ if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue;
|
|
|
+
|
|
|
+ const line = lines[lineIdx]!;
|
|
|
+ let exprMatch;
|
|
|
+ while ((exprMatch = exprRegex.exec(line)) !== null) {
|
|
|
+ const expr = exprMatch[1]!;
|
|
|
+ // Extract function calls: identifiers followed by (
|
|
|
+ // Matches: cn(...), buttonVariants(...), obj.method(...)
|
|
|
+ const callRegex = /\b([a-zA-Z_$][\w$.]*)\s*\(/g;
|
|
|
+ let callMatch;
|
|
|
+ while ((callMatch = callRegex.exec(expr)) !== null) {
|
|
|
+ const calleeName = callMatch[1]!;
|
|
|
+ // Skip Svelte runes, control flow keywords, and common non-function patterns
|
|
|
+ if (SVELTE_RUNES.has(calleeName)) continue;
|
|
|
+ if (calleeName === 'if' || calleeName === 'else' || calleeName === 'each' || calleeName === 'await') continue;
|
|
|
+
|
|
|
+ this.unresolvedReferences.push({
|
|
|
+ fromNodeId: componentNodeId,
|
|
|
+ referenceName: calleeName,
|
|
|
+ referenceKind: 'calls',
|
|
|
+ line: lineIdx + 1, // 1-indexed
|
|
|
+ column: exprMatch.index + callMatch.index,
|
|
|
+ filePath: this.filePath,
|
|
|
+ language: 'svelte',
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|