Skip to content

Commit e53155d

Browse files
BridgeJS: import from external ECMAScript modules, and split snippet origins (#795)
* BridgeJS: support importing from external ECMAScript modules Extend `from: .module(...)` to accept bare specifiers like `node:path` or an npm package, add `jsName: .default` for default exports, and emit named imports so a wrong export name now fails at module-link time instead of at call time. * BridgeJS: fix named-import regressions found in review Do not require a module export for a wrapper-only `@JSClass`, since a named import is a link-time requirement and nothing looks that name up; accept `jsName: nil` and the explicit `.name(...)` spelling; and validate the tagged `from` form like the plain-string form. * BridgeJS: split snippet and external module import origins Use `from: .snippet("/my-file.js")` for a JavaScript file shipped with the Swift target and `from: .module("node:path")` for an external module, so each keeps its own validation and each mistaken form points at the other. The skeleton encoding is unchanged. * BridgeJS: tag both snippet and module origins in the skeleton Encode `.snippet` as `{"kind":"snippet","path":...}` alongside the existing tagged module form, so the JSON mirrors the Swift cases and a snippet path can no longer encode into a shape that fails to decode.
1 parent 5ce3050 commit e53155d

34 files changed

Lines changed: 2817 additions & 164 deletions

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 198 additions & 31 deletions
Large diffs are not rendered by default.

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2515,7 +2515,13 @@ extension BridgeJSLink {
25152515
}
25162516

25172517
func callConstructor(jsName: String, swiftTypeName: String, fromObjectExpr: String) throws {
2518-
let ctorExpr = Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: jsName)
2518+
try callConstructor(
2519+
ctorExpr: Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: jsName),
2520+
swiftTypeName: swiftTypeName
2521+
)
2522+
}
2523+
2524+
func callConstructor(ctorExpr: String, swiftTypeName: String) throws {
25192525
let call = "new \(ctorExpr)(\(parameterForwardings.joined(separator: ", ")))"
25202526
let type: BridgeType = .jsObject(swiftTypeName)
25212527
let loweringFragment = try IntrinsicJSFragment.lowerReturn(type: type, context: context)
@@ -2580,12 +2586,18 @@ extension BridgeJSLink {
25802586
}
25812587

25822588
func getImportProperty(name: String, fromObjectExpr: String, returnType: BridgeType) throws {
2589+
try getImportProperty(
2590+
accessExpr: Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: name),
2591+
returnType: returnType
2592+
)
2593+
}
2594+
2595+
func getImportProperty(accessExpr expr: String, returnType: BridgeType) throws {
25832596
if returnType == .void {
25842597
throw BridgeJSLinkError(message: "Void is not supported for imported JS properties")
25852598
}
25862599

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

25902602
let returnExpr: String?
25912603
if loweringFragment.parameters.count == 0 {
@@ -2623,8 +2635,7 @@ extension BridgeJSLink {
26232635
}
26242636

26252637
static func propertyAccessExpr(objectExpr: String, propertyName: String) -> String {
2626-
if propertyName.range(of: #"^[$A-Z_][0-9A-Z_$]*$"#, options: [.regularExpression, .caseInsensitive]) != nil
2627-
{
2638+
if ImportedJSModuleRegistry.isValidJSIdentifier(propertyName) {
26282639
return "\(objectExpr).\(propertyName)"
26292640
}
26302641
let escapedName = BridgeJSLink.escapeForJavaScriptStringLiteral(propertyName)
@@ -3469,12 +3480,13 @@ extension BridgeJSLink {
34693480
try thunkBuilder.liftParameter(param: param)
34703481
}
34713482
let jsName = function.jsName ?? function.name
3472-
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3483+
let calleeExpr = try importedModuleRegistry.memberExpression(
34733484
swiftModuleName: importObjectBuilder.moduleName,
3474-
from: function.from
3485+
from: function.from,
3486+
memberName: jsName
34753487
)
34763488

3477-
try thunkBuilder.call(name: jsName, fromObjectExpr: importRootExpr)
3489+
try thunkBuilder.call(calleeExpr: calleeExpr)
34783490
let funcLines = thunkBuilder.renderFunction(name: function.abiName(context: nil))
34793491
if function.from == nil {
34803492
importObjectBuilder.appendDts(
@@ -3496,13 +3508,13 @@ extension BridgeJSLink {
34963508
intrinsicRegistry: intrinsicRegistry
34973509
)
34983510
let jsName = getter.jsName ?? getter.name
3499-
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3511+
let accessExpr = try importedModuleRegistry.memberExpression(
35003512
swiftModuleName: importObjectBuilder.moduleName,
3501-
from: getter.from
3513+
from: getter.from,
3514+
memberName: jsName
35023515
)
35033516
try thunkBuilder.getImportProperty(
3504-
name: jsName,
3505-
fromObjectExpr: importRootExpr,
3517+
accessExpr: accessExpr,
35063518
returnType: getter.type
35073519
)
35083520
let abiName = getter.abiName(context: nil)
@@ -3602,14 +3614,14 @@ extension BridgeJSLink {
36023614
for param in constructor.parameters {
36033615
try thunkBuilder.liftParameter(param: param)
36043616
}
3605-
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3617+
let ctorExpr = try importedModuleRegistry.memberExpression(
36063618
swiftModuleName: importObjectBuilder.moduleName,
3607-
from: type.from
3619+
from: type.from,
3620+
memberName: type.jsName ?? type.name
36083621
)
36093622
try thunkBuilder.callConstructor(
3610-
jsName: type.jsName ?? type.name,
3611-
swiftTypeName: type.name,
3612-
fromObjectExpr: importRootExpr
3623+
ctorExpr: ctorExpr,
3624+
swiftTypeName: type.name
36133625
)
36143626
let abiName = constructor.abiName(context: type)
36153627
let funcLines = thunkBuilder.renderFunction(name: abiName)
@@ -3661,13 +3673,10 @@ extension BridgeJSLink {
36613673
for param in method.parameters {
36623674
try thunkBuilder.liftParameter(param: param)
36633675
}
3664-
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3676+
let constructorExpr = try importedModuleRegistry.memberExpression(
36653677
swiftModuleName: swiftModuleName,
3666-
from: context.from
3667-
)
3668-
let constructorExpr = ImportedThunkBuilder.propertyAccessExpr(
3669-
objectExpr: importRootExpr,
3670-
propertyName: context.jsName ?? context.name
3678+
from: context.from,
3679+
memberName: context.jsName ?? context.name
36713680
)
36723681

36733682
try thunkBuilder.callStaticMethod(on: constructorExpr, name: method.jsName ?? method.name)

Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift

Lines changed: 196 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,24 @@
22
import BridgeJSSkeleton
33
#endif
44

5+
import Foundation
6+
57
final class ImportedJSModuleRegistry {
6-
struct Reference: Hashable {
8+
/// A JavaScript module that imported declarations are read from.
9+
///
10+
/// A `snippet` reference is a file inside a Swift target, which packaging copies
11+
/// into the generated output. A `module` reference is a bare specifier resolved by
12+
/// the JavaScript host (a bundler, an import map, or Node's `node_modules` lookup),
13+
/// so it has no file and nothing to copy. Because a bare specifier names the same
14+
/// module no matter which Swift module mentions it — and ECMAScript caches module
15+
/// instances — it is keyed by specifier alone and shared across targets.
16+
enum Reference: Hashable {
17+
case snippet(swiftModuleName: String, path: String)
18+
case module(specifier: String)
19+
}
20+
21+
/// A JavaScript file shipped in a Swift target that packaging must copy into the output.
22+
struct SnippetFile: Hashable {
723
let swiftModuleName: String
824
let path: String
925

@@ -12,56 +28,211 @@ final class ImportedJSModuleRegistry {
1228
}
1329
}
1430

15-
private var aliases: [Reference: String] = [:]
31+
private struct Binding {
32+
let index: Int
33+
/// Member names looked up on this module, sorted for stable output.
34+
let members: [String]
35+
/// Whether every member name is a valid JavaScript identifier, and so can be
36+
/// reached with a named import instead of a namespace property lookup.
37+
let usesNamedImports: Bool
38+
}
39+
40+
private var bindings: [Reference: Binding] = [:]
1641
private(set) var references: [Reference] = []
1742

43+
/// The snippet files packaging must copy, in deterministic order.
44+
var snippetFiles: [SnippetFile] {
45+
references.compactMap { reference in
46+
guard case .snippet(let swiftModuleName, let path) = reference else { return nil }
47+
return SnippetFile(swiftModuleName: swiftModuleName, path: path)
48+
}
49+
}
50+
1851
func configure(skeletons: [BridgeJSSkeleton]) {
19-
aliases.removeAll(keepingCapacity: true)
52+
bindings.removeAll(keepingCapacity: true)
2053
references = Self.collectReferences(skeletons: skeletons)
54+
55+
var membersByReference: [Reference: Set<String>] = [:]
56+
for skeleton in skeletons {
57+
Self.forEachMemberLookup(skeleton: skeleton) { reference, memberName in
58+
membersByReference[reference, default: []].insert(memberName)
59+
}
60+
}
61+
2162
for (index, reference) in references.enumerated() {
22-
aliases[reference] = "__bjs_imported_module_\(index)"
63+
let members = (membersByReference[reference] ?? []).sorted()
64+
// A reference with no member lookups keeps the namespace form: a named import
65+
// is a hard link-time requirement, so importing a name nothing references
66+
// would fail the whole module load if the module does not export it.
67+
bindings[reference] = Binding(
68+
index: index,
69+
members: members,
70+
usesNamedImports: !members.isEmpty && members.allSatisfy(Self.isValidJSIdentifier)
71+
)
2372
}
2473
}
2574

2675
static func collectReferences(skeletons: [BridgeJSSkeleton]) -> [Reference] {
2776
var references = Set<Reference>()
2877
for skeleton in skeletons {
29-
for file in skeleton.imported?.children ?? [] {
30-
let origins =
31-
file.functions.compactMap(\.from)
32-
+ file.globalGetters.compactMap(\.from)
33-
+ file.types.compactMap(\.from)
34-
for case .module(let path) in origins {
35-
references.insert(Reference(swiftModuleName: skeleton.moduleName, path: path))
36-
}
78+
forEachOrigin(skeleton: skeleton) { reference in
79+
references.insert(reference)
3780
}
3881
}
39-
return references.sorted {
40-
($0.swiftModuleName, $0.path) < ($1.swiftModuleName, $1.path)
82+
return references.sorted(by: isOrderedBefore)
83+
}
84+
85+
static func collectSnippetFiles(skeletons: [BridgeJSSkeleton]) -> [SnippetFile] {
86+
collectReferences(skeletons: skeletons).compactMap { reference in
87+
guard case .snippet(let swiftModuleName, let path) = reference else { return nil }
88+
return SnippetFile(swiftModuleName: swiftModuleName, path: path)
4189
}
4290
}
4391

44-
func namespaceExpression(swiftModuleName: String, from: JSImportFrom?) throws -> String {
92+
/// Visits every module origin mentioned by the skeleton, whether or not code
93+
/// generation looks a member up on it.
94+
///
95+
/// This is what decides which modules are imported at all, and for snippets which
96+
/// files packaging copies. It stays broader than `forEachMemberLookup` so
97+
/// that a module mentioned only by a wrapper-only `@JSClass` is still imported,
98+
/// preserving its side effects.
99+
private static func forEachOrigin(
100+
skeleton: BridgeJSSkeleton,
101+
_ body: (Reference) -> Void
102+
) {
103+
func visit(from: JSImportFrom?) {
104+
guard let reference = Self.reference(swiftModuleName: skeleton.moduleName, from: from) else { return }
105+
body(reference)
106+
}
107+
for file in skeleton.imported?.children ?? [] {
108+
for function in file.functions { visit(from: function.from) }
109+
for getter in file.globalGetters { visit(from: getter.from) }
110+
for type in file.types { visit(from: type.from) }
111+
}
112+
}
113+
114+
/// Visits every module member lookup that code generation will emit.
115+
///
116+
/// The member name taken here must match what the corresponding emitter in
117+
/// `BridgeJSLink` looks up, and must not include names it never emits: a named
118+
/// import is a hard link-time requirement, so recording a member that no
119+
/// generated code references would make the module fail to load whenever the
120+
/// module does not happen to export that name.
121+
///
122+
/// A class contributes a single binding that serves both its constructor and its
123+
/// static methods, and only when it has one of those. Instance methods, getters,
124+
/// and setters contribute nothing because they go through an already-constructed
125+
/// instance, so a wrapper-only `@JSClass` needs no export from the module at all.
126+
private static func forEachMemberLookup(
127+
skeleton: BridgeJSSkeleton,
128+
_ body: (Reference, String) -> Void
129+
) {
130+
func visit(from: JSImportFrom?, memberName: String) {
131+
guard let reference = Self.reference(swiftModuleName: skeleton.moduleName, from: from) else { return }
132+
body(reference, memberName)
133+
}
134+
for file in skeleton.imported?.children ?? [] {
135+
for function in file.functions {
136+
visit(from: function.from, memberName: function.jsName ?? function.name)
137+
}
138+
for getter in file.globalGetters {
139+
visit(from: getter.from, memberName: getter.jsName ?? getter.name)
140+
}
141+
for type in file.types {
142+
guard type.constructor != nil || !type.staticMethods.isEmpty else { continue }
143+
visit(from: type.from, memberName: type.jsName ?? type.name)
144+
}
145+
}
146+
}
147+
148+
private static func reference(swiftModuleName: String, from: JSImportFrom?) -> Reference? {
149+
switch from {
150+
case .snippet(let path):
151+
return .snippet(swiftModuleName: swiftModuleName, path: path)
152+
case .module(let specifier):
153+
return .module(specifier: specifier)
154+
case .global, nil:
155+
return nil
156+
}
157+
}
158+
159+
private static func isOrderedBefore(_ lhs: Reference, _ rhs: Reference) -> Bool {
160+
switch (lhs, rhs) {
161+
case (.snippet(let lhsModule, let lhsPath), .snippet(let rhsModule, let rhsPath)):
162+
return (lhsModule, lhsPath) < (rhsModule, rhsPath)
163+
case (.module(let lhsSpecifier), .module(let rhsSpecifier)):
164+
return lhsSpecifier < rhsSpecifier
165+
case (.snippet, .module):
166+
return true
167+
case (.module, .snippet):
168+
return false
169+
}
170+
}
171+
172+
/// Whether `name` can appear as a bare identifier in generated JavaScript.
173+
static func isValidJSIdentifier(_ name: String) -> Bool {
174+
name.range(of: #"^[$A-Z_][0-9A-Z_$]*$"#, options: [.regularExpression, .caseInsensitive]) != nil
175+
}
176+
177+
/// Returns the JavaScript expression that evaluates to `memberName` of the given origin.
178+
func memberExpression(
179+
swiftModuleName: String,
180+
from: JSImportFrom?,
181+
memberName: String
182+
) throws -> String {
45183
switch from {
46184
case nil:
47-
return "imports"
185+
return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr(objectExpr: "imports", propertyName: memberName)
48186
case .global:
49-
return "globalThis"
50-
case .module(let path):
51-
let reference = Reference(swiftModuleName: swiftModuleName, path: path)
52-
guard let alias = aliases[reference] else {
187+
return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr(
188+
objectExpr: "globalThis",
189+
propertyName: memberName
190+
)
191+
case .snippet, .module:
192+
guard let reference = Self.reference(swiftModuleName: swiftModuleName, from: from),
193+
let binding = bindings[reference]
194+
else {
53195
throw BridgeJSLinkError(
54-
message: "Missing JavaScript module \(swiftModuleName)\(path)"
196+
message:
197+
"Missing JavaScript module \(swiftModuleName)\(from?.snippetPath ?? from?.moduleSpecifier ?? "")"
55198
)
56199
}
57-
return alias
200+
if binding.usesNamedImports {
201+
return Self.namedImportBinding(index: binding.index, memberName: memberName)
202+
}
203+
return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr(
204+
objectExpr: Self.namespaceAlias(index: binding.index),
205+
propertyName: memberName
206+
)
58207
}
59208
}
60209

210+
private static func namespaceAlias(index: Int) -> String {
211+
"__bjs_imported_module_\(index)"
212+
}
213+
214+
private static func namedImportBinding(index: Int, memberName: String) -> String {
215+
"__bjs_import_\(index)_\(memberName)"
216+
}
217+
61218
var importLines: [String] {
62-
references.enumerated().map { index, reference in
63-
let path = BridgeJSLink.escapeForJavaScriptStringLiteral(reference.relativeOutputPath)
64-
return "import * as __bjs_imported_module_\(index) from \"./\(path)\";"
219+
references.compactMap { reference in
220+
guard let binding = bindings[reference] else { return nil }
221+
let specifier: String
222+
switch reference {
223+
case .snippet(let swiftModuleName, let path):
224+
let output = SnippetFile(swiftModuleName: swiftModuleName, path: path).relativeOutputPath
225+
specifier = "./" + BridgeJSLink.escapeForJavaScriptStringLiteral(output)
226+
case .module(let moduleSpecifier):
227+
specifier = BridgeJSLink.escapeForJavaScriptStringLiteral(moduleSpecifier)
228+
}
229+
guard binding.usesNamedImports else {
230+
return "import * as \(Self.namespaceAlias(index: binding.index)) from \"\(specifier)\";"
231+
}
232+
let clauses = binding.members.map {
233+
"\($0) as \(Self.namedImportBinding(index: binding.index, memberName: $0))"
234+
}
235+
return "import { \(clauses.joined(separator: ", ")) } from \"\(specifier)\";"
65236
}
66237
}
67238
}

0 commit comments

Comments
 (0)