swift-objc.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. /**
  2. * Swift ↔ Objective-C bridge resolver.
  3. *
  4. * Closes the cross-language flow gap in mixed iOS codebases. The pure
  5. * bridging name math lives in `../swift-objc-bridge.ts`; this file wires
  6. * it into the resolution pipeline.
  7. *
  8. * **Two directions to close:**
  9. *
  10. * 1. **Swift call → ObjC method** — A Swift caller writes
  11. * `imageDownloader.download(url:completion:)`. Tree-sitter-swift parses
  12. * this as a call_expression whose callee identifier is `download`
  13. * (parameter labels live in the argument list, not the callee). The
  14. * name-matcher tries to find any node named `download` and fails (no
  15. * Swift method by that name in this project; the ObjC implementation is
  16. * `-downloadURL:completion:`). We catch it here: from the bare Swift
  17. * name `download`, look up ObjC methods whose bridged Swift base name
  18. * would be `download` (using `swiftBaseNamesForObjcSelector`'s reverse
  19. * map, precomputed once per session).
  20. *
  21. * 2. **ObjC call → Swift method** — An ObjC caller writes
  22. * `[swiftThing fooWithBar:42]`. Tree-sitter-objc parses this as a
  23. * message_expression with selector `fooWithBar:` (after the multi-
  24. * keyword fix in this branch). The name-matcher tries to find a node
  25. * named `fooWithBar:` — no Swift node has colons in its name, so it
  26. * fails. We catch it: from the ObjC selector, derive candidate Swift
  27. * base names (`['fooWithBar', 'foo']`), and look up Swift methods
  28. * named those.
  29. *
  30. * **Provenance:** every edge produced here is recorded as a framework-
  31. * resolved reference (`resolvedBy: 'framework'`) with `confidence: 0.7`
  32. * (matches the django ORM dynamic-dispatch precedent — not exact, but
  33. * deterministic from the bridging rule).
  34. */
  35. import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
  36. import type { Node } from '../../types';
  37. import {
  38. swiftBaseNamesForObjcSelector,
  39. isObjcExposed,
  40. } from '../swift-objc-bridge';
  41. /**
  42. * Memoized "Swift base name → ObjC method nodes" map.
  43. *
  44. * Built lazily on first `resolve()` per resolver instance — the resolver is
  45. * recreated when the index is rebuilt, so this naturally invalidates with
  46. * the graph. Keyed by ResolutionContext identity so multiple projects sharing
  47. * a process (the daemon) don't bleed maps between them.
  48. */
  49. const objcByCandidateSwiftBase: WeakMap<
  50. ResolutionContext,
  51. Map<string, Node[]>
  52. > = new WeakMap();
  53. /**
  54. * Build the reverse-bridge map: for every ObjC method node in the graph,
  55. * compute the Swift base names that would auto-bridge to its selector and
  56. * record the node under each.
  57. *
  58. * Runs once per resolver lifetime; the cost scales linearly with the count
  59. * of ObjC method nodes. On Wikipedia-iOS (~2500 files, ~25k ObjC methods)
  60. * this is a few hundred ms — much cheaper than re-parsing source on each
  61. * unresolved ref.
  62. */
  63. /**
  64. * Names that are too generic to bridge with any precision. These are common
  65. * Cocoa / NSObject conventions that almost every ObjC class implements; if a
  66. * Swift caller writes `init()` or `description`, mapping it to an arbitrary
  67. * project-local ObjC method of the same name produces noise, not signal.
  68. *
  69. * Critically, refs of these names virtually always resolve via the regular
  70. * name-matcher (every project has many `init` nodes) — skipping them here
  71. * just keeps the bridge from competing with name-match on already-handled
  72. * refs.
  73. */
  74. const GENERIC_NAMES = new Set([
  75. 'init',
  76. 'description',
  77. 'debugDescription',
  78. 'hash',
  79. 'isEqual',
  80. 'isEqualTo',
  81. 'copy',
  82. 'mutableCopy',
  83. 'class',
  84. 'self',
  85. 'count',
  86. 'length',
  87. 'value',
  88. 'name',
  89. 'data',
  90. 'string',
  91. 'object',
  92. 'add',
  93. 'remove',
  94. 'update',
  95. 'load',
  96. 'save',
  97. 'reload',
  98. 'cancel',
  99. 'start',
  100. 'stop',
  101. 'pause',
  102. 'resume',
  103. 'close',
  104. 'open',
  105. 'show',
  106. 'hide',
  107. 'toString',
  108. 'dealloc',
  109. 'release',
  110. 'retain',
  111. 'autorelease',
  112. ]);
  113. function buildObjcMap(context: ResolutionContext): Map<string, Node[]> {
  114. const cached = objcByCandidateSwiftBase.get(context);
  115. if (cached) return cached;
  116. const map = new Map<string, Node[]>();
  117. const objcMethods = context
  118. .getNodesByKind('method')
  119. .filter((n) => n.language === 'objc');
  120. for (const node of objcMethods) {
  121. const candidates = swiftBaseNamesForObjcSelector(node.name);
  122. for (const c of candidates) {
  123. // Skip the trivial case where the Swift base name equals the ObjC
  124. // method name verbatim (no colons) — the regular name-matcher
  125. // already handles that and our map would just duplicate the work.
  126. if (c === node.name && !node.name.includes(':')) continue;
  127. // Skip generic Cocoa names (init, description, etc.) — they would
  128. // false-positive against any project-local ObjC method of the same
  129. // name. The regular name-matcher handles them.
  130. if (GENERIC_NAMES.has(c)) continue;
  131. const arr = map.get(c);
  132. if (arr) arr.push(node);
  133. else map.set(c, [node]);
  134. }
  135. }
  136. objcByCandidateSwiftBase.set(context, map);
  137. return map;
  138. }
  139. /**
  140. * Window of source text around a Swift declaration used by `isObjcExposed`
  141. * to spot `@objc` / `@nonobjc` annotations. Read line above + the
  142. * declaration line — Swift attributes typically sit on the preceding line
  143. * (`@objc` on a line of its own) or inline.
  144. */
  145. const SOURCE_PROBE_LINES = 3;
  146. /**
  147. * Read a small window of source ending at `node.startLine`, used to
  148. * inspect Swift attribute annotations attached to a declaration. Returns
  149. * an empty string if the source can't be read.
  150. */
  151. function declarationSourceWindow(node: Node, context: ResolutionContext): string {
  152. const content = context.readFile(node.filePath);
  153. if (!content) return '';
  154. const lines = content.split(/\r?\n/);
  155. const startIdx = Math.max(0, node.startLine - 1 - SOURCE_PROBE_LINES);
  156. const endIdx = Math.min(lines.length, node.startLine);
  157. return lines.slice(startIdx, endIdx).join('\n');
  158. }
  159. /**
  160. * Try to resolve a Swift caller's bare reference to an ObjC implementation.
  161. *
  162. * Strategy: look up the ObjC reverse-bridge map for nodes whose Swift base
  163. * name would match. Return the first match (matches the existing
  164. * single-target resolution contract).
  165. */
  166. function resolveSwiftCallToObjc(
  167. ref: UnresolvedRef,
  168. context: ResolutionContext
  169. ): ResolvedRef | null {
  170. // Swift call sites of `obj.foo(bar:)` reach the resolver as either bare
  171. // name `foo` (tree-sitter-swift) or qualified `obj.foo` — strip prefix.
  172. const rawName = ref.referenceName.includes('.')
  173. ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
  174. : ref.referenceName;
  175. const map = buildObjcMap(context);
  176. const candidates = map.get(rawName);
  177. if (!candidates || candidates.length === 0) return null;
  178. // Prefer ObjC methods whose corresponding Swift declaration isn't itself
  179. // present (so we don't wrongly redirect a Swift call to ObjC when a Swift
  180. // method of the same name is the real target — that's the in-language case
  181. // and should already be resolved by the name-matcher). Since this resolver
  182. // runs AFTER exact-match, any matching Swift node would already have won;
  183. // so a candidate reaching us is a legitimate cross-language hit.
  184. const target = candidates[0];
  185. if (!target) return null;
  186. return {
  187. original: ref,
  188. targetNodeId: target.id,
  189. confidence: 0.6,
  190. resolvedBy: 'framework',
  191. };
  192. }
  193. /**
  194. * Try to resolve an ObjC caller's selector reference to a Swift `@objc`
  195. * implementation.
  196. *
  197. * Strategy: derive candidate Swift base names from the selector via
  198. * `swiftBaseNamesForObjcSelector`. For each, look up Swift methods named
  199. * that and verify with a source-window check that the declaration is
  200. * `@objc`-exposed (filters out false matches where a Swift function
  201. * happens to share the name but isn't bridged).
  202. */
  203. function resolveObjcCallToSwift(
  204. ref: UnresolvedRef,
  205. context: ResolutionContext
  206. ): ResolvedRef | null {
  207. // ObjC call sites get receiver-prefixed when the receiver isn't self/super
  208. // (see tree-sitter.ts message_expression handling): `[obj foo:bar:]`
  209. // becomes `obj.foo:bar:`. Strip the receiver prefix to recover the raw
  210. // selector for the bridge math.
  211. const rawSelector = ref.referenceName.includes('.')
  212. ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
  213. : ref.referenceName;
  214. // Bridge math only applies to selector-shape names (contain `:`).
  215. if (!rawSelector.includes(':')) return null;
  216. const candidates = swiftBaseNamesForObjcSelector(rawSelector);
  217. for (const candidate of candidates) {
  218. const matches = context
  219. .getNodesByName(candidate)
  220. .filter((n) => n.language === 'swift' && (n.kind === 'method' || n.kind === 'function'));
  221. for (const match of matches) {
  222. const window = declarationSourceWindow(match, context);
  223. if (isObjcExposed(window)) {
  224. return {
  225. original: ref,
  226. targetNodeId: match.id,
  227. confidence: 0.6,
  228. resolvedBy: 'framework',
  229. };
  230. }
  231. }
  232. }
  233. return null;
  234. }
  235. export const swiftObjcBridgeResolver: FrameworkResolver = {
  236. name: 'swift-objc-bridge',
  237. // Applies to both languages — bridging crosses the boundary.
  238. languages: ['swift', 'objc'],
  239. /**
  240. * Detect: this resolver is relevant when the project has both Swift and
  241. * Objective-C source. Either-side-only projects don't need bridging
  242. * (and the empty reverse-map would be a no-op anyway).
  243. */
  244. detect(context) {
  245. const files = context.getAllFiles();
  246. let hasSwift = false;
  247. let hasObjc = false;
  248. for (const f of files) {
  249. if (f.endsWith('.swift')) hasSwift = true;
  250. else if (f.endsWith('.m') || f.endsWith('.mm')) hasObjc = true;
  251. if (hasSwift && hasObjc) return true;
  252. }
  253. return false;
  254. },
  255. /**
  256. * Let selector-shape references (anything containing a `:`) through the
  257. * resolver's name-exists pre-filter — no Swift node has a colon in its
  258. * name, so without this opt-in those refs would be dropped before
  259. * `resolve()` sees them. Also opt-in `setX:`-style names that aren't
  260. * otherwise declared symbols, in case the Swift side is a property.
  261. */
  262. claimsReference(name) {
  263. if (name.includes(':')) return true;
  264. // Bare names without colons are handled by the regular name-exists
  265. // pre-filter — no need to opt them in here.
  266. return false;
  267. },
  268. /**
  269. * Route based on which language the caller is in. The two directions are
  270. * symmetric in shape but very different in implementation (forward
  271. * direction uses the precomputed reverse-bridge map; reverse direction
  272. * uses the deterministic name-derivation).
  273. */
  274. resolve(ref, context) {
  275. if (ref.language === 'swift') {
  276. return resolveSwiftCallToObjc(ref, context);
  277. }
  278. if (ref.language === 'objc') {
  279. return resolveObjcCallToSwift(ref, context);
  280. }
  281. return null;
  282. },
  283. };