Files
codegraph/src/extraction/svelte-extractor.ts
T
Colby MchenryandClaude Opus 5.5 de0a4eb362 fix(vue): a self-closing <script /> no longer swallows the next script (#3070)
The Vue extractor read a self-closing `<script src="./legacy.js" />` block
as an opening tag running on to the next `</script>`, as the Astro and
Svelte extractors did until PR #3063 (PRs #2995 and #2997) landed. The
`<script setup>` after it was parsed with whatever lay in between and
contributed nothing, and a `<template>` in between lost its calls and its
components too. One copy of the same pattern sat in each of 9 places in 5
files, and that fix reached the 6 copies in Astro, Svelte and the viewer.

sfc-script.ts, which the Vue, Svelte and Astro extractors already share,
now exports the script pattern and the script-or-style pattern, built from
@maxmilian's regexes, plus sfcBlockLineRanges, the line ranges a template
scan skips. A self-closing tag has no body, so it covers nothing and isn't
blanked, and calls in its attributes are read. All 9 sites use them: each
extractor's script blocks, Astro's covered ranges, both Svelte template
scans, Vue's template-components scan, the Vue template-calls blanking
and the viewer's highlight regions. The pair also accepts a closing tag
written with a space, `</script >`, as the template-calls blanking always
did; the viewer finds a block's body from its opening tag for that reason.

Validation on main 61776927 (Windows 11):
- Red/green: 4 Vue tests (a `<script src ... />` block before
  `<script setup>`, one before `<template>`, an attribute call on a
  self-closing script in the template, and `</script >`) fail with the
  previous sources and pass; the 6 Astro and Svelte tests still pass.
- A/B: 5 Vue repos (vuejs/core, vuejs/docs, NekoBox, justgage,
  element-plus-playground; none has a self-closing script) are
  byte-identical to main. 7 Astro repos, 2 Svelte subtrees (shadcn-svelte
  docs routes, SvelteKit's basics test app) and the flask and express
  controls are byte-identical to the Astro and Svelte fix before this one.

Fixes #2989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:02:53 +00:00

286 lines
10 KiB
TypeScript

import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Language } from '../types';
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script';
/** 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
*
* Svelte files are multi-language (script + template + style). Rather than
* 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 {
private filePath: string;
private source: string;
private nodes: Node[] = [];
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];
constructor(filePath: string, source: string) {
this.filePath = filePath;
this.source = source;
}
/**
* Extract from Svelte source
*/
extract(): ExtractionResult {
const startTime = Date.now();
try {
// The file, holding the component the .svelte file is
this.nodes.push(sfcFileNode(this.filePath, this.source, 'svelte'));
const componentNode = this.createComponentNode();
this.edges.push({ source: `file:${this.filePath}`, target: componentNode.id, kind: 'contains' });
// Extract and process script blocks
const scriptBlocks = this.extractScriptBlocks();
for (const block of scriptBlocks) {
this.processScriptBlock(block, componentNode.id);
}
// Extract function calls from template expressions ({fn(...)})
this.extractTemplateCalls(componentNode.id, scriptBlocks);
// Extract component usages from template (<ComponentName>)
this.extractTemplateComponents(componentNode.id);
// 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)}`,
severity: 'error',
code: 'parse_error',
});
}
return {
nodes: this.nodes,
edges: this.edges,
unresolvedReferences: this.unresolvedReferences,
errors: this.errors,
durationMs: Date.now() - startTime,
};
}
/**
* Create a component node for the .svelte file
*/
private createComponentNode(): Node {
const lines = this.source.split('\n');
const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath;
const componentName = fileName.replace(/\.svelte$/, '');
const id = generateNodeId(this.filePath, 'component', componentName, 1);
const node: Node = {
id,
kind: 'component',
name: componentName,
qualifiedName: `${this.filePath}::${componentName}`,
filePath: this.filePath,
language: 'svelte',
startLine: 1,
endLine: lines.length,
startColumn: 0,
endColumn: lines[lines.length - 1]?.length || 0,
isExported: true, // Svelte components are always importable
updatedAt: Date.now(),
};
this.nodes.push(node);
return node;
}
/**
* Extract <script> blocks from the Svelte source
*/
private extractScriptBlocks(): Array<{
content: string;
startLine: number;
isModule: boolean;
isTypeScript: boolean;
}> {
const blocks: Array<{
content: string;
startLine: number;
isModule: boolean;
isTypeScript: boolean;
}> = [];
// A self-closing `<script ... />` (e.g. a third-party script in
// `<svelte:head>`) has no body; match it on its own so it cannot run on to
// the next `</script>`.
const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT);
let match;
while ((match = scriptRegex.exec(this.source)) !== null) {
if (match.groups?.content === undefined) continue;
const attrs = match[1] || '';
const content = match.groups.content;
// Detect TypeScript from lang attribute
const isTypeScript = sfcScriptLanguage(attrs, 'svelte') === 'typescript';
// Detect module script: Svelte 4's `context="module"`, Svelte 5's `module`
const isModule = /context\s*=\s*["']module["']|(?:^|\s)module(?=[\s=]|$)/.test(attrs);
// Calculate the 0-indexed line where the content begins. The content
// starts right after the opening tag's `>` — its leading `\n` is part
// of the content, so relative line 1 sits ON the tag's closing line
// (adding 1 here double-counted the embedded newline and shifted every
// script-block symbol down a line).
const beforeScript = this.source.substring(0, match.index);
const scriptTagLine = (beforeScript.match(/\n/g) || []).length;
const openingTag = match[0].substring(0, match[0].indexOf('>') + 1);
const openingTagLines = (openingTag.match(/\n/g) || []).length;
const contentStartLine = scriptTagLine + openingTagLines; // 0-indexed line
blocks.push({
content,
startLine: contentStartLine,
isModule,
isTypeScript,
});
}
return blocks;
}
/**
* Process a script block by delegating to TreeSitterExtractor
*/
private processScriptBlock(
block: { content: string; startLine: number; isModule: boolean; isTypeScript: boolean },
componentNodeId: string
): void {
const scriptLanguage: Language = block.isTypeScript ? 'typescript' : 'javascript';
// Check if the script language parser is available
if (!isLanguageSupported(scriptLanguage)) {
this.errors.push({
message: `Parser for ${scriptLanguage} not available, cannot parse Svelte script block`,
severity: 'warning',
});
return;
}
// Delegate to TreeSitterExtractor
const extractor = new TreeSitterExtractor(this.filePath, block.content, scriptLanguage);
const result = extractor.extract();
foldScriptResult(
result,
{ filePath: this.filePath, componentNodeId, lineOffset: block.startLine, language: 'svelte', perInstance: !block.isModule },
{ nodes: this.nodes, edges: this.edges, unresolvedReferences: this.unresolvedReferences, errors: this.errors }
);
}
/**
* 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 {
// Line ranges covered by <script> and <style> blocks, so we skip them. A
// self-closing tag covers nothing: its attributes
// (`src={assetUrl('/analytics.js')}`) are template expressions.
const coveredRanges = sfcBlockLineRanges(this.source);
// 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',
});
}
}
}
}
/**
* Extract component usages from the Svelte template.
*
* PascalCase tags like <Modal>, <Button />, <DevServerPreview> represent
* component instantiations — analogous to function calls in imperative code.
* Capturing these creates graph edges from parent to child components and
* gives codegraph_explore anchor points in the template markup.
*/
private extractTemplateComponents(componentNodeId: string): void {
// Build ranges covered by <script> and <style> blocks to skip them
const coveredRanges = sfcBlockLineRanges(this.source);
const lines = this.source.split('\n');
// Match PascalCase opening/self-closing tags (closing tags </Foo> start with </ so won't match)
const componentTagRegex = /<([A-Z][a-zA-Z0-9_$]*)\b/g;
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue;
const line = lines[lineIdx]!;
let match;
while ((match = componentTagRegex.exec(line)) !== null) {
const componentName = match[1]!;
this.unresolvedReferences.push({
fromNodeId: componentNodeId,
referenceName: componentName,
referenceKind: 'references',
line: lineIdx + 1, // 1-indexed
column: match.index + 1,
filePath: this.filePath,
language: 'svelte',
});
}
}
}
}