Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 65 additions & 12 deletions Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,14 @@ public final class SwiftToSkeleton {
enumDecl.attributes.hasJSAttribute()
{
swiftPath.insert(enumDecl.name.text, at: 0)
} else if let structDecl = parent.as(StructDeclSyntax.self),
structDecl.attributes.hasJSAttribute()
{
swiftPath.insert(structDecl.name.text, at: 0)
} else if let classDecl = parent.as(ClassDeclSyntax.self),
classDecl.attributes.hasJSAttribute()
{
swiftPath.insert(classDecl.name.text, at: 0)
}
currentNode = parent.parent
}
Expand Down Expand Up @@ -648,6 +656,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
var state: State {
return stateStack.current
}

let parent: SwiftToSkeleton

init(parent: SwiftToSkeleton) {
Expand Down Expand Up @@ -1453,6 +1462,10 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
guard namespaceResult.isValid else {
return .skipChildren
}
let effectiveNamespace = effectiveNamespace(
resolvedNamespace: namespaceResult.namespace,
parentTypeNamespace: computeParentTypeNamespace(for: node)
)
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
for: node,
Expand All @@ -1466,10 +1479,10 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
constructor: nil,
methods: [],
properties: [],
namespace: namespaceResult.namespace,
namespace: effectiveNamespace,
identityMode: classIdentityMode
)
let uniqueKey = makeKey(name: name, namespace: namespaceResult.namespace)
let uniqueKey = makeKey(name: name, namespace: effectiveNamespace)

stateStack.push(state: .classBody(name: name, key: uniqueKey))
exportedClassByName[uniqueKey] = exportedClass
Expand Down Expand Up @@ -1558,6 +1571,10 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
guard namespaceResult.isValid else {
return .skipChildren
}
let effectiveNamespace = effectiveNamespace(
resolvedNamespace: namespaceResult.namespace,
parentTypeNamespace: computeParentTypeNamespace(for: node)
)
let emitStyle = extractEnumStyle(from: jsAttribute) ?? .const
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
Expand All @@ -1566,7 +1583,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
)

let tsFullPath: String
if let namespace = namespaceResult.namespace, !namespace.isEmpty {
if let namespace = effectiveNamespace, !namespace.isEmpty {
tsFullPath = namespace.joined(separator: ".") + "." + name
} else {
tsFullPath = name
Expand All @@ -1580,13 +1597,13 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
explicitAccessControl: explicitAccessControl,
cases: [], // Will be populated in visit(EnumCaseDeclSyntax)
rawType: SwiftEnumRawType(rawType),
namespace: namespaceResult.namespace,
namespace: effectiveNamespace,
emitStyle: emitStyle,
staticMethods: [],
staticProperties: []
)

let enumUniqueKey = makeKey(name: name, namespace: namespaceResult.namespace)
let enumUniqueKey = makeKey(name: name, namespace: effectiveNamespace)
exportedEnumByName[enumUniqueKey] = exportedEnum
exportedEnumNames.append(enumUniqueKey)

Expand Down Expand Up @@ -1685,18 +1702,22 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
guard namespaceResult.isValid else {
return .skipChildren
}
let effectiveNamespace = effectiveNamespace(
resolvedNamespace: namespaceResult.namespace,
parentTypeNamespace: computeParentTypeNamespace(for: node)
)
_ = computeExplicitAtLeastInternalAccessControl(
for: node,
message: "Protocol visibility must be at least internal"
)

let protocolUniqueKey = makeKey(name: name, namespace: namespaceResult.namespace)
let protocolUniqueKey = makeKey(name: name, namespace: effectiveNamespace)

exportedProtocolByName[protocolUniqueKey] = ExportedProtocol(
name: name,
methods: [],
properties: [],
namespace: namespaceResult.namespace
namespace: effectiveNamespace
)

stateStack.push(state: .protocolBody(name: name, key: protocolUniqueKey))
Expand All @@ -1707,7 +1728,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
if let exportedFunction = visitProtocolMethod(
node: funcDecl,
protocolName: name,
namespace: namespaceResult.namespace
namespace: effectiveNamespace
) {
methods.append(exportedFunction)
}
Expand All @@ -1720,7 +1741,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
name: name,
methods: methods,
properties: exportedProtocolByName[protocolUniqueKey]?.properties ?? [],
namespace: namespaceResult.namespace
namespace: effectiveNamespace
)

exportedProtocolByName[protocolUniqueKey] = exportedProtocol
Expand All @@ -1742,6 +1763,10 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
guard namespaceResult.isValid else {
return .skipChildren
}
let effectiveNamespace = effectiveNamespace(
resolvedNamespace: namespaceResult.namespace,
parentTypeNamespace: computeParentTypeNamespace(for: node)
)
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
for: node,
Expand Down Expand Up @@ -1791,22 +1816,22 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
type: fieldType,
isReadonly: true,
isStatic: false,
namespace: namespaceResult.namespace,
namespace: effectiveNamespace,
staticContext: nil
)
properties.append(property)
}
}
}

