1
0

swift-objc-bridge-resolver.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import { describe, it, expect } from 'vitest';
  2. import type { Node } from '../src/types';
  3. import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
  4. import { swiftObjcBridgeResolver } from '../src/resolution/frameworks/swift-objc';
  5. /**
  6. * Lightweight ResolutionContext mock — implements only the methods the
  7. * bridge resolver actually calls. Anything else throws so a leaked call
  8. * surfaces loudly in tests.
  9. */
  10. function makeContext(nodes: Node[], fileContents: Record<string, string> = {}): ResolutionContext {
  11. const byName = new Map<string, Node[]>();
  12. for (const n of nodes) {
  13. const arr = byName.get(n.name);
  14. if (arr) arr.push(n);
  15. else byName.set(n.name, [n]);
  16. }
  17. const allFiles = new Set(nodes.map((n) => n.filePath));
  18. return {
  19. getNodesInFile: (fp) => nodes.filter((n) => n.filePath === fp),
  20. getNodesByName: (name) => byName.get(name) ?? [],
  21. getNodesByQualifiedName: () => { throw new Error('not used'); },
  22. getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind),
  23. getNodesByLowerName: () => { throw new Error('not used'); },
  24. fileExists: (fp) => allFiles.has(fp),
  25. readFile: (fp) => fileContents[fp] ?? null,
  26. getProjectRoot: () => '/test',
  27. getAllFiles: () => Array.from(allFiles),
  28. getImportMappings: () => [],
  29. };
  30. }
  31. function method(name: string, language: 'swift' | 'objc', filePath: string, startLine = 10): Node {
  32. return {
  33. id: `${language}:${filePath}:${name}:${startLine}`,
  34. kind: 'method',
  35. name,
  36. qualifiedName: `${filePath}::${name}`,
  37. filePath,
  38. language,
  39. startLine,
  40. endLine: startLine + 5,
  41. startColumn: 0,
  42. endColumn: 0,
  43. updatedAt: Date.now(),
  44. } as Node;
  45. }
  46. function ref(name: string, language: 'swift' | 'objc', filePath: string): UnresolvedRef {
  47. return {
  48. fromNodeId: `caller:${filePath}`,
  49. referenceName: name,
  50. referenceKind: 'calls',
  51. line: 1,
  52. column: 0,
  53. filePath,
  54. language,
  55. };
  56. }
  57. describe('swiftObjcBridgeResolver integration', () => {
  58. describe('detect()', () => {
  59. it('returns true when both .swift and .m files exist', () => {
  60. const ctx = makeContext([
  61. method('foo', 'swift', 'A.swift'),
  62. method('bar', 'objc', 'B.m'),
  63. ]);
  64. expect(swiftObjcBridgeResolver.detect(ctx)).toBe(true);
  65. });
  66. it('returns false when only .swift files exist', () => {
  67. const ctx = makeContext([method('foo', 'swift', 'A.swift')]);
  68. expect(swiftObjcBridgeResolver.detect(ctx)).toBe(false);
  69. });
  70. it('returns true when .swift and .mm exist (ObjC++)', () => {
  71. const ctx = makeContext([
  72. method('foo', 'swift', 'A.swift'),
  73. method('bar', 'objc', 'B.mm'),
  74. ]);
  75. expect(swiftObjcBridgeResolver.detect(ctx)).toBe(true);
  76. });
  77. });
  78. describe('claimsReference()', () => {
  79. it('claims selector-shape names (contain :)', () => {
  80. expect(swiftObjcBridgeResolver.claimsReference?.('fooWithBar:')).toBe(true);
  81. expect(swiftObjcBridgeResolver.claimsReference?.('tableView:didSelectRowAtIndexPath:')).toBe(true);
  82. expect(swiftObjcBridgeResolver.claimsReference?.('setName:')).toBe(true);
  83. });
  84. it('does not claim bare names (handled by normal name-matcher)', () => {
  85. expect(swiftObjcBridgeResolver.claimsReference?.('foo')).toBe(false);
  86. expect(swiftObjcBridgeResolver.claimsReference?.('init')).toBe(false);
  87. });
  88. });
  89. describe('resolve() — Swift → ObjC direction', () => {
  90. it('resolves Swift call to Cocoa-style ObjC method (fetchEntry → fetchEntryForKey:)', () => {
  91. // Swift writes `cache.fetchEntry(forKey: "x")` → ref name `fetchEntry`.
  92. // ObjC method is `fetchEntryForKey:` (preposition-prefix shape).
  93. // `fetchEntry` is project-specific (not in the generic-names blocklist
  94. // that filters init/count/description/etc. to avoid Cocoa noise).
  95. const objcTarget = method('fetchEntryForKey:', 'objc', 'Cache.m');
  96. const ctx = makeContext([objcTarget]);
  97. const result = swiftObjcBridgeResolver.resolve(
  98. ref('fetchEntry', 'swift', 'Caller.swift'),
  99. ctx
  100. );
  101. expect(result).not.toBeNull();
  102. expect(result?.targetNodeId).toBe(objcTarget.id);
  103. expect(result?.resolvedBy).toBe('framework');
  104. expect(result?.confidence).toBe(0.6);
  105. });
  106. it('does NOT bridge generic Cocoa names like "init" or "description"', () => {
  107. // Bridging Swift `init()` calls to arbitrary ObjC `init*:` methods is
  108. // noise — every NSObject subclass has them. The regular name-matcher
  109. // handles `init` on its own.
  110. const objcInit = method('initWithFrame:', 'objc', 'View.m');
  111. const ctx = makeContext([objcInit]);
  112. const result = swiftObjcBridgeResolver.resolve(
  113. ref('init', 'swift', 'Caller.swift'),
  114. ctx
  115. );
  116. expect(result).toBeNull();
  117. });
  118. it('resolves bridged "With" form: Swift `play(song:)` → ObjC `playWithSong:`', () => {
  119. const objcTarget = method('playWithSong:', 'objc', 'Player.m');
  120. const ctx = makeContext([objcTarget]);
  121. const result = swiftObjcBridgeResolver.resolve(
  122. ref('play', 'swift', 'Caller.swift'),
  123. ctx
  124. );
  125. expect(result?.targetNodeId).toBe(objcTarget.id);
  126. });
  127. it('returns null when no matching ObjC method exists', () => {
  128. const ctx = makeContext([method('unrelated:thing:', 'objc', 'X.m')]);
  129. const result = swiftObjcBridgeResolver.resolve(
  130. ref('completelyDifferent', 'swift', 'Caller.swift'),
  131. ctx
  132. );
  133. expect(result).toBeNull();
  134. });
  135. });
  136. describe('resolve() — ObjC → Swift direction', () => {
  137. it('resolves ObjC selector to @objc-exposed Swift method (exporter form)', () => {
  138. // Swift @objc export of `func animate(xAxisDuration:, yAxisDuration:)`
  139. // produces ObjC selector `animateWithXAxisDuration:yAxisDuration:`
  140. // (always "With" insertion on first explicit label).
  141. const swiftTarget = method('animate', 'swift', 'Chart.swift', 10);
  142. const ctx = makeContext([swiftTarget], {
  143. 'Chart.swift':
  144. '\n'.repeat(8) +
  145. '@objc open func animate(xAxisDuration: Double, yAxisDuration: Double) {}\n',
  146. });
  147. const result = swiftObjcBridgeResolver.resolve(
  148. ref('animateWithXAxisDuration:yAxisDuration:', 'objc', 'Caller.m'),
  149. ctx
  150. );
  151. expect(result?.targetNodeId).toBe(swiftTarget.id);
  152. expect(result?.resolvedBy).toBe('framework');
  153. });
  154. it('does NOT resolve if the Swift method is not @objc-exposed', () => {
  155. const swiftTarget = method('animate', 'swift', 'Chart.swift', 10);
  156. const ctx = makeContext([swiftTarget], {
  157. // Plain `func` without @objc — bridge correctly skips it
  158. 'Chart.swift':
  159. '\n'.repeat(8) +
  160. 'func animate(xAxisDuration: Double, yAxisDuration: Double) {}\n',
  161. });
  162. const result = swiftObjcBridgeResolver.resolve(
  163. ref('animateWithXAxisDuration:yAxisDuration:', 'objc', 'Caller.m'),
  164. ctx
  165. );
  166. expect(result).toBeNull();
  167. });
  168. it('resolves init selectors to Swift init', () => {
  169. const swiftTarget = method('init', 'swift', 'MyClass.swift', 10);
  170. const ctx = makeContext([swiftTarget], {
  171. 'MyClass.swift':
  172. '\n'.repeat(8) + '@objc init(name: String, age: Int) {}\n',
  173. });
  174. const result = swiftObjcBridgeResolver.resolve(
  175. ref('initWithName:age:', 'objc', 'Caller.m'),
  176. ctx
  177. );
  178. expect(result?.targetNodeId).toBe(swiftTarget.id);
  179. });
  180. it('returns null for selectors with no derivable Swift candidates that exist', () => {
  181. const ctx = makeContext([]);
  182. const result = swiftObjcBridgeResolver.resolve(
  183. ref('someUnknownThing:', 'objc', 'Caller.m'),
  184. ctx
  185. );
  186. expect(result).toBeNull();
  187. });
  188. });
  189. });