Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 96 additions & 10 deletions Plugins/BridgeJS/Sources/BridgeJSCore/ClosureCodegen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public struct ClosureCodegen {
let closureParams = signature.parameters.map { "\(sendingPrefix)\($0.closureSwiftType)" }.joined(
separator: ", "
)
let swiftEffects = (signature.isAsync ? " async" : "") + (signature.isThrows ? " throws" : "")
let swiftEffects = (signature.isAsync ? " async" : "") + (signature.isThrows ? " throws(JSException)" : "")
let swiftReturnType = signature.returnType.closureSwiftType
return "(\(closureParams))\(swiftEffects) -> \(swiftReturnType)"
}
Expand Down Expand Up @@ -73,7 +73,17 @@ public struct ClosureCodegen {
helperEnumDeclPrinter.indent {
helperEnumDeclPrinter.write("let callback = JSObject.bridgeJSLiftParameter(callbackId)")
let parameters: String
if signature.parameters.isEmpty {
if signature.isThrows || signature.isAsync {
let sendingPrefix = signature.sendingParameters ? "sending " : ""
let typedParams =
signature.parameters.enumerated().map { index, paramType in
"param\(index): \(sendingPrefix)\(paramType.closureSwiftType)"
}.joined(separator: ", ")
let returnType = signature.returnType.closureSwiftType
let effects =
(signature.isAsync ? " async" : "") + (signature.isThrows ? " throws(JSException)" : "")
parameters = " (\(typedParams))\(effects) -> \(returnType)"
} else if signature.parameters.isEmpty {
parameters = ""
} else if signature.parameters.count == 1 {
parameters = " param0"
Expand Down Expand Up @@ -146,22 +156,25 @@ public struct ClosureCodegen {
liftedParams.append("\(paramType.swiftType).bridgeJSLiftParameter(\(argNames.joined(separator: ", ")))")
}

let closureCallExpr = ExprSyntax("closure(\(raw: liftedParams.joined(separator: ", ")))")
let tryPrefix = signature.isThrows ? "try " : ""
let closureCallExpr = ExprSyntax("\(raw: tryPrefix)closure(\(raw: liftedParams.joined(separator: ", ")))")
let asyncTryPrefix = (signature.isThrows ? "try " : "") + "await "
let asyncClosureCallExpr = ExprSyntax(
"\(raw: asyncTryPrefix)closure(\(raw: liftedParams.joined(separator: ", ")))"
)

let abiReturnWasmType = try signature.returnType.loweringReturnInfo().returnType
let abiReturnWasmType =
signature.isAsync
? try BridgeType.jsObject(nil).loweringReturnInfo().returnType
: try signature.returnType.loweringReturnInfo().returnType

// Build signature using SwiftSignatureBuilder
let funcSignature = SwiftSignatureBuilder.buildABIFunctionSignature(
abiParameters: abiParams,
returnType: abiReturnWasmType
)

// Build function declaration using helper
let funcDecl = SwiftCodePattern.buildExposedFunctionDecl(
abiName: abiName,
signature: funcSignature
) { printer in
printer.write("let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure")
let emitCallAndLower: (CodeFragmentPrinter) -> Void = { printer in
if signature.returnType == .void {
printer.write(closureCallExpr.description)
} else {
Expand Down Expand Up @@ -189,6 +202,79 @@ public struct ClosureCodegen {
}
}

let emitAsyncCallAndLower: (CodeFragmentPrinter) -> Void = { printer in
printer.write("let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure")
let resolveType = signature.returnType
let resolveName = "Promise_resolve_\(resolveType.mangleTypeName)"
let rejectName = "Promise_reject"
let closureHead: String
if signature.isThrows {
let returnSpelling = resolveType == .void ? "" : " -> \(resolveType.closureSwiftType)"
closureHead = " () async throws(JSException)\(returnSpelling) in"
} else {
closureHead = ""
}
printer.write("return _bjs_makePromise(resolve: \(resolveName), reject: \(rejectName)) {\(closureHead)")
printer.indent {
if resolveType == .void {
printer.write(asyncClosureCallExpr.description)
} else {
printer.write("return \(asyncClosureCallExpr)")
}
}
printer.write("}")
}

let catchPlaceholderStmt = abiReturnWasmType?.swiftReturnPlaceholderStmt

// Build function declaration using helper
let funcDecl = SwiftCodePattern.buildExposedFunctionDecl(
abiName: abiName,
signature: funcSignature
) { printer in
if signature.isAsync {
emitAsyncCallAndLower(printer)
} else if signature.isThrows {
printer.write(
"let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure"
)
printer.write("do {")
printer.indent {
emitCallAndLower(printer)
}
printer.write("} catch let error {")
printer.indent {
printer.write("if let error = error.thrownValue.object {")
printer.indent {
printer.write("withExtendedLifetime(error) {")
printer.indent {
printer.write("_swift_js_throw(Int32(bitPattern: $0.id))")
}
printer.write("}")
}
printer.write("} else {")
printer.indent {
printer.write("let jsError = JSError(message: error.description)")
printer.write("withExtendedLifetime(jsError.jsObject) {")
printer.indent {
printer.write("_swift_js_throw(Int32(bitPattern: $0.id))")
}
printer.write("}")
}
printer.write("}")
if let catchPlaceholderStmt {
printer.write(catchPlaceholderStmt)
}
}
printer.write("}")
} else {
printer.write(
"let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure"
)
emitCallAndLower(printer)
}
}

return DeclSyntax(funcDecl)
}

Expand Down
9 changes: 4 additions & 5 deletions Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,7 @@ public struct ImportTS {
}
}

// Add exception check for ImportTS context (skipped for async, where
// errors are funneled through the JS-side reject path)
if !effects.isAsync && context == .importTS {
if !effects.isAsync && (context == .importTS || effects.isThrows) {
body.write("if let error = _swift_js_take_exception() { throw error }")
}
}
Expand Down Expand Up @@ -323,18 +321,19 @@ public struct ImportTS {
let innerBody = body
body = CodeFragmentPrinter()

let tryKeyword = effects.isThrows ? "try" : "try!"
let rejectFactory = "makeRejectClosure: { JSTypedClosure<(sending JSValue) -> Void>($0) }"
if returnType == .void {
let resolveFactory = "makeResolveClosure: { JSTypedClosure<() -> Void>($0) }"
body.write(
"try await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
"\(tryKeyword) await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
)
} else {
let resolveSwiftType = returnType.closureSwiftType
let resolveFactory =
"makeResolveClosure: { JSTypedClosure<(sending \(resolveSwiftType)) -> Void>($0) }"
body.write(
"let resolved = try await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
"let resolved = \(tryKeyword) await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
)
}
body.indent {
Expand Down
20 changes: 15 additions & 5 deletions Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,21 @@ import SwiftSyntax
import class Foundation.ProcessInfo

public struct DiagnosticError: Error {
public enum Severity: String, Sendable {
case error
case warning
}

public let node: Syntax
public let message: String
public let hint: String?
public let severity: Severity

public init(node: some SyntaxProtocol, message: String, hint: String? = nil) {
public init(node: some SyntaxProtocol, message: String, hint: String? = nil, severity: Severity = .error) {
self.node = Syntax(node)
self.message = message
self.hint = hint
self.severity = severity
}

/// Formats the diagnostic error as a string.
Expand All @@ -166,12 +173,14 @@ public struct DiagnosticError: Error {

let lineNumberWidth = max(3, String(lines.count).count)

let severityLabel = severity.rawValue
let severityColor = severity == .warning ? ANSI.boldYellow : ANSI.boldRed
let header: String = {
guard colorize else {
return "\(displayFileName):\(startLocation.line):\(startLocation.column): error: \(message)"
return "\(displayFileName):\(startLocation.line):\(startLocation.column): \(severityLabel): \(message)"
}
return
"\(displayFileName):\(startLocation.line):\(startLocation.column): \(ANSI.boldRed)error: \(ANSI.boldDefault)\(message)\(ANSI.reset)"
"\(displayFileName):\(startLocation.line):\(startLocation.column): \(severityColor)\(severityLabel): \(ANSI.boldDefault)\(message)\(ANSI.reset)"
}()

let highlightStartColumn = min(max(1, startLocation.column), mainLine.utf8.count + 1)
Expand Down Expand Up @@ -227,8 +236,8 @@ public struct DiagnosticError: Error {
let pointerSpacing = max(0, highlightStartColumn - 1)
let pointerMessage: String = {
let pointer = String(repeating: " ", count: pointerSpacing) + "`- "
guard colorize else { return pointer + "error: \(message)" }
return pointer + "\(ANSI.boldRed)error: \(ANSI.boldDefault)\(message)\(ANSI.reset)"
guard colorize else { return pointer + "\(severityLabel): \(message)" }
return pointer + "\(severityColor)\(severityLabel): \(ANSI.boldDefault)\(message)\(ANSI.reset)"
}()
descriptionParts.append(
Self.formatSourceLine(
Expand Down Expand Up @@ -304,6 +313,7 @@ public struct BridgeJSCoreDiagnosticError: Swift.Error, CustomStringConvertible
private enum ANSI {
static let reset = "\u{001B}[0;0m"
static let boldRed = "\u{001B}[1;31m"
static let boldYellow = "\u{001B}[1;33m"
static let boldDefault = "\u{001B}[1;39m"
static let cyan = "\u{001B}[0;36m"
static let underline = "\u{001B}[4;39m"
Expand Down
104 changes: 84 additions & 20 deletions Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public final class SwiftToSkeleton {
private var sourceFiles: [(sourceFile: SourceFileSyntax, inputFilePath: String)] = []
private var usedExternalModules = Set<String>()

/// Non-fatal diagnostics collected during `finalize()`. These do not fail the build.
public private(set) var warnings: [(file: String, diagnostic: DiagnosticError)] = []

public init(
progress: ProgressReporting,
moduleName: String,
Expand Down Expand Up @@ -87,10 +90,15 @@ public final class SwiftToSkeleton {
)
importCollector.walk(sourceFile)

let importErrorsFatal = importCollector.errors.filter { !$0.message.contains("Unsupported type '") }
if !exportCollector.errors.isEmpty || !importErrorsFatal.isEmpty {
let exportErrors = exportCollector.errors.filter { $0.severity == .error }
let importErrorsFatal = importCollector.errors.filter {
$0.severity == .error && !$0.message.contains("Unsupported type '")
}
let fileWarnings = (exportCollector.errors + importCollector.errors).filter { $0.severity == .warning }
warnings.append(contentsOf: fileWarnings.map { (file: inputFilePath, diagnostic: $0) })
if !exportErrors.isEmpty || !importErrorsFatal.isEmpty {
perSourceErrors.append(
(inputFilePath: inputFilePath, errors: exportCollector.errors + importErrorsFatal)
(inputFilePath: inputFilePath, errors: exportErrors + importErrorsFatal)
)
}

Expand Down Expand Up @@ -191,7 +199,40 @@ public final class SwiftToSkeleton {
}

let isAsync = functionType.effectSpecifiers?.asyncSpecifier != nil
let isThrows = functionType.effectSpecifiers?.throwsClause != nil

if isAsync, !returnType.isAsyncResolvable {
errors.append(
DiagnosticError(
node: functionType,
message:
"Returning '\(returnType.swiftType)' from an async closure is not yet supported",
hint:
"Return a type lowerable through the async resolve ABI "
+ "(String/Int/Bool/Double/Float/raw-value or case-only enum/@JS struct/JSObject/Optional/Array/Dictionary), "
+ "or make the closure non-async."
)
)
return nil
}

var isThrows = false
if let throwsClause = functionType.effectSpecifiers?.throwsClause {
guard let thrownType = throwsClause.type,
thrownType.trimmedDescription == "JSException"
else {
errors.append(
DiagnosticError(
node: throwsClause,
message:
"Only JSException is supported for thrown type of Swift closures, "
+ "got \(throwsClause.type?.trimmedDescription ?? "unspecified")",
hint: "Annotate the closure as `throws(JSException)`"
)
)
return nil
}
isThrows = true
}

return .closure(
ClosureSignature(
Expand Down Expand Up @@ -569,6 +610,37 @@ private enum ExportSwiftConstants {
static let supportedRawTypes = SwiftEnumRawType.supportedTypeNames
}

/// Warns about Swift closures handed to JavaScript with an `async throws(JSException)` signature.
/// Captureless closure values lose their thrown error at runtime due to a Swift compiler bug.
private func asyncThrowsClosureWarning(node: some SyntaxProtocol) -> DiagnosticError {
DiagnosticError(
node: node,
message:
"async throwing closures passed to JavaScript may lose thrown errors due to a Swift compiler bug "
+ "(swiftlang/swift#89320) unless the closure value captures state",
hint:
"Pass a closure that captures state, or see the BridgeJS closure documentation for details",
severity: .warning
)
}

extension BridgeType {
fileprivate var containsAsyncThrowsClosure: Bool {
switch self {
case .closure(let signature, _):
return signature.isAsync && signature.isThrows
case .nullable(let wrapped, _):
return wrapped.containsAsyncThrowsClosure
case .array(let element):
return element.containsAsyncThrowsClosure
case .dictionary(let value):
return value.containsAsyncThrowsClosure
default:
return false
}
}
}

extension AttributeSyntax {
/// The attribute name as text when it is a simple identifier (e.g. "JS", "JSFunction").
/// Prefer this over `attributeName.trimmedDescription` for name checks to avoid unnecessary string work.
Expand Down Expand Up @@ -1028,22 +1100,6 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
guard let type = resolvedType else {
continue // Skip unsupported types
}
if case .closure(let signature, _) = type {
if signature.isAsync {
diagnose(
node: param.type,
message: "Async is not supported for Swift closures yet."
)
continue
}
if signature.isThrows {
diagnose(
node: param.type,
message: "Throws is not supported for Swift closures yet."
)
continue
}
}
if case .nullable(let wrappedType, _) = type, wrappedType.isOptional {
diagnoseNestedOptional(node: param.type, type: param.type.trimmedDescription)
continue
Expand Down Expand Up @@ -1177,6 +1233,9 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {

guard let type = resolvedType else { return nil }
returnType = type
if returnType.containsAsyncThrowsClosure {
errors.append(asyncThrowsClosureWarning(node: returnClause.type))
}
} else {
returnType = .void
}
Expand Down Expand Up @@ -2836,6 +2895,11 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
guard let bridgeType = withLookupErrors({ parent.lookupType(for: type, errors: &$0) }) else {
return nil
}
if case .closure(let signature, useJSTypedClosure: true) = bridgeType,
signature.isAsync, signature.isThrows
{
errors.append(asyncThrowsClosureWarning(node: type))
}
let nameToken = param.secondName ?? param.firstName
let name = SwiftToSkeleton.normalizeIdentifier(nameToken.text)
let labelToken = param.secondName == nil ? nil : param.firstName
Expand Down
Loading
Loading