let structUniqueKey = makeKey(name: name, namespace: namespaceResult.namespace)
let structUniqueKey = makeKey(name: name, namespace: effectiveNamespace)
let exportedStruct = ExportedStruct(
name: name,
swiftCallName: swiftCallName,
explicitAccessControl: explicitAccessControl,
properties: properties,
methods: [],
namespace: namespaceResult.namespace
namespace: effectiveNamespace
)

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

private func computeParentTypeNamespace(for node: some SyntaxProtocol) -> [String]? {
var path: [String] = []
var currentNode: Syntax? = node.parent

while let parent = currentNode {
if let structDecl = parent.as(StructDeclSyntax.self),
structDecl.attributes.hasJSAttribute()
{
path.insert(structDecl.name.text, at: 0)
} else if let classDecl = parent.as(ClassDeclSyntax.self),
classDecl.attributes.hasJSAttribute()
{
path.insert(classDecl.name.text, at: 0)
}
currentNode = parent.parent
}

return path.isEmpty ? nil : path
}

private func effectiveNamespace(
resolvedNamespace: [String]?,
parentTypeNamespace: [String]?
) -> [String]? {
let combined = (parentTypeNamespace ?? []) + (resolvedNamespace ?? [])
return combined.isEmpty ? nil : combined
}

/// Requires the node to have at least internal access control.
private func computeExplicitAtLeastInternalAccessControl(
for node: some WithModifiersSyntax,
Expand Down
58 changes: 58 additions & 0 deletions Plugins/BridgeJS/Tests/BridgeJSMacrosTests/JSClassMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,64 @@ import BridgeJSMacros
)
}

@Test func nestedJSClassStruct() {
let combinedSpecs: [String: MacroSpec] = [
"JSClass": MacroSpec(type: JSClassMacro.self, conformances: ["_JSBridgedClass"]),
"JSGetter": MacroSpec(type: JSGetterMacro.self),
]
TestSupport.assertMacroExpansion(
"""
@JSClass
struct User {
@JSGetter
var stats: Stats

@JSClass
struct Stats {
@JSGetter
var health: Int
}
}
""",
expandedSource: """
struct User {
var stats: Stats {
get throws(JSException) {
return try _$User_stats_get(self.jsObject)
}
}
struct Stats {
var health: Int {
get throws(JSException) {
return try _$Stats_health_get(self.jsObject)
}
}

let jsObject: JSObject

init(unsafelyWrapping jsObject: JSObject) {
self.jsObject = jsObject
}
}

let jsObject: JSObject

init(unsafelyWrapping jsObject: JSObject) {
self.jsObject = jsObject
}
}

extension User.Stats: _JSBridgedClass {
}

extension User: _JSBridgedClass {
}
""",
macroSpecs: combinedSpecs,
indentationWidth: indentationWidth
)
}

@Test func fileprivateStructIsRejected() {
TestSupport.assertMacroExpansion(
"""
Expand Down
69 changes: 69 additions & 0 deletions Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,75 @@ import Testing
#expect(description.contains("<stdin>:2:"))
}

// MARK: - Nested type validation

@Test
func nestedStructInsideClassSucceeds() throws {
let source = """
@JS class User {
@JS struct Stats {
var health: Int
}
}
"""
let swiftAPI = SwiftToSkeleton(
progress: .silent,
moduleName: "TestModule",
exposeToGlobal: false,
externalModuleIndex: .empty
)
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
let skeleton = try swiftAPI.finalize()
#expect(skeleton.exported != nil)
let structs = skeleton.exported?.structs ?? []
#expect(structs.count == 1)
#expect(structs.first?.swiftCallName == "User.Stats")
}

@Test
func nestedClassInsideStructSucceeds() throws {
let source = """
@JS struct Container {
var value: Int
@JS class Inner {
}
}
"""
let swiftAPI = SwiftToSkeleton(
progress: .silent,
moduleName: "TestModule",
exposeToGlobal: false,
externalModuleIndex: .empty
)
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
let skeleton = try swiftAPI.finalize()
#expect(skeleton.exported != nil)
let classes = skeleton.exported?.classes ?? []
#expect(classes.count == 1)
#expect(classes.first?.swiftCallName == "Container.Inner")
}

@Test
func structInsideEnumNamespaceSucceeds() throws {
let source = """
@JS enum API {
@JS struct Point {
var x: Double
var y: Double
}
}
"""
let swiftAPI = SwiftToSkeleton(
progress: .silent,
moduleName: "TestModule",
exposeToGlobal: false,
externalModuleIndex: .empty
)
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
let skeleton = try swiftAPI.finalize()
#expect(skeleton.exported != nil)
}

@Test
func omitsNextLineWhenErrorIsOnLastLine() throws {
let source = """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@JS class User {
@JS func getName() -> String {
return "test"
}

@JS struct Stats {
var health: Int
var score: Double
}
}
Loading
Loading