Skip to content

Commit d8c198a

Browse files
committed
BridgeJS: Recognize cross-module extension conformances to @js protocols
A module may declare that a @js type exported by one of its dependencies conforms to a @js protocol it defines: // ModuleB, depending on ModuleA @js protocol GraphNode { var id: String { get } } extension Building: GraphNode {} // Building is ModuleA's @js struct @js public func store<T: BridgedSwiftGenericBridgeable & GraphNode>(_ n: T) -> T { n } The Swift compiler sees the retroactive conformance directly, so the generic thunk already accepted ModuleA's type - but the JS-side token check did not: the conformance was declared in ModuleB's sources while the token belongs to ModuleA, and neither skeleton recorded it, so a valid call threw a spurious TypeError. Extension conformances whose target does not resolve in-module are now resolved against the external module index and recorded in the skeleton (externalJSProtocolConformances, keyed by the conformer's Swift dot path, e.g. `Models.Site`). The link layer sees every module's skeleton, maps that dot path to the defining module's token (`Models_Site`), merges the record into that token's entry, and the refinement closure applies on top.
1 parent ff403eb commit d8c198a

5 files changed

Lines changed: 147 additions & 10 deletions

File tree

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

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -428,16 +428,25 @@ public final class SwiftToSkeleton {
428428
/// style, and the JS-side generic constraint check
429429
/// (`__bjs_tokenConformances`) must not reject a type that genuinely
430430
/// conforms.
431+
///
432+
/// An extension may also target a `@JS` type exported by a *dependency*
433+
/// module. The Swift compiler sees that retroactive conformance directly,
434+
/// so the generic thunk accepts the type; those conformances are recorded
435+
/// in `externalJSProtocolConformances` (keyed by the type's Swift dot
436+
/// path) for the link layer to merge into the defining module's token
437+
/// entry.
431438
private func mergeExtensionDeclaredJSProtocolConformances(into exported: inout ExportedSkeleton) {
432-
var protocolsByTarget: [String: [String]] = [:]
439+
var targets: [(name: String, type: TypeSyntax, protocols: [String])] = []
433440
for (sourceFile, _) in sourceFiles {
434441
let collector = ExtensionConformanceCollector(parent: self)
435442
collector.walk(sourceFile)
436-
for (target, protocols) in collector.protocolsByTarget {
437-
protocolsByTarget[target, default: []].append(contentsOf: protocols)
438-
}
443+
targets.append(contentsOf: collector.targets)
444+
}
445+
guard !targets.isEmpty else { return }
446+
var protocolsByTarget: [String: [String]] = [:]
447+
for target in targets {
448+
protocolsByTarget[target.name, default: []].append(contentsOf: target.protocols)
439449
}
440-
guard !protocolsByTarget.isEmpty else { return }
441450

442451
func merge(_ existing: inout [String]?, additions: [String]) {
443452
var result = existing ?? []
@@ -447,21 +456,54 @@ public final class SwiftToSkeleton {
447456
existing = result.isEmpty ? nil : result
448457
}
449458

459+
var matchedTargets: Set<String> = []
450460
for index in exported.structs.indices {
451461
if let additions = protocolsByTarget[exported.structs[index].swiftCallName] {
452462
merge(&exported.structs[index].conformedJSProtocols, additions: additions)
463+
matchedTargets.insert(exported.structs[index].swiftCallName)
453464
}
454465
}
455466
for index in exported.classes.indices {
456467
if let additions = protocolsByTarget[exported.classes[index].swiftCallName] {
457468
merge(&exported.classes[index].conformedJSProtocols, additions: additions)
469+
matchedTargets.insert(exported.classes[index].swiftCallName)
458470
}
459471
}
460472
for index in exported.enums.indices {
461473
if let additions = protocolsByTarget[exported.enums[index].swiftCallName] {
462474
merge(&exported.enums[index].conformedJSProtocols, additions: additions)
475+
matchedTargets.insert(exported.enums[index].swiftCallName)
476+
}
477+
}
478+
479+
// Whatever did not match an in-module type may be a bridged type from a
480+
// dependency module; record the conformance under the dependency type's
481+
// Swift dot path for the link layer to resolve to its token.
482+
var externalConformances: [String: [String]] = [:]
483+
for target in targets where !matchedTargets.contains(target.name) {
484+
var scratchErrors: [DiagnosticError] = []
485+
guard let externalType = resolveExternal(for: target.type, errors: &scratchErrors) else { continue }
486+
// Key by the dependency type's Swift dot path (its `swiftCallName`);
487+
// the link layer resolves that to the type's token, which for a
488+
// namespaced type (`Models.Building` -> `Models_Building`) is not
489+
// the last name component.
490+
let externalName: String?
491+
switch externalType {
492+
case .swiftStruct(let name), .swiftHeapObject(let name),
493+
.caseEnum(let name), .rawValueEnum(let name, _), .associatedValueEnum(let name):
494+
externalName = name
495+
default:
496+
externalName = nil
497+
}
498+
guard let externalName else { continue }
499+
for protocolName in target.protocols
500+
where !(externalConformances[externalName] ?? []).contains(protocolName) {
501+
externalConformances[externalName, default: []].append(protocolName)
463502
}
464503
}
504+
if !externalConformances.isEmpty {
505+
exported.externalJSProtocolConformances = externalConformances
506+
}
465507
}
466508

467509
private static let jsTypedArrayTypealiasNames: [String: String] = [
@@ -4137,7 +4179,7 @@ extension GenericArgumentListSyntax {
41374179
/// matches the skeleton's `swiftCallName` for both top-level and nested types.
41384180
private final class ExtensionConformanceCollector: SyntaxVisitor {
41394181
private let parent: SwiftToSkeleton
4140-
var protocolsByTarget: [String: [String]] = [:]
4182+
var targets: [(name: String, type: TypeSyntax, protocols: [String])] = []
41414183

41424184
init(parent: SwiftToSkeleton) {
41434185
self.parent = parent
@@ -4146,12 +4188,17 @@ private final class ExtensionConformanceCollector: SyntaxVisitor {
41464188

41474189
override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
41484190
guard let inheritanceClause = node.inheritanceClause else { return .visitChildren }
4149-
let target = node.extendedType.trimmedDescription
4191+
var protocols: [String] = []
41504192
for inherited in inheritanceClause.inheritedTypes {
41514193
if let name = parent.resolveJSProtocolConstraint(for: inherited.type) {
4152-
protocolsByTarget[target, default: []].append(name)
4194+
protocols.append(name)
41534195
}
41544196
}
4197+
if !protocols.isEmpty {
4198+
targets.append(
4199+
(name: node.extendedType.trimmedDescription, type: node.extendedType, protocols: protocols)
4200+
)
4201+
}
41554202
return .visitChildren
41564203
}
41574204
}

‎Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,12 +517,32 @@ public struct BridgeJSLink {
517517
/// Swift, so the JS-side check must agree.
518518
private func generateTokenConformancesDeclaration() -> String {
519519
var inheritedByProtocol: [String: [String]] = [:]
520+
// Every bridgeable type's token, keyed by its Swift dot path: external
521+
// conformance records name the type by dot path (the only spelling the
522+
// declaring module knows), and only the defining module's skeleton can
523+
// turn that into the token (`Models.Building` -> `Models_Building`).
524+
var tokenBySwiftCallName: [String: String] = [:]
525+
for unified in skeletons {
526+
guard let exported = unified.exported else { continue }
527+
for structDef in exported.structs { tokenBySwiftCallName[structDef.swiftCallName] = structDef.abiName }
528+
for klass in exported.classes { tokenBySwiftCallName[klass.swiftCallName] = klass.abiName }
529+
for enumDef in exported.enums { tokenBySwiftCallName[enumDef.swiftCallName] = enumDef.abiName }
530+
}
531+
// Conformances a module declares through extensions on another
532+
// module's @JS types, keyed by the conformer's token.
533+
var externalByToken: [String: [String]] = [:]
520534
for unified in skeletons {
521535
for protocolDef in unified.exported?.protocols ?? [] {
522536
if let inherited = protocolDef.inheritedJSProtocols, !inherited.isEmpty {
523537
inheritedByProtocol[protocolDef.name] = inherited
524538
}
525539
}
540+
for (swiftCallName, protocols) in unified.exported?.externalJSProtocolConformances ?? [:] {
541+
guard let token = tokenBySwiftCallName[swiftCallName] else { continue }
542+
for protocolName in protocols where !(externalByToken[token] ?? []).contains(protocolName) {
543+
externalByToken[token, default: []].append(protocolName)
544+
}
545+
}
526546
}
527547
func transitiveClosure(of protocols: [String]) -> [String] {
528548
var seen: [String] = []
@@ -536,8 +556,12 @@ public struct BridgeJSLink {
536556
}
537557
var entries: [String] = []
538558
func append(token: String, protocols: [String]?) {
539-
guard let protocols, !protocols.isEmpty else { return }
540-
let list = transitiveClosure(of: protocols).map { "\"\($0)\"" }.joined(separator: ", ")
559+
var combined = protocols ?? []
560+
for protocolName in externalByToken[token] ?? [] where !combined.contains(protocolName) {
561+
combined.append(protocolName)
562+
}
563+
guard !combined.isEmpty else { return }
564+
let list = transitiveClosure(of: combined).map { "\"\($0)\"" }.joined(separator: ", ")
541565
entries.append("\"\(token)\": [\(list)]")
542566
}
543567
for unified in skeletons {

‎Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,13 @@ public struct ExportedSkeleton: Codable {
13041304
/// When `"none"` or `nil`, no identity tracking is performed.
13051305
public var identityMode: String?
13061306

1307+
/// `@JS protocol` conformances this module declares through extensions on
1308+
/// `@JS` types exported by *other* modules, keyed by the conformer's Swift
1309+
/// dot path (`swiftCallName`, e.g. `Models.Building`). The Swift compiler
1310+
/// sees the retroactive conformance directly; this record lets the link
1311+
/// layer teach the JS-side token conformance check about it too.
1312+
public var externalJSProtocolConformances: [String: [String]]?
1313+
13071314
public init(
13081315
functions: [ExportedFunction],
13091316
classes: [ExportedClass],

‎Plugins/BridgeJS/Tests/BridgeJSToolTests/GenericExportDiagnosticsTests.swift‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,48 @@ import Testing
166166
}
167167
}
168168

169+
@Test
170+
func crossModuleExtensionConformanceReachesTokenConformances() throws {
171+
// A conformance declared in this module on a @JS type exported by a
172+
// dependency is a real retroactive conformance in Swift, so the
173+
// JS-side token check must accept the dependency type's token.
174+
let moduleA = try makeSkeleton(
175+
"""
176+
@JS public struct Building {
177+
public var id: String
178+
@JS public init(id: String) { self.id = id }
179+
}
180+
@JS(namespace: "Models") public struct Site {
181+
public var id: String
182+
@JS public init(id: String) { self.id = id }
183+
}
184+
""",
185+
moduleName: "ModuleA"
186+
)
187+
let moduleB = try makeSkeleton(
188+
"""
189+
@JS protocol GraphNode { var id: String { get } }
190+
extension Building: GraphNode {}
191+
// `Models` is a JS namespace, not a Swift scope: the Swift type is
192+
// `Site`, but its token is the ABI name `Models_Site`.
193+
extension Site: GraphNode {}
194+
@JS public func store<T: BridgedSwiftGenericBridgeable & GraphNode>(_ n: T) -> T { n }
195+
""",
196+
moduleName: "ModuleB",
197+
dependencies: [(moduleName: "ModuleA", skeleton: moduleA)]
198+
)
199+
var link = BridgeJSLink()
200+
let encoder = JSONEncoder()
201+
try link.addSkeletonFile(data: encoder.encode(moduleA))
202+
try link.addSkeletonFile(data: encoder.encode(moduleB))
203+
let js = try link.link().outputJs
204+
#expect(
205+
js.contains(
206+
#"const __bjs_tokenConformances = { "Building": ["GraphNode"], "Models_Site": ["GraphNode"] };"#
207+
)
208+
)
209+
}
210+
169211
@Test
170212
func compositionConstraintIsParsed() throws {
171213
let skeleton = try makeSkeleton(

‎Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Function.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,23 @@ export type Exports = {
297297
298298
TypeScript checks the constraint at compile time, but `BridgeType` tokens are erased at runtime, so the generated JavaScript wrapper enforces it again when the call crosses the bridge: the token's type is validated against the required protocols and a non-conforming token throws a catchable `TypeError` *before* the call enters WebAssembly, e.g. `BridgeJS: type 'Int' does not conform to required protocol 'GraphNode'`. Because nothing has been lowered yet, the shared value stack stays balanced and later calls are unaffected. (The Swift entry thunk repeats the check with a conditional metatype cast as defense-in-depth, but through the generated wrapper that trap is unreachable.)
299299
300+
Conformances count no matter how they are declared: on the type itself, in an extension in the same module, or in an extension on a `@JS` type exported by a dependency module (a retroactive conformance). Protocol refinement is honored too — a conformer of `@JS protocol Refined: Base` satisfies a constraint on `Base`, exactly as it does in Swift (see <doc:Exporting-Swift-Protocols>):
301+
302+
```swift
303+
@JS protocol Site: GraphNode { // Site refines GraphNode
304+
var region: String { get }
305+
}
306+
307+
@JS struct Campus: Site { // conforms to Site, and therefore GraphNode
308+
var id: String
309+
var region: String
310+
}
311+
```
312+
313+
```javascript
314+
exports.store({ id: "campus-1", region: "north" }, BridgeTypes.Campus); // satisfies T: GraphNode
315+
```
316+
300317
Generics also work on methods. On a `@JS` class or struct they apply to both instance and static methods. A `@JS enum` (including a namespace-style enum) has no instance methods in BridgeJS, so generics there apply to static methods only. The constraint, the trailing `BridgeType` token, and the generic-or-`Void` return rule all carry over unchanged.
301318

302319
## Supported Features

0 commit comments

Comments
 (0)