Skip to content

Commit cd3f8dc

Browse files
committed
BridgeJS: Support nested @js types inside structs and classes
1 parent 44ebc38 commit cd3f8dc

7 files changed

Lines changed: 645 additions & 5 deletions

File tree

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,14 @@ public final class SwiftToSkeleton {
504504
enumDecl.attributes.hasJSAttribute()
505505
{
506506
swiftPath.insert(enumDecl.name.text, at: 0)
507+
} else if let structDecl = parent.as(StructDeclSyntax.self),
508+
structDecl.attributes.hasJSAttribute()
509+
{
510+
swiftPath.insert(structDecl.name.text, at: 0)
511+
} else if let classDecl = parent.as(ClassDeclSyntax.self),
512+
classDecl.attributes.hasJSAttribute()
513+
{
514+
swiftPath.insert(classDecl.name.text, at: 0)
507515
}
508516
currentNode = parent.parent
509517
}
@@ -648,6 +656,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
648656
var state: State {
649657
return stateStack.current
650658
}
659+
651660
let parent: SwiftToSkeleton
652661

653662
init(parent: SwiftToSkeleton) {
@@ -1453,6 +1462,10 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
14531462
guard namespaceResult.isValid else {
14541463
return .skipChildren
14551464
}
1465+
let effectiveNamespace = mergeNamespaces(
1466+
namespaceResult.namespace,
1467+
computeParentTypeNamespace(for: node)
1468+
)
14561469
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
14571470
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
14581471
for: node,
@@ -1466,10 +1479,10 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
14661479
constructor: nil,
14671480
methods: [],
14681481
properties: [],
1469-
namespace: namespaceResult.namespace,
1482+
namespace: effectiveNamespace,
14701483
identityMode: classIdentityMode
14711484
)
1472-
let uniqueKey = makeKey(name: name, namespace: namespaceResult.namespace)
1485+
let uniqueKey = makeKey(name: name, namespace: effectiveNamespace)
14731486

14741487
stateStack.push(state: .classBody(name: name, key: uniqueKey))
14751488
exportedClassByName[uniqueKey] = exportedClass
@@ -1742,6 +1755,10 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
17421755
guard namespaceResult.isValid else {
17431756
return .skipChildren
17441757
}
1758+
let effectiveNamespace = mergeNamespaces(
1759+
namespaceResult.namespace,
1760+
computeParentTypeNamespace(for: node)
1761+
)
17451762
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
17461763
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
17471764
for: node,
@@ -1791,22 +1808,22 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
17911808
type: fieldType,
17921809
isReadonly: true,
17931810
isStatic: false,
1794-
namespace: namespaceResult.namespace,
1811+
namespace: effectiveNamespace,
17951812
staticContext: nil
17961813
)
17971814
properties.append(property)
17981815
}
17991816
}
18001817
}
18011818

1802-
let structUniqueKey = makeKey(name: name, namespace: namespaceResult.namespace)
1819+
let structUniqueKey = makeKey(name: name, namespace: effectiveNamespace)
18031820
let exportedStruct = ExportedStruct(
18041821
name: name,
18051822
swiftCallName: swiftCallName,
18061823
explicitAccessControl: explicitAccessControl,
18071824
properties: properties,
18081825
methods: [],
1809-
namespace: namespaceResult.namespace
1826+
namespace: effectiveNamespace
18101827
)
18111828

18121829
exportedStructByName[structUniqueKey] = exportedStruct
@@ -2035,6 +2052,35 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
20352052
return namespace.isEmpty ? nil : namespace
20362053
}
20372054

2055+
private func computeParentTypeNamespace(for node: some SyntaxProtocol) -> [String]? {
2056+
var path: [String] = []
2057+
var currentNode: Syntax? = node.parent
2058+
2059+
while let parent = currentNode {
2060+
if let structDecl = parent.as(StructDeclSyntax.self),
2061+
structDecl.attributes.hasJSAttribute()
2062+
{
2063+
path.insert(structDecl.name.text, at: 0)
2064+
} else if let classDecl = parent.as(ClassDeclSyntax.self),
2065+
classDecl.attributes.hasJSAttribute()
2066+
{
2067+
path.insert(classDecl.name.text, at: 0)
2068+
}
2069+
currentNode = parent.parent
2070+
}
2071+
2072+
return path.isEmpty ? nil : path
2073+
}
2074+
2075+
private func mergeNamespaces(_ base: [String]?, _ extra: [String]?) -> [String]? {
2076+
switch (base, extra) {
2077+
case (nil, nil): return nil
2078+
case (let ns?, nil): return ns
2079+
case (nil, let ns?): return ns
2080+
case (let base?, let extra?): return extra + base
2081+
}
2082+
}
2083+
20382084
/// Requires the node to have at least internal access control.
20392085
private func computeExplicitAtLeastInternalAccessControl(
20402086
for node: some WithModifiersSyntax,

Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,75 @@ import Testing
165165
#expect(description.contains("<stdin>:2:"))
166166
}
167167

