Skip to content

Commit e04f4e0

Browse files
committed
BridgeJS: Support @js protocol constraints on generic exports
The same protocol compositions now work on exported generics: @js public func storeNode<T: BridgedSwiftGenericBridgeable & GraphNode>(_ node: T) -> T { node } Unlike imports, the Swift compiler cannot check a JavaScript caller, so the constraint is enforced at the boundary: the link layer records every @js type's conformed @js protocols (declared on the type or via an extension) in a token-to-conformances map, and the generated wrapper resolves the caller's BridgeTypes token against the constraint before anything is lowered. A non-conforming token throws a catchable TypeError without entering wasm, keeping the shared value stack balanced; the Swift-side generic signature then guarantees the constraint holds inside the thunk. The d.ts wrapper signature carries 'T extends GraphNode', so TypeScript users get the check at compile time as well.
1 parent c7565fb commit e04f4e0

31 files changed

Lines changed: 3411 additions & 124 deletions

‎Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift‎

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -189,22 +189,24 @@ public class ExportSwift {
189189
/// When non-empty, `render(abiName:)` wraps the standard body in a
190190
/// generics prologue: the entry thunk takes one trailing type-ID ABI
191191
/// parameter per generic, recovers the concrete types from their
192-
/// `BridgeJSTypeHandle`s, and opens them into a chain of helper
192+
/// `BridgeJSTypeHandle`s (enforcing any `@JS protocol` constraints via
193+
/// a conditional metatype cast), and opens them into a chain of helper
193194
/// functions; the innermost helper runs the standard body with the
194195
/// generic parameters bound.
195-
let genericParameterNames: [String]
196+
let genericParameters: [GenericParameter]
197+
var genericParameterNames: [String] { genericParameters.map(\.name) }
196198

197199
/// The async return type settled through `_bjs_makePromise`'s `Promise_resolve_<mangled>`
198200
/// helper. Set for every `async` thunk.
199201
var asyncResolveReturnType: BridgeType?
200202

201203
var parameterBindings: [CodeBlockItemSyntax] = []
202204

203-
init(effects: Effects, returnType: BridgeType, genericParameterNames: [String] = []) throws {
205+
init(effects: Effects, returnType: BridgeType, genericParameters: [GenericParameter] = []) throws {
204206
self.effects = effects
205-
self.genericParameterNames = genericParameterNames
207+
self.genericParameters = genericParameters
206208
guard effects.isAsync else { return }
207-
guard genericParameterNames.isEmpty else {
209+
guard genericParameters.isEmpty else {
208210
throw BridgeJSCoreError("Generic exported functions cannot be 'async' yet")
209211
}
210212
guard returnType.isAsyncResolvable else {
@@ -528,16 +530,29 @@ public class ExportSwift {
528530
return abiReturnType?.swiftReturnPlaceholderStmt ?? "return"
529531
}
530532

533+
/// Swift spellings of a generic parameter's `@JS protocol` constraints,
534+
/// qualified as written in source (`GraphKit.Node`) so the generated
535+
/// generic clause resolves; the JS-side check keys on the bare names.
536+
func protocolConstraints(_ genericName: String) -> [String] {
537+
guard let parameter = genericParameters.first(where: { $0.name == genericName }) else { return [] }
538+
return parameter.swiftConstraints ?? parameter.constraints
539+
}
540+
531541
/// The constraint composition a generic parameter is opened under, e.g.
532-
/// `BridgedSwiftGenericBridgeable`. A hook for protocol constraints.
542+
/// `BridgedSwiftGenericBridgeable & GraphNode`.
533543
func constraintComposition(_ genericName: String) -> String {
534-
"BridgedSwiftGenericBridgeable"
544+
(["BridgedSwiftGenericBridgeable"] + protocolConstraints(genericName)).joined(separator: " & ")
535545
}
536546

537547
/// The existential metatype spelling used to thread a not-yet-opened
538-
/// generic parameter through the open chain.
548+
/// generic parameter through the open chain, e.g.
549+
/// `any (BridgedSwiftGenericBridgeable & GraphNode).Type`.
539550
func existentialMetatype(_ genericName: String) -> String {
540-
"any BridgedSwiftGenericBridgeable.Type"
551+
let constraints = protocolConstraints(genericName)
552+
if constraints.isEmpty {
553+
return "any BridgedSwiftGenericBridgeable.Type"
554+
}
555+
return "any (\(constraintComposition(genericName))).Type"
541556
}
542557

543558
/// Renders the wasm entry point for a generic exported function.
@@ -591,12 +606,29 @@ public class ExportSwift {
591606
// `BridgeJSTypeHandle`; recover the type from it. A raw wasm
592607
// caller passing a garbage ID is undefined behavior — the
593608
// generated JS wrapper validates tokens before calling in.
609+
let constraints = protocolConstraints(genericName)
610+
let recoveredName =
611+
constraints.isEmpty ? metatypeName(genericName) : "\(metatypeName(genericName))Base"
594612
printer.write(
595-
"let \(metatypeName(genericName)) = "
613+
"let \(recoveredName) = "
596614
+ "Unmanaged<BridgeJSTypeHandle>.fromOpaque("
597615
+ "UnsafeRawPointer(bitPattern: UInt(UInt32(bitPattern: \(typeIdName(genericName)))))!"
598616
+ ").takeUnretainedValue().type"
599617
)
618+
if !constraints.isEmpty {
619+
// Defense-in-depth: the generated JS wrapper already
620+
// rejects non-conforming tokens with a TypeError, so
621+
// this cast can only fail for a raw wasm caller.
622+
printer.write(
623+
"guard let \(metatypeName(genericName)) = \(recoveredName) as? \(existentialMetatype(genericName)) else {"
624+
)
625+
printer.indent {
626+
printer.write(
627+
"fatalError(\"BridgeJS: type '\\(\(recoveredName))' does not conform to required protocol(s): \(constraints.joined(separator: ", "))\")"
628+
)
629+
}
630+
printer.write("}")
631+
}
600632
}
601633
let open1Arguments = genericNames.map { metatypeName($0) } + concreteABINames
602634
printer.write("_\(abiName)_open1(\(open1Arguments.joined(separator: ", ")))")
@@ -776,7 +808,7 @@ public class ExportSwift {
776808
let builder = try ExportedThunkBuilder(
777809
effects: function.effects,
778810
returnType: function.returnType,
779-
genericParameterNames: function.genericParameterNames
811+
genericParameters: function.genericParameters ?? []
780812
)
781813
for param in function.parameters {
782814
try builder.liftParameter(param: param)
@@ -823,7 +855,7 @@ public class ExportSwift {
823855
let builder = try ExportedThunkBuilder(
824856
effects: method.effects,
825857
returnType: method.returnType,
826-
genericParameterNames: method.genericParameterNames
858+
genericParameters: method.genericParameters ?? []
827859
)
828860
if !method.effects.isStatic {
829861
try builder.liftParameter(param: Parameter(label: nil, name: "_self", type: instanceSelfType))

‎Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift‎

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ public final class SwiftToSkeleton {
300300
collector.finalize(&exported)
301301
}
302302

303+
mergeExtensionDeclaredJSProtocolConformances(into: &exported)
304+
303305
perSourceErrors.append(contentsOf: diagnoseProtocolConformances(in: exported))
304306

305307
if !perSourceErrors.isEmpty {
@@ -418,6 +420,50 @@ public final class SwiftToSkeleton {
418420
return diagnostics
419421
}
420422

423+
/// Folds `extension Foo: SomeJSProtocol {}` conformances into the exported
424+
/// skeleton's `conformedJSProtocols` lists.
425+
///
426+
/// The per-declaration collectors only see the type's own inheritance
427+
/// clause, but declaring the conformance in an extension is common Swift
428+
/// style, and the JS-side generic constraint check
429+
/// (`__bjs_tokenConformances`) must not reject a type that genuinely
430+
/// conforms.
431+
private func mergeExtensionDeclaredJSProtocolConformances(into exported: inout ExportedSkeleton) {
432+
var protocolsByTarget: [String: [String]] = [:]
433+
for (sourceFile, _) in sourceFiles {
434+
let collector = ExtensionConformanceCollector(parent: self)
435+
collector.walk(sourceFile)
436+
for (target, protocols) in collector.protocolsByTarget {
437+
protocolsByTarget[target, default: []].append(contentsOf: protocols)
438+
}
439+
}
440+
guard !protocolsByTarget.isEmpty else { return }
441+
442+
func merge(_ existing: inout [String]?, additions: [String]) {
443+
var result = existing ?? []
444+
for name in additions where !result.contains(name) {
445+
result.append(name)
446+
}
447+
existing = result.isEmpty ? nil : result
448+
}
449+
450+
for index in exported.structs.indices {
451+
if let additions = protocolsByTarget[exported.structs[index].swiftCallName] {
452+
merge(&exported.structs[index].conformedJSProtocols, additions: additions)
453+
}
454+
}
455+
for index in exported.classes.indices {
456+
if let additions = protocolsByTarget[exported.classes[index].swiftCallName] {
457+
merge(&exported.classes[index].conformedJSProtocols, additions: additions)
458+
}
459+
}
460+
for index in exported.enums.indices {
461+
if let additions = protocolsByTarget[exported.enums[index].swiftCallName] {
462+
merge(&exported.enums[index].conformedJSProtocols, additions: additions)
463+
}
464+
}
465+
}
466+
421467
private static let jsTypedArrayTypealiasNames: [String: String] = [
422468
"Int8": "JSInt8Array",
423469
"UInt8": "JSUint8Array",
@@ -1575,6 +1621,20 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
15751621
}
15761622
}
15771623

1624+
/// Names of the `@JS protocol`s a type's inheritance clause declares
1625+
/// conformance to. Non-protocol entries (raw types, plain Swift protocols)
1626+
/// are ignored.
1627+
private func collectConformedJSProtocols(from inheritanceClause: InheritanceClauseSyntax?) -> [String]? {
1628+
guard let inheritanceClause else { return nil }
1629+
var names: [String] = []
1630+
for inherited in inheritanceClause.inheritedTypes {
1631+
if let name = parent.resolveJSProtocolConstraint(for: inherited.type), !names.contains(name) {
1632+
names.append(name)
1633+
}
1634+
}
1635+
return names.isEmpty ? nil : names
1636+
}
1637+
15781638
/// Shared parameter parsing logic used by functions, initializers, and protocol methods
15791639
private func parseParameters(
15801640
from parameterClause: FunctionParameterClauseSyntax,
@@ -1682,19 +1742,16 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
16821742
return nil
16831743
}
16841744

1685-
var genericParameterNames: [String] = []
1745+
var genericParameters: [GenericParameter] = []
16861746
if let genericClause = node.genericParameterClause {
16871747
for genericParam in genericClause.parameters {
1688-
let constraint = genericParam.inheritedType?.trimmedDescription ?? ""
1689-
guard SwiftToSkeleton.isBridgeableGenericConstraint(constraint) else {
1690-
diagnose(
1691-
node: node,
1692-
message:
1693-
"Generic parameter '\(genericParam.name.text)' must be constrained to 'BridgedSwiftGenericBridgeable' to be used with @JS."
1694-
)
1748+
switch parent.parseGenericParameterConstraints(genericParam, attributeName: "@JS") {
1749+
case .failed(let message):
1750+
diagnose(node: node, message: message)
16951751
return nil
1752+
case .parsed(let genericParameter):
1753+
genericParameters.append(genericParameter)
16961754
}
1697-
genericParameterNames.append(genericParam.name.text)
16981755
}
16991756
if node.genericWhereClause != nil {
17001757
diagnose(
@@ -1719,6 +1776,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
17191776
}
17201777
}
17211778

1779+
let genericParameterNames = genericParameters.map(\.name)
1780+
17221781
let name = node.name.text
17231782
let jsName = extractValidatedJSName(from: jsAttribute)
17241783

@@ -1883,7 +1942,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
18831942
namespace: finalNamespace,
18841943
staticContext: staticContext,
18851944
documentation: extractDocumentation(from: node),
1886-
genericParameters: genericParameterNames.isEmpty ? nil : genericParameterNames
1945+
genericParameters: genericParameters.isEmpty ? nil : genericParameters
18871946
)
18881947
}
18891948

@@ -2275,7 +2334,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
22752334
jsNamespace: namespaceResult.jsNamespace,
22762335
identityMode: classIdentityMode,
22772336
documentation: extractDocumentation(from: node),
2278-
isFinal: isFinal
2337+
isFinal: isFinal,
2338+
conformedJSProtocols: collectConformedJSProtocols(from: node.inheritanceClause)
22792339
)
22802340
let uniqueKey = makeKey(name: name, namespace: effectiveNamespace)
22812341

@@ -2440,7 +2500,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
24402500
emitStyle: emitStyle,
24412501
staticMethods: [],
24422502
staticProperties: [],
2443-
documentation: extractDocumentation(from: node)
2503+
documentation: extractDocumentation(from: node),
2504+
conformedJSProtocols: collectConformedJSProtocols(from: node.inheritanceClause)
24442505
)
24452506

24462507
let enumUniqueKey = makeKey(name: name, namespace: effectiveNamespace)
@@ -2693,7 +2754,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
26932754
methods: [],
26942755
namespace: effectiveNamespace,
26952756
jsNamespace: namespaceResult.jsNamespace,
2696-
documentation: extractDocumentation(from: node)
2757+
documentation: extractDocumentation(from: node),
2758+
conformedJSProtocols: collectConformedJSProtocols(from: node.inheritanceClause)
26972759
)
26982760

26992761
exportedStructByName[structUniqueKey] = exportedStruct
@@ -4076,3 +4138,27 @@ extension GenericArgumentListSyntax {
40764138
#endif
40774139
}
40784140
}
4141+
4142+
/// Collects `@JS protocol` conformances declared through extensions, keyed by
4143+
/// the extended type's (possibly qualified) name as written in source, which
4144+
/// matches the skeleton's `swiftCallName` for both top-level and nested types.
4145+
private final class ExtensionConformanceCollector: SyntaxVisitor {
4146+
private let parent: SwiftToSkeleton
4147+
var protocolsByTarget: [String: [String]] = [:]
4148+
4149+
init(parent: SwiftToSkeleton) {
4150+
self.parent = parent
4151+
super.init(viewMode: .sourceAccurate)
4152+
}
4153+
4154+
override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
4155+
guard let inheritanceClause = node.inheritanceClause else { return .visitChildren }
4156+
let target = node.extendedType.trimmedDescription
4157+
for inherited in inheritanceClause.inheritedTypes {
4158+
if let name = parent.resolveJSProtocolConstraint(for: inherited.type) {
4159+
protocolsByTarget[target, default: []].append(name)
4160+
}
4161+
}
4162+
return .visitChildren
4163+
}
4164+
}

0 commit comments

Comments
 (0)