| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- /**
- * 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<string, string>();
- 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: <builder>.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<string>,
- 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;
- }
|