Skip to content

Commit fbe3640

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

8 files changed

Lines changed: 702 additions & 5 deletions

File tree

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 50 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 = effectiveNamespace(
1466+
resolvedNamespace: namespaceResult.namespace,
1467+
parentTypeNamespace: 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 = effectiveNamespace(
1759+
resolvedNamespace: namespaceResult.namespace,
1760+
parentTypeNamespace: 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,34 @@ 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 effectiveNamespace(
2076+
resolvedNamespace: [String]?,
2077+
parentTypeNamespace: [String]?
2078+
) -> [String]? {
2079+
let combined = (parentTypeNamespace ?? []) + (resolvedNamespace ?? [])
2080+
return combined.isEmpty ? nil : combined
2081+
}
2082+
20382083
/// Requires the node to have at least internal access control.
20392084
private func computeExplicitAtLeastInternalAccessControl(
20402085
for node: some WithModifiersSyntax,

Plugins/BridgeJS/Tests/BridgeJSMacrosTests/JSClassMacroTests.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,64 @@ import BridgeJSMacros
445445
)
446446
}
447447

448+
@Test func nestedJSClassStruct() {
449+
let combinedSpecs: [String: MacroSpec] = [
450+
"JSClass": MacroSpec(type: JSClassMacro.self, conformances: ["_JSBridgedClass"]),
451+
"JSGetter": MacroSpec(type: JSGetterMacro.self),
452+
]
453+
TestSupport.assertMacroExpansion(
454+
"""
455+
@JSClass
456+
struct User {
457+
@JSGetter
458+
var stats: Stats
459+
460+
@JSClass
461+
struct Stats {
462+
@JSGetter
463+
var health: Int
464+
}
465+
}
466+
""",
467+
expandedSource: """
468+
struct User {
469+
var stats: Stats {
470+
get throws(JSException) {
471+
return try _$User_stats_get(self.jsObject)
472+
}
473+
}
474+
struct Stats {
475+
var health: Int {
476+
get throws(JSException) {
477+
return try _$Stats_health_get(self.jsObject)
478+
}
479+
}
480+
481+
let jsObject: JSObject
482+
483+
init(unsafelyWrapping jsObject: JSObject) {
484+
self.jsObject = jsObject
485+
}
486+
}
487+
488+
let jsObject: JSObject
489+
490+
init(unsafelyWrapping jsObject: JSObject) {
491+
self.jsObject = jsObject
492+
}
493+
}
494+
495+
extension User.Stats: _JSBridgedClass {
496+
}
497+
498+
extension User: _JSBridgedClass {
499+
}
500+
""",
501+
macroSpecs: combinedSpecs,
502+
indentationWidth: indentationWidth
503+
)
504+
}
505+
448506
@Test func fileprivateStructIsRejected() {
449507
TestSupport.assertMacroExpansion(
450508
"""

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+
}

0 commit comments

Comments
 (0)