/** * Swift Framework Resolver * * Handles SwiftUI, UIKit, and Vapor (server-side Swift) patterns. */ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; export const swiftUIResolver: FrameworkResolver = { name: 'swiftui', languages: ['swift'], detect(context: ResolutionContext): boolean { // Check for SwiftUI imports in Swift files const allFiles = context.getAllFiles(); for (const file of allFiles) { if (file.endsWith('.swift')) { const content = context.readFile(file); if (content && content.includes('import SwiftUI')) { return true; } } } // Check for Xcode project with SwiftUI for (const file of allFiles) { if (file.endsWith('.xcodeproj') || file.endsWith('.xcworkspace')) { return true; } } return false; }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: View references (SwiftUI views are PascalCase ending in View) if (ref.referenceName.endsWith('View') && /^[A-Z]/.test(ref.referenceName)) { const result = resolveByNameAndKind(ref.referenceName, VIEW_KINDS, VIEW_DIRS, context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.85, resolvedBy: 'framework', }; } } // Pattern 2: ViewModel/ObservableObject references if (ref.referenceName.endsWith('ViewModel') || ref.referenceName.endsWith('Store') || ref.referenceName.endsWith('Manager')) { const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VIEWMODEL_DIRS, context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.85, resolvedBy: 'framework', }; } } // Pattern 3: Model references if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { const result = resolveByNameAndKind(ref.referenceName, MODEL_KINDS, MODEL_DIRS, context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.7, resolvedBy: 'framework', }; } } return null; }, extract(filePath, content) { if (!filePath.endsWith('.swift')) return { nodes: [], references: [] }; const nodes: Node[] = []; const now = Date.now(); const safe = stripCommentsForRegex(content, 'swift'); // Extract SwiftUI View structs // struct ContentView: View { ... } const viewPattern = /struct\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*View/g; let match: RegExpExecArray | null; while ((match = viewPattern.exec(safe)) !== null) { const [, viewName] = match; const line = safe.slice(0, match.index).split('\n').length; nodes.push({ id: `view:${filePath}:${viewName}:${line}`, kind: 'component', name: viewName!, qualifiedName: `${filePath}::${viewName}`, filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: 'swift', updatedAt: now, }); } // Extract @main App entry point const appPattern = /@main\s+struct\s+(\w+)\s*:\s*App/g; while ((match = appPattern.exec(safe)) !== null) { const [, appName] = match; const line = safe.slice(0, match.index).split('\n').length; nodes.push({ id: `app:${filePath}:${appName}:${line}`, kind: 'class', name: appName!, qualifiedName: `${filePath}::${appName}`, filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: 'swift', updatedAt: now, }); } return { nodes, references: [] }; }, }; export const uikitResolver: FrameworkResolver = { name: 'uikit', languages: ['swift'], detect(context: ResolutionContext): boolean { const allFiles = context.getAllFiles(); for (const file of allFiles) { if (file.endsWith('.swift')) { const content = context.readFile(file); if (content && ( content.includes('import UIKit') || content.includes('UIViewController') || content.includes('UIView') )) { return true; } } } return false; }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: ViewController references if (ref.referenceName.endsWith('ViewController')) { const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VC_DIRS, context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.85, resolvedBy: 'framework', }; } } // Pattern 2: UIView subclass references if (ref.referenceName.endsWith('View') && !ref.referenceName.endsWith('ViewController')) { const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, UIVIEW_DIRS, context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework', }; } } // Pattern 3: Cell references if (ref.referenceName.endsWith('Cell')) { const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CELL_DIRS, context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.85, resolvedBy: 'framework', }; } } // Pattern 4: Delegate/DataSource references if (ref.referenceName.endsWith('Delegate') || ref.referenceName.endsWith('DataSource')) { const result = resolveByNameAndKind(ref.referenceName, PROTOCOL_KINDS, [], context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework', }; } } return null; }, extract(filePath, content) { if (!filePath.endsWith('.swift')) return { nodes: [], references: [] }; const nodes: Node[] = []; const now = Date.now(); const safe = stripCommentsForRegex(content, 'swift'); // Extract UIViewController subclasses const vcPattern = /class\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*UIViewController/g; let match: RegExpExecArray | null; while ((match = vcPattern.exec(safe)) !== null) { const [, vcName] = match; const line = safe.slice(0, match.index).split('\n').length; nodes.push({ id: `viewcontroller:${filePath}:${vcName}:${line}`, kind: 'class', name: vcName!, qualifiedName: `${filePath}::${vcName}`, filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: 'swift', updatedAt: now, }); } // Extract UIView subclasses const viewPattern = /class\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*UIView[^C]/g; while ((match = viewPattern.exec(safe)) !== null) { const [, viewName] = match; const line = safe.slice(0, match.index).split('\n').length; nodes.push({ id: `uiview:${filePath}:${viewName}:${line}`, kind: 'class', name: viewName!, qualifiedName: `${filePath}::${viewName}`, filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: 'swift', updatedAt: now, }); } return { nodes, references: [] }; }, }; export const vaporResolver: FrameworkResolver = { name: 'vapor', languages: ['swift'], detect(context: ResolutionContext): boolean { // Check for Package.swift with Vapor dependency const packageSwift = context.readFile('Package.swift'); if (packageSwift && packageSwift.includes('vapor')) { return true; } // Check for Vapor imports const allFiles = context.getAllFiles(); for (const file of allFiles) { if (file.endsWith('.swift')) { const content = context.readFile(file); if (content && content.includes('import Vapor')) { return true; } } } return false; }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // Pattern 1: Controller references if (ref.referenceName.endsWith('Controller')) { const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_CONTROLLER_DIRS, context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.85, resolvedBy: 'framework', }; } } // Pattern 2: Model references (Fluent) if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FLUENT_MODEL_DIRS, context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.75, resolvedBy: 'framework', }; } } // Pattern 3: Middleware references if (ref.referenceName.endsWith('Middleware')) { const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_MIDDLEWARE_DIRS, context); if (result) { return { original: ref, targetNodeId: result, confidence: 0.8, resolvedBy: 'framework', }; } } return null; }, extract(filePath, content) { if (!filePath.endsWith('.swift')) return { nodes: [], references: [] }; const nodes: Node[] = []; const references: UnresolvedRef[] = []; const now = Date.now(); const safe = stripCommentsForRegex(content, 'swift'); // Build a group-var → path-prefix map first. Modern Vapor routes live on a // grouped builder (`let todos = routes.grouped("todos"); todos.get(use: index)` // or `routes.group("todos") { todos in todos.get(use: index) }`), so the path // comes from the group, not the call. Roots (app/routes/router) have no prefix. const groupPrefix = new Map(); const segJoin = (existing: string, segsStr: string): string => { const segs = (segsStr.match(/"([^"]*)"/g) || []).map((s) => s.slice(1, -1)); return existing + segs.map((s) => '/' + s).join(''); }; let gm: RegExpExecArray | null; // let X = Y.grouped("a", "b") const groupedRegex = /\blet\s+(\w+)\s*=\s*(\w+)\.grouped\s*\(([^)]*)\)/g; while ((gm = groupedRegex.exec(safe)) !== null) { groupPrefix.set(gm[1]!, segJoin(groupPrefix.get(gm[2]!) ?? '', gm[3]!)); } // Y.group("a") { X in ... } const groupClosureRegex = /\b(\w+)\.group\s*\(([^)]*)\)\s*\{\s*(\w+)\s+in/g; while ((gm = groupClosureRegex.exec(safe)) !== null) { groupPrefix.set(gm[3]!, segJoin(groupPrefix.get(gm[1]!) ?? '', gm[2]!)); } // Vapor: .METHOD([path segs,] use: handler). Any receiver (app, // routes, or a grouped var); path segments optional and may be non-string // (`BlogUser.parameter`, `:id`, a path constant) so accept any comma-separated // args before `use:` — the label keeps only the string parts. `use:` // discriminates a real route from Environment.get("X")/req.parameters.get("X"). const routeRegex = /\b(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*((?:[^,()]+,\s*)*)use:\s*([A-Za-z_][\w.]*)/g; let match: RegExpExecArray | null; while ((match = routeRegex.exec(safe)) !== null) { const [, receiver, method, segsStr, handlerExpr] = match; const line = safe.slice(0, match.index).split('\n').length; const upper = method!.toUpperCase(); const routePath = (groupPrefix.get(receiver!) ?? '') + segJoin('', segsStr!) || '/'; const routeNode: Node = { id: `route:${filePath}:${line}:${upper}:${routePath}`, kind: 'route', name: `${upper} ${routePath}`, qualifiedName: `${filePath}::route:${routePath}`, filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: 'swift', updatedAt: now, }; nodes.push(routeNode); // Last segment of a dotted handler (self.list / UserController.list -> list) const handlerName = handlerExpr!.split('.').pop(); if (handlerName) { references.push({ fromNodeId: routeNode.id, referenceName: handlerName, referenceKind: 'references', line, column: 0, filePath, language: 'swift', }); } } return { nodes, references }; }, }; // Directory patterns const VIEW_DIRS = ['/Views/', '/View/', '/Screens/', '/Components/', '/UI/']; const VIEWMODEL_DIRS = ['/ViewModels/', '/ViewModel/', '/Stores/', '/Managers/', '/Services/']; const MODEL_DIRS = ['/Models/', '/Model/', '/Entities/', '/Domain/']; const VC_DIRS = ['/ViewControllers/', '/ViewController/', '/Controllers/', '/Screens/']; const UIVIEW_DIRS = ['/Views/', '/View/', '/UI/', '/Components/']; const CELL_DIRS = ['/Cells/', '/Cell/', '/Views/', '/TableViewCells/', '/CollectionViewCells/']; const VAPOR_CONTROLLER_DIRS = ['/Controllers/', '/Controller/', '/Routes/']; const FLUENT_MODEL_DIRS = ['/Models/', '/Model/', '/Entities/', '/Database/']; const VAPOR_MIDDLEWARE_DIRS = ['/Middleware/', '/Middlewares/']; const VIEW_KINDS = new Set(['struct', 'component']); const CLASS_KINDS = new Set(['class']); const MODEL_KINDS = new Set(['struct', 'class']); const PROTOCOL_KINDS = new Set(['protocol']); const VAPOR_CONTROLLER_KINDS = new Set(['class', 'struct']); /** * Resolve a symbol by name using indexed queries instead of scanning all files. */ function resolveByNameAndKind( name: string, kinds: Set, preferredDirPatterns: string[], context: ResolutionContext, ): string | null { const candidates = context.getNodesByName(name); if (candidates.length === 0) return null; const kindFiltered = candidates.filter((n) => kinds.has(n.kind)); if (kindFiltered.length === 0) return null; // Prefer candidates in framework-conventional directories if (preferredDirPatterns.length > 0) { const preferred = kindFiltered.filter((n) => preferredDirPatterns.some((d) => n.filePath.includes(d)) ); if (preferred.length > 0) return preferred[0]!.id; } // Fall back to any match return kindFiltered[0]!.id; }