Skip to content

Commit 566d76b

Browse files
committed
BridgeJS: Diagnose struct initializer parameter order mismatch
1 parent f483b91 commit 566d76b

3 files changed

Lines changed: 116 additions & 2 deletions

File tree

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1818,11 +1818,51 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
18181818
}
18191819

18201820
override func visitPost(_ node: StructDeclSyntax) {
1821-
if case .structBody(_, _) = stateStack.current {
1821+
if case .structBody(_, let structKey) = stateStack.current {
18221822
stateStack.pop()
1823+
validateStructInitOrder(node: node, structKey: structKey)
18231824
}
18241825
}
18251826

1827+
/// Validate that a `@JS` struct's explicit init parameters match its stored
1828+
/// property declarations in order. The stack ABI pushes/pops fields in
1829+
/// declaration order, so `bridgeJSStackPop()` always reconstructs the struct
1830+
/// using that order. If the user writes an init with a different parameter
1831+
/// order the generated code would fail to compile.
1832+
private func validateStructInitOrder(node: StructDeclSyntax, structKey: String) {
1833+
guard let exportedStruct = exportedStructByName[structKey],
1834+
let constructor = exportedStruct.constructor
1835+
else {
1836+
// No explicit @JS init — synthesized memberwise init is assumed,
1837+
// which always matches declaration order.
1838+
return
1839+
}
1840+
1841+
let instanceProps = exportedStruct.properties.filter { !$0.isStatic }
1842+
let expectedLabels = instanceProps.map(\.name)
1843+
let actualLabels = constructor.parameters.compactMap(\.label)
1844+
1845+
guard expectedLabels != actualLabels else { return }
1846+
1847+
// Find the @JS init node so we can point the diagnostic at it.
1848+
let initNode: (any SyntaxProtocol) =
1849+
node.memberBlock.members
1850+
.compactMap { $0.decl.as(InitializerDeclSyntax.self) }
1851+
.first(where: { $0.attributes.hasJSAttribute() })
1852+
?? node
1853+
1854+
let expectedOrder = expectedLabels.joined(separator: ", ")
1855+
let actualOrder = actualLabels.joined(separator: ", ")
1856+
1857+
diagnose(
1858+
node: initNode,
1859+
message:
1860+
"@JS struct initializer parameters must match stored properties in declaration order. Expected (\(expectedOrder)), got (\(actualOrder))",
1861+
hint:
1862+
"Reorder the initializer parameters to match the property declaration order, or remove the @JS init to use the synthesized memberwise initializer"
1863+
)
1864+
}
1865+
18261866
private func visitProtocolMethod(
18271867
node: FunctionDeclSyntax,
18281868
protocolName: String,

Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import SwiftSyntax
33
import Testing
44

55
@testable import BridgeJSCore
6+
@testable import BridgeJSSkeleton
67

78
@Suite struct DiagnosticsTests {
89
/// Returns the first parameter's type node from a function in the source (the first `@JS func`-like decl), for pinpointing diagnostics.
@@ -165,6 +166,76 @@ import Testing
165166
#expect(description.contains("<stdin>:2:"))
166167
}
167168

169+
// MARK: - Struct init order validation
170+
171+
@Test
172+
func structInitMismatchedOrderProducesDiagnostic() throws {
173+
let source = """
174+
@JS struct Animal {
175+
var size: Double
176+
var age: Int
177+
178+
@JS init(age: Int, size: Double) {
179+
self.age = age
180+
self.size = size
181+
}
182+
}
183+
"""
184+
let swiftAPI = SwiftToSkeleton(
185+
progress: .silent,
186+
moduleName: "TestModule",
187+
exposeToGlobal: false,
188+
externalModuleIndex: .empty
189+
)
190+
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
191+
#expect(throws: BridgeJSCoreDiagnosticError.self) {
192+
_ = try swiftAPI.finalize()
193+
}
194+
}
195+
196+
@Test
197+
func structInitMatchingOrderSucceeds() throws {
198+
let source = """
199+
@JS struct Point {
200+
var x: Double
201+
var y: Double
202+
203+
@JS init(x: Double, y: Double) {
204+
self.x = x
205+
self.y = y
206+
}
207+
}
208+
"""
209+
let swiftAPI = SwiftToSkeleton(
210+
progress: .silent,
211+
moduleName: "TestModule",
212+
exposeToGlobal: false,
213+
externalModuleIndex: .empty
214+
)
215+
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
216+
let skeleton = try swiftAPI.finalize()
217+
#expect(skeleton.exported != nil)
218+
}
219+
220+
@Test
221+
func structWithoutExplicitInitSucceeds() throws {
222+
let source = """
223+
@JS struct Point {
224+
var x: Double
225+
var y: Double
226+
}
227+
"""
228+
let swiftAPI = SwiftToSkeleton(
229+
progress: .silent,
230+
moduleName: "TestModule",
231+
exposeToGlobal: false,
232+
externalModuleIndex: .empty
233+
)
234+
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
235+
let skeleton = try swiftAPI.finalize()
236+
#expect(skeleton.exported != nil)
237+
}
238+
168239
@Test
169240
func omitsNextLineWhenErrorIsOnLastLine() throws {
170241
let source = """

Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/SwiftTypedClosureAccess.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,5 +281,8 @@
281281
}
282282
]
283283
},
284-
"moduleName" : "TestModule"
284+
"moduleName" : "TestModule",
285+
"usedExternalModules" : [
286+
287+
]
285288
}

0 commit comments

Comments
 (0)