From f822943cf98594d9a4c26850255b548538120f57 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 26 Jan 2026 19:06:46 +0900 Subject: [PATCH 1/2] BridgeJS: support closures in imported APIs Enable closure parameter/return types for imported JS interfaces and emit the required closure glue from BridgeJSTool. --- .../Sources/BridgeJSCore/ClosureCodegen.swift | 337 ++++++++++++++++++ .../Sources/BridgeJSCore/ExportSwift.swift | 265 -------------- .../Sources/BridgeJSCore/ImportTS.swift | 53 ++- .../Sources/BridgeJSLink/BridgeJSLink.swift | 38 +- .../Sources/BridgeJSLink/JSGlueGen.swift | 23 +- .../Sources/BridgeJSTool/BridgeJSTool.swift | 11 +- .../BridgeJSToolTests/ExportSwiftTests.swift | 13 +- .../SwiftClosureImports.swift | 4 + .../BridgeJSToolTests/ImportTSTests.swift | 47 ++- .../SwiftClosureImports.ImportMacros.d.ts | 19 + .../SwiftClosureImports.ImportMacros.js | 257 +++++++++++++ .../GlobalGetter.ImportMacros.swift | 34 ++ .../SwiftClosureImports.ImportMacros.swift | 84 +++++ 13 files changed, 899 insertions(+), 286 deletions(-) create mode 100644 Plugins/BridgeJS/Sources/BridgeJSCore/ClosureCodegen.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/SwiftClosureImports.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/SwiftClosureImports.ImportMacros.d.ts create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/SwiftClosureImports.ImportMacros.js create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/GlobalGetter.ImportMacros.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/SwiftClosureImports.ImportMacros.swift diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ClosureCodegen.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ClosureCodegen.swift new file mode 100644 index 000000000..5d6545668 --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ClosureCodegen.swift @@ -0,0 +1,337 @@ +import SwiftBasicFormat +import SwiftSyntax +import SwiftSyntaxBuilder +#if canImport(BridgeJSSkeleton) +import BridgeJSSkeleton +#endif + +public struct ClosureCodegen { + public init() {} + + func collectClosureSignatures(from parameters: [Parameter], into signatures: inout Set) { + for param in parameters { + collectClosureSignatures(from: param.type, into: &signatures) + } + } + + func collectClosureSignatures(from type: BridgeType, into signatures: inout Set) { + switch type { + case .closure(let signature): + signatures.insert(signature) + for paramType in signature.parameters { + collectClosureSignatures(from: paramType, into: &signatures) + } + collectClosureSignatures(from: signature.returnType, into: &signatures) + case .optional(let wrapped): + collectClosureSignatures(from: wrapped, into: &signatures) + default: + break + } + } + + func renderClosureHelpers(_ signature: ClosureSignature) throws -> [DeclSyntax] { + let mangledName = signature.mangleName + let helperName = "_BJS_Closure_\(mangledName)" + let boxClassName = "_BJS_ClosureBox_\(mangledName)" + + let closureParams = signature.parameters.enumerated().map { _, type in + "\(type.swiftType)" + }.joined(separator: ", ") + + let swiftEffects = (signature.isAsync ? " async" : "") + (signature.isThrows ? " throws" : "") + let swiftReturnType = signature.returnType.swiftType + let closureType = "(\(closureParams))\(swiftEffects) -> \(swiftReturnType)" + + let externName = "invoke_js_callback_\(signature.moduleName)_\(mangledName)" + + // Use CallJSEmission to generate the callback invocation + let builder = ImportTS.CallJSEmission( + moduleName: "bjs", + abiName: externName, + context: .exportSwift + ) + + // Lower the callback parameter + try builder.lowerParameter(param: Parameter(label: nil, name: "callback", type: .jsObject(nil))) + + // Lower each closure parameter + for (index, paramType) in signature.parameters.enumerated() { + try builder.lowerParameter(param: Parameter(label: nil, name: "param\(index)", type: paramType)) + } + + // Generate the call and return value lifting + try builder.call(returnType: signature.returnType) + try builder.liftReturnValue(returnType: signature.returnType) + + // Get the body code + let bodyCode = builder.getBody() + + // Generate extern declaration using CallJSEmission + let externDecl = builder.renderImportDecl() + + let boxClassDecl: DeclSyntax = """ + private final class \(raw: boxClassName): _BridgedSwiftClosureBox { + let closure: \(raw: closureType) + init(_ closure: @escaping \(raw: closureType)) { + self.closure = closure + } + } + """ + + let helperEnumDecl = EnumDeclSyntax( + modifiers: DeclModifierListSyntax { + DeclModifierSyntax(name: .keyword(.private)) + }, + name: .identifier(helperName), + memberBlockBuilder: { + DeclSyntax( + FunctionDeclSyntax( + modifiers: DeclModifierListSyntax { + DeclModifierSyntax(name: .keyword(.static)) + }, + name: .identifier("bridgeJSLower"), + signature: FunctionSignatureSyntax( + parameterClause: FunctionParameterClauseSyntax { + FunctionParameterSyntax( + firstName: .wildcardToken(), + secondName: .identifier("closure"), + colon: .colonToken(), + type: TypeSyntax("@escaping \(raw: closureType)") + ) + }, + returnClause: ReturnClauseSyntax( + arrow: .arrowToken(), + type: IdentifierTypeSyntax(name: .identifier("UnsafeMutableRawPointer")) + ) + ), + body: CodeBlockSyntax { + "let box = \(raw: boxClassName)(closure)" + "return Unmanaged.passRetained(box).toOpaque()" + } + ) + ) + + DeclSyntax( + FunctionDeclSyntax( + modifiers: DeclModifierListSyntax { + DeclModifierSyntax(name: .keyword(.static)) + }, + name: .identifier("bridgeJSLift"), + signature: FunctionSignatureSyntax( + parameterClause: FunctionParameterClauseSyntax { + FunctionParameterSyntax( + firstName: .wildcardToken(), + secondName: .identifier("callbackId"), + colon: .colonToken(), + type: IdentifierTypeSyntax(name: .identifier("Int32")) + ) + }, + returnClause: ReturnClauseSyntax( + arrow: .arrowToken(), + type: IdentifierTypeSyntax(name: .identifier(closureType)) + ) + ), + body: CodeBlockSyntax { + "let callback = JSObject.bridgeJSLiftParameter(callbackId)" + ReturnStmtSyntax( + expression: ClosureExprSyntax( + leftBrace: .leftBraceToken(), + signature: ClosureSignatureSyntax( + capture: ClosureCaptureClauseSyntax( + leftSquare: .leftSquareToken(), + items: ClosureCaptureListSyntax { + #if canImport(SwiftSyntax602) + ClosureCaptureSyntax( + name: .identifier("", presence: .missing), + initializer: InitializerClauseSyntax( + equal: .equalToken(presence: .missing), + nil, + value: ExprSyntax("callback") + ), + trailingTrivia: nil + ) + #else + ClosureCaptureSyntax( + expression: ExprSyntax("callback") + ) + #endif + }, + rightSquare: .rightSquareToken() + ), + parameterClause: .simpleInput( + ClosureShorthandParameterListSyntax { + for (index, _) in signature.parameters.enumerated() { + ClosureShorthandParameterSyntax(name: .identifier("param\(index)")) + } + } + ), + inKeyword: .keyword(.in) + ), + statements: CodeBlockItemListSyntax { + SwiftCodePattern.buildWasmConditionalCompilation(wasmBody: bodyCode.statements) + }, + rightBrace: .rightBraceToken() + ) + ) + } + ) + ) + } + ) + return [externDecl, boxClassDecl, DeclSyntax(helperEnumDecl)] + } + + func renderClosureInvokeHandler(_ signature: ClosureSignature) throws -> DeclSyntax { + let boxClassName = "_BJS_ClosureBox_\(signature.mangleName)" + let abiName = "invoke_swift_closure_\(signature.moduleName)_\(signature.mangleName)" + + // Build ABI parameters directly with WasmCoreType (no string conversion needed) + var abiParams: [(name: String, type: WasmCoreType)] = [("boxPtr", .pointer)] + var liftedParams: [String] = [] + + for (index, paramType) in signature.parameters.enumerated() { + let paramName = "param\(index)" + let liftInfo = try paramType.liftParameterInfo() + + for (argName, wasmType) in liftInfo.parameters { + let fullName = + liftInfo.parameters.count > 1 ? "\(paramName)\(argName.capitalizedFirstLetter)" : paramName + abiParams.append((fullName, wasmType)) + } + + let argNames = liftInfo.parameters.map { (argName, _) in + liftInfo.parameters.count > 1 ? "\(paramName)\(argName.capitalizedFirstLetter)" : paramName + } + liftedParams.append("\(paramType.swiftType).bridgeJSLiftParameter(\(argNames.joined(separator: ", ")))") + } + + let closureCallExpr = ExprSyntax("box.closure(\(raw: liftedParams.joined(separator: ", ")))") + + // Determine return type + let abiReturnWasmType: WasmCoreType? + if signature.returnType == .void { + abiReturnWasmType = nil + } else if let wasmType = try signature.returnType.loweringReturnInfo().returnType { + abiReturnWasmType = wasmType + } else { + abiReturnWasmType = nil + } + + // Build signature using SwiftSignatureBuilder + let funcSignature = SwiftSignatureBuilder.buildABIFunctionSignature( + abiParameters: abiParams, + returnType: abiReturnWasmType + ) + + // Build body + let body = CodeBlockItemListSyntax { + "let box = Unmanaged<\(raw: boxClassName)>.fromOpaque(boxPtr).takeUnretainedValue()" + if signature.returnType == .void { + closureCallExpr + } else { + "let result = \(closureCallExpr)" + "return result.bridgeJSLowerReturn()" + } + } + + // Build function declaration using helper + let funcDecl = SwiftCodePattern.buildExposedFunctionDecl( + abiName: abiName, + signature: funcSignature, + body: body + ) + + return DeclSyntax(funcDecl) + } + + public func renderSupport(for skeleton: BridgeJSSkeleton) throws -> String? { + var closureSignatures: Set = [] + + if let exported = skeleton.exported { + for function in exported.functions { + collectClosureSignatures(from: function.parameters, into: &closureSignatures) + collectClosureSignatures(from: function.returnType, into: &closureSignatures) + } + for klass in exported.classes { + if let constructor = klass.constructor { + collectClosureSignatures(from: constructor.parameters, into: &closureSignatures) + } + for method in klass.methods { + collectClosureSignatures(from: method.parameters, into: &closureSignatures) + collectClosureSignatures(from: method.returnType, into: &closureSignatures) + } + for property in klass.properties { + collectClosureSignatures(from: property.type, into: &closureSignatures) + } + } + for proto in exported.protocols { + for method in proto.methods { + collectClosureSignatures(from: method.parameters, into: &closureSignatures) + collectClosureSignatures(from: method.returnType, into: &closureSignatures) + } + for property in proto.properties { + collectClosureSignatures(from: property.type, into: &closureSignatures) + } + } + for structDecl in exported.structs { + for property in structDecl.properties { + collectClosureSignatures(from: property.type, into: &closureSignatures) + } + if let constructor = structDecl.constructor { + collectClosureSignatures(from: constructor.parameters, into: &closureSignatures) + } + for method in structDecl.methods { + collectClosureSignatures(from: method.parameters, into: &closureSignatures) + collectClosureSignatures(from: method.returnType, into: &closureSignatures) + } + } + for enumDecl in exported.enums { + for method in enumDecl.staticMethods { + collectClosureSignatures(from: method.parameters, into: &closureSignatures) + collectClosureSignatures(from: method.returnType, into: &closureSignatures) + } + for property in enumDecl.staticProperties { + collectClosureSignatures(from: property.type, into: &closureSignatures) + } + } + } + + if let imported = skeleton.imported { + for fileSkeleton in imported.children { + for getter in fileSkeleton.globalGetters { + collectClosureSignatures(from: getter.type, into: &closureSignatures) + } + for function in fileSkeleton.functions { + collectClosureSignatures(from: function.parameters, into: &closureSignatures) + collectClosureSignatures(from: function.returnType, into: &closureSignatures) + } + for type in fileSkeleton.types { + if let constructor = type.constructor { + collectClosureSignatures(from: constructor.parameters, into: &closureSignatures) + } + for getter in type.getters { + collectClosureSignatures(from: getter.type, into: &closureSignatures) + } + for setter in type.setters { + collectClosureSignatures(from: setter.type, into: &closureSignatures) + } + for method in type.methods { + collectClosureSignatures(from: method.parameters, into: &closureSignatures) + collectClosureSignatures(from: method.returnType, into: &closureSignatures) + } + } + } + } + + guard !closureSignatures.isEmpty else { return nil } + + var decls: [DeclSyntax] = [] + for signature in closureSignatures.sorted(by: { $0.mangleName < $1.mangleName }) { + decls.append(contentsOf: try renderClosureHelpers(signature)) + decls.append(try renderClosureInvokeHandler(signature)) + } + + let format = BasicFormat() + return decls.map { $0.formatted(using: format).description }.joined(separator: "\n\n") + } +} diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift index b8790ec97..a40f9c2b6 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift @@ -47,30 +47,6 @@ public class ExportSwift { func renderSwiftGlue() throws -> String? { var decls: [DeclSyntax] = [] - let closureCodegen = ClosureCodegen() - var closureSignatures: Set = [] - for function in skeleton.functions { - closureCodegen.collectClosureSignatures(from: function.parameters, into: &closureSignatures) - closureCodegen.collectClosureSignatures(from: function.returnType, into: &closureSignatures) - } - for klass in skeleton.classes { - if let constructor = klass.constructor { - closureCodegen.collectClosureSignatures(from: constructor.parameters, into: &closureSignatures) - } - for method in klass.methods { - closureCodegen.collectClosureSignatures(from: method.parameters, into: &closureSignatures) - closureCodegen.collectClosureSignatures(from: method.returnType, into: &closureSignatures) - } - for property in klass.properties { - closureCodegen.collectClosureSignatures(from: property.type, into: &closureSignatures) - } - } - - for signature in closureSignatures.sorted(by: { $0.mangleName < $1.mangleName }) { - decls.append(contentsOf: try closureCodegen.renderClosureHelpers(signature)) - decls.append(try closureCodegen.renderClosureInvokeHandler(signature)) - } - let protocolCodegen = ProtocolCodegen() for proto in skeleton.protocols { decls.append(contentsOf: try protocolCodegen.renderProtocolWrapper(proto, moduleName: moduleName)) @@ -773,247 +749,6 @@ public class ExportSwift { } } -// MARK: - ClosureCodegen - -struct ClosureCodegen { - func collectClosureSignatures(from parameters: [Parameter], into signatures: inout Set) { - for param in parameters { - collectClosureSignatures(from: param.type, into: &signatures) - } - } - - func collectClosureSignatures(from type: BridgeType, into signatures: inout Set) { - switch type { - case .closure(let signature): - signatures.insert(signature) - for paramType in signature.parameters { - collectClosureSignatures(from: paramType, into: &signatures) - } - collectClosureSignatures(from: signature.returnType, into: &signatures) - case .optional(let wrapped): - collectClosureSignatures(from: wrapped, into: &signatures) - default: - break - } - } - - func renderClosureHelpers(_ signature: ClosureSignature) throws -> [DeclSyntax] { - let mangledName = signature.mangleName - let helperName = "_BJS_Closure_\(mangledName)" - let boxClassName = "_BJS_ClosureBox_\(mangledName)" - - let closureParams = signature.parameters.enumerated().map { index, type in - "\(type.swiftType)" - }.joined(separator: ", ") - - let swiftEffects = (signature.isAsync ? " async" : "") + (signature.isThrows ? " throws" : "") - let swiftReturnType = signature.returnType.swiftType - let closureType = "(\(closureParams))\(swiftEffects) -> \(swiftReturnType)" - - let externName = "invoke_js_callback_\(signature.moduleName)_\(mangledName)" - - // Use CallJSEmission to generate the callback invocation - let builder = ImportTS.CallJSEmission( - moduleName: "bjs", - abiName: externName, - context: .exportSwift - ) - - // Lower the callback parameter - try builder.lowerParameter(param: Parameter(label: nil, name: "callback", type: .jsObject(nil))) - - // Lower each closure parameter - for (index, paramType) in signature.parameters.enumerated() { - try builder.lowerParameter(param: Parameter(label: nil, name: "param\(index)", type: paramType)) - } - - // Generate the call and return value lifting - try builder.call(returnType: signature.returnType) - try builder.liftReturnValue(returnType: signature.returnType) - - // Get the body code - let bodyCode = builder.getBody() - - // Generate extern declaration using CallJSEmission - let externDecl = builder.renderImportDecl() - - let boxClassDecl: DeclSyntax = """ - private final class \(raw: boxClassName): _BridgedSwiftClosureBox { - let closure: \(raw: closureType) - init(_ closure: @escaping \(raw: closureType)) { - self.closure = closure - } - } - """ - - let helperEnumDecl = EnumDeclSyntax( - modifiers: DeclModifierListSyntax { - DeclModifierSyntax(name: .keyword(.private)) - }, - name: .identifier(helperName), - memberBlockBuilder: { - DeclSyntax( - FunctionDeclSyntax( - modifiers: DeclModifierListSyntax { - DeclModifierSyntax(name: .keyword(.static)) - }, - name: .identifier("bridgeJSLower"), - signature: FunctionSignatureSyntax( - parameterClause: FunctionParameterClauseSyntax { - FunctionParameterSyntax( - firstName: .wildcardToken(), - secondName: .identifier("closure"), - colon: .colonToken(), - type: TypeSyntax("@escaping \(raw: closureType)") - ) - }, - returnClause: ReturnClauseSyntax( - arrow: .arrowToken(), - type: IdentifierTypeSyntax(name: .identifier("UnsafeMutableRawPointer")) - ) - ), - body: CodeBlockSyntax { - "let box = \(raw: boxClassName)(closure)" - "return Unmanaged.passRetained(box).toOpaque()" - } - ) - ) - - DeclSyntax( - FunctionDeclSyntax( - modifiers: DeclModifierListSyntax { - DeclModifierSyntax(name: .keyword(.static)) - }, - name: .identifier("bridgeJSLift"), - signature: FunctionSignatureSyntax( - parameterClause: FunctionParameterClauseSyntax { - FunctionParameterSyntax( - firstName: .wildcardToken(), - secondName: .identifier("callbackId"), - colon: .colonToken(), - type: IdentifierTypeSyntax(name: .identifier("Int32")) - ) - }, - returnClause: ReturnClauseSyntax( - arrow: .arrowToken(), - type: IdentifierTypeSyntax(name: .identifier(closureType)) - ) - ), - body: CodeBlockSyntax { - "let callback = JSObject.bridgeJSLiftParameter(callbackId)" - ReturnStmtSyntax( - expression: ClosureExprSyntax( - leftBrace: .leftBraceToken(), - signature: ClosureSignatureSyntax( - capture: ClosureCaptureClauseSyntax( - leftSquare: .leftSquareToken(), - items: ClosureCaptureListSyntax { - #if canImport(SwiftSyntax602) - ClosureCaptureSyntax( - name: .identifier("", presence: .missing), - initializer: InitializerClauseSyntax( - equal: .equalToken(presence: .missing), - nil, - value: ExprSyntax("callback") - ), - trailingTrivia: nil - ) - #else - ClosureCaptureSyntax( - expression: ExprSyntax("callback") - ) - #endif - }, - rightSquare: .rightSquareToken() - ), - parameterClause: .simpleInput( - ClosureShorthandParameterListSyntax { - for (index, _) in signature.parameters.enumerated() { - ClosureShorthandParameterSyntax(name: .identifier("param\(index)")) - } - } - ), - inKeyword: .keyword(.in) - ), - statements: CodeBlockItemListSyntax { - SwiftCodePattern.buildWasmConditionalCompilation(wasmBody: bodyCode.statements) - }, - rightBrace: .rightBraceToken() - ) - ) - } - ) - ) - } - ) - return [externDecl, boxClassDecl, DeclSyntax(helperEnumDecl)] - } - - func renderClosureInvokeHandler(_ signature: ClosureSignature) throws -> DeclSyntax { - let boxClassName = "_BJS_ClosureBox_\(signature.mangleName)" - let abiName = "invoke_swift_closure_\(signature.moduleName)_\(signature.mangleName)" - - // Build ABI parameters directly with WasmCoreType (no string conversion needed) - var abiParams: [(name: String, type: WasmCoreType)] = [("boxPtr", .pointer)] - var liftedParams: [String] = [] - - for (index, paramType) in signature.parameters.enumerated() { - let paramName = "param\(index)" - let liftInfo = try paramType.liftParameterInfo() - - for (argName, wasmType) in liftInfo.parameters { - let fullName = - liftInfo.parameters.count > 1 ? "\(paramName)\(argName.capitalizedFirstLetter)" : paramName - abiParams.append((fullName, wasmType)) - } - - let argNames = liftInfo.parameters.map { (argName, _) in - liftInfo.parameters.count > 1 ? "\(paramName)\(argName.capitalizedFirstLetter)" : paramName - } - liftedParams.append("\(paramType.swiftType).bridgeJSLiftParameter(\(argNames.joined(separator: ", ")))") - } - - let closureCallExpr = ExprSyntax("box.closure(\(raw: liftedParams.joined(separator: ", ")))") - - // Determine return type - let abiReturnWasmType: WasmCoreType? - if signature.returnType == .void { - abiReturnWasmType = nil - } else if let wasmType = try signature.returnType.loweringReturnInfo().returnType { - abiReturnWasmType = wasmType - } else { - abiReturnWasmType = nil - } - - // Build signature using SwiftSignatureBuilder - let funcSignature = SwiftSignatureBuilder.buildABIFunctionSignature( - abiParameters: abiParams, - returnType: abiReturnWasmType - ) - - // Build body - let body = CodeBlockItemListSyntax { - "let box = Unmanaged<\(raw: boxClassName)>.fromOpaque(boxPtr).takeUnretainedValue()" - if signature.returnType == .void { - closureCallExpr - } else { - "let result = \(closureCallExpr)" - "return result.bridgeJSLowerReturn()" - } - } - - // Build function declaration using helper - let funcDecl = SwiftCodePattern.buildExposedFunctionDecl( - abiName: abiName, - signature: funcSignature, - body: body - ) - - return DeclSyntax(funcDecl) - } - -} - // MARK: - StackCodegen /// Helper for stack-based lifting and lowering operations. diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift index 27f5a7c51..b824bf8d8 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift @@ -30,6 +30,7 @@ public struct ImportTS { /// Finalizes the import process and generates Swift code public func finalize() throws -> String? { var decls: [DeclSyntax] = [] + for skeleton in self.skeleton.children { for getter in skeleton.globalGetters { let getterDecls = try renderSwiftGlobalGetter(getter, topLevelDecls: &decls) @@ -97,6 +98,16 @@ public struct ImportTS { "\(param.name)\($0.name.capitalizedFirstLetter)" } + let initializerExpr: ExprSyntax + switch param.type { + case .closure(let signature): + initializerExpr = ExprSyntax( + "_BJS_Closure_\(raw: signature.mangleName).bridgeJSLower(\(raw: param.name))" + ) + default: + initializerExpr = ExprSyntax("\(raw: param.name).bridgeJSLowerParameter()") + } + // Always add destructuring statement to body (unified for single and multiple) let pattern: PatternSyntax if destructuredNames.count == 1 { @@ -123,7 +134,7 @@ public struct ImportTS { PatternBindingSyntax( pattern: pattern, initializer: InitializerClauseSyntax( - value: ExprSyntax("\(raw: param.name).bridgeJSLowerParameter()") + value: initializerExpr ) ) } @@ -209,10 +220,15 @@ public struct ImportTS { } else { abiReturnType = liftingInfo.valueToLift let liftExpr: ExprSyntax - if liftingInfo.valueToLift != nil { - liftExpr = "\(raw: returnType.swiftType).bridgeJSLiftReturn(ret)" - } else { - liftExpr = "\(raw: returnType.swiftType).bridgeJSLiftReturn()" + switch returnType { + case .closure(let signature): + liftExpr = ExprSyntax("_BJS_Closure_\(raw: signature.mangleName).bridgeJSLift(ret)") + default: + if liftingInfo.valueToLift != nil { + liftExpr = "\(raw: returnType.swiftType).bridgeJSLiftReturn(ret)" + } else { + liftExpr = "\(raw: returnType.swiftType).bridgeJSLiftReturn()" + } } body.append( CodeBlockItemSyntax( @@ -543,13 +559,14 @@ struct SwiftSignatureBuilder { ) -> FunctionParameterClauseSyntax { return FunctionParameterClauseSyntax(parametersBuilder: { for param in parameters { + let paramTypeSyntax = buildParameterTypeSyntax(from: param.type) if useWildcardLabels { // Always use wildcard labels: "_ name: Type" FunctionParameterSyntax( firstName: .wildcardToken(), secondName: .identifier(param.name), colon: .colonToken(), - type: buildTypeSyntax(from: param.type) + type: paramTypeSyntax ) } else { let label = param.label ?? param.name @@ -559,7 +576,7 @@ struct SwiftSignatureBuilder { firstName: .identifier(label), secondName: nil, colon: .colonToken(), - type: buildTypeSyntax(from: param.type) + type: paramTypeSyntax ) } else if param.label == nil { // No label specified: use wildcard "_ name: Type" @@ -567,7 +584,7 @@ struct SwiftSignatureBuilder { firstName: .wildcardToken(), secondName: .identifier(param.name), colon: .colonToken(), - type: buildTypeSyntax(from: param.type) + type: paramTypeSyntax ) } else { // External label differs: "label count: Int" @@ -575,7 +592,7 @@ struct SwiftSignatureBuilder { firstName: .identifier(label), secondName: .identifier(param.name), colon: .colonToken(), - type: buildTypeSyntax(from: param.type) + type: paramTypeSyntax ) } } @@ -653,6 +670,18 @@ struct SwiftSignatureBuilder { let identifierType = IdentifierTypeSyntax(name: .identifier(type.swiftType)) return TypeSyntax(identifierType) } + + /// Builds a parameter type syntax from a BridgeType. + /// + /// Swift closure parameters must be `@escaping` because they are boxed and can be invoked from JavaScript. + static func buildParameterTypeSyntax(from type: BridgeType) -> TypeSyntax { + switch type { + case .closure: + return TypeSyntax("@escaping \(raw: type.swiftType)") + default: + return buildTypeSyntax(from: type) + } + } } enum SwiftCodePattern { @@ -844,7 +873,8 @@ extension BridgeType { case .jsObject: return .jsObject case .void: return .void case .closure: - throw BridgeJSCoreError("Closure types are not yet supported in TypeScript imports") + // Swift closure is boxed and passed to JS as a pointer. + return LoweringParameterInfo(loweredParameters: [("pointer", .pointer)]) case .swiftHeapObject(let className): switch context { case .importTS: @@ -927,7 +957,8 @@ extension BridgeType { case .jsObject: return .jsObject case .void: return .void case .closure: - throw BridgeJSCoreError("Closure types are not yet supported in TypeScript imports") + // JS returns a callback ID for closures, which Swift lifts to a typed closure. + return LiftingReturnInfo(valueToLift: .i32) case .swiftHeapObject(let className): switch context { case .importTS: diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift index 625bfc364..929e6e4cd 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift @@ -589,10 +589,14 @@ public struct BridgeJSLink { printer.write("}") for unified in skeletons { - guard let skeleton = unified.exported else { continue } let moduleName = unified.moduleName var closureSignatures: Set = [] - collectClosureSignatures(from: skeleton, into: &closureSignatures) + if let exported = unified.exported { + collectClosureSignatures(from: exported, into: &closureSignatures) + } + if let imported = unified.imported { + collectClosureSignatures(from: imported, into: &closureSignatures) + } guard !closureSignatures.isEmpty else { continue } @@ -640,6 +644,36 @@ public struct BridgeJSLink { } } + private func collectClosureSignatures( + from skeleton: ImportedModuleSkeleton, + into signatures: inout Set + ) { + for fileSkeleton in skeleton.children { + for getter in fileSkeleton.globalGetters { + collectClosureSignatures(from: getter.type, into: &signatures) + } + for function in fileSkeleton.functions { + collectClosureSignatures(from: function.parameters, into: &signatures) + collectClosureSignatures(from: function.returnType, into: &signatures) + } + for type in fileSkeleton.types { + if let constructor = type.constructor { + collectClosureSignatures(from: constructor.parameters, into: &signatures) + } + for getter in type.getters { + collectClosureSignatures(from: getter.type, into: &signatures) + } + for setter in type.setters { + collectClosureSignatures(from: setter.type, into: &signatures) + } + for method in type.methods { + collectClosureSignatures(from: method.parameters, into: &signatures) + collectClosureSignatures(from: method.returnType, into: &signatures) + } + } + } + } + private func collectClosureSignatures(from parameters: [Parameter], into signatures: inout Set) { for param in parameters { collectClosureSignatures(from: param.type, into: &signatures) diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/JSGlueGen.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/JSGlueGen.swift index 944c1659b..d9223faf8 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/JSGlueGen.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/JSGlueGen.swift @@ -1459,8 +1459,14 @@ struct IntrinsicJSFragment: Sendable { } ) } - case .closure: - throw BridgeJSLinkError(message: "Closure parameters not yet implemented for imported JS functions") + case .closure(let signature): + let lowerFuncName = "lower_closure_\(signature.moduleName)_\(signature.mangleName)" + return IntrinsicJSFragment( + parameters: ["boxPtr"], + printCode: { arguments, scope, printer, cleanupCode in + return ["bjs[\"\(lowerFuncName)\"](\(arguments[0]))"] + } + ) case .namespaceEnum(let string): throw BridgeJSLinkError( message: @@ -1524,7 +1530,18 @@ struct IntrinsicJSFragment: Sendable { return swiftStructLowerReturn(fullName: fullName) } case .closure: - throw BridgeJSLinkError(message: "Closure return values not yet implemented for imported JS functions") + return IntrinsicJSFragment( + parameters: ["value"], + printCode: { arguments, scope, printer, cleanupCode in + let value = arguments[0] + printer.write("if (typeof \(value) !== \"function\") {") + printer.indent { + printer.write("throw new TypeError(\"Expected a function\")") + } + printer.write("}") + return ["\(JSGlueVariableScope.reservedSwift).memory.retain(\(value))"] + } + ) case .namespaceEnum(let string): throw BridgeJSLinkError( message: "Namespace enums are not supported to be returned from imported JS functions: \(string)" diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift index f75c7b758..3fb7f8114 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift @@ -152,15 +152,22 @@ import BridgeJSUtilities } var importer: ImportTS? if let skeleton = skeleton.imported { - importer = ImportTS(progress: progress, moduleName: moduleName, skeleton: skeleton) + importer = ImportTS( + progress: progress, + moduleName: moduleName, + skeleton: skeleton + ) } + // Generate unified closure support for both import/export to avoid duplicate symbols when concatenating. + let closureSupport = try ClosureCodegen().renderSupport(for: skeleton) + let importResult = try importer?.finalize() let exportResult = try exporter?.finalize() // Combine and write unified Swift output let outputSwiftURL = outputDirectory.appending(path: "BridgeJS.swift") - let combinedSwift = [exportResult, importResult].compactMap { $0 } + let combinedSwift = [closureSupport, exportResult, importResult].compactMap { $0 } let outputSwift = combineGeneratedSwift(combinedSwift) let shouldWrite = doubleDashOptions["always-write"] == "true" || !outputSwift.isEmpty if shouldWrite { diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ExportSwiftTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ExportSwiftTests.swift index fd86a2ad7..fb31ac837 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ExportSwiftTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ExportSwiftTests.swift @@ -15,8 +15,17 @@ import Testing sourceLocation: Testing.SourceLocation = #_sourceLocation ) throws { guard let exported = skeleton.exported else { return } - let exportSwift = ExportSwift(progress: .silent, moduleName: skeleton.moduleName, skeleton: exported) - let outputSwift = try #require(try exportSwift.finalize()) + let exportSwift = ExportSwift( + progress: .silent, + moduleName: skeleton.moduleName, + skeleton: exported + ) + let closureSupport = try ClosureCodegen().renderSupport(for: skeleton) + let exportResult = try #require(try exportSwift.finalize()) + let outputSwift = ([closureSupport, exportResult] as [String?]) + .compactMap { $0?.trimmingCharacters(in: .newlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n\n") try assertSnapshot( name: name, filePath: filePath, diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/SwiftClosureImports.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/SwiftClosureImports.swift new file mode 100644 index 000000000..6efd641b9 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/SwiftClosureImports.swift @@ -0,0 +1,4 @@ +@JSFunction func applyInt(_ value: Int, _ transform: (Int) -> Int) throws(JSException) -> Int + +@JSFunction func makeAdder(_ base: Int) throws(JSException) -> (Int) -> Int + diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportTSTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportTSTests.swift index 158e4511e..89c7dd11d 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportTSTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportTSTests.swift @@ -3,11 +3,14 @@ import Foundation import SwiftParser @testable import BridgeJSCore @testable import TS2Swift +@testable import BridgeJSSkeleton @Suite struct ImportTSTests { static let inputsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent( "Inputs" ) + static let importMacroInputsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("ImportMacroInputs") static func collectInputs() -> [String] { let fileManager = FileManager.default @@ -15,6 +18,12 @@ import SwiftParser return inputs.filter { $0.hasSuffix(".d.ts") } } + static func collectImportMacroInputs() -> [String] { + let fileManager = FileManager.default + let inputs = try! fileManager.contentsOfDirectory(atPath: Self.importMacroInputsDirectory.path) + return inputs.filter { $0.hasSuffix(".swift") } + } + @Test(arguments: collectInputs()) func snapshot(input: String) throws { let url = Self.inputsDirectory.appendingPathComponent(input) @@ -44,7 +53,14 @@ import SwiftParser guard let imported = skeleton.imported else { return } let importTS = ImportTS(progress: .silent, moduleName: "Check", skeleton: imported) - let outputSwift = try #require(try importTS.finalize()) + let importResult = try #require(try importTS.finalize()) + let closureSupport = try ClosureCodegen().renderSupport( + for: BridgeJSSkeleton(moduleName: "Check", imported: imported) + ) + let outputSwift = ([closureSupport, importResult] as [String?]) + .compactMap { $0?.trimmingCharacters(in: .newlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n\n") try assertSnapshot( name: name, filePath: #filePath, @@ -53,4 +69,33 @@ import SwiftParser fileExtension: "swift" ) } + + @Test(arguments: collectImportMacroInputs()) + func snapshotImportMacroInput(input: String) throws { + let url = Self.importMacroInputsDirectory.appendingPathComponent(input) + let name = url.deletingPathExtension().lastPathComponent + + let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) + let importSwift = SwiftToSkeleton(progress: .silent, moduleName: "Check", exposeToGlobal: false) + importSwift.addSourceFile(sourceFile, inputFilePath: "\(name).swift") + let skeleton = try importSwift.finalize() + + guard let imported = skeleton.imported else { return } + let importTS = ImportTS(progress: .silent, moduleName: "Check", skeleton: imported) + let importResult = try #require(try importTS.finalize()) + let closureSupport = try ClosureCodegen().renderSupport( + for: BridgeJSSkeleton(moduleName: "Check", imported: imported) + ) + let outputSwift = ([closureSupport, importResult] as [String?]) + .compactMap { $0?.trimmingCharacters(in: .newlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + try assertSnapshot( + name: name + ".ImportMacros", + filePath: #filePath, + function: #function, + input: outputSwift.data(using: .utf8)!, + fileExtension: "swift" + ) + } } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/SwiftClosureImports.ImportMacros.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/SwiftClosureImports.ImportMacros.d.ts new file mode 100644 index 000000000..ebf493910 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/SwiftClosureImports.ImportMacros.d.ts @@ -0,0 +1,19 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +export type Exports = { +} +export type Imports = { + applyInt(value: number, transform: (arg0: number) => number): number; + makeAdder(base: number): (arg0: number) => number; +} +export function createInstantiator(options: { + imports: Imports; +}, swift: any): Promise<{ + addImports: (importObject: WebAssembly.Imports) => void; + setInstance: (instance: WebAssembly.Instance) => void; + createExports: (instance: WebAssembly.Instance) => Exports; +}>; \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/SwiftClosureImports.ImportMacros.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/SwiftClosureImports.ImportMacros.js new file mode 100644 index 000000000..db434c2ab --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/SwiftClosureImports.ImportMacros.js @@ -0,0 +1,257 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +export async function createInstantiator(options, swift) { + let instance; + let memory; + let setException; + const textDecoder = new TextDecoder("utf-8"); + const textEncoder = new TextEncoder("utf-8"); + let tmpRetString; + let tmpRetBytes; + let tmpRetException; + let tmpRetOptionalBool; + let tmpRetOptionalInt; + let tmpRetOptionalFloat; + let tmpRetOptionalDouble; + let tmpRetOptionalHeapObject; + let tmpRetTag; + let tmpRetStrings = []; + let tmpRetInts = []; + let tmpRetF32s = []; + let tmpRetF64s = []; + let tmpParamInts = []; + let tmpParamF32s = []; + let tmpParamF64s = []; + let tmpRetPointers = []; + let tmpParamPointers = []; + const enumHelpers = {}; + const structHelpers = {}; + + let _exports = null; + let bjs = null; + + return { + /** + * @param {WebAssembly.Imports} importObject + */ + addImports: (importObject, importsContext) => { + bjs = {}; + importObject["bjs"] = bjs; + const imports = options.getImports(importsContext); + bjs["swift_js_return_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + tmpRetString = textDecoder.decode(bytes); + } + bjs["swift_js_init_memory"] = function(sourceId, bytesPtr) { + const source = swift.memory.getObject(sourceId); + const bytes = new Uint8Array(memory.buffer, bytesPtr); + bytes.set(source); + } + bjs["swift_js_make_js_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + return swift.memory.retain(textDecoder.decode(bytes)); + } + bjs["swift_js_init_memory_with_result"] = function(ptr, len) { + const target = new Uint8Array(memory.buffer, ptr, len); + target.set(tmpRetBytes); + tmpRetBytes = undefined; + } + bjs["swift_js_throw"] = function(id) { + tmpRetException = swift.memory.retainByRef(id); + } + bjs["swift_js_retain"] = function(id) { + return swift.memory.retainByRef(id); + } + bjs["swift_js_release"] = function(id) { + swift.memory.release(id); + } + bjs["swift_js_push_tag"] = function(tag) { + tmpRetTag = tag; + } + bjs["swift_js_push_int"] = function(v) { + tmpRetInts.push(v | 0); + } + bjs["swift_js_push_f32"] = function(v) { + tmpRetF32s.push(Math.fround(v)); + } + bjs["swift_js_push_f64"] = function(v) { + tmpRetF64s.push(v); + } + bjs["swift_js_push_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + const value = textDecoder.decode(bytes); + tmpRetStrings.push(value); + } + bjs["swift_js_pop_param_int32"] = function() { + return tmpParamInts.pop(); + } + bjs["swift_js_pop_param_f32"] = function() { + return tmpParamF32s.pop(); + } + bjs["swift_js_pop_param_f64"] = function() { + return tmpParamF64s.pop(); + } + bjs["swift_js_push_pointer"] = function(pointer) { + tmpRetPointers.push(pointer); + } + bjs["swift_js_pop_param_pointer"] = function() { + return tmpParamPointers.pop(); + } + bjs["swift_js_return_optional_bool"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalBool = null; + } else { + tmpRetOptionalBool = value !== 0; + } + } + bjs["swift_js_return_optional_int"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalInt = null; + } else { + tmpRetOptionalInt = value | 0; + } + } + bjs["swift_js_return_optional_float"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalFloat = null; + } else { + tmpRetOptionalFloat = Math.fround(value); + } + } + bjs["swift_js_return_optional_double"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalDouble = null; + } else { + tmpRetOptionalDouble = value; + } + } + bjs["swift_js_return_optional_string"] = function(isSome, ptr, len) { + if (isSome === 0) { + tmpRetString = null; + } else { + const bytes = new Uint8Array(memory.buffer, ptr, len); + tmpRetString = textDecoder.decode(bytes); + } + } + bjs["swift_js_return_optional_object"] = function(isSome, objectId) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = swift.memory.getObject(objectId); + } + } + bjs["swift_js_return_optional_heap_object"] = function(isSome, pointer) { + if (isSome === 0) { + tmpRetOptionalHeapObject = null; + } else { + tmpRetOptionalHeapObject = pointer; + } + } + bjs["swift_js_get_optional_int_presence"] = function() { + return tmpRetOptionalInt != null ? 1 : 0; + } + bjs["swift_js_get_optional_int_value"] = function() { + const value = tmpRetOptionalInt; + tmpRetOptionalInt = undefined; + return value; + } + bjs["swift_js_get_optional_string"] = function() { + const str = tmpRetString; + tmpRetString = undefined; + if (str == null) { + return -1; + } else { + const bytes = textEncoder.encode(str); + tmpRetBytes = bytes; + return bytes.length; + } + } + bjs["swift_js_get_optional_float_presence"] = function() { + return tmpRetOptionalFloat != null ? 1 : 0; + } + bjs["swift_js_get_optional_float_value"] = function() { + const value = tmpRetOptionalFloat; + tmpRetOptionalFloat = undefined; + return value; + } + bjs["swift_js_get_optional_double_presence"] = function() { + return tmpRetOptionalDouble != null ? 1 : 0; + } + bjs["swift_js_get_optional_double_value"] = function() { + const value = tmpRetOptionalDouble; + tmpRetOptionalDouble = undefined; + return value; + } + bjs["swift_js_get_optional_heap_object_pointer"] = function() { + const pointer = tmpRetOptionalHeapObject; + tmpRetOptionalHeapObject = undefined; + return pointer || 0; + } + + bjs["invoke_js_callback_TestModule_10TestModuleSi_Si"] = function(callbackId, param0Id) { + try { + const callback = swift.memory.getObject(callbackId); + let param0 = param0Id; + const result = callback(param0); + return result | 0; + } catch (error) { + setException?.(error); + return 0; + } + }; + + bjs["lower_closure_TestModule_10TestModuleSi_Si"] = function(closurePtr) { + return function(param0) { + try { + return instance.exports.invoke_swift_closure_TestModule_10TestModuleSi_Si(closurePtr, param0) | 0; + } catch (error) { + setException?.(error); + throw error; + } + }; + }; + const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; + TestModule["bjs_applyInt"] = function bjs_applyInt(value, transform) { + try { + let ret = imports.applyInt(value, bjs["lower_closure_TestModule_10TestModuleSi_Si"](transform)); + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_makeAdder"] = function bjs_makeAdder(base) { + try { + let ret = imports.makeAdder(base); + if (typeof ret !== "function") { + throw new TypeError("Expected a function") + } + return swift.memory.retain(ret); + } catch (error) { + setException(error); + return 0 + } + } + }, + setInstance: (i) => { + instance = i; + memory = instance.exports.memory; + + setException = (error) => { + instance.exports._swift_js_exception.value = swift.memory.retain(error) + } + }, + /** @param {WebAssembly.Instance} instance */ + createExports: (instance) => { + const js = swift.memory.heap; + const exports = { + }; + _exports = exports; + return exports; + }, + } +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/GlobalGetter.ImportMacros.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/GlobalGetter.ImportMacros.swift new file mode 100644 index 000000000..b5442b09e --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/GlobalGetter.ImportMacros.swift @@ -0,0 +1,34 @@ +#if arch(wasm32) +@_extern(wasm, module: "Check", name: "bjs_console_get") +fileprivate func bjs_console_get() -> Int32 +#else +fileprivate func bjs_console_get() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$console_get() throws(JSException) -> JSConsole { + let ret = bjs_console_get() + if let error = _swift_js_take_exception() { + throw error + } + return JSConsole.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "Check", name: "bjs_JSConsole_log") +fileprivate func bjs_JSConsole_log(_ self: Int32, _ message: Int32) -> Void +#else +fileprivate func bjs_JSConsole_log(_ self: Int32, _ message: Int32) -> Void { + fatalError("Only available on WebAssembly") +} +#endif + +func _$JSConsole_log(_ self: JSObject, _ message: String) throws(JSException) -> Void { + let selfValue = self.bridgeJSLowerParameter() + let messageValue = message.bridgeJSLowerParameter() + bjs_JSConsole_log(selfValue, messageValue) + if let error = _swift_js_take_exception() { + throw error + } +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/SwiftClosureImports.ImportMacros.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/SwiftClosureImports.ImportMacros.swift new file mode 100644 index 000000000..d1a4c4a54 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/SwiftClosureImports.ImportMacros.swift @@ -0,0 +1,84 @@ +#if arch(wasm32) +@_extern(wasm, module: "bjs", name: "invoke_js_callback_Check_5CheckSi_Si") +fileprivate func invoke_js_callback_Check_5CheckSi_Si(_ callback: Int32, _ param0: Int32) -> Int32 +#else +fileprivate func invoke_js_callback_Check_5CheckSi_Si(_ callback: Int32, _ param0: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +private final class _BJS_ClosureBox_5CheckSi_Si: _BridgedSwiftClosureBox { + let closure: (Int) -> Int + init(_ closure: @escaping (Int) -> Int) { + self.closure = closure + } +} + +private enum _BJS_Closure_5CheckSi_Si { + static func bridgeJSLower(_ closure: @escaping (Int) -> Int) -> UnsafeMutableRawPointer { + let box = _BJS_ClosureBox_5CheckSi_Si(closure) + return Unmanaged.passRetained(box).toOpaque() + } + static func bridgeJSLift(_ callbackId: Int32) -> (Int) -> Int { + let callback = JSObject.bridgeJSLiftParameter(callbackId) + return { [callback] param0 in + #if arch(wasm32) + let callbackValue = callback.bridgeJSLowerParameter() + let param0Value = param0.bridgeJSLowerParameter() + let ret = invoke_js_callback_Check_5CheckSi_Si(callbackValue, param0Value) + return Int.bridgeJSLiftReturn(ret) + #else + fatalError("Only available on WebAssembly") + #endif + } + } +} + +@_expose(wasm, "invoke_swift_closure_Check_5CheckSi_Si") +@_cdecl("invoke_swift_closure_Check_5CheckSi_Si") +public func _invoke_swift_closure_Check_5CheckSi_Si(_ boxPtr: UnsafeMutableRawPointer, _ param0: Int32) -> Int32 { + #if arch(wasm32) + let box = Unmanaged<_BJS_ClosureBox_5CheckSi_Si>.fromOpaque(boxPtr).takeUnretainedValue() + let result = box.closure(Int.bridgeJSLiftParameter(param0)) + return result.bridgeJSLowerReturn() + #else + fatalError("Only available on WebAssembly") + #endif +} + +#if arch(wasm32) +@_extern(wasm, module: "Check", name: "bjs_applyInt") +fileprivate func bjs_applyInt(_ value: Int32, _ transform: UnsafeMutableRawPointer) -> Int32 +#else +fileprivate func bjs_applyInt(_ value: Int32, _ transform: UnsafeMutableRawPointer) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$applyInt(_ value: Int, _ transform: (Int) -> Int) throws(JSException) -> Int { + let valueValue = value.bridgeJSLowerParameter() + let transformPointer = _BJS_Closure_5CheckSi_Si.bridgeJSLower(transform) + let ret = bjs_applyInt(valueValue, transformPointer) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "Check", name: "bjs_makeAdder") +fileprivate func bjs_makeAdder(_ base: Int32) -> Int32 +#else +fileprivate func bjs_makeAdder(_ base: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$makeAdder(_ base: Int) throws(JSException) -> (Int) -> Int { + let baseValue = base.bridgeJSLowerParameter() + let ret = bjs_makeAdder(baseValue) + if let error = _swift_js_take_exception() { + throw error + } + return _BJS_Closure_5CheckSi_Si.bridgeJSLift(ret) +} \ No newline at end of file From cef2d6fa9423370cca1e53a2c1d20b9daf1ffee6 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 26 Jan 2026 19:07:16 +0900 Subject: [PATCH 2/2] Runtime tests: cover imported JS closures Add runtime import fixtures and tests for passing Swift closures into JS and receiving JS functions back as Swift closures. --- .../SwiftClosureImports.swift | 1 - .../SwiftClosureImports.ImportMacros.swift | 2 +- .../Generated/BridgeJS.swift | 139 ++++++++++++ .../Generated/JavaScript/BridgeJS.json | 206 ++++++++++++++++++ .../BridgeJSRuntimeTests/ImportAPITests.swift | 30 +++ .../ImportClosureAPIs.swift | 11 + Tests/prelude.mjs | 19 +- 7 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 Tests/BridgeJSRuntimeTests/ImportClosureAPIs.swift diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/SwiftClosureImports.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/SwiftClosureImports.swift index 6efd641b9..d9f92fffb 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/SwiftClosureImports.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/SwiftClosureImports.swift @@ -1,4 +1,3 @@ @JSFunction func applyInt(_ value: Int, _ transform: (Int) -> Int) throws(JSException) -> Int @JSFunction func makeAdder(_ base: Int) throws(JSException) -> (Int) -> Int - diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/SwiftClosureImports.ImportMacros.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/SwiftClosureImports.ImportMacros.swift index d1a4c4a54..9e0959157 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/SwiftClosureImports.ImportMacros.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ImportTSTests/SwiftClosureImports.ImportMacros.swift @@ -55,7 +55,7 @@ fileprivate func bjs_applyInt(_ value: Int32, _ transform: UnsafeMutableRawPoint } #endif -func _$applyInt(_ value: Int, _ transform: (Int) -> Int) throws(JSException) -> Int { +func _$applyInt(_ value: Int, _ transform: @escaping (Int) -> Int) throws(JSException) -> Int { let valueValue = value.bridgeJSLowerParameter() let transformPointer = _BJS_Closure_5CheckSi_Si.bridgeJSLower(transform) let ret = bjs_applyInt(valueValue, transformPointer) diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index 1b39e77b8..ad0357d4e 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -537,6 +537,52 @@ public func _invoke_swift_closure_BridgeJSRuntimeTests_20BridgeJSRuntimeTestsSi_ #endif } +#if arch(wasm32) +@_extern(wasm, module: "bjs", name: "invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTestsSi_y") +fileprivate func invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTestsSi_y(_ callback: Int32, _ param0: Int32) -> Void +#else +fileprivate func invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTestsSi_y(_ callback: Int32, _ param0: Int32) -> Void { + fatalError("Only available on WebAssembly") +} +#endif + +private final class _BJS_ClosureBox_20BridgeJSRuntimeTestsSi_y: _BridgedSwiftClosureBox { + let closure: (Int) -> Void + init(_ closure: @escaping (Int) -> Void) { + self.closure = closure + } +} + +private enum _BJS_Closure_20BridgeJSRuntimeTestsSi_y { + static func bridgeJSLower(_ closure: @escaping (Int) -> Void) -> UnsafeMutableRawPointer { + let box = _BJS_ClosureBox_20BridgeJSRuntimeTestsSi_y(closure) + return Unmanaged.passRetained(box).toOpaque() + } + static func bridgeJSLift(_ callbackId: Int32) -> (Int) -> Void { + let callback = JSObject.bridgeJSLiftParameter(callbackId) + return { [callback] param0 in + #if arch(wasm32) + let callbackValue = callback.bridgeJSLowerParameter() + let param0Value = param0.bridgeJSLowerParameter() + invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTestsSi_y(callbackValue, param0Value) + #else + fatalError("Only available on WebAssembly") + #endif + } + } +} + +@_expose(wasm, "invoke_swift_closure_BridgeJSRuntimeTests_20BridgeJSRuntimeTestsSi_y") +@_cdecl("invoke_swift_closure_BridgeJSRuntimeTests_20BridgeJSRuntimeTestsSi_y") +public func _invoke_swift_closure_BridgeJSRuntimeTests_20BridgeJSRuntimeTestsSi_y(_ boxPtr: UnsafeMutableRawPointer, _ param0: Int32) -> Void { + #if arch(wasm32) + let box = Unmanaged<_BJS_ClosureBox_20BridgeJSRuntimeTestsSi_y>.fromOpaque(boxPtr).takeUnretainedValue() + box.closure(Int.bridgeJSLiftParameter(param0)) + #else + fatalError("Only available on WebAssembly") + #endif +} + #if arch(wasm32) @_extern(wasm, module: "bjs", name: "invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTestsSq5ThemeO_SS") fileprivate func invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTestsSq5ThemeO_SS(_ callback: Int32, _ param0IsSome: Int32, _ param0Value: Int32) -> Int32 @@ -6431,4 +6477,97 @@ func _$JsGreeter_changeName(_ self: JSObject, _ name: String) throws(JSException if let error = _swift_js_take_exception() { throw error } +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_jsApplyInt") +fileprivate func bjs_jsApplyInt(_ value: Int32, _ transform: UnsafeMutableRawPointer) -> Int32 +#else +fileprivate func bjs_jsApplyInt(_ value: Int32, _ transform: UnsafeMutableRawPointer) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$jsApplyInt(_ value: Int, _ transform: @escaping (Int) -> Int) throws(JSException) -> Int { + let valueValue = value.bridgeJSLowerParameter() + let transformPointer = _BJS_Closure_20BridgeJSRuntimeTestsSi_Si.bridgeJSLower(transform) + let ret = bjs_jsApplyInt(valueValue, transformPointer) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_jsMakeAdder") +fileprivate func bjs_jsMakeAdder(_ base: Int32) -> Int32 +#else +fileprivate func bjs_jsMakeAdder(_ base: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$jsMakeAdder(_ base: Int) throws(JSException) -> (Int) -> Int { + let baseValue = base.bridgeJSLowerParameter() + let ret = bjs_jsMakeAdder(baseValue) + if let error = _swift_js_take_exception() { + throw error + } + return _BJS_Closure_20BridgeJSRuntimeTestsSi_Si.bridgeJSLift(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_jsMapString") +fileprivate func bjs_jsMapString(_ value: Int32, _ transform: UnsafeMutableRawPointer) -> Int32 +#else +fileprivate func bjs_jsMapString(_ value: Int32, _ transform: UnsafeMutableRawPointer) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$jsMapString(_ value: String, _ transform: @escaping (String) -> String) throws(JSException) -> String { + let valueValue = value.bridgeJSLowerParameter() + let transformPointer = _BJS_Closure_20BridgeJSRuntimeTestsSS_SS.bridgeJSLower(transform) + let ret = bjs_jsMapString(valueValue, transformPointer) + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_jsMakePrefixer") +fileprivate func bjs_jsMakePrefixer(_ prefix: Int32) -> Int32 +#else +fileprivate func bjs_jsMakePrefixer(_ prefix: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$jsMakePrefixer(_ prefix: String) throws(JSException) -> (String) -> String { + let prefixValue = prefix.bridgeJSLowerParameter() + let ret = bjs_jsMakePrefixer(prefixValue) + if let error = _swift_js_take_exception() { + throw error + } + return _BJS_Closure_20BridgeJSRuntimeTestsSS_SS.bridgeJSLift(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_jsCallTwice") +fileprivate func bjs_jsCallTwice(_ value: Int32, _ callback: UnsafeMutableRawPointer) -> Int32 +#else +fileprivate func bjs_jsCallTwice(_ value: Int32, _ callback: UnsafeMutableRawPointer) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif + +func _$jsCallTwice(_ value: Int, _ callback: @escaping (Int) -> Void) throws(JSException) -> Int { + let valueValue = value.bridgeJSLowerParameter() + let callbackPointer = _BJS_Closure_20BridgeJSRuntimeTestsSi_y.bridgeJSLower(callback) + let ret = bjs_jsCallTwice(valueValue, callbackPointer) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) } \ No newline at end of file diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index 727508f3c..de90f183c 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -9174,6 +9174,212 @@ ], "types" : [ + ] + }, + { + "functions" : [ + { + "name" : "jsApplyInt", + "parameters" : [ + { + "name" : "value", + "type" : { + "int" : { + + } + } + }, + { + "name" : "transform", + "type" : { + "closure" : { + "_0" : { + "isAsync" : false, + "isThrows" : false, + "mangleName" : "20BridgeJSRuntimeTestsSi_Si", + "moduleName" : "BridgeJSRuntimeTests", + "parameters" : [ + { + "int" : { + + } + } + ], + "returnType" : { + "int" : { + + } + } + } + } + } + } + ], + "returnType" : { + "int" : { + + } + } + }, + { + "name" : "jsMakeAdder", + "parameters" : [ + { + "name" : "base", + "type" : { + "int" : { + + } + } + } + ], + "returnType" : { + "closure" : { + "_0" : { + "isAsync" : false, + "isThrows" : false, + "mangleName" : "20BridgeJSRuntimeTestsSi_Si", + "moduleName" : "BridgeJSRuntimeTests", + "parameters" : [ + { + "int" : { + + } + } + ], + "returnType" : { + "int" : { + + } + } + } + } + } + }, + { + "name" : "jsMapString", + "parameters" : [ + { + "name" : "value", + "type" : { + "string" : { + + } + } + }, + { + "name" : "transform", + "type" : { + "closure" : { + "_0" : { + "isAsync" : false, + "isThrows" : false, + "mangleName" : "20BridgeJSRuntimeTestsSS_SS", + "moduleName" : "BridgeJSRuntimeTests", + "parameters" : [ + { + "string" : { + + } + } + ], + "returnType" : { + "string" : { + + } + } + } + } + } + } + ], + "returnType" : { + "string" : { + + } + } + }, + { + "name" : "jsMakePrefixer", + "parameters" : [ + { + "name" : "prefix", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "closure" : { + "_0" : { + "isAsync" : false, + "isThrows" : false, + "mangleName" : "20BridgeJSRuntimeTestsSS_SS", + "moduleName" : "BridgeJSRuntimeTests", + "parameters" : [ + { + "string" : { + + } + } + ], + "returnType" : { + "string" : { + + } + } + } + } + } + }, + { + "name" : "jsCallTwice", + "parameters" : [ + { + "name" : "value", + "type" : { + "int" : { + + } + } + }, + { + "name" : "callback", + "type" : { + "closure" : { + "_0" : { + "isAsync" : false, + "isThrows" : false, + "mangleName" : "20BridgeJSRuntimeTestsSi_y", + "moduleName" : "BridgeJSRuntimeTests", + "parameters" : [ + { + "int" : { + + } + } + ], + "returnType" : { + "void" : { + + } + } + } + } + } + } + ], + "returnType" : { + "int" : { + + } + } + } + ], + "types" : [ + ] } ] diff --git a/Tests/BridgeJSRuntimeTests/ImportAPITests.swift b/Tests/BridgeJSRuntimeTests/ImportAPITests.swift index f0112ed1a..20f96c682 100644 --- a/Tests/BridgeJSRuntimeTests/ImportAPITests.swift +++ b/Tests/BridgeJSRuntimeTests/ImportAPITests.swift @@ -84,4 +84,34 @@ class ImportAPITests: XCTestCase { XCTAssertEqual(try greeter.prefix, "Hello") } + + func testClosureParameterIntToInt() throws { + let result = try jsApplyInt(21) { $0 * 2 } + XCTAssertEqual(result, 42) + } + + func testClosureReturnIntToInt() throws { + let add10 = try jsMakeAdder(10) + XCTAssertEqual(add10(0), 10) + XCTAssertEqual(add10(32), 42) + } + + func testClosureParameterStringToString() throws { + let result = try jsMapString("Hello") { value in + value + ", world!" + } + XCTAssertEqual(result, "Hello, world!") + } + + func testClosureReturnStringToString() throws { + let prefixer = try jsMakePrefixer("Hello, ") + XCTAssertEqual(prefixer("world!"), "Hello, world!") + } + + func testClosureParameterIntToVoid() throws { + var total = 0 + let ret = try jsCallTwice(5) { total += $0 } + XCTAssertEqual(ret, 5) + XCTAssertEqual(total, 10) + } } diff --git a/Tests/BridgeJSRuntimeTests/ImportClosureAPIs.swift b/Tests/BridgeJSRuntimeTests/ImportClosureAPIs.swift new file mode 100644 index 000000000..bb913300b --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/ImportClosureAPIs.swift @@ -0,0 +1,11 @@ +@_spi(Experimental) import JavaScriptKit + +@JSFunction func jsApplyInt(_ value: Int, _ transform: @escaping (Int) -> Int) throws(JSException) -> Int + +@JSFunction func jsMakeAdder(_ base: Int) throws(JSException) -> (Int) -> Int + +@JSFunction func jsMapString(_ value: String, _ transform: @escaping (String) -> String) throws(JSException) -> String + +@JSFunction func jsMakePrefixer(_ `prefix`: String) throws(JSException) -> (String) -> String + +@JSFunction func jsCallTwice(_ value: Int, _ callback: @escaping (Int) -> Void) throws(JSException) -> Int diff --git a/Tests/prelude.mjs b/Tests/prelude.mjs index a5611523c..1b1f1d2e0 100644 --- a/Tests/prelude.mjs +++ b/Tests/prelude.mjs @@ -71,6 +71,23 @@ export async function setupOptions(options, context) { } BridgeJSRuntimeTests_runAsyncWorks(exports); return; + }, + jsApplyInt: (v, fn) => { + return fn(v); + }, + jsMakeAdder: (base) => { + return (v) => base + v; + }, + jsMapString: (value, fn) => { + return fn(value); + }, + jsMakePrefixer: (prefix) => { + return (name) => `${prefix}${name}`; + }, + jsCallTwice: (v, fn) => { + fn(v); + fn(v); + return v; } }; }, @@ -241,7 +258,7 @@ function BridgeJSRuntimeTests_runJsWorks(instance, exports) { assert.equal(ph.intValue, 777); // Should have parsed and set intValue assert.equal(ph.computedReadWrite, "Value: 777"); - // Test computed readonly property + // Test computed readonly property assert.equal(ph.computedReadonly, 1554); // intValue * 2 = 777 * 2 // Test property with observers