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
37 changes: 36 additions & 1 deletion Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1843,11 +1843,46 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
}

override func visitPost(_ node: StructDeclSyntax) {
if case .structBody(_, _) = stateStack.current {
if case .structBody(_, let structKey) = stateStack.current {
stateStack.pop()
validateStructInitOrder(node: node, structKey: structKey)
}
}

private func validateStructInitOrder(node: StructDeclSyntax, structKey: String) {
guard let exportedStruct = exportedStructByName[structKey],
let constructor = exportedStruct.constructor
else {
// No explicit @JS init — synthesized memberwise init is assumed,
// which always matches declaration order.
return
}

let instanceProps = exportedStruct.properties.filter { !$0.isStatic }
let expectedLabels = instanceProps.map(\.name)
let actualLabels = constructor.parameters.compactMap(\.label)

guard expectedLabels != actualLabels else { return }

// Find the @JS init node so we can point the diagnostic at it.
let initNode: (any SyntaxProtocol) =
node.memberBlock.members
.compactMap { $0.decl.as(InitializerDeclSyntax.self) }
.first(where: { $0.attributes.hasJSAttribute() })
?? node

let expectedOrder = expectedLabels.joined(separator: ", ")
let actualOrder = actualLabels.joined(separator: ", ")

diagnose(
node: initNode,
message:
"@JS struct initializer parameters must match stored properties in declaration order. Expected (\(expectedOrder)), got (\(actualOrder))",
hint:
"Reorder the initializer parameters to match the property declaration order, or remove the @JS init to use the synthesized memberwise initializer"
)
}

private func visitProtocolMethod(
node: FunctionDeclSyntax,
protocolName: String,
Expand Down
71 changes: 71 additions & 0 deletions Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SwiftSyntax
import Testing

@testable import BridgeJSCore
@testable import BridgeJSSkeleton

@Suite struct DiagnosticsTests {
/// Returns the first parameter's type node from a function in the source (the first `@JS func`-like decl), for pinpointing diagnostics.
Expand Down Expand Up @@ -234,6 +235,76 @@ import Testing
#expect(skeleton.exported != nil)
}

// MARK: - Struct init order validation

@Test
func structInitMismatchedOrderProducesDiagnostic() throws {
let source = """
@JS struct Animal {
var size: Double
var age: Int

@JS init(age: Int, size: Double) {
self.age = age
self.size = size
}
}
"""
let swiftAPI = SwiftToSkeleton(
progress: .silent,
moduleName: "TestModule",
exposeToGlobal: false,
externalModuleIndex: .empty
)
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
#expect(throws: BridgeJSCoreDiagnosticError.self) {
_ = try swiftAPI.finalize()
}
}

@Test
func structInitMatchingOrderSucceeds() throws {
let source = """
@JS struct Point {
var x: Double
var y: Double

@JS init(x: Double, y: Double) {
self.x = x
self.y = y
}
}
"""
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 structWithoutExplicitInitSucceeds() throws {
let source = """
@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
Loading