Skip to content

Commit c6055e7

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 ebc501f commit c6055e7

11 files changed

Lines changed: 1090 additions & 112 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
@@ -1452,15 +1452,42 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
14521452
return .array(elements)
14531453
}
14541454

1455+
/// Resolves a type that may reference one of the enclosing declaration's
1456+
/// generic parameters (bare `T` or wrapped `[T]`, `T?`, `[String: T]`),
1457+
/// falling back to the regular type lookup for concrete types.
1458+
private func lookupTypeWithGenerics(
1459+
for type: TypeSyntax,
1460+
genericParameterNames: [String],
1461+
errors: inout [DiagnosticError]
1462+
) -> BridgeType? {
1463+
switch resolveGenericTypeReference(for: type, genericParameterNames: genericParameterNames) {
1464+
case .resolved(let bridgeType):
1465+
return bridgeType
1466+
case .rejected(let message):
1467+
if let message {
1468+
errors.append(DiagnosticError(node: Syntax(type), message: message))
1469+
return nil
1470+
}
1471+
return parent.lookupType(for: type, errors: &errors)
1472+
}
1473+
}
1474+
14551475
/// Shared parameter parsing logic used by functions, initializers, and protocol methods
14561476
private func parseParameters(
14571477
from parameterClause: FunctionParameterClauseSyntax,
1458-
allowDefaults: Bool = true
1478+
allowDefaults: Bool = true,
1479+
genericParameterNames: [String] = []
14591480
) -> [Parameter] {
14601481
var parameters: [Parameter] = []
14611482

14621483
for param in parameterClause.parameters {
1463-
let resolvedType = withLookupErrors { self.parent.lookupType(for: param.type, errors: &$0) }
1484+
let resolvedType = withLookupErrors {
1485+
self.lookupTypeWithGenerics(
1486+
for: param.type,
1487+
genericParameterNames: genericParameterNames,
1488+
errors: &$0
1489+
)
1490+
}
14641491
guard let type = resolvedType else {
14651492
continue // Skip unsupported types
14661493
}
@@ -1552,13 +1579,41 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
15521579
return nil
15531580
}
15541581

1555-
if let genericClause = node.genericParameterClause, let firstGenericParam = genericClause.parameters.first {
1556-
diagnose(
1557-
node: firstGenericParam,
1558-
message:
1559-
"Generic parameters on exported @JS functions are not supported yet. Generic functions are currently only supported on imported @JSFunction declarations."
1560-
)
1561-
return nil
1582+
var genericParameterNames: [String] = []
1583+
if let genericClause = node.genericParameterClause {
1584+
for genericParam in genericClause.parameters {
1585+
let constraint = genericParam.inheritedType?.trimmedDescription
1586+
guard SwiftToSkeleton.isBridgeableGenericConstraint(constraint) else {
1587+
diagnose(
1588+
node: node,
1589+
message:
1590+
"Generic parameter '\(genericParam.name.text)' must be constrained to 'BridgedSwiftGenericBridgeable' to be used with @JS."
1591+
)
1592+
return nil
1593+
}
1594+
genericParameterNames.append(genericParam.name.text)
1595+
}
1596+
if node.genericWhereClause != nil {
1597+
diagnose(
1598+
node: node,
1599+
message: "'where' clauses are not supported on generic @JS functions."
1600+
)
1601+
return nil
1602+
}
1603+
if node.signature.effectSpecifiers?.asyncSpecifier != nil {
1604+
diagnose(
1605+
node: node,
1606+
message: "Generic @JS functions cannot be 'async' yet."
1607+
)
1608+
return nil
1609+
}
1610+
if node.signature.effectSpecifiers?.throwsClause != nil {
1611+
diagnose(
1612+
node: node,
1613+
message: "Generic @JS functions cannot be 'throws' yet."
1614+
)
1615+
return nil
1616+
}
15621617
}
15631618

15641619
let name = node.name.text
@@ -1591,10 +1646,20 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
15911646
)
15921647
}
15931648

