Explorar o código

Add Svelte language support with SvelteKit framework resolver

- Add 'svelte' to Language type, DEFAULT_CONFIG includes, grammars, and config validation
- Add SvelteExtractor that extracts <script> blocks and delegates to TS/JS TreeSitterExtractor
- Add Svelte framework resolver for runes ($state, $derived, $effect, etc.), store auto-subscriptions, SvelteKit module aliases ($app/*, $env/*, $lib/*), and SvelteKit route detection
- Update README to list Svelte and Dart in supported languages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Colby McHenry hai 4 meses
pai
achega
4c983ba082

+ 4 - 2
README.md

@@ -128,8 +128,8 @@ Know exactly what breaks before you change it. Trace callers, callees, and the f
 <tr>
 <td width="33%" valign="top">
 
-### 🌍 15+ Languages
-TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin—all with the same API.
+### 🌍 17+ Languages
+TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Dart, Svelte—all with the same API.
 
 </td>
 <td width="33%" valign="top">
@@ -598,6 +598,8 @@ The `.codegraph/config.json` file controls indexing behavior:
 | C++ | `.cpp`, `.hpp`, `.cc` | Full support |
 | Swift | `.swift` | Basic support |
 | Kotlin | `.kt` | Basic support |
+| Dart | `.dart` | Full support |
+| Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) |
 
 ## 🔧 Troubleshooting
 

+ 1 - 0
src/config.ts

@@ -54,6 +54,7 @@ export function validateConfig(config: unknown): config is CodeGraphConfig {
     'go',
     'rust',
     'java',
+    'svelte',
     'unknown',
   ];
   if (!c.languages.every((l) => validLanguages.includes(l as Language))) return false;

+ 5 - 2
src/extraction/grammars.ts

@@ -9,7 +9,7 @@ import Parser from 'tree-sitter';
 import { Language } from '../types';
 
 type GrammarLoader = () => unknown;
-type GrammarLanguage = Exclude<Language, 'liquid' | 'unknown'>;
+type GrammarLanguage = Exclude<Language, 'svelte' | 'liquid' | 'unknown'>;
 
 /**
  * Lazy grammar loaders — each language's native binding is only loaded
@@ -115,6 +115,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
   '.kts': 'kotlin',
   '.dart': 'dart',
   '.liquid': 'liquid',
+  '.svelte': 'svelte',
 };
 
 /**
@@ -186,6 +187,7 @@ export function detectLanguage(filePath: string): Language {
  * Check if a language is supported by currently available parsers.
  */
 export function isLanguageSupported(language: Language): boolean {
+  if (language === 'svelte') return true; // custom extractor (script block delegation)
   if (language === 'liquid') return true; // custom regex extractor
   if (language === 'unknown') return false;
   return loadGrammar(language) !== null;
@@ -197,7 +199,7 @@ export function isLanguageSupported(language: Language): boolean {
 export function getSupportedLanguages(): Language[] {
   const available = (Object.keys(grammarLoaders) as GrammarLanguage[])
     .filter((language) => loadGrammar(language) !== null);
-  return [...available, 'liquid'];
+  return [...available, 'svelte', 'liquid'];
 }
 
 /**
@@ -241,6 +243,7 @@ export function getLanguageDisplayName(language: Language): string {
     swift: 'Swift',
     kotlin: 'Kotlin',
     dart: 'Dart',
+    svelte: 'Svelte',
     liquid: 'Liquid',
     unknown: 'Unknown',
   };

+ 201 - 0
src/extraction/tree-sitter.ts

@@ -2287,6 +2287,201 @@ export class LiquidExtractor {
   }
 }
 
+/**
+ * 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.
+ *
+ * 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 {
+      // Create component node for the .svelte file itself
+      const componentNode = this.createComponentNode();
+
+      // Extract and process script blocks
+      const scriptBlocks = this.extractScriptBlocks();
+
+      for (const block of scriptBlocks) {
+        this.processScriptBlock(block, componentNode.id);
+      }
+    } catch (error) {
+      captureException(error, { operation: 'svelte-extraction', filePath: this.filePath });
+      this.errors.push({
+        message: `Svelte extraction error: ${error instanceof Error ? error.message : String(error)}`,
+        severity: '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;
+    }> = [];
+
+    const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g;
+    let match;
+
+    while ((match = scriptRegex.exec(this.source)) !== null) {
+      const attrs = match[1] || '';
+      const content = match.groups?.content || match[2] || '';
+
+      // Detect TypeScript from lang attribute
+      const isTypeScript = /lang\s*=\s*["'](ts|typescript)["']/.test(attrs);
+
+      // Detect module script
+      const isModule = /context\s*=\s*["']module["']/.test(attrs);
+
+      // Calculate start line of the script content (line after <script>)
+      const beforeScript = this.source.substring(0, match.index);
+      const scriptTagLine = (beforeScript.match(/\n/g) || []).length;
+      // The content starts on the line after the opening <script> tag
+      const openingTag = match[0].substring(0, match[0].indexOf('>') + 1);
+      const openingTagLines = (openingTag.match(/\n/g) || []).length;
+      const contentStartLine = scriptTagLine + openingTagLines + 1; // 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();
+
+    // Offset line numbers from script block back to .svelte file positions
+    for (const node of result.nodes) {
+      node.startLine += block.startLine;
+      node.endLine += block.startLine;
+      node.language = 'svelte'; // Mark as svelte, not TS/JS
+
+      this.nodes.push(node);
+
+      // Add containment edge from component to this node
+      this.edges.push({
+        source: componentNodeId,
+        target: node.id,
+        kind: 'contains',
+      });
+    }
+
+    // Offset edges (they reference line numbers)
+    for (const edge of result.edges) {
+      if (edge.line) {
+        edge.line += block.startLine;
+      }
+      this.edges.push(edge);
+    }
+
+    // Offset unresolved references
+    for (const ref of result.unresolvedReferences) {
+      ref.line += block.startLine;
+      ref.filePath = this.filePath;
+      ref.language = 'svelte';
+      this.unresolvedReferences.push(ref);
+    }
+
+    // Carry over errors
+    for (const error of result.errors) {
+      if (error.line) {
+        error.line += block.startLine;
+      }
+      this.errors.push(error);
+    }
+  }
+}
+
 /**
  * Extract nodes and edges from source code
  */
@@ -2297,6 +2492,12 @@ export function extractFromSource(
 ): ExtractionResult {
   const detectedLanguage = language || detectLanguage(filePath);
 
+  // Use custom extractor for Svelte
+  if (detectedLanguage === 'svelte') {
+    const extractor = new SvelteExtractor(filePath, source);
+    return extractor.extract();
+  }
+
   // Use custom extractor for Liquid
   if (detectedLanguage === 'liquid') {
     const extractor = new LiquidExtractor(filePath, source);

+ 3 - 0
src/resolution/frameworks/index.ts

@@ -8,6 +8,7 @@ import { FrameworkResolver, ResolutionContext } from '../types';
 import { laravelResolver } from './laravel';
 import { expressResolver } from './express';
 import { reactResolver } from './react';
+import { svelteResolver } from './svelte';
 import { djangoResolver, flaskResolver, fastapiResolver } from './python';
 import { railsResolver } from './ruby';
 import { springResolver } from './java';
@@ -25,6 +26,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   // JavaScript/TypeScript
   expressResolver,
   reactResolver,
+  svelteResolver,
   // Python
   djangoResolver,
   flaskResolver,
@@ -88,6 +90,7 @@ export function registerFrameworkResolver(resolver: FrameworkResolver): void {
 export { laravelResolver, FACADE_MAPPINGS } from './laravel';
 export { expressResolver } from './express';
 export { reactResolver } from './react';
+export { svelteResolver } from './svelte';
 export { djangoResolver, flaskResolver, fastapiResolver } from './python';
 export { railsResolver } from './ruby';
 export { springResolver } from './java';

+ 300 - 0
src/resolution/frameworks/svelte.ts

@@ -0,0 +1,300 @@
+/**
+ * Svelte / SvelteKit Framework Resolver
+ *
+ * Handles Svelte component references, Svelte 5 runes,
+ * store auto-subscriptions, and SvelteKit route/module patterns.
+ */
+
+import { Node } from '../../types';
+import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
+
+/**
+ * Svelte 5 runes — compiler-provided, not user code
+ */
+const SVELTE_RUNES = new Set([
+  '$state',
+  '$state.raw',
+  '$state.snapshot',
+  '$derived',
+  '$derived.by',
+  '$effect',
+  '$effect.pre',
+  '$effect.root',
+  '$effect.tracking',
+  '$props',
+  '$bindable',
+  '$inspect',
+  '$host',
+]);
+
+/**
+ * SvelteKit framework-provided module prefixes
+ */
+const SVELTEKIT_MODULE_PREFIXES = [
+  '$app/navigation',
+  '$app/stores',
+  '$app/environment',
+  '$app/forms',
+  '$app/paths',
+  '$env/static/private',
+  '$env/static/public',
+  '$env/dynamic/private',
+  '$env/dynamic/public',
+];
+
+export const svelteResolver: FrameworkResolver = {
+  name: 'svelte',
+
+  detect(context: ResolutionContext): boolean {
+    // Check for svelte or @sveltejs/kit in package.json
+    const packageJson = context.readFile('package.json');
+    if (packageJson) {
+      try {
+        const pkg = JSON.parse(packageJson);
+        const deps = { ...pkg.dependencies, ...pkg.devDependencies };
+        if (deps.svelte || deps['@sveltejs/kit']) {
+          return true;
+        }
+      } catch {
+        // Invalid JSON
+      }
+    }
+
+    // Check for .svelte files in project
+    const allFiles = context.getAllFiles();
+    return allFiles.some((f) => f.endsWith('.svelte'));
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    // Pattern 1: Svelte runes ($state, $derived, $effect, etc.)
+    if (isRuneReference(ref.referenceName)) {
+      // Runes are compiler-provided — return a high-confidence "framework" resolution
+      // so CodeGraph doesn't waste time searching for user-defined symbols.
+      // We use the fromNodeId as targetNodeId since runes don't have real targets.
+      return {
+        original: ref,
+        targetNodeId: ref.fromNodeId,
+        confidence: 1.0,
+        resolvedBy: 'framework',
+      };
+    }
+
+    // Pattern 2: Store auto-subscriptions ($storeName)
+    if (ref.referenceName.startsWith('$') && !ref.referenceName.startsWith('$$')) {
+      const storeName = ref.referenceName.substring(1);
+      const storeNode = context.getNodesByName(storeName).find(
+        (n) => n.kind === 'variable' || n.kind === 'constant'
+      );
+      if (storeNode) {
+        return {
+          original: ref,
+          targetNodeId: storeNode.id,
+          confidence: 0.85,
+          resolvedBy: 'framework',
+        };
+      }
+    }
+
+    // Pattern 3: SvelteKit module imports ($app/*, $env/*, $lib/*)
+    if (ref.referenceKind === 'imports' && ref.referenceName.startsWith('$')) {
+      // $lib/* resolves to src/lib/* — try to find the target file
+      if (ref.referenceName.startsWith('$lib/')) {
+        const libPath = ref.referenceName.replace('$lib/', 'src/lib/');
+        // Try common extensions
+        for (const ext of ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js']) {
+          const fullPath = libPath + ext;
+          if (context.fileExists(fullPath)) {
+            const nodes = context.getNodesInFile(fullPath);
+            if (nodes.length > 0) {
+              return {
+                original: ref,
+                targetNodeId: nodes[0]!.id,
+                confidence: 0.9,
+                resolvedBy: 'framework',
+              };
+            }
+          }
+        }
+      }
+
+      // $app/* and $env/* are framework-provided
+      if (SVELTEKIT_MODULE_PREFIXES.some((prefix) => ref.referenceName.startsWith(prefix))) {
+        return {
+          original: ref,
+          targetNodeId: ref.fromNodeId,
+          confidence: 1.0,
+          resolvedBy: 'framework',
+        };
+      }
+    }
+
+    // Pattern 4: Component references (PascalCase) — resolve to .svelte files
+    if (isPascalCase(ref.referenceName) && ref.referenceKind === 'calls') {
+      const result = resolveComponent(ref.referenceName, ref.filePath, context);
+      if (result) {
+        return {
+          original: ref,
+          targetNodeId: result,
+          confidence: 0.8,
+          resolvedBy: 'framework',
+        };
+      }
+    }
+
+    return null;
+  },
+
+  extractNodes(filePath: string, _content: string): Node[] {
+    const nodes: Node[] = [];
+    const now = Date.now();
+
+    // Detect SvelteKit route files
+    const fileName = filePath.split(/[/\\]/).pop() || '';
+    const routeMatch = getSvelteKitRouteInfo(fileName);
+
+    if (routeMatch) {
+      // Extract route path from directory structure
+      // e.g., src/routes/blog/[slug]/+page.svelte -> /blog/:slug
+      const routePath = filePathToSvelteKitRoute(filePath);
+
+      if (routePath) {
+        nodes.push({
+          id: `route:${filePath}:${routePath}:1`,
+          kind: 'route',
+          name: routePath,
+          qualifiedName: `${filePath}::route:${routePath}`,
+          filePath,
+          startLine: 1,
+          endLine: 1,
+          startColumn: 0,
+          endColumn: 0,
+          language: filePath.endsWith('.svelte') ? 'svelte' : 'typescript',
+          updatedAt: now,
+        });
+      }
+    }
+
+    return nodes;
+  },
+};
+
+/**
+ * Check if a reference name is a Svelte rune
+ */
+function isRuneReference(name: string): boolean {
+  // Direct match (e.g. $state, $derived)
+  if (SVELTE_RUNES.has(name)) return true;
+
+  // Rune method calls come through as the base rune name
+  // e.g. $state.raw -> the call is to "$state" with ".raw" accessed as property
+  // Check if it's a base rune that has sub-methods
+  if (name === '$state' || name === '$derived' || name === '$effect') return true;
+
+  return false;
+}
+
+/**
+ * Check if string is PascalCase
+ */
+function isPascalCase(str: string): boolean {
+  return /^[A-Z][a-zA-Z0-9]*$/.test(str);
+}
+
+/**
+ * Resolve a Svelte component reference to its .svelte file
+ */
+function resolveComponent(
+  name: string,
+  fromFile: string,
+  context: ResolutionContext
+): string | null {
+  // Look for matching .svelte files
+  const allFiles = context.getAllFiles();
+  const svelteFiles = allFiles.filter((f) => f.endsWith('.svelte'));
+
+  // Check for exact name match (Button -> Button.svelte)
+  for (const file of svelteFiles) {
+    const fileName = file.split(/[/\\]/).pop() || '';
+    const componentName = fileName.replace(/\.svelte$/, '');
+    if (componentName === name) {
+      const nodes = context.getNodesInFile(file);
+      const component = nodes.find((n) => n.kind === 'component' && n.name === name);
+      if (component) {
+        return component.id;
+      }
+    }
+  }
+
+  // Check same directory first for better specificity
+  const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/'));
+  for (const file of svelteFiles) {
+    if (file.startsWith(fromDir)) {
+      const fileName = file.split(/[/\\]/).pop() || '';
+      const componentName = fileName.replace(/\.svelte$/, '');
+      if (componentName === name) {
+        const nodes = context.getNodesInFile(file);
+        const component = nodes.find((n) => n.kind === 'component');
+        if (component) {
+          return component.id;
+        }
+      }
+    }
+  }
+
+  return null;
+}
+
+/**
+ * SvelteKit route file patterns
+ */
+const SVELTEKIT_ROUTE_FILES: Record<string, string> = {
+  '+page.svelte': 'page',
+  '+page.ts': 'page-load',
+  '+page.js': 'page-load',
+  '+page.server.ts': 'page-server-load',
+  '+page.server.js': 'page-server-load',
+  '+layout.svelte': 'layout',
+  '+layout.ts': 'layout-load',
+  '+layout.js': 'layout-load',
+  '+layout.server.ts': 'layout-server-load',
+  '+layout.server.js': 'layout-server-load',
+  '+server.ts': 'api-endpoint',
+  '+server.js': 'api-endpoint',
+  '+error.svelte': 'error-page',
+};
+
+/**
+ * Check if filename is a SvelteKit route file
+ */
+function getSvelteKitRouteInfo(fileName: string): string | null {
+  return SVELTEKIT_ROUTE_FILES[fileName] || null;
+}
+
+/**
+ * Convert a file path to a SvelteKit route path
+ */
+function filePathToSvelteKitRoute(filePath: string): string | null {
+  // Normalize to forward slashes
+  const normalized = filePath.replace(/\\/g, '/');
+
+  // Find the routes directory
+  const routesIndex = normalized.indexOf('/routes/');
+  if (routesIndex === -1) return null;
+
+  // Extract the path after routes/
+  const afterRoutes = normalized.substring(routesIndex + '/routes/'.length);
+
+  // Remove the file name
+  const lastSlash = afterRoutes.lastIndexOf('/');
+  const dirPath = lastSlash === -1 ? '' : afterRoutes.substring(0, lastSlash);
+
+  // Convert SvelteKit param syntax [param] to :param
+  let route = '/' + dirPath
+    .replace(/\[\.\.\.([^\]]+)\]/g, '*$1')  // [...rest] -> *rest
+    .replace(/\[{2}([^\]]+)\]{2}/g, ':$1?') // [[optional]] -> :optional?
+    .replace(/\[([^\]]+)\]/g, ':$1');        // [param] -> :param
+
+  if (route === '/') return '/';
+  // Remove trailing slash
+  return route.replace(/\/$/, '');
+}

+ 3 - 0
src/types.ts

@@ -72,6 +72,7 @@ export type Language =
   | 'swift'
   | 'kotlin'
   | 'dart'
+  | 'svelte'
   | 'liquid'
   | 'unknown';
 
@@ -511,6 +512,8 @@ export const DEFAULT_CONFIG: CodeGraphConfig = {
     '**/*.kts',
     // Dart
     '**/*.dart',
+    // Svelte
+    '**/*.svelte',
     // Liquid (Shopify themes)
     '**/*.liquid',
   ],