Skip to content

Commit 2dd870e

Browse files
authored
Merge pull request #735 from swiftwasm/kr/nested-type-diagnostic
BridgeJS: Support nested @js types inside structs and classes
2 parents 44ebc38 + 6eb6668 commit 2dd870e

8 files changed

Lines changed: 717 additions & 12 deletions

File tree

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 65 additions & 12 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
@@ -1558,6 +1571,10 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
15581571
guard namespaceResult.isValid else {
15591572
return .skipChildren
15601573
}
1574+
let effectiveNamespace = effectiveNamespace(
1575+
resolvedNamespace: namespaceResult.namespace,
1576+
parentTypeNamespace: computeParentTypeNamespace(for: node)
1577+
)
15611578
let emitStyle = extractEnumStyle(from: jsAttribute) ?? .const
15621579
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
15631580
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
@@ -1566,7 +1583,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
15661583
)
15671584

15681585
let tsFullPath: String
1569-
if let namespace = namespaceResult.namespace, !namespace.isEmpty {
1586+
if let namespace = effectiveNamespace, !namespace.isEmpty {
15701587
tsFullPath = namespace.joined(separator: ".") + "." + name
15711588
} else {
15721589
tsFullPath = name
@@ -1580,13 +1597,13 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
15801597
explicitAccessControl: explicitAccessControl,
15811598
cases: [], // Will be populated in visit(EnumCaseDeclSyntax)
15821599
rawType: SwiftEnumRawType(rawType),
1583-
namespace: namespaceResult.namespace,
1600+
namespace: effectiveNamespace,
15841601
emitStyle: emitStyle,
15851602
staticMethods: [],
15861603
staticProperties: []
15871604
)
15881605

1589-
let enumUniqueKey = makeKey(name: name, namespace: namespaceResult.namespace)
1606+
let enumUniqueKey = makeKey(name: name, namespace: effectiveNamespace)
15901607
exportedEnumByName[enumUniqueKey] = exportedEnum
15911608
exportedEnumNames.append(enumUniqueKey)
15921609

@@ -1685,18 +1702,22 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
16851702
guard namespaceResult.isValid else {
16861703
return .skipChildren
16871704
}
1705+
let effectiveNamespace = effectiveNamespace(
1706+
resolvedNamespace: namespaceResult.namespace,
1707+
parentTypeNamespace: computeParentTypeNamespace(for: node)
1708+
)
16881709
_ = computeExplicitAtLeastInternalAccessControl(
16891710
for: node,
16901711
message: "Protocol visibility must be at least internal"
16911712
)
16921713

1693-
let protocolUniqueKey = makeKey(name: name, namespace: namespaceResult.namespace)
1714+
let protocolUniqueKey = makeKey(name: name, namespace: effectiveNamespace)
16941715

16951716
exportedProtocolByName[protocolUniqueKey] = ExportedProtocol(
16961717
name: name,
16971718
methods: [],
16981719
properties: [],
1699-
namespace: namespaceResult.namespace
1720+
namespace: effectiveNamespace
17001721
)
17011722

17021723
stateStack.push(state: .protocolBody(name: name, key: protocolUniqueKey))
@@ -1707,7 +1728,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
17071728
if let exportedFunction = visitProtocolMethod(
17081729
node: funcDecl,
17091730
protocolName: name,
1710-
namespace: namespaceResult.namespace
1731+
namespace: effectiveNamespace
17111732
) {
17121733
methods.append(exportedFunction)
17131734
}
@@ -1720,7 +1741,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
17201741
name: name,
17211742
methods: methods,
17221743
properties: exportedProtocolByName[protocolUniqueKey]?.properties ?? [],
1723-
namespace: namespaceResult.namespace
1744+
namespace: effectiveNamespace
17241745
)
17251746

17261747
exportedProtocolByName[protocolUniqueKey] = exportedProtocol
@@ -1742,6 +1763,10 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
17421763
guard namespaceResult.isValid else {
17431764
return .skipChildren
17441765
}
1766+
let effectiveNamespace = effectiveNamespace(
1767+
resolvedNamespace: namespaceResult.namespace,
1768+
parentTypeNamespace: computeParentTypeNamespace(for: node)
1769+
)
17451770
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
17461771
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
17471772
for: node,
@@ -1791,22 +1816,22 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
17911816
type: fieldType,
17921817
isReadonly: true,
17931818
isStatic: false,
1794-
namespace: namespaceResult.namespace,
1819+
namespace: effectiveNamespace,
17951820
staticContext: nil
17961821
)
17971822
properties.append(property)
17981823
}
17991824
}
18001825
}
18011826

1802-
let structUniqueKey = makeKey(name: name, namespace: namespaceResult.namespace)
1827+
let structUniqueKey = makeKey(name: name, namespace: effectiveNamespace)
18031828
let exportedStruct = ExportedStruct(
18041829
name: name,
18051830
swiftCallName: swiftCallName,
18061831
explicitAccessControl: explicitAccessControl,
18071832
properties: properties,
18081833
methods: [],
1809-
namespace: namespaceResult.namespace
1834+
namespace: effectiveNamespace
18101835
)
18111836

18121837
exportedStructByName[structUniqueKey] = exportedStruct
@@ -2035,6 +2060,34 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
20352060
return namespace.isEmpty ? nil : namespace
20362061
}
20372062

2063+
private func computeParentTypeNamespace(for node: some SyntaxProtocol) -> [String]? {
2064+
var path: [String] = []
2065+
var currentNode: Syntax? = node.parent
2066+
2067+
while let parent = currentNode {
2068+
if let structDecl = parent.as(StructDeclSyntax.self),
2069+
structDecl.attributes.hasJSAttribute()
2070+
{
2071+
path.insert(structDecl.name.text, at: 0)
2072+
} else if let classDecl = parent.as(ClassDeclSyntax.self),
2073+
classDecl.attributes.hasJSAttribute()
2074+
{
2075+
path.insert(classDecl.name.text, at: 0)
2076+
}
2077+
currentNode = parent.parent
2078+
}
2079+
2080+
return path.isEmpty ? nil : path
2081+
}
2082+
2083+
private func effectiveNamespace(
2084+
resolvedNamespace: [String]?,
2085+
parentTypeNamespace: [String]?
2086+
) -> [String]? {
2087+
let combined = (parentTypeNamespace ?? []) + (resolvedNamespace ?? [])
2088+
return combined.isEmpty ? nil : combined
2089+
}
2090+
20382091
/// Requires the node to have at least internal access control.
20392092
private func computeExplicitAtLeastInternalAccessControl(
20402093
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+
}

0 commit comments

Comments
 (0)