mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-11 09:55:39 +08:00
The arg-list group `(?:[^,()]+,\s*)*` was ambiguous: the trailing `\s*` and the next iteration's `[^,()]+` could both claim the same run of spaces, so a `.METHOD(...)` call with many comma-separated args that never reaches `use:` forced an exponential search. Measured on `app.get(arg0: value0, ...)`: 40ms at 20 args, 647ms at 24, 41.7s at 30, and no result after 120s at 60. Anchoring each repetition at a comma (`(?:[^,()]+,)*\s*`) makes the split unique — `,` is outside the char class, so there is nothing to re-partition. Same input is now 0.09ms at 1000 args. Match behaviour is unchanged: all four capture groups are identical on 18 hand-written Vapor route shapes (no args, single/multi path segments, `X.parameter`, multi-line calls, Environment.get non-matches) and on 200k fuzzed inputs. Fixes #1544
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
/**
|
|
* 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").
|
|
// Each arg repetition must end at a comma, and `,` is outside the char class,
|
|
// so the split is unique and matching stays linear. The earlier
|
|
// `(?:[^,()]+,\s*)*` was ambiguous — the trailing `\s*` and the next
|
|
// iteration's `[^,()]+` could both claim the same spaces — which backtracked
|
|
// exponentially on a long arg list that never reaches `use:`.
|
|
// The tail is `\s*` rather than a lazy `[^,()]*?` on purpose: both are
|
|
// linear, but the lazy form drops the "`use:` is preceded by a comma"
|
|
// requirement and widens the match set — `req.get(foo.use: bar)` would then
|
|
// be indexed as a route (groups `["req","get","foo.","bar"]`) where both
|
|
// this pattern and the original match nothing.
|
|
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;
|
|
}
|