Skip to content
Merged
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
229 changes: 198 additions & 31 deletions Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Large diffs are not rendered by default.

53 changes: 31 additions & 22 deletions Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2515,7 +2515,13 @@ extension BridgeJSLink {
}

func callConstructor(jsName: String, swiftTypeName: String, fromObjectExpr: String) throws {
let ctorExpr = Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: jsName)
try callConstructor(
ctorExpr: Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: jsName),
swiftTypeName: swiftTypeName
)
}

func callConstructor(ctorExpr: String, swiftTypeName: String) throws {
let call = "new \(ctorExpr)(\(parameterForwardings.joined(separator: ", ")))"
let type: BridgeType = .jsObject(swiftTypeName)
let loweringFragment = try IntrinsicJSFragment.lowerReturn(type: type, context: context)
Expand Down Expand Up @@ -2580,12 +2586,18 @@ extension BridgeJSLink {
}

func getImportProperty(name: String, fromObjectExpr: String, returnType: BridgeType) throws {
try getImportProperty(
accessExpr: Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: name),
returnType: returnType
)
}

func getImportProperty(accessExpr expr: String, returnType: BridgeType) throws {
if returnType == .void {
throw BridgeJSLinkError(message: "Void is not supported for imported JS properties")
}

let loweringFragment = try IntrinsicJSFragment.lowerReturn(type: returnType, context: context)
let expr = Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: name)

let returnExpr: String?
if loweringFragment.parameters.count == 0 {
Expand Down Expand Up @@ -2623,8 +2635,7 @@ extension BridgeJSLink {
}

static func propertyAccessExpr(objectExpr: String, propertyName: String) -> String {
if propertyName.range(of: #"^[$A-Z_][0-9A-Z_$]*$"#, options: [.regularExpression, .caseInsensitive]) != nil
{
if ImportedJSModuleRegistry.isValidJSIdentifier(propertyName) {
return "\(objectExpr).\(propertyName)"
}
let escapedName = BridgeJSLink.escapeForJavaScriptStringLiteral(propertyName)
Expand Down Expand Up @@ -3469,12 +3480,13 @@ extension BridgeJSLink {
try thunkBuilder.liftParameter(param: param)
}
let jsName = function.jsName ?? function.name
let importRootExpr = try importedModuleRegistry.namespaceExpression(
let calleeExpr = try importedModuleRegistry.memberExpression(
swiftModuleName: importObjectBuilder.moduleName,
from: function.from
from: function.from,
memberName: jsName
)

try thunkBuilder.call(name: jsName, fromObjectExpr: importRootExpr)
try thunkBuilder.call(calleeExpr: calleeExpr)
let funcLines = thunkBuilder.renderFunction(name: function.abiName(context: nil))
if function.from == nil {
importObjectBuilder.appendDts(
Expand All @@ -3496,13 +3508,13 @@ extension BridgeJSLink {
intrinsicRegistry: intrinsicRegistry
)
let jsName = getter.jsName ?? getter.name
let importRootExpr = try importedModuleRegistry.namespaceExpression(
let accessExpr = try importedModuleRegistry.memberExpression(
swiftModuleName: importObjectBuilder.moduleName,
from: getter.from
from: getter.from,
memberName: jsName
)
try thunkBuilder.getImportProperty(
name: jsName,
fromObjectExpr: importRootExpr,
accessExpr: accessExpr,
returnType: getter.type
)
let abiName = getter.abiName(context: nil)
Expand Down Expand Up @@ -3602,14 +3614,14 @@ extension BridgeJSLink {
for param in constructor.parameters {
try thunkBuilder.liftParameter(param: param)
}
let importRootExpr = try importedModuleRegistry.namespaceExpression(
let ctorExpr = try importedModuleRegistry.memberExpression(
swiftModuleName: importObjectBuilder.moduleName,
from: type.from
from: type.from,
memberName: type.jsName ?? type.name
)
try thunkBuilder.callConstructor(
jsName: type.jsName ?? type.name,
swiftTypeName: type.name,
fromObjectExpr: importRootExpr
ctorExpr: ctorExpr,
swiftTypeName: type.name
)
let abiName = constructor.abiName(context: type)
let funcLines = thunkBuilder.renderFunction(name: abiName)
Expand Down Expand Up @@ -3661,13 +3673,10 @@ extension BridgeJSLink {
for param in method.parameters {
try thunkBuilder.liftParameter(param: param)
}
let importRootExpr = try importedModuleRegistry.namespaceExpression(
let constructorExpr = try importedModuleRegistry.memberExpression(
swiftModuleName: swiftModuleName,
from: context.from
)
let constructorExpr = ImportedThunkBuilder.propertyAccessExpr(
objectExpr: importRootExpr,
propertyName: context.jsName ?? context.name
from: context.from,
memberName: context.jsName ?? context.name
)

try thunkBuilder.callStaticMethod(on: constructorExpr, name: method.jsName ?? method.name)
Expand Down
221 changes: 196 additions & 25 deletions Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,24 @@
import BridgeJSSkeleton
#endif

import Foundation

final class ImportedJSModuleRegistry {
struct Reference: Hashable {
/// A JavaScript module that imported declarations are read from.
///
/// A `snippet` reference is a file inside a Swift target, which packaging copies
/// into the generated output. A `module` reference is a bare specifier resolved by
/// the JavaScript host (a bundler, an import map, or Node's `node_modules` lookup),
/// so it has no file and nothing to copy. Because a bare specifier names the same
/// module no matter which Swift module mentions it — and ECMAScript caches module
/// instances — it is keyed by specifier alone and shared across targets.
enum Reference: Hashable {
case snippet(swiftModuleName: String, path: String)
case module(specifier: String)
}

/// A JavaScript file shipped in a Swift target that packaging must copy into the output.
struct SnippetFile: Hashable {
let swiftModuleName: String
let path: String

Expand All @@ -12,56 +28,211 @@ final class ImportedJSModuleRegistry {
}
}

private var aliases: [Reference: String] = [:]
private struct Binding {
let index: Int
/// Member names looked up on this module, sorted for stable output.
let members: [String]
/// Whether every member name is a valid JavaScript identifier, and so can be
/// reached with a named import instead of a namespace property lookup.
let usesNamedImports: Bool
}

private var bindings: [Reference: Binding] = [:]
private(set) var references: [Reference] = []

/// The snippet files packaging must copy, in deterministic order.
var snippetFiles: [SnippetFile] {
references.compactMap { reference in
guard case .snippet(let swiftModuleName, let path) = reference else { return nil }
return SnippetFile(swiftModuleName: swiftModuleName, path: path)
}
}

func configure(skeletons: [BridgeJSSkeleton]) {
aliases.removeAll(keepingCapacity: true)
bindings.removeAll(keepingCapacity: true)
references = Self.collectReferences(skeletons: skeletons)

var membersByReference: [Reference: Set<String>] = [:]
for skeleton in skeletons {
Self.forEachMemberLookup(skeleton: skeleton) { reference, memberName in
membersByReference[reference, default: []].insert(memberName)
}
}

for (index, reference) in references.enumerated() {
aliases[reference] = "__bjs_imported_module_\(index)"
let members = (membersByReference[reference] ?? []).sorted()
// A reference with no member lookups keeps the namespace form: a named import
// is a hard link-time requirement, so importing a name nothing references
// would fail the whole module load if the module does not export it.
bindings[reference] = Binding(
index: index,
members: members,
usesNamedImports: !members.isEmpty && members.allSatisfy(Self.isValidJSIdentifier)
)
}
}

static func collectReferences(skeletons: [BridgeJSSkeleton]) -> [Reference] {
var references = Set<Reference>()
for skeleton in skeletons {
for file in skeleton.imported?.children ?? [] {
let origins =
file.functions.compactMap(\.from)
+ file.globalGetters.compactMap(\.from)
+ file.types.compactMap(\.from)
for case .module(let path) in origins {
references.insert(Reference(swiftModuleName: skeleton.moduleName, path: path))
}
forEachOrigin(skeleton: skeleton) { reference in
references.insert(reference)
}
}
return references.sorted {
($0.swiftModuleName, $0.path) < ($1.swiftModuleName, $1.path)
return references.sorted(by: isOrderedBefore)
}

static func collectSnippetFiles(skeletons: [BridgeJSSkeleton]) -> [SnippetFile] {
collectReferences(skeletons: skeletons).compactMap { reference in
guard case .snippet(let swiftModuleName, let path) = reference else { return nil }
return SnippetFile(swiftModuleName: swiftModuleName, path: path)
}
}

func namespaceExpression(swiftModuleName: String, from: JSImportFrom?) throws -> String {
/// Visits every module origin mentioned by the skeleton, whether or not code
/// generation looks a member up on it.
///
/// This is what decides which modules are imported at all, and for snippets which
/// files packaging copies. It stays broader than `forEachMemberLookup` so
/// that a module mentioned only by a wrapper-only `@JSClass` is still imported,
/// preserving its side effects.
private static func forEachOrigin(
skeleton: BridgeJSSkeleton,
_ body: (Reference) -> Void
) {
func visit(from: JSImportFrom?) {
guard let reference = Self.reference(swiftModuleName: skeleton.moduleName, from: from) else { return }
body(reference)
}
for file in skeleton.imported?.children ?? [] {
for function in file.functions { visit(from: function.from) }
for getter in file.globalGetters { visit(from: getter.from) }
for type in file.types { visit(from: type.from) }
}
}

/// Visits every module member lookup that code generation will emit.
///
/// The member name taken here must match what the corresponding emitter in
/// `BridgeJSLink` looks up, and must not include names it never emits: a named
/// import is a hard link-time requirement, so recording a member that no
/// generated code references would make the module fail to load whenever the
/// module does not happen to export that name.
///
/// A class contributes a single binding that serves both its constructor and its
/// static methods, and only when it has one of those. Instance methods, getters,
/// and setters contribute nothing because they go through an already-constructed
/// instance, so a wrapper-only `@JSClass` needs no export from the module at all.
private static func forEachMemberLookup(
skeleton: BridgeJSSkeleton,
_ body: (Reference, String) -> Void
) {
func visit(from: JSImportFrom?, memberName: String) {
guard let reference = Self.reference(swiftModuleName: skeleton.moduleName, from: from) else { return }
body(reference, memberName)
}
for file in skeleton.imported?.children ?? [] {
for function in file.functions {
visit(from: function.from, memberName: function.jsName ?? function.name)
}
for getter in file.globalGetters {
visit(from: getter.from, memberName: getter.jsName ?? getter.name)
}
for type in file.types {
guard type.constructor != nil || !type.staticMethods.isEmpty else { continue }
visit(from: type.from, memberName: type.jsName ?? type.name)
}
}
}

private static func reference(swiftModuleName: String, from: JSImportFrom?) -> Reference? {
switch from {
case .snippet(let path):
return .snippet(swiftModuleName: swiftModuleName, path: path)
case .module(let specifier):
return .module(specifier: specifier)
case .global, nil:
return nil
}
}

private static func isOrderedBefore(_ lhs: Reference, _ rhs: Reference) -> Bool {
switch (lhs, rhs) {
case (.snippet(let lhsModule, let lhsPath), .snippet(let rhsModule, let rhsPath)):
return (lhsModule, lhsPath) < (rhsModule, rhsPath)
case (.module(let lhsSpecifier), .module(let rhsSpecifier)):
return lhsSpecifier < rhsSpecifier
case (.snippet, .module):
return true
case (.module, .snippet):
return false
}
}

/// Whether `name` can appear as a bare identifier in generated JavaScript.
static func isValidJSIdentifier(_ name: String) -> Bool {
name.range(of: #"^[$A-Z_][0-9A-Z_$]*$"#, options: [.regularExpression, .caseInsensitive]) != nil
}

/// Returns the JavaScript expression that evaluates to `memberName` of the given origin.
func memberExpression(
swiftModuleName: String,
from: JSImportFrom?,
memberName: String
) throws -> String {
switch from {
case nil:
return "imports"
return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr(objectExpr: "imports", propertyName: memberName)
case .global:
return "globalThis"
case .module(let path):
let reference = Reference(swiftModuleName: swiftModuleName, path: path)
guard let alias = aliases[reference] else {
return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr(
objectExpr: "globalThis",
propertyName: memberName
)
case .snippet, .module:
guard let reference = Self.reference(swiftModuleName: swiftModuleName, from: from),
let binding = bindings[reference]
else {
throw BridgeJSLinkError(
message: "Missing JavaScript module \(swiftModuleName)\(path)"
message:
"Missing JavaScript module \(swiftModuleName)\(from?.snippetPath ?? from?.moduleSpecifier ?? "")"
)
}
return alias
if binding.usesNamedImports {
return Self.namedImportBinding(index: binding.index, memberName: memberName)
}
return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr(
objectExpr: Self.namespaceAlias(index: binding.index),
propertyName: memberName
)
}
}

private static func namespaceAlias(index: Int) -> String {
"__bjs_imported_module_\(index)"
}

private static func namedImportBinding(index: Int, memberName: String) -> String {
"__bjs_import_\(index)_\(memberName)"
}

var importLines: [String] {
references.enumerated().map { index, reference in
let path = BridgeJSLink.escapeForJavaScriptStringLiteral(reference.relativeOutputPath)
return "import * as __bjs_imported_module_\(index) from \"./\(path)\";"
references.compactMap { reference in
guard let binding = bindings[reference] else { return nil }
let specifier: String
switch reference {
case .snippet(let swiftModuleName, let path):
let output = SnippetFile(swiftModuleName: swiftModuleName, path: path).relativeOutputPath
specifier = "./" + BridgeJSLink.escapeForJavaScriptStringLiteral(output)
case .module(let moduleSpecifier):
specifier = BridgeJSLink.escapeForJavaScriptStringLiteral(moduleSpecifier)
}
guard binding.usesNamedImports else {
return "import * as \(Self.namespaceAlias(index: binding.index)) from \"\(specifier)\";"
}
let clauses = binding.members.map {
"\($0) as \(Self.namedImportBinding(index: binding.index, memberName: $0))"
}
return "import { \(clauses.joined(separator: ", ")) } from \"\(specifier)\";"
}
}
}
Loading
Loading