Skip to content

Commit 507dae6

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 777848d commit 507dae6

11 files changed

Lines changed: 1096 additions & 120 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
@@ -1555,15 +1555,42 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
15551555
return .array(elements)
15561556
}
15571557

1558+
/// Resolves a type that may reference one of the enclosing declaration's
1559+
/// generic parameters (bare `T` or wrapped `[T]`, `T?`, `[String: T]`),
1560+
/// falling back to the regular type lookup for concrete types.
1561+
private func lookupTypeWithGenerics(
1562+
for type: TypeSyntax,
1563+
genericParameterNames: [String],
1564+
errors: inout [DiagnosticError]
1565+
) -> BridgeType? {
1566+
switch resolveGenericTypeReference(for: type, genericParameterNames: genericParameterNames) {
1567+
case .resolved(let bridgeType):
1568+
return bridgeType
1569+
case .rejected(let message):
1570+
if let message {
1571+
errors.append(DiagnosticError(node: Syntax(type), message: message))
1572+
return nil
1573+
}
1574+
return parent.lookupType(for: type, errors: &errors)
1575+
}
1576+
}
1577+
15581578
/// Shared parameter parsing logic used by functions, initializers, and protocol methods
15591579
private func parseParameters(
15601580
from parameterClause: FunctionParameterClauseSyntax,
1561-
allowDefaults: Bool = true
1581+
allowDefaults: Bool = true,
1582+
genericParameterNames: [String] = []
15621583
) -> [Parameter] {
15631584
var parameters: [Parameter] = []
15641585

15651586
for param in parameterClause.parameters {
1566-
let resolvedType = withLookupErrors { self.parent.lookupType(for: param.type, errors: &$0) }
1587+
let resolvedType = withLookupErrors {
1588+
self.lookupTypeWithGenerics(
1589+
for: param.type,
1590+
genericParameterNames: genericParameterNames,
1591+
errors: &$0
1592+
)
1593+
}
15671594
guard let type = resolvedType else {
15681595
continue // Skip unsupported types
15691596
}
@@ -1655,13 +1682,41 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
16551682
return nil
16561683
}
16571684

1658-
if let genericClause = node.genericParameterClause, let firstGenericParam = genericClause.parameters.first {
1659-
diagnose(
1660-
node: firstGenericParam,
1661-
message:
1662-
"Generic parameters on exported @JS functions are not supported yet. Generic functions are currently only supported on imported @JSFunction declarations."
1663-
)
1664-
return nil
1685+
var genericParameterNames: [String] = []
1686+
if let genericClause = node.genericParameterClause {
1687+
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+
)
1695+
return nil
1696+
}
1697+
genericParameterNames.append(genericParam.name.text)
1698+
}
1699+
if node.genericWhereClause != nil {
1700+
diagnose(
1701+
node: node,
1702+
message: "'where' clauses are not supported on generic @JS functions."
1703+
)
1704+
return nil
1705+
}
1706+
if node.signature.effectSpecifiers?.asyncSpecifier != nil {
1707+
diagnose(
1708+
node: node,
1709+
message: "Generic @JS functions cannot be 'async' yet."
1710+
)
1711+
return nil
1712+
}
1713+
if node.signature.effectSpecifiers?.throwsClause != nil {
1714+
diagnose(
1715+
node: node,
1716+
message: "Generic @JS functions cannot be 'throws' yet."
1717+
)
1718+
return nil
1719+
}
16651720
}
16661721

16671722
let name = node.name.text
@@ -1694,10 +1749,20 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
16941749
)
16951750
}
16961751

