feat: Add complete Svelte language support with template call extraction

Addresses Svelte function calls invisible in template expressions and ugly destructured variable names. Adds SvelteExtractor that delegates `
This commit is contained in:
Colby McHenry
2026-04-07 12:27:50 -05:00
parent b872459f19
commit 2ae9a465ec
3 changed files with 93 additions and 0 deletions
+77
View File
@@ -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',
});
}
}
}
}
}
+11
View File
@@ -590,6 +590,12 @@ export class TreeSitterExtractor {
// Languages with methodsAreTopLevel (e.g. Go) always treat them as methods
// Languages with getReceiverType (e.g. Rust) extract as method when receiver is found
if (!this.isInsideClassLikeNode() && !this.extractor.methodsAreTopLevel && !receiverType) {
// Skip method_definition nodes inside object literals (getters/setters/methods
// in inline objects). These are ephemeral and create noise (e.g., Svelte context
// objects: `ctx.set({ get view() { ... } })`).
if (node.parent?.type === 'object' || node.parent?.type === 'object_expression') {
return;
}
// Not inside a class-like node and no receiver type, treat as function
this.extractFunction(node);
return;
@@ -929,6 +935,11 @@ export class TreeSitterExtractor {
const valueNode = getChildByField(child, 'value');
if (nameNode) {
// Skip destructured patterns (e.g., `let { x, y } = $props()` in Svelte)
// These produce ugly multi-line names like "{ class: className }"
if (nameNode.type === 'object_pattern' || nameNode.type === 'array_pattern') {
continue;
}
const name = getNodeText(nameNode, this.source);
// Arrow functions / function expressions: extract as function instead of variable
if (valueNode && (valueNode.type === 'arrow_function' || valueNode.type === 'function_expression')) {