Skip to content

Commit bf8d01e

Browse files
committed
BridgeJS: Support generic functions on exported Swift APIs
Export half of generic function support (swiftwasm#398); the import half landed in swiftwasm#799 and this reuses its ABI: values cross on each type's existing stack layout, and a trailing i32 type ID per generic parameter selects the concrete type, so the per-function glue stays type-agnostic. @js public func identity<T: BridgedSwiftGenericBridgeable>(_ value: T) -> T { value } const n = exports.identity(42, BridgeTypes.Int); // TS erases generics, const p = exports.identity(pt, BridgeTypes.Point); // so callers pass a token How it works: - The wasm entry point is a concrete @_expose thunk taking the trailing type IDs. Because a type ID is the address of the type's BridgeJSTypeHandle, the thunk recovers the concrete type directly via Unmanaged.fromOpaque and reifies T through an opened existential (nested opening chain for multiple parameters); the body then runs the same pop-call-push sequence a concrete export would. - Generic thunks always hoist stack-using arguments: the JS wrapper lowers self and every argument in declaration order, so the pops must run in reverse declaration order ahead of any self pop embedded in the callee expression. - The JS wrapper resolves the caller's BridgeTypes token through the map built during type-handle registration and throws a catchable TypeError for unknown tokens before anything is lowered, so the shared value stack stays balanced. A raw wasm caller passing a garbage type ID is undefined behavior; the generated wrapper is the only supported caller. - Tokens are unqualified type names, so linking two modules that define same-named @js types fails the build while generics are in use. - Generics work on top-level functions and on instance and static methods of @js classes, structs, and enums, with T bare or wrapped as [T], T?, or [String: T]. - Type recovery needs the handle's metatype storage (an existential), so exported generic thunks are fatalError stubs under Embedded Swift; imports remain Embedded-compatible. Unsupported forms (async or throws generics, where clauses, unused generic parameters, concrete non-Void returns, generic initializers, default parameter values, nested wrappings) are rejected with build-time diagnostics.
1 parent 53e8cc1 commit bf8d01e

11 files changed

Lines changed: 1093 additions & 119 deletions

File tree

‎Plugins/BridgeJS/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ graph LR
9898
| `Dictionary<K, V>` | `Record<K, V>` | - | [#495](https://github.com/swiftwasm/JavaScriptKit/issues/495) |
9999
| `Set<T>` | `Set<T>` | - | [#397](https://github.com/swiftwasm/JavaScriptKit/issues/397) |
100100
| `Foundation.URL` | `string` | - | [#496](https://github.com/swiftwasm/JavaScriptKit/issues/496) |
101-
| Generic function or method (`T`, `[T]`, `T?`, `[String: T]`) | `<T>(value: T): T` | Depends on `T` | ✅ imports only ([#398](https://github.com/swiftwasm/JavaScriptKit/issues/398) for exports) |
101+
| Generic function or method (`T`, `[T]`, `T?`, `[String: T]`) | `<T>(value: T, type: BridgeType<T>)` | Depends on `T` | ✅ |
102102

103103
### Import-specific (TypeScript -> Swift)
104104

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

Lines changed: 206 additions & 12 deletions
Large diffs are not rendered by default.

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

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,15 +1460,42 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
14601460
return .array(elements)
14611461
}
14621462

1463+
/// Resolves a type that may reference one of the enclosing declaration's
1464+
/// generic parameters (bare `T` or wrapped `[T]`, `T?`, `[String: T]`),
1465+
/// falling back to the regular type lookup for concrete types.
1466+
private func lookupTypeWithGenerics(
1467+
for type: TypeSyntax,
1468+
genericParameterNames: [String],
1469+
errors: inout [DiagnosticError]
1470+
) -> BridgeType? {
1471+
switch resolveGenericTypeReference(for: type, genericParameterNames: genericParameterNames) {
1472+
case .resolved(let bridgeType):
1473+
return bridgeType
1474+
case .rejected(let message):
1475+
if let message {
1476+
errors.append(DiagnosticError(node: Syntax(type), message: message))
1477+
return nil
1478+
}
1479+
return parent.lookupType(for: type, errors: &errors)
1480+
}
1481+
}
1482+
14631483
/// Shared parameter parsing logic used by functions, initializers, and protocol methods
14641484
private func parseParameters(
14651485
from parameterClause: FunctionParameterClauseSyntax,
1466-
allowDefaults: Bool = true
1486+
allowDefaults: Bool = true,
1487+
genericParameterNames: [String] = []
14671488
) -> [Parameter] {
14681489
var parameters: [Parameter] = []
14691490

14701491
for param in parameterClause.parameters {
1471-
let resolvedType = withLookupErrors { self.parent.lookupType(for: param.type, errors: &$0) }
1492+
let resolvedType = withLookupErrors {
1493+
self.lookupTypeWithGenerics(
1494+
for: param.type,
1495+
genericParameterNames: genericParameterNames,
1496+
errors: &$0
1497+
)
1498+
}
14721499
guard let type = resolvedType else {
14731500
continue // Skip unsupported types
14741501
}
@@ -1560,13 +1587,41 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
15601587
return nil
15611588
}
15621589

1563-
if let genericClause = node.genericParameterClause, let firstGenericParam = genericClause.parameters.first {
1564-
diagnose(
1565-
node: firstGenericParam,
1566-
message:
1567-
"Generic parameters on exported @JS functions are not supported yet. Generic functions are currently only supported on imported @JSFunction declarations."
1568-
)
1569-
return nil
1590+
var genericParameterNames: [String] = []
1591+
if let genericClause = node.genericParameterClause {
1592+
for genericParam in genericClause.parameters {
1593+
let constraint = genericParam.inheritedType?.trimmedDescription
1594+
guard SwiftToSkeleton.isBridgeableGenericConstraint(constraint) else {
1595+
diagnose(
1596+
node: node,
1597+
message:
1598+
"Generic parameter '\(genericParam.name.text)' must be constrained to 'BridgedSwiftGenericBridgeable' to be used with @JS."
1599+
)
1600+
return nil
1601+
}
1602+
genericParameterNames.append(genericParam.name.text)
1603+
}
1604+
if node.genericWhereClause != nil {
1605+
diagnose(
1606+
node: node,
1607+
message: "'where' clauses are not supported on generic @JS functions."
1608+
)
1609+
return nil
1610+
}
1611+
if node.signature.effectSpecifiers?.asyncSpecifier != nil {
1612+
diagnose(
1613+
node: node,
1614+
message: "Generic @JS functions cannot be 'async' yet."
1615+
)
1616+
return nil
1617+
}
1618+
if node.signature.effectSpecifiers?.throwsClause != nil {
1619+
diagnose(
1620+
node: node,
1621+
message: "Generic @JS functions cannot be 'throws' yet."
1622+
)
1623+
return nil
1624+
}
15701625
}
15711626

15721627
let name = node.name.text
@@ -1599,10 +1654,20 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
15991654
)
16001655
}
16011656

1602-
let parameters = parseParameters(from: node.signature.parameterClause, allowDefaults: true)
1657+
let parameters = parseParameters(
1658+
from: node.signature.parameterClause,
1659+
allowDefaults: true,
1660+
genericParameterNames: genericParameterNames
1661+
)
16031662
let returnType: BridgeType
16041663
if let returnClause = node.signature.returnClause {
1605-
let resolvedType = withLookupErrors { self.parent.lookupType(for: returnClause.type, errors: &$0) }
1664+
let resolvedType = withLookupErrors {
1665+
self.lookupTypeWithGenerics(
1666+
for: returnClause.type,
1667+
genericParameterNames: genericParameterNames,
1668+
errors: &$0
1669+
)
1670+
}
16061671

16071672
if let type = resolvedType, case .nullable(let wrappedType, _) = type, wrappedType.isOptional {
16081673
diagnoseNestedOptional(node: returnClause.type, type: returnClause.type.trimmedDescription)
@@ -1615,6 +1680,51 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
16151680
returnType = .void
16161681
}
16171682

1683+
if !genericParameterNames.isEmpty {
1684+
// The generated JS wrapper appends one required `BridgeType` token
1685+
// parameter per generic parameter after the declared ones, so a
1686+
// defaulted parameter could never actually be omitted by a caller.
1687+
// Reject the combination instead of silently requiring the value.
1688+
if parameters.contains(where: { $0.defaultValue != nil }) {
1689+
diagnose(
1690+
node: node,
1691+
message: "Default parameter values are not supported on generic @JS functions.",
1692+
hint:
1693+
"JavaScript callers pass a trailing BridgeType token after the declared parameters, so a defaulted parameter could never be omitted. Remove the default value or provide a non-generic overload."
1694+
)
1695+
return nil
1696+
}
1697+
let returnGenericName: String?
1698+
switch returnType {
1699+
case .void:
1700+
returnGenericName = nil
1701+
default:
1702+
guard let returnName = returnType.referencedGenericName,
1703+
genericParameterNames.contains(returnName)
1704+
else {
1705+
diagnose(
1706+
node: node,
1707+
message:
1708+
"A generic @JS function must return the generic type (optionally wrapped in '[T]', 'T?' or '[String: T]') or Void."
1709+
)
1710+
return nil
1711+
}
1712+
returnGenericName = returnName
1713+
}
1714+
for genericName in genericParameterNames {
1715+
let usedInParameter = parameters.contains { $0.type.referencedGenericName == genericName }
1716+
let usedInReturn = returnGenericName == genericName
1717+
if !usedInParameter && !usedInReturn {
1718+
diagnose(
1719+
node: node,
1720+
message:
1721+
"The generic parameter '\(genericName)' must be used in at least one parameter or the return type of a generic @JS function."
1722+
)
1723+
return nil
1724+
}
1725+
}
1726+
}
1727+
16181728
let abiName: String
16191729
let staticContext: StaticContext?
16201730

@@ -1677,7 +1787,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
16771787
effects: effects,
16781788
namespace: finalNamespace,
16791789
staticContext: staticContext,
1680-
documentation: extractDocumentation(from: node)
1790+
documentation: extractDocumentation(from: node),
1791+
genericParameters: genericParameterNames.isEmpty ? nil : genericParameterNames
16811792
)
16821793
}
16831794

@@ -1837,6 +1948,19 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
18371948

18381949
diagnoseUnsupportedJSName(from: jsAttribute)
18391950

1951+
// Without this early check, the generic parameter would fall through to
1952+
// type lookup and surface as a confusing "Unsupported type 'T'". Generic
1953+
// *imported* initializers are supported, so users will try the exported
1954+
// spelling.
1955+
if node.genericParameterClause != nil || node.genericWhereClause != nil {
1956+
diagnose(
1957+
node: node,
1958+
message: "Generic initializers are not supported on exported @JS types yet.",
1959+
hint: "Use a generic @JS method or a generic @JSFunction initializer on an imported @JSClass instead."
1960+
)
1961+
return .skipChildren
1962+
}
1963+
18401964
switch state {
18411965
case .classBody(_, let classKey):
18421966
if extractNamespace(from: jsAttribute) != nil {
@@ -2514,6 +2638,19 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
25142638
diagnoseUnsupportedJSName(from: jsAttribute)
25152639
}
25162640

2641+
// Without this early check the generic parameter falls through to type
2642+
// lookup and surfaces as "Unsupported type 'T'", pointing away from the
2643+
// actual cause.
2644+
if node.genericParameterClause != nil || node.genericWhereClause != nil {
2645+
diagnose(
2646+
node: node,
2647+
message: "Generic requirements are not supported on @JS protocols yet.",
2648+
hint:
2649+
"Constrain a generic @JS function or method to the protocol instead: '<T: BridgedSwiftGenericBridgeable & \(protocolName)>'."
2650+
)
2651+
return nil
2652+
}
2653+
25172654
let name = node.name.text
25182655

25192656
let parameters = parseParameters(from: node.signature.parameterClause, allowDefaults: false)

0 commit comments

Comments
 (0)