1697-
let parameters = parseParameters(from: node.signature.parameterClause, allowDefaults: true)
1752+
let parameters = parseParameters(
1753+
from: node.signature.parameterClause,
1754+
allowDefaults: true,
1755+
genericParameterNames: genericParameterNames
1756+
)
16981757
let returnType: BridgeType
16991758
if let returnClause = node.signature.returnClause {
1700-
let resolvedType = withLookupErrors { self.parent.lookupType(for: returnClause.type, errors: &$0) }
1759+
let resolvedType = withLookupErrors {
1760+
self.lookupTypeWithGenerics(
1761+
for: returnClause.type,
1762+
genericParameterNames: genericParameterNames,
1763+
errors: &$0
1764+
)
1765+
}
17011766

17021767
if let type = resolvedType, case .nullable(let wrappedType, _) = type, wrappedType.isOptional {
17031768
diagnoseNestedOptional(node: returnClause.type, type: returnClause.type.trimmedDescription)
@@ -1710,6 +1775,51 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
17101775
returnType = .void
17111776
}
17121777

1778+
if !genericParameterNames.isEmpty {
1779+
// The generated JS wrapper appends one required `BridgeType` token
1780+
// parameter per generic parameter after the declared ones, so a
1781+
// defaulted parameter could never actually be omitted by a caller.
1782+
// Reject the combination instead of silently requiring the value.
1783+
if parameters.contains(where: { $0.defaultValue != nil }) {
1784+
diagnose(
1785+
node: node,
1786+
message: "Default parameter values are not supported on generic @JS functions.",
1787+
hint:
1788+
"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."
1789+
)
1790+
return nil
1791+
}
1792+
let returnGenericName: String?
1793+
switch returnType {
1794+
case .void:
1795+
returnGenericName = nil
1796+
default:
1797+
guard let returnName = returnType.referencedGenericName,
1798+
genericParameterNames.contains(returnName)
1799+
else {
1800+
diagnose(
1801+
node: node,
1802+
message:
1803+
"A generic @JS function must return the generic type (optionally wrapped in '[T]', 'T?' or '[String: T]') or Void."
1804+
)
1805+
return nil
1806+
}
1807+
returnGenericName = returnName
1808+
}
1809+
for genericName in genericParameterNames {
1810+
let usedInParameter = parameters.contains { $0.type.referencedGenericName == genericName }
1811+
let usedInReturn = returnGenericName == genericName
1812+
if !usedInParameter && !usedInReturn {
1813+
diagnose(
1814+
node: node,
1815+
message:
1816+
"The generic parameter '\(genericName)' must be used in at least one parameter or the return type of a generic @JS function."
1817+
)
1818+
return nil
1819+
}
1820+
}
1821+
}
1822+
17131823
let abiName: String
17141824
let staticContext: StaticContext?
17151825

@@ -1772,7 +1882,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
17721882
effects: effects,
17731883
namespace: finalNamespace,
17741884
staticContext: staticContext,
1775-
documentation: extractDocumentation(from: node)
1885+
documentation: extractDocumentation(from: node),
1886+
genericParameters: genericParameterNames.isEmpty ? nil : genericParameterNames
17761887
)
17771888
}
17781889

@@ -1932,6 +2043,19 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
19322043

19332044
diagnoseUnsupportedJSName(from: jsAttribute)
19342045

2046+
// Without this early check, the generic parameter would fall through to
2047+
// type lookup and surface as a confusing "Unsupported type 'T'". Generic
2048+
// *imported* initializers are supported, so users will try the exported
2049+
// spelling.
2050+
if node.genericParameterClause != nil || node.genericWhereClause != nil {
2051+
diagnose(
2052+
node: node,
2053+
message: "Generic initializers are not supported on exported @JS types yet.",
2054+
hint: "Use a generic @JS method or a generic @JSFunction initializer on an imported @JSClass instead."
2055+
)
2056+
return .skipChildren
2057+
}
2058+
19352059
switch state {
19362060
case .classBody(_, let classKey):
19372061
if extractNamespace(from: jsAttribute) != nil {
@@ -2630,6 +2754,19 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
26302754
diagnoseUnsupportedJSName(from: jsAttribute)
26312755
}
26322756

2757+
// Without this early check the generic parameter falls through to type
2758+
// lookup and surfaces as "Unsupported type 'T'", pointing away from the
2759+
// actual cause.
2760+
if node.genericParameterClause != nil || node.genericWhereClause != nil {
2761+
diagnose(
2762+
node: node,
2763+
message: "Generic requirements are not supported on @JS protocols yet.",
2764+
hint:
2765+
"Constrain a generic @JS function or method to the protocol instead: '<T: BridgedSwiftGenericBridgeable & \(protocolName)>'."
2766+
)
2767+
return nil
2768+
}
2769+
26332770
let name = node.name.text
26342771

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

0 commit comments

Comments
 (0)