Skip to content

Commit f0b8652

Browse files
committed
BridgeJS: Correctly emit @js methods in extensions
1 parent 3eff48a commit f0b8652

19 files changed

+496
-1
lines changed

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 59 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,52 @@ 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+
func resolveExtension(_ ext: ExtensionDeclSyntax) -> Bool {
1430+
let name = ext.extendedType.trimmedDescription
1431+
let state: State
1432+
if let entry = exportedClassByName.first(where: { $0.value.name == name }) {
1433+
state = .classBody(name: name, key: entry.key)
1434+
} else if let entry = exportedStructByName.first(where: { $0.value.name == name }) {
1435+
state = .structBody(name: name, key: entry.key)
1436+
} else if let entry = exportedEnumByName.first(where: { $0.value.name == name }) {
1437+
state = .enumBody(name: name, key: entry.key)
1438+
} else {
1439+
return false
1440+
}
1441+
stateStack.push(state: state)
1442+
for member in ext.memberBlock.members {
1443+
walk(member)
1444+
}
1445+
stateStack.pop()
1446+
return true
1447+
}
1448+
13911449
override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
13921450
guard let jsAttribute = node.attributes.firstJSAttribute else {
13931451
return .skipChildren

Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,24 @@ 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+
187+
170188
@Test
171189
func codegenSkipsEmptySkeletons() throws {
172190
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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,15 @@ 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+
48+
extension Calculator {
49+
@JS static func cube(value: Int) -> Int {
50+
return value * value * value
51+
}
52+
}

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

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

15+
extension Greeter {
16+
@JS func greetEnthusiastically() -> String {
17+
return "Hey, " + self.name + "!!!"
18+
}
19+
}
20+
1521
@JS func takeGreeter(greeter: Greeter) {
1622
print(greeter.greet())
1723
}

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

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

6262
@JS func roundtripContainer(_ container: Container) -> Container
63+
64+
extension DataPoint {
65+
@JS func distanceFromOrigin() -> Double {
66+
return (x * x + y * y).squareRoot()
67+
}
68+
}
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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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 {
45+
var jsValue: JSValue {
46+
return .object(JSObject(id: UInt32(bitPattern: _bjs_Greeter_wrap(Unmanaged.passRetained(self).toOpaque()))))
47+
}
48+
}
49+
50+
#if arch(wasm32)
51+
@_extern(wasm, module: "TestModule", name: "bjs_Greeter_wrap")
52+
fileprivate func _bjs_Greeter_wrap_extern(_ pointer: UnsafeMutableRawPointer) -> Int32
53+
#else
54+
fileprivate func _bjs_Greeter_wrap_extern(_ pointer: UnsafeMutableRawPointer) -> Int32 {
55+
fatalError("Only available on WebAssembly")
56+
}
57+
#endif
58+
@inline(never) fileprivate func _bjs_Greeter_wrap(_ pointer: UnsafeMutableRawPointer) -> Int32 {
59+
return _bjs_Greeter_wrap_extern(pointer)
60+
}

Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/StaticFunctions.Global.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,45 @@
125125

126126
}
127127
}
128+
},
129+
{
130+
"abiName" : "bjs_MathUtils_static_divide",
131+
"effects" : {
132+
"isAsync" : false,
133+
"isStatic" : true,
134+
"isThrows" : false
135+
},
136+
"name" : "divide",
137+
"parameters" : [
138+
{
139+
"label" : "a",
140+
"name" : "a",
141+
"type" : {
142+
"int" : {
143+
144+
}
145+
}
146+
},
147+
{
148+
"label" : "b",
149+
"name" : "b",
150+
"type" : {
151+
"int" : {
152+
153+
}
154+
}
155+
}
156+
],
157+
"returnType" : {
158+
"int" : {
159+
160+
}
161+
},
162+
"staticContext" : {
163+
"className" : {
164+
"_0" : "MathUtils"
165+
}
166+
}
128167
}
129168
],
130169
"name" : "MathUtils",
@@ -182,6 +221,36 @@
182221
"_0" : "Calculator"
183222
}
184223
}
224+
},
225+
{
226+
"abiName" : "bjs_Calculator_static_cube",
227+
"effects" : {
228+
"isAsync" : false,
229+
"isStatic" : true,
230+
"isThrows" : false
231+
},
232+
"name" : "cube",
233+
"parameters" : [
234+
{
235+
"label" : "value",
236+
"name" : "value",
237+
"type" : {
238+
"int" : {
239+
240+
}
241+
}
242+
}
243+
],
244+
"returnType" : {
245+
"int" : {
246+
247+
}
248+
},
249+
"staticContext" : {
250+
"enumName" : {
251+
"_0" : "Calculator"
252+
}
253+
}
185254
}
186255
],
187256
"staticProperties" : [

0 commit comments

Comments
 (0)