168+
// MARK: - Nested type validation
169+
170+
@Test
171+
func nestedStructInsideClassSucceeds() throws {
172+
let source = """
173+
@JS class User {
174+
@JS struct Stats {
175+
var health: Int
176+
}
177+
}
178+
"""
179+
let swiftAPI = SwiftToSkeleton(
180+
progress: .silent,
181+
moduleName: "TestModule",
182+
exposeToGlobal: false,
183+
externalModuleIndex: .empty
184+
)
185+
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
186+
let skeleton = try swiftAPI.finalize()
187+
#expect(skeleton.exported != nil)
188+
let structs = skeleton.exported?.structs ?? []
189+
#expect(structs.count == 1)
190+
#expect(structs.first?.swiftCallName == "User.Stats")
191+
}
192+
193+
@Test
194+
func nestedClassInsideStructSucceeds() throws {
195+
let source = """
196+
@JS struct Container {
197+
var value: Int
198+
@JS class Inner {
199+
}
200+
}
201+
"""
202+
let swiftAPI = SwiftToSkeleton(
203+
progress: .silent,
204+
moduleName: "TestModule",
205+
exposeToGlobal: false,
206+
externalModuleIndex: .empty
207+
)
208+
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
209+
let skeleton = try swiftAPI.finalize()
210+
#expect(skeleton.exported != nil)
211+
let classes = skeleton.exported?.classes ?? []
212+
#expect(classes.count == 1)
213+
#expect(classes.first?.swiftCallName == "Container.Inner")
214+
}
215+
216+
@Test
217+
func structInsideEnumNamespaceSucceeds() throws {
218+
let source = """
219+
@JS enum API {
220+
@JS struct Point {
221+
var x: Double
222+
var y: Double
223+
}
224+
}
225+
"""
226+
let swiftAPI = SwiftToSkeleton(
227+
progress: .silent,
228+
moduleName: "TestModule",
229+
exposeToGlobal: false,
230+
externalModuleIndex: .empty
231+
)
232+
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
233+
let skeleton = try swiftAPI.finalize()
234+
#expect(skeleton.exported != nil)
235+
}
236+
168237
@Test
169238
func omitsNextLineWhenErrorIsOnLastLine() throws {
170239
let source = """
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
@JS class User {
2+
@JS func getName() -> String {
3+
return "test"
4+
}
5+
6+
@JS struct Stats {
7+
var health: Int
8+
var score: Double
9+
}
10+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
{
2+
"exported" : {
3+
"classes" : [
4+
{
5+
"methods" : [
6+
{
7+
"abiName" : "bjs_User_getName",
8+
"effects" : {
9+
"isAsync" : false,
10+
"isStatic" : false,
11+
"isThrows" : false
12+
},
13+
"name" : "getName",
14+
"parameters" : [
15+
16+
],
17+
"returnType" : {
18+
"string" : {
19+
20+
}
21+
}
22+
}
23+
],
24+
"name" : "User",
25+
"properties" : [
26+
27+
],
28+
"swiftCallName" : "User"
29+
}
30+
],
31+
"enums" : [
32+
33+
],
34+
"exposeToGlobal" : false,
35+
"functions" : [
36+
37+
],
38+
"protocols" : [
39+
40+
],
41+
"structs" : [
42+
{
43+
"methods" : [
44+
45+
],
46+
"name" : "Stats",
47+
"namespace" : [
48+
"User"
49+
],
50+
"properties" : [
51+
{
52+
"isReadonly" : true,
53+
"isStatic" : false,
54+
"name" : "health",
55+
"namespace" : [
56+
"User"
57+
],
58+
"type" : {
59+
"integer" : {
60+
"_0" : {
61+
"isSigned" : true,
62+
"width" : "word"
63+
}
64+
}
65+
}
66+
},
67+
{
68+
"isReadonly" : true,
69+
"isStatic" : false,
70+
"name" : "score",
71+
"namespace" : [
72+
"User"
73+
],
74+
"type" : {
75+
"double" : {
76+
77+
}
78+
}
79+
}
80+
],
81+
"swiftCallName" : "User.Stats"
82+
}
83+
]
84+
},
85+
"moduleName" : "TestModule",
86+
"usedExternalModules" : [
87+
88+
]
89+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
extension User.Stats: _BridgedSwiftStruct {
2+
@_spi(BridgeJS) @_transparent public static func bridgeJSStackPop() -> User.Stats {
3+
let score = Double.bridgeJSStackPop()
4+
let health = Int.bridgeJSStackPop()
5+
return User.Stats(health: health, score: score)
6+
}
7+
8+
@_spi(BridgeJS) @_transparent public consuming func bridgeJSStackPush() {
9+
self.health.bridgeJSStackPush()
10+
self.score.bridgeJSStackPush()
11+
}
12+
13+
init(unsafelyCopying jsObject: JSObject) {
14+
_bjs_struct_lower_User_Stats(jsObject.bridgeJSLowerParameter())
15+
self = Self.bridgeJSStackPop()
16+
}
17+
18+
func toJSObject() -> JSObject {
19+
let __bjs_self = self
20+
__bjs_self.bridgeJSStackPush()
21+
return JSObject(id: UInt32(bitPattern: _bjs_struct_lift_User_Stats()))
22+
}
23+
}
24+
25+
#if arch(wasm32)
26+
@_extern(wasm, module: "bjs", name: "swift_js_struct_lower_User_Stats")
27+
fileprivate func _bjs_struct_lower_User_Stats_extern(_ objectId: Int32) -> Void
28+
#else
29+
fileprivate func _bjs_struct_lower_User_Stats_extern(_ objectId: Int32) -> Void {
30+
fatalError("Only available on WebAssembly")
31+
}
32+
#endif
33+
@inline(never) fileprivate func _bjs_struct_lower_User_Stats(_ objectId: Int32) -> Void {
34+
return _bjs_struct_lower_User_Stats_extern(objectId)
35+
}
36+
37+
#if arch(wasm32)
38+
@_extern(wasm, module: "bjs", name: "swift_js_struct_lift_User_Stats")
39+
fileprivate func _bjs_struct_lift_User_Stats_extern() -> Int32
40+
#else
41+
fileprivate func _bjs_struct_lift_User_Stats_extern() -> Int32 {
42+
fatalError("Only available on WebAssembly")
43+
}
44+
#endif
45+
@inline(never) fileprivate func _bjs_struct_lift_User_Stats() -> Int32 {
46+
return _bjs_struct_lift_User_Stats_extern()
47+
}
48+
49+
@_expose(wasm, "bjs_User_getName")
50+
@_cdecl("bjs_User_getName")
51+
public func _bjs_User_getName(_ _self: UnsafeMutableRawPointer) -> Void {
52+
#if arch(wasm32)
53+
let ret = User.bridgeJSLiftParameter(_self).getName()
54+
return ret.bridgeJSLowerReturn()
55+
#else
56+
fatalError("Only available on WebAssembly")
57+
#endif
58+
}
59+
60+
@_expose(wasm, "bjs_User_deinit")
61+
@_cdecl("bjs_User_deinit")
62+
public func _bjs_User_deinit(_ pointer: UnsafeMutableRawPointer) -> Void {
63+
#if arch(wasm32)
64+
Unmanaged<User>.fromOpaque(pointer).release()
65+
#else
66+
fatalError("Only available on WebAssembly")
67+
#endif
68+
}
69+
70+
extension User: ConvertibleToJSValue, _BridgedSwiftHeapObject, _BridgedSwiftProtocolExportable {
71+
var jsValue: JSValue {
72+
return .object(JSObject(id: UInt32(bitPattern: _bjs_User_wrap(Unmanaged.passRetained(self).toOpaque()))))
73+
}
74+
consuming func bridgeJSLowerAsProtocolReturn() -> Int32 {
75+
_bjs_User_wrap(Unmanaged.passRetained(self).toOpaque())
76+
}
77+
}
78+
79+
#if arch(wasm32)
80+
@_extern(wasm, module: "TestModule", name: "bjs_User_wrap")
81+
fileprivate func _bjs_User_wrap_extern(_ pointer: UnsafeMutableRawPointer) -> Int32
82+
#else
83+
fileprivate func _bjs_User_wrap_extern(_ pointer: UnsafeMutableRawPointer) -> Int32 {
84+
fatalError("Only available on WebAssembly")
85+
}
86+
#endif
87+
@inline(never) fileprivate func _bjs_User_wrap(_ pointer: UnsafeMutableRawPointer) -> Int32 {
88+
return _bjs_User_wrap_extern(pointer)
89+
}

0 commit comments

Comments
 (0)