1594-
let parameters = parseParameters(from: node.signature.parameterClause, allowDefaults: true)
1649+
let parameters = parseParameters(
1650+
from: node.signature.parameterClause,
1651+
allowDefaults: true,
1652+
genericParameterNames: genericParameterNames
1653+
)
15951654
let returnType: BridgeType
15961655
if let returnClause = node.signature.returnClause {
1597-
let resolvedType = withLookupErrors { self.parent.lookupType(for: returnClause.type, errors: &$0) }
1656+
let resolvedType = withLookupErrors {
1657+
self.lookupTypeWithGenerics(
1658+
for: returnClause.type,
1659+
genericParameterNames: genericParameterNames,
1660+
errors: &$0
1661+
)
1662+
}
15981663

15991664
if let type = resolvedType, case .nullable(let wrappedType, _) = type, wrappedType.isOptional {
16001665
diagnoseNestedOptional(node: returnClause.type, type: returnClause.type.trimmedDescription)
@@ -1607,6 +1672,51 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
16071672
returnType = .void
16081673
}
16091674

1675+
if !genericParameterNames.isEmpty {
1676+
// The generated JS wrapper appends one required `BridgeType` token
1677+
// parameter per generic parameter after the declared ones, so a
1678+
// defaulted parameter could never actually be omitted by a caller.
1679+
// Reject the combination instead of silently requiring the value.
1680+
if parameters.contains(where: { $0.defaultValue != nil }) {
1681+
diagnose(
1682+
node: node,
1683+
message: "Default parameter values are not supported on generic @JS functions.",
1684+
hint:
1685+
"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."
1686+
)
1687+
return nil
1688+
}
1689+
let returnGenericName: String?
1690+
switch returnType {
1691+
case .void:
1692+
returnGenericName = nil
1693+
default:
1694+
guard let returnName = returnType.referencedGenericName,
1695+
genericParameterNames.contains(returnName)
1696+
else {
1697+
diagnose(
1698+
node: node,
1699+
message:
1700+
"A generic @JS function must return the generic type (optionally wrapped in '[T]', 'T?' or '[String: T]') or Void."
1701+
)
1702+
return nil
1703+
}
1704+
returnGenericName = returnName
1705+
}
1706+
for genericName in genericParameterNames {
1707+
let usedInParameter = parameters.contains { $0.type.referencedGenericName == genericName }
1708+
let usedInReturn = returnGenericName == genericName
1709+
if !usedInParameter && !usedInReturn {
1710+
diagnose(
1711+
node: node,
1712+
message:
1713+
"The generic parameter '\(genericName)' must be used in at least one parameter or the return type of a generic @JS function."
1714+
)
1715+
return nil
1716+
}
1717+
}
1718+
}
1719+
16101720
let abiName: String
16111721
let staticContext: StaticContext?
16121722

@@ -1669,7 +1779,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
16691779
effects: effects,
16701780
namespace: finalNamespace,
16711781
staticContext: staticContext,
1672-
documentation: extractDocumentation(from: node)
1782+
documentation: extractDocumentation(from: node),
1783+
genericParameters: genericParameterNames.isEmpty ? nil : genericParameterNames
16731784
)
16741785
}
16751786

@@ -1829,6 +1940,19 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
18291940

18301941
diagnoseUnsupportedJSName(from: jsAttribute)
18311942

1943+
// Without this early check, the generic parameter would fall through to
1944+
// type lookup and surface as a confusing "Unsupported type 'T'". Generic
1945+
// *imported* initializers are supported, so users will try the exported
1946+
// spelling.
1947+
if node.genericParameterClause != nil || node.genericWhereClause != nil {
1948+
diagnose(
1949+
node: node,
1950+
message: "Generic initializers are not supported on exported @JS types yet.",
1951+
hint: "Use a generic @JS method or a generic @JSFunction initializer on an imported @JSClass instead."
1952+
)
1953+
return .skipChildren
1954+
}
1955+
18321956
switch state {
18331957
case .classBody(_, let classKey):
18341958
if extractNamespace(from: jsAttribute) != nil {
@@ -2503,6 +2627,19 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
25032627
diagnoseUnsupportedJSName(from: jsAttribute)
25042628
}
25052629

2630+
// Without this early check the generic parameter falls through to type
2631+
// lookup and surfaces as "Unsupported type 'T'", pointing away from the
2632+
// actual cause.
2633+
if node.genericParameterClause != nil || node.genericWhereClause != nil {
2634+
diagnose(
2635+
node: node,
2636+
message: "Generic requirements are not supported on @JS protocols yet.",
2637+
hint:
2638+
"Constrain a generic @JS function or method to the protocol instead: '<T: BridgedSwiftGenericBridgeable & \(protocolName)>'."
2639+
)
2640+
return nil
2641+
}
2642+
25062643
let name = node.name.text
25072644

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

0 commit comments

Comments
 (0)