Skip to content

Commit 7be7d4e

Browse files
krodakwfltaylorMaxDesiatov
authored
BridgeJS: Re-land extension method support (reverted in #703) (#706)
* BridgeJS: Correctly emit @js methods in extensions * BridgeJS: Improve test coverage for @js methods and properties in extensions * Fix formatting * Update test code to avoid accidentally introduced failure * Fix CI: update snapshots, formatting, runtime test, add docs and review feedback * BridgeJS: Regenerate snapshots and runtime bindings against current main --------- Co-authored-by: William Taylor <git@will.au> Co-authored-by: Max Desiatov <m_desiatov@apple.com>
1 parent c36a742 commit 7be7d4e

33 files changed

+1586
-1
lines changed

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,14 @@ public final class SwiftToSkeleton {
4343
var perSourceErrors: [(inputFilePath: String, errors: [DiagnosticError])] = []
4444
var importedFiles: [ImportedFileSkeleton] = []
4545
var exported = ExportedSkeleton(functions: [], classes: [], enums: [], exposeToGlobal: exposeToGlobal)
46+
var exportCollectors: [ExportSwiftAPICollector] = []
4647

4748
for (sourceFile, inputFilePath) in sourceFiles {
4849
progress.print("Processing \(inputFilePath)")
4950

5051
let exportCollector = ExportSwiftAPICollector(parent: self)
5152
exportCollector.walk(sourceFile)
53+
exportCollectors.append(exportCollector)
5254

5355
let typeNameCollector = ImportSwiftMacrosJSImportTypeNameCollector(viewMode: .sourceAccurate)
5456
typeNameCollector.walk(sourceFile)
@@ -74,7 +76,15 @@ public final class SwiftToSkeleton {
7476
if !importedFile.isEmpty {
7577
importedFiles.append(importedFile)
7678
}
77-
exportCollector.finalize(&exported)
79+
}
80+
81+
// Resolve extensions against all collectors. This needs to happen at this point so we can resolve both same file and cross file extensions.
82+
for source in exportCollectors {
83+
source.resolveDeferredExtensions(against: exportCollectors)
84+
}
85+
86+
for collector in exportCollectors {
87+
collector.finalize(&exported)
7888
}
7989

8090
if !perSourceErrors.isEmpty {
@@ -486,6 +496,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
486496
var exportedStructNames: [String] = []
487497
var exportedStructByName: [String: ExportedStruct] = [:]
488498
var errors: [DiagnosticError] = []
499+
/// Extensions collected during the walk, to be resolved after all files have been walked
500+
var deferredExtensions: [ExtensionDeclSyntax] = []
489501

490502
func finalize(_ result: inout ExportedSkeleton) {
491503
result.functions.append(contentsOf: exportedFunctions)
@@ -1388,6 +1400,64 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
13881400
}
13891401
}
13901402

1403+
override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
1404+
// Defer until all type declarations in the module have been collected.
1405+
deferredExtensions.append(node)
1406+
return .skipChildren
1407+
}
1408+
1409+
func resolveDeferredExtensions(against collectors: [ExportSwiftAPICollector]) {
1410+
for ext in deferredExtensions {
1411+
var resolved = false
1412+
for collector in collectors {
1413+
if collector.resolveExtension(ext) {
1414+
resolved = true
1415+
break
1416+
}
1417+
}
1418+
if !resolved {
1419+
diagnose(
1420+
node: ext.extendedType,
1421+
message: "Unsupported type '\(ext.extendedType.trimmedDescription)'.",
1422+
hint: "You can only extend `@JS` annotated types defined in the same module"
1423+
)
1424+
}
1425+
}
1426+
}
1427+
1428+
/// Walks extension members under the matching type’s state, returning whether the type was found.
1429+
///
1430+
/// Note: The lookup scans dictionaries keyed by `makeKey(name:namespace:)`, matching only by
1431+
/// plain name. If two types share a name but differ by namespace, `.first(where:)` picks
1432+
/// whichever comes first. This is acceptable today since namespace collisions are unlikely,
1433+
/// but may need refinement if namespace-qualified extension resolution is added.
1434+
func resolveExtension(_ ext: ExtensionDeclSyntax) -> Bool {
1435+
let name = ext.extendedType.trimmedDescription
1436+
let state: State
1437+
if let entry = exportedClassByName.first(where: { $0.value.name == name }) {
1438+
state = .classBody(name: name, key: entry.key)
1439+
} else if let entry = exportedStructByName.first(where: { $0.value.name == name }) {
1440+
state = .structBody(name: name, key: entry.key)
1441+
} else if let entry = exportedEnumByName.first(where: { $0.value.name == name }) {
1442+
state = .enumBody(name: name, key: entry.key)
1443+
} else if exportedProtocolByName.values.contains(where: { $0.name == name }) {
1444+
diagnose(
1445+
node: ext.extendedType,
1446+
message: "Protocol extensions are not supported by BridgeJS.",
1447+
hint: "You cannot extend `@JS` protocol '\(name)' with additional members"
1448+
)
1449+
return true
1450+
} else {
1451+
return false
1452+
}
1453+
stateStack.push(state: state)
1454+
for member in ext.memberBlock.members {
1455+
walk(member)
1456+
}
1457+
stateStack.pop()
1458+
return true
1459+
}
1460+
13911461
override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
13921462
guard let jsAttribute = node.attributes.firstJSAttribute else {
13931463
return .skipChildren

Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,23 @@ import Testing
167167
try snapshotCodegen(skeleton: skeleton, name: "CrossFileFunctionTypes.ReverseOrder")
168168
}
169169

170+
@Test
171+
func codegenCrossFileExtension() throws {
172+
let swiftAPI = SwiftToSkeleton(progress: .silent, moduleName: "TestModule", exposeToGlobal: false)
173+
let classURL = Self.multifileInputsDirectory.appendingPathComponent("CrossFileExtensionClass.swift")
174+
swiftAPI.addSourceFile(
175+
Parser.parse(source: try String(contentsOf: classURL, encoding: .utf8)),
176+
inputFilePath: "CrossFileExtensionClass.swift"
177+
)
178+
let extensionURL = Self.multifileInputsDirectory.appendingPathComponent("CrossFileExtension.swift")
179+
swiftAPI.addSourceFile(
180+
Parser.parse(source: try String(contentsOf: extensionURL, encoding: .utf8)),
181+
inputFilePath: "CrossFileExtension.swift"
182+
)
183+
let skeleton = try swiftAPI.finalize()
184+
try snapshotCodegen(skeleton: skeleton, name: "CrossFileExtension")
185+
}
186+
170187
@Test
171188
func codegenSkipsEmptySkeletons() throws {
172189
let swiftAPI = SwiftToSkeleton(progress: .silent, moduleName: "TestModule", exposeToGlobal: false)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
extension Greeter {
2+
@JS func greetFormally() -> String {
3+
return "Good day, " + self.name + "."
4+
}
5+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@JS class Greeter {
2+
@JS init(name: String) {}
3+
@JS func greet() -> String { return "" }
4+
}

Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/StaticFunctions.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,19 @@ enum APIResult {
3838
}
3939
}
4040
}
41+
42+
extension MathUtils {
43+
@JS static func divide(a: Int, b: Int) -> Int {
44+
return a / b
45+
}
46+
47+
@JS static var pi: Double { 3.14159 }
48+
}
49+
50+
extension Calculator {
51+
@JS static func cube(value: Int) -> Int {
52+
return value * value * value
53+
}
54+
55+
@JS static var version: String { "1.0" }
56+
}

Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/SwiftClass.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@
1212
}
1313
}
1414

