| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- /**
- * Swift ↔ Objective-C bridge resolver.
- *
- * Closes the cross-language flow gap in mixed iOS codebases. The pure
- * bridging name math lives in `../swift-objc-bridge.ts`; this file wires
- * it into the resolution pipeline.
- *
- * **Two directions to close:**
- *
- * 1. **Swift call → ObjC method** — A Swift caller writes
- * `imageDownloader.download(url:completion:)`. Tree-sitter-swift parses
- * this as a call_expression whose callee identifier is `download`
- * (parameter labels live in the argument list, not the callee). The
- * name-matcher tries to find any node named `download` and fails (no
- * Swift method by that name in this project; the ObjC implementation is
- * `-downloadURL:completion:`). We catch it here: from the bare Swift
- * name `download`, look up ObjC methods whose bridged Swift base name
- * would be `download` (using `swiftBaseNamesForObjcSelector`'s reverse
- * map, precomputed once per session).
- *
- * 2. **ObjC call → Swift method** — An ObjC caller writes
- * `[swiftThing fooWithBar:42]`. Tree-sitter-objc parses this as a
- * message_expression with selector `fooWithBar:` (after the multi-
- * keyword fix in this branch). The name-matcher tries to find a node
- * named `fooWithBar:` — no Swift node has colons in its name, so it
- * fails. We catch it: from the ObjC selector, derive candidate Swift
- * base names (`['fooWithBar', 'foo']`), and look up Swift methods
- * named those.
- *
- * **Provenance:** every edge produced here is recorded as a framework-
- * resolved reference (`resolvedBy: 'framework'`) with `confidence: 0.7`
- * (matches the django ORM dynamic-dispatch precedent — not exact, but
- * deterministic from the bridging rule).
- */
- import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
- import type { Node } from '../../types';
- import {
- swiftBaseNamesForObjcSelector,
- isObjcExposed,
- } from '../swift-objc-bridge';
- /**
- * Memoized "Swift base name → ObjC method nodes" map.
- *
- * Built lazily on first `resolve()` per resolver instance — the resolver is
- * recreated when the index is rebuilt, so this naturally invalidates with
- * the graph. Keyed by ResolutionContext identity so multiple projects sharing
- * a process (the daemon) don't bleed maps between them.
- */
- const objcByCandidateSwiftBase: WeakMap<
- ResolutionContext,
- Map<string, Node[]>
- > = new WeakMap();
- /**
- * Build the reverse-bridge map: for every ObjC method node in the graph,
- * compute the Swift base names that would auto-bridge to its selector and
- * record the node under each.
- *
- * Runs once per resolver lifetime; the cost scales linearly with the count
- * of ObjC method nodes. On Wikipedia-iOS (~2500 files, ~25k ObjC methods)
- * this is a few hundred ms — much cheaper than re-parsing source on each
- * unresolved ref.
- */
- /**
- * Names that are too generic to bridge with any precision. These are common
- * Cocoa / NSObject conventions that almost every ObjC class implements; if a
- * Swift caller writes `init()` or `description`, mapping it to an arbitrary
- * project-local ObjC method of the same name produces noise, not signal.
- *
- * Critically, refs of these names virtually always resolve via the regular
- * name-matcher (every project has many `init` nodes) — skipping them here
- * just keeps the bridge from competing with name-match on already-handled
- * refs.
- */
- const GENERIC_NAMES = new Set([
- 'init',
- 'description',
- 'debugDescription',
- 'hash',
- 'isEqual',
- 'isEqualTo',
- 'copy',
- 'mutableCopy',
- 'class',
- 'self',
- 'count',
- 'length',
- 'value',
- 'name',
- 'data',
- 'string',
- 'object',
- 'add',
- 'remove',
- 'update',
- 'load',
- 'save',
- 'reload',
- 'cancel',
- 'start',
- 'stop',
- 'pause',
- 'resume',
- 'close',
- 'open',
- 'show',
- 'hide',
- 'toString',
- 'dealloc',
- 'release',
- 'retain',
- 'autorelease',
- ]);
- function buildObjcMap(context: ResolutionContext): Map<string, Node[]> {
- const cached = objcByCandidateSwiftBase.get(context);
- if (cached) return cached;
- const map = new Map<string, Node[]>();
- const objcMethods = context
- .getNodesByKind('method')
- .filter((n) => n.language === 'objc');
- for (const node of objcMethods) {
- const candidates = swiftBaseNamesForObjcSelector(node.name);
- for (const c of candidates) {
- // Skip the trivial case where the Swift base name equals the ObjC
- // method name verbatim (no colons) — the regular name-matcher
- // already handles that and our map would just duplicate the work.
- if (c === node.name && !node.name.includes(':')) continue;
- // Skip generic Cocoa names (init, description, etc.) — they would
- // false-positive against any project-local ObjC method of the same
- // name. The regular name-matcher handles them.
- if (GENERIC_NAMES.has(c)) continue;
- const arr = map.get(c);
- if (arr) arr.push(node);
- else map.set(c, [node]);
- }
- }
- objcByCandidateSwiftBase.set(context, map);
- return map;
- }
- /**
- * Window of source text around a Swift declaration used by `isObjcExposed`
- * to spot `@objc` / `@nonobjc` annotations. Read line above + the
- * declaration line — Swift attributes typically sit on the preceding line
- * (`@objc` on a line of its own) or inline.
- */
- const SOURCE_PROBE_LINES = 3;
- /**
- * Read a small window of source ending at `node.startLine`, used to
- * inspect Swift attribute annotations attached to a declaration. Returns
- * an empty string if the source can't be read.
- */
- function declarationSourceWindow(node: Node, context: ResolutionContext): string {
- const content = context.readFile(node.filePath);
- if (!content) return '';
- const lines = content.split(/\r?\n/);
- const startIdx = Math.max(0, node.startLine - 1 - SOURCE_PROBE_LINES);
- const endIdx = Math.min(lines.length, node.startLine);
- return lines.slice(startIdx, endIdx).join('\n');
- }
- /**
- * Try to resolve a Swift caller's bare reference to an ObjC implementation.
- *
- * Strategy: look up the ObjC reverse-bridge map for nodes whose Swift base
- * name would match. Return the first match (matches the existing
- * single-target resolution contract).
- */
- function resolveSwiftCallToObjc(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- // Swift call sites of `obj.foo(bar:)` reach the resolver as either bare
- // name `foo` (tree-sitter-swift) or qualified `obj.foo` — strip prefix.
- const rawName = ref.referenceName.includes('.')
- ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
- : ref.referenceName;
- const map = buildObjcMap(context);
- const candidates = map.get(rawName);
- if (!candidates || candidates.length === 0) return null;
- // Prefer ObjC methods whose corresponding Swift declaration isn't itself
- // present (so we don't wrongly redirect a Swift call to ObjC when a Swift
- // method of the same name is the real target — that's the in-language case
- // and should already be resolved by the name-matcher). Since this resolver
- // runs AFTER exact-match, any matching Swift node would already have won;
- // so a candidate reaching us is a legitimate cross-language hit.
- const target = candidates[0];
- if (!target) return null;
- return {
- original: ref,
- targetNodeId: target.id,
- confidence: 0.6,
- resolvedBy: 'framework',
- };
- }
- /**
- * Try to resolve an ObjC caller's selector reference to a Swift `@objc`
- * implementation.
- *
- * Strategy: derive candidate Swift base names from the selector via
- * `swiftBaseNamesForObjcSelector`. For each, look up Swift methods named
- * that and verify with a source-window check that the declaration is
- * `@objc`-exposed (filters out false matches where a Swift function
- * happens to share the name but isn't bridged).
- */
- function resolveObjcCallToSwift(
- ref: UnresolvedRef,
- context: ResolutionContext
- ): ResolvedRef | null {
- // ObjC call sites get receiver-prefixed when the receiver isn't self/super
- // (see tree-sitter.ts message_expression handling): `[obj foo:bar:]`
- // becomes `obj.foo:bar:`. Strip the receiver prefix to recover the raw
- // selector for the bridge math.
- const rawSelector = ref.referenceName.includes('.')
- ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
- : ref.referenceName;
- // Bridge math only applies to selector-shape names (contain `:`).
- if (!rawSelector.includes(':')) return null;
- const candidates = swiftBaseNamesForObjcSelector(rawSelector);
- for (const candidate of candidates) {
- const matches = context
- .getNodesByName(candidate)
- .filter((n) => n.language === 'swift' && (n.kind === 'method' || n.kind === 'function'));
- for (const match of matches) {
- const window = declarationSourceWindow(match, context);
- if (isObjcExposed(window)) {
- return {
- original: ref,
- targetNodeId: match.id,
- confidence: 0.6,
- resolvedBy: 'framework',
- };
- }
- }
- }
- return null;
- }
- export const swiftObjcBridgeResolver: FrameworkResolver = {
- name: 'swift-objc-bridge',
- // Applies to both languages — bridging crosses the boundary.
- languages: ['swift', 'objc'],
- /**
- * Detect: this resolver is relevant when the project has both Swift and
- * Objective-C source. Either-side-only projects don't need bridging
- * (and the empty reverse-map would be a no-op anyway).
- */
- detect(context) {
- const files = context.getAllFiles();
- let hasSwift = false;
- let hasObjc = false;
- for (const f of files) {
- if (f.endsWith('.swift')) hasSwift = true;
- else if (f.endsWith('.m') || f.endsWith('.mm')) hasObjc = true;
- if (hasSwift && hasObjc) return true;
- }
- return false;
- },
- /**
- * Let selector-shape references (anything containing a `:`) through the
- * resolver's name-exists pre-filter — no Swift node has a colon in its
- * name, so without this opt-in those refs would be dropped before
- * `resolve()` sees them. Also opt-in `setX:`-style names that aren't
- * otherwise declared symbols, in case the Swift side is a property.
- */
- claimsReference(name) {
- if (name.includes(':')) return true;
- // Bare names without colons are handled by the regular name-exists
- // pre-filter — no need to opt them in here.
- return false;
- },
- /**
- * Route based on which language the caller is in. The two directions are
- * symmetric in shape but very different in implementation (forward
- * direction uses the precomputed reverse-bridge map; reverse direction
- * uses the deterministic name-derivation).
- */
- resolve(ref, context) {
- if (ref.language === 'swift') {
- return resolveSwiftCallToObjc(ref, context);
- }
- if (ref.language === 'objc') {
- return resolveObjcCallToSwift(ref, context);
- }
- return null;
- },
- };
|