swift.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. /**
  2. * Swift Framework Resolver
  3. *
  4. * Handles SwiftUI, UIKit, and Vapor (server-side Swift) patterns.
  5. */
  6. import { Node } from '../../types';
  7. import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
  8. import { stripCommentsForRegex } from '../strip-comments';
  9. export const swiftUIResolver: FrameworkResolver = {
  10. name: 'swiftui',
  11. languages: ['swift'],
  12. detect(context: ResolutionContext): boolean {
  13. // Check for SwiftUI imports in Swift files
  14. const allFiles = context.getAllFiles();
  15. for (const file of allFiles) {
  16. if (file.endsWith('.swift')) {
  17. const content = context.readFile(file);
  18. if (content && content.includes('import SwiftUI')) {
  19. return true;
  20. }
  21. }
  22. }
  23. // Check for Xcode project with SwiftUI
  24. for (const file of allFiles) {
  25. if (file.endsWith('.xcodeproj') || file.endsWith('.xcworkspace')) {
  26. return true;
  27. }
  28. }
  29. return false;
  30. },
  31. resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
  32. // Pattern 1: View references (SwiftUI views are PascalCase ending in View)
  33. if (ref.referenceName.endsWith('View') && /^[A-Z]/.test(ref.referenceName)) {
  34. const result = resolveByNameAndKind(ref.referenceName, VIEW_KINDS, VIEW_DIRS, context);
  35. if (result) {
  36. return {
  37. original: ref,
  38. targetNodeId: result,
  39. confidence: 0.85,
  40. resolvedBy: 'framework',
  41. };
  42. }
  43. }
  44. // Pattern 2: ViewModel/ObservableObject references
  45. if (ref.referenceName.endsWith('ViewModel') || ref.referenceName.endsWith('Store') || ref.referenceName.endsWith('Manager')) {
  46. const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VIEWMODEL_DIRS, context);
  47. if (result) {
  48. return {
  49. original: ref,
  50. targetNodeId: result,
  51. confidence: 0.85,
  52. resolvedBy: 'framework',
  53. };
  54. }
  55. }
  56. // Pattern 3: Model references
  57. if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
  58. const result = resolveByNameAndKind(ref.referenceName, MODEL_KINDS, MODEL_DIRS, context);
  59. if (result) {
  60. return {
  61. original: ref,
  62. targetNodeId: result,
  63. confidence: 0.7,
  64. resolvedBy: 'framework',
  65. };
  66. }
  67. }
  68. return null;
  69. },
  70. extract(filePath, content) {
  71. if (!filePath.endsWith('.swift')) return { nodes: [], references: [] };
  72. const nodes: Node[] = [];
  73. const now = Date.now();
  74. const safe = stripCommentsForRegex(content, 'swift');
  75. // Extract SwiftUI View structs
  76. // struct ContentView: View { ... }
  77. const viewPattern = /struct\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*View/g;
  78. let match: RegExpExecArray | null;
  79. while ((match = viewPattern.exec(safe)) !== null) {
  80. const [, viewName] = match;
  81. const line = safe.slice(0, match.index).split('\n').length;
  82. nodes.push({
  83. id: `view:${filePath}:${viewName}:${line}`,
  84. kind: 'component',
  85. name: viewName!,
  86. qualifiedName: `${filePath}::${viewName}`,
  87. filePath,
  88. startLine: line,
  89. endLine: line,
  90. startColumn: 0,
  91. endColumn: match[0].length,
  92. language: 'swift',
  93. updatedAt: now,
  94. });
  95. }
  96. // Extract @main App entry point
  97. const appPattern = /@main\s+struct\s+(\w+)\s*:\s*App/g;
  98. while ((match = appPattern.exec(safe)) !== null) {
  99. const [, appName] = match;
  100. const line = safe.slice(0, match.index).split('\n').length;
  101. nodes.push({
  102. id: `app:${filePath}:${appName}:${line}`,
  103. kind: 'class',
  104. name: appName!,
  105. qualifiedName: `${filePath}::${appName}`,
  106. filePath,
  107. startLine: line,
  108. endLine: line,
  109. startColumn: 0,
  110. endColumn: match[0].length,
  111. language: 'swift',
  112. updatedAt: now,
  113. });
  114. }
  115. return { nodes, references: [] };
  116. },
  117. };
  118. export const uikitResolver: FrameworkResolver = {
  119. name: 'uikit',
  120. languages: ['swift'],
  121. detect(context: ResolutionContext): boolean {
  122. const allFiles = context.getAllFiles();
  123. for (const file of allFiles) {
  124. if (file.endsWith('.swift')) {
  125. const content = context.readFile(file);
  126. if (content && (
  127. content.includes('import UIKit') ||
  128. content.includes('UIViewController') ||
  129. content.includes('UIView')
  130. )) {
  131. return true;
  132. }
  133. }
  134. }
  135. return false;
  136. },
  137. resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
  138. // Pattern 1: ViewController references
  139. if (ref.referenceName.endsWith('ViewController')) {
  140. const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, VC_DIRS, context);
  141. if (result) {
  142. return {
  143. original: ref,
  144. targetNodeId: result,
  145. confidence: 0.85,
  146. resolvedBy: 'framework',
  147. };
  148. }
  149. }
  150. // Pattern 2: UIView subclass references
  151. if (ref.referenceName.endsWith('View') && !ref.referenceName.endsWith('ViewController')) {
  152. const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, UIVIEW_DIRS, context);
  153. if (result) {
  154. return {
  155. original: ref,
  156. targetNodeId: result,
  157. confidence: 0.8,
  158. resolvedBy: 'framework',
  159. };
  160. }
  161. }
  162. // Pattern 3: Cell references
  163. if (ref.referenceName.endsWith('Cell')) {
  164. const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, CELL_DIRS, context);
  165. if (result) {
  166. return {
  167. original: ref,
  168. targetNodeId: result,
  169. confidence: 0.85,
  170. resolvedBy: 'framework',
  171. };
  172. }
  173. }
  174. // Pattern 4: Delegate/DataSource references
  175. if (ref.referenceName.endsWith('Delegate') || ref.referenceName.endsWith('DataSource')) {
  176. const result = resolveByNameAndKind(ref.referenceName, PROTOCOL_KINDS, [], context);
  177. if (result) {
  178. return {
  179. original: ref,
  180. targetNodeId: result,
  181. confidence: 0.8,
  182. resolvedBy: 'framework',
  183. };
  184. }
  185. }
  186. return null;
  187. },
  188. extract(filePath, content) {
  189. if (!filePath.endsWith('.swift')) return { nodes: [], references: [] };
  190. const nodes: Node[] = [];
  191. const now = Date.now();
  192. const safe = stripCommentsForRegex(content, 'swift');
  193. // Extract UIViewController subclasses
  194. const vcPattern = /class\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*UIViewController/g;
  195. let match: RegExpExecArray | null;
  196. while ((match = vcPattern.exec(safe)) !== null) {
  197. const [, vcName] = match;
  198. const line = safe.slice(0, match.index).split('\n').length;
  199. nodes.push({
  200. id: `viewcontroller:${filePath}:${vcName}:${line}`,
  201. kind: 'class',
  202. name: vcName!,
  203. qualifiedName: `${filePath}::${vcName}`,
  204. filePath,
  205. startLine: line,
  206. endLine: line,
  207. startColumn: 0,
  208. endColumn: match[0].length,
  209. language: 'swift',
  210. updatedAt: now,
  211. });
  212. }
  213. // Extract UIView subclasses
  214. const viewPattern = /class\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*UIView[^C]/g;
  215. while ((match = viewPattern.exec(safe)) !== null) {
  216. const [, viewName] = match;
  217. const line = safe.slice(0, match.index).split('\n').length;
  218. nodes.push({
  219. id: `uiview:${filePath}:${viewName}:${line}`,
  220. kind: 'class',
  221. name: viewName!,
  222. qualifiedName: `${filePath}::${viewName}`,
  223. filePath,
  224. startLine: line,
  225. endLine: line,
  226. startColumn: 0,
  227. endColumn: match[0].length,
  228. language: 'swift',
  229. updatedAt: now,
  230. });
  231. }
  232. return { nodes, references: [] };
  233. },
  234. };
  235. export const vaporResolver: FrameworkResolver = {
  236. name: 'vapor',
  237. languages: ['swift'],
  238. detect(context: ResolutionContext): boolean {
  239. // Check for Package.swift with Vapor dependency
  240. const packageSwift = context.readFile('Package.swift');
  241. if (packageSwift && packageSwift.includes('vapor')) {
  242. return true;
  243. }
  244. // Check for Vapor imports
  245. const allFiles = context.getAllFiles();
  246. for (const file of allFiles) {
  247. if (file.endsWith('.swift')) {
  248. const content = context.readFile(file);
  249. if (content && content.includes('import Vapor')) {
  250. return true;
  251. }
  252. }
  253. }
  254. return false;
  255. },
  256. resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
  257. // Pattern 1: Controller references
  258. if (ref.referenceName.endsWith('Controller')) {
  259. const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_CONTROLLER_DIRS, context);
  260. if (result) {
  261. return {
  262. original: ref,
  263. targetNodeId: result,
  264. confidence: 0.85,
  265. resolvedBy: 'framework',
  266. };
  267. }
  268. }
  269. // Pattern 2: Model references (Fluent)
  270. if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
  271. const result = resolveByNameAndKind(ref.referenceName, CLASS_KINDS, FLUENT_MODEL_DIRS, context);
  272. if (result) {
  273. return {
  274. original: ref,
  275. targetNodeId: result,
  276. confidence: 0.75,
  277. resolvedBy: 'framework',
  278. };
  279. }
  280. }
  281. // Pattern 3: Middleware references
  282. if (ref.referenceName.endsWith('Middleware')) {
  283. const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_MIDDLEWARE_DIRS, context);
  284. if (result) {
  285. return {
  286. original: ref,
  287. targetNodeId: result,
  288. confidence: 0.8,
  289. resolvedBy: 'framework',
  290. };
  291. }
  292. }
  293. return null;
  294. },
  295. extract(filePath, content) {
  296. if (!filePath.endsWith('.swift')) return { nodes: [], references: [] };
  297. const nodes: Node[] = [];
  298. const references: UnresolvedRef[] = [];
  299. const now = Date.now();
  300. const safe = stripCommentsForRegex(content, 'swift');
  301. // Build a group-var → path-prefix map first. Modern Vapor routes live on a
  302. // grouped builder (`let todos = routes.grouped("todos"); todos.get(use: index)`
  303. // or `routes.group("todos") { todos in todos.get(use: index) }`), so the path
  304. // comes from the group, not the call. Roots (app/routes/router) have no prefix.
  305. const groupPrefix = new Map<string, string>();
  306. const segJoin = (existing: string, segsStr: string): string => {
  307. const segs = (segsStr.match(/"([^"]*)"/g) || []).map((s) => s.slice(1, -1));
  308. return existing + segs.map((s) => '/' + s).join('');
  309. };
  310. let gm: RegExpExecArray | null;
  311. // let X = Y.grouped("a", "b")
  312. const groupedRegex = /\blet\s+(\w+)\s*=\s*(\w+)\.grouped\s*\(([^)]*)\)/g;
  313. while ((gm = groupedRegex.exec(safe)) !== null) {
  314. groupPrefix.set(gm[1]!, segJoin(groupPrefix.get(gm[2]!) ?? '', gm[3]!));
  315. }
  316. // Y.group("a") { X in ... }
  317. const groupClosureRegex = /\b(\w+)\.group\s*\(([^)]*)\)\s*\{\s*(\w+)\s+in/g;
  318. while ((gm = groupClosureRegex.exec(safe)) !== null) {
  319. groupPrefix.set(gm[3]!, segJoin(groupPrefix.get(gm[1]!) ?? '', gm[2]!));
  320. }
  321. // Vapor: <builder>.METHOD([path segs,] use: handler). Any receiver (app,
  322. // routes, or a grouped var); path segments optional and may be non-string
  323. // (`BlogUser.parameter`, `:id`, a path constant) so accept any comma-separated
  324. // args before `use:` — the label keeps only the string parts. `use:`
  325. // discriminates a real route from Environment.get("X")/req.parameters.get("X").
  326. const routeRegex = /\b(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*((?:[^,()]+,\s*)*)use:\s*([A-Za-z_][\w.]*)/g;
  327. let match: RegExpExecArray | null;
  328. while ((match = routeRegex.exec(safe)) !== null) {
  329. const [, receiver, method, segsStr, handlerExpr] = match;
  330. const line = safe.slice(0, match.index).split('\n').length;
  331. const upper = method!.toUpperCase();
  332. const routePath = (groupPrefix.get(receiver!) ?? '') + segJoin('', segsStr!) || '/';
  333. const routeNode: Node = {
  334. id: `route:${filePath}:${line}:${upper}:${routePath}`,
  335. kind: 'route',
  336. name: `${upper} ${routePath}`,
  337. qualifiedName: `${filePath}::route:${routePath}`,
  338. filePath,
  339. startLine: line,
  340. endLine: line,
  341. startColumn: 0,
  342. endColumn: match[0].length,
  343. language: 'swift',
  344. updatedAt: now,
  345. };
  346. nodes.push(routeNode);
  347. // Last segment of a dotted handler (self.list / UserController.list -> list)
  348. const handlerName = handlerExpr!.split('.').pop();
  349. if (handlerName) {
  350. references.push({
  351. fromNodeId: routeNode.id,
  352. referenceName: handlerName,
  353. referenceKind: 'references',
  354. line,
  355. column: 0,
  356. filePath,
  357. language: 'swift',
  358. });
  359. }
  360. }
  361. return { nodes, references };
  362. },
  363. };
  364. // Directory patterns
  365. const VIEW_DIRS = ['/Views/', '/View/', '/Screens/', '/Components/', '/UI/'];
  366. const VIEWMODEL_DIRS = ['/ViewModels/', '/ViewModel/', '/Stores/', '/Managers/', '/Services/'];
  367. const MODEL_DIRS = ['/Models/', '/Model/', '/Entities/', '/Domain/'];
  368. const VC_DIRS = ['/ViewControllers/', '/ViewController/', '/Controllers/', '/Screens/'];
  369. const UIVIEW_DIRS = ['/Views/', '/View/', '/UI/', '/Components/'];
  370. const CELL_DIRS = ['/Cells/', '/Cell/', '/Views/', '/TableViewCells/', '/CollectionViewCells/'];
  371. const VAPOR_CONTROLLER_DIRS = ['/Controllers/', '/Controller/', '/Routes/'];
  372. const FLUENT_MODEL_DIRS = ['/Models/', '/Model/', '/Entities/', '/Database/'];
  373. const VAPOR_MIDDLEWARE_DIRS = ['/Middleware/', '/Middlewares/'];
  374. const VIEW_KINDS = new Set(['struct', 'component']);
  375. const CLASS_KINDS = new Set(['class']);
  376. const MODEL_KINDS = new Set(['struct', 'class']);
  377. const PROTOCOL_KINDS = new Set(['protocol']);
  378. const VAPOR_CONTROLLER_KINDS = new Set(['class', 'struct']);
  379. /**
  380. * Resolve a symbol by name using indexed queries instead of scanning all files.
  381. */
  382. function resolveByNameAndKind(
  383. name: string,
  384. kinds: Set<string>,
  385. preferredDirPatterns: string[],
  386. context: ResolutionContext,
  387. ): string | null {
  388. const candidates = context.getNodesByName(name);
  389. if (candidates.length === 0) return null;
  390. const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
  391. if (kindFiltered.length === 0) return null;
  392. // Prefer candidates in framework-conventional directories
  393. if (preferredDirPatterns.length > 0) {
  394. const preferred = kindFiltered.filter((n) =>
  395. preferredDirPatterns.some((d) => n.filePath.includes(d))
  396. );
  397. if (preferred.length > 0) return preferred[0]!.id;
  398. }
  399. // Fall back to any match
  400. return kindFiltered[0]!.id;
  401. }