15+
extension Greeter {
16+
@JS func greetEnthusiastically() -> String {
17+
return "Hey, " + self.name + "!!!"
18+
}
19+
20+
@JS var nameCount: Int { name.count }
21+
22+
@JS static func greetAnonymously() -> String {
23+
return "Hello."
24+
}
25+
26+
@JS static var defaultGreeting: String { "Hello, world!" }
27+
}
28+
1529
@JS func takeGreeter(greeter: Greeter) {
1630
print(greeter.greet())
1731
}

Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/SwiftStruct.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,26 @@
6060
}
6161

6262
@JS func roundtripContainer(_ container: Container) -> Container
63+
64+
@JS struct Vector2D {
65+
var dx: Double
66+
var dy: Double
67+
}
68+
69+
extension Vector2D {
70+
@JS func magnitude() -> Double {
71+
return (dx * dx + dy * dy).squareRoot()
72+
}
73+
74+
@JS func scaled(by factor: Double) -> Vector2D {
75+
return Vector2D(dx: dx * factor, dy: dy * factor)
76+
}
77+
}
78+
79+
extension DataPoint {
80+
@JS static func origin() -> DataPoint {
81+
return DataPoint(x: 0, y: 0, label: "origin", optCount: nil, optFlag: nil)
82+
}
83+
84+
@JS static var dimensions: Int { 2 }
85+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
{
2+
"exported" : {
3+
"classes" : [
4+
{
5+
"constructor" : {
6+
"abiName" : "bjs_Greeter_init",
7+
"effects" : {
8+
"isAsync" : false,
9+
"isStatic" : false,
10+
"isThrows" : false
11+
},
12+
"parameters" : [
13+
{
14+
"label" : "name",
15+
"name" : "name",
16+
"type" : {
17+
"string" : {
18+
19+
}
20+
}
21+
}
22+
]
23+
},
24+
"methods" : [
25+
{
26+
"abiName" : "bjs_Greeter_greet",
27+
"effects" : {
28+
"isAsync" : false,
29+
"isStatic" : false,
30+
"isThrows" : false
31+
},
32+
"name" : "greet",
33+
"parameters" : [
34+
35+
],
36+
"returnType" : {
37+
"string" : {
38+
39+
}
40+
}
41+
},
42+
{
43+
"abiName" : "bjs_Greeter_greetFormally",
44+
"effects" : {
45+
"isAsync" : false,
46+
"isStatic" : false,
47+
"isThrows" : false
48+
},
49+
"name" : "greetFormally",
50+
"parameters" : [
51+
52+
],
53+
"returnType" : {
54+
"string" : {
55+
56+
}
57+
}
58+
}
59+
],
60+
"name" : "Greeter",
61+
"properties" : [
62+
63+
],
64+
"swiftCallName" : "Greeter"
65+
}
66+
],
67+
"enums" : [
68+
69+
],
70+
"exposeToGlobal" : false,
71+
"functions" : [
72+
73+
],
74+
"protocols" : [
75+
76+
],
77+
"structs" : [
78+
79+
]
80+
},
81+
"moduleName" : "TestModule"
82+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
@_expose(wasm, "bjs_Greeter_init")
2+
@_cdecl("bjs_Greeter_init")
3+
public func _bjs_Greeter_init(_ nameBytes: Int32, _ nameLength: Int32) -> UnsafeMutableRawPointer {
4+
#if arch(wasm32)
5+
let ret = Greeter(name: String.bridgeJSLiftParameter(nameBytes, nameLength))
6+
return ret.bridgeJSLowerReturn()
7+
#else
8+
fatalError("Only available on WebAssembly")
9+
#endif
10+
}
11+
12+
@_expose(wasm, "bjs_Greeter_greet")
13+
@_cdecl("bjs_Greeter_greet")
14+
public func _bjs_Greeter_greet(_ _self: UnsafeMutableRawPointer) -> Void {
15+
#if arch(wasm32)
16+
let ret = Greeter.bridgeJSLiftParameter(_self).greet()
17+
return ret.bridgeJSLowerReturn()
18+
#else
19+
fatalError("Only available on WebAssembly")
20+
#endif
21+
}
22+
23+
@_expose(wasm, "bjs_Greeter_greetFormally")
24+
@_cdecl("bjs_Greeter_greetFormally")
25+
public func _bjs_Greeter_greetFormally(_ _self: UnsafeMutableRawPointer) -> Void {
26+
#if arch(wasm32)
27+
let ret = Greeter.bridgeJSLiftParameter(_self).greetFormally()
28+
return ret.bridgeJSLowerReturn()
29+
#else
30+
fatalError("Only available on WebAssembly")
31+
#endif
32+
}
33+
34+
@_expose(wasm, "bjs_Greeter_deinit")
35+
@_cdecl("bjs_Greeter_deinit")
36+
public func _bjs_Greeter_deinit(_ pointer: UnsafeMutableRawPointer) -> Void {
37+
#if arch(wasm32)
38+
Unmanaged<Greeter>.fromOpaque(pointer).release()
39+
#else
40+
fatalError("Only available on WebAssembly")
41+
#endif
42+
}
43+
44+
extension Greeter: ConvertibleToJSValue, _BridgedSwiftHeapObject, _BridgedSwiftProtocolExportable {
45+
var jsValue: JSValue {
46+
return .object(JSObject(id: UInt32(bitPattern: _bjs_Greeter_wrap(Unmanaged.passRetained(self).toOpaque()))))
47+
}
48+
consuming func bridgeJSLowerAsProtocolReturn() -> Int32 {
49+
_bjs_Greeter_wrap(Unmanaged.passRetained(self).toOpaque())
50+
}
51+
}
52+
53+
#if arch(wasm32)
54+
@_extern(wasm, module: "TestModule", name: "bjs_Greeter_wrap")
55+
fileprivate func _bjs_Greeter_wrap_extern(_ pointer: UnsafeMutableRawPointer) -> Int32
56+
#else
57+
fileprivate func _bjs_Greeter_wrap_extern(_ pointer: UnsafeMutableRawPointer) -> Int32 {
58+
fatalError("Only available on WebAssembly")
59+
}
60+
#endif
61+
@inline(never) fileprivate func _bjs_Greeter_wrap(_ pointer: UnsafeMutableRawPointer) -> Int32 {
62+
return _bjs_Greeter_wrap_extern(pointer)
63+
}

0 commit comments

Comments
 (0)