Skip to content

Commit 7d83dec

Browse files
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.
1 parent 5ce3050 commit 7d83dec

30 files changed

Lines changed: 2462 additions & 130 deletions

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 143 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -98,22 +98,16 @@ public final class SwiftToSkeleton {
9898
importCollector.importedFunctions.compactMap(\.from)
9999
+ importCollector.importedTypes.compactMap(\.from)
100100
+ importCollector.importedGlobalGetters.compactMap(\.from)
101-
let modulePaths = Set(importOrigins.compactMap(\.modulePath))
101+
// Only target-local module paths are validated here. Bare specifiers are
102+
// resolved by the JavaScript host (a bundler, an import map, or Node's
103+
// `node_modules` lookup), so there is nothing we can check without
104+
// rejecting setups that legitimately work.
105+
let modulePaths = Set(importOrigins.compactMap(\.localModulePath))
102106
for path in modulePaths.sorted() {
103107
if validatedJavaScriptModulePaths.contains(path) {
104108
continue
105109
}
106110
let pathNode = importCollector.importedModulePathNodes[path] ?? Syntax(sourceFile)
107-
guard path.hasPrefix("/") else {
108-
importCollector.errors.append(
109-
DiagnosticError(
110-
node: pathNode,
111-
message: "JavaScript module paths must start with '/' to indicate the Swift target root: "
112-
+ "'\(path)'."
113-
)
114-
)
115-
continue
116-
}
117111
guard !path.split(separator: "/").contains("..") else {
118112
importCollector.errors.append(
119113
DiagnosticError(
@@ -2548,21 +2542,101 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
25482542
}
25492543
}
25502544

2551-
/// Extracts the `jsName` argument value from an attribute, if present.
2552-
static func extractJSName(from attribute: AttributeSyntax) -> String? {
2553-
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
2554-
return nil
2555-
}
2556-
for argument in arguments {
2557-
if argument.label?.text == "jsName",
2558-
let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self),
2559-
let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self)
2560-
{
2561-
return segment.content.text
2562-
}
2563-
}
2545+
}
2546+
2547+
/// The result of reading a `jsName:` argument.
2548+
struct ExtractedJSName {
2549+
/// The JavaScript member name to look up.
2550+
///
2551+
/// `.default` normalizes to `"default"`: in ECMAScript a module's default
2552+
/// export *is* its `default` named export, so no separate representation
2553+
/// is needed downstream.
2554+
let memberName: String
2555+
/// True when the source spelled `.default` rather than a string literal.
2556+
let isDefaultExportSpelling: Bool
2557+
}
2558+
2559+
/// Extracts the `jsName` argument value from an attribute, if present.
2560+
private func extractJSName(from attribute: AttributeSyntax) -> ExtractedJSName? {
2561+
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self),
2562+
let argument = arguments.first(where: { $0.label?.text == "jsName" })
2563+
else {
25642564
return nil
25652565
}
2566+
2567+
if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self),
2568+
let value = stringLiteral.representedLiteralValue
2569+
{
2570+
return ExtractedJSName(memberName: value, isDefaultExportSpelling: false)
2571+
}
2572+
2573+
// Accept `.default`, `JSName.default`, and the backticked spellings.
2574+
let description = argument.expression.trimmedDescription
2575+
let caseName = description.split(separator: ".").last.map(String.init) ?? description
2576+
if caseName == "default" || caseName == "`default`" {
2577+
return ExtractedJSName(memberName: "default", isDefaultExportSpelling: true)
2578+
}
2579+
2580+
errors.append(
2581+
DiagnosticError(
2582+
node: argument.expression,
2583+
message: "jsName must be a string literal or '.default'."
2584+
)
2585+
)
2586+
return nil
2587+
}
2588+
2589+
/// Validates that a `jsName: .default` spelling appears somewhere it can mean something.
2590+
///
2591+
/// `.default` names the default export of an ECMAScript module, so it only makes
2592+
/// sense on a top-level declaration that has a `from: .module(...)` origin.
2593+
private func validateDefaultExportUsage(
2594+
_ extracted: ExtractedJSName?,
2595+
from: JSImportFrom?,
2596+
node: some SyntaxProtocol,
2597+
isSetter: Bool = false
2598+
) {
2599+
guard let extracted, extracted.isDefaultExportSpelling else { return }
2600+
2601+
if isSetter {
2602+
errors.append(
2603+
DiagnosticError(
2604+
node: node,
2605+
message: "'jsName: .default' is not supported on @JSSetter; "
2606+
+ "ECMAScript module bindings are read-only."
2607+
)
2608+
)
2609+
return
2610+
}
2611+
if case .jsClassBody = state {
2612+
errors.append(
2613+
DiagnosticError(
2614+
node: node,
2615+
message: "'jsName: .default' is not supported on a class member; "
2616+
+ "members have no module origin. Did you mean jsName: \"default\"?"
2617+
)
2618+
)
2619+
return
2620+
}
2621+
switch from {
2622+
case .module:
2623+
return
2624+
case .global:
2625+
errors.append(
2626+
DiagnosticError(
2627+
node: node,
2628+
message: "'jsName: .default' requires 'from: .module(...)'; "
2629+
+ "globalThis has no default export."
2630+
)
2631+
)
2632+
case nil:
2633+
errors.append(
2634+
DiagnosticError(
2635+
node: node,
2636+
message: "'jsName: .default' requires 'from: .module(...)'."
2637+
)
2638+
)
2639+
}
25662640
}
25672641

25682642
private func extractJSImportFrom(from attribute: AttributeSyntax) -> JSImportFrom? {
@@ -2588,6 +2662,26 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
25882662
)
25892663
return nil
25902664
}
2665+
guard !path.isEmpty else {
2666+
errors.append(
2667+
DiagnosticError(
2668+
node: literal,
2669+
message: "JavaScript module specifier must not be empty."
2670+
)
2671+
)
2672+
return nil
2673+
}
2674+
guard !path.hasPrefix("./"), !path.hasPrefix("../"), path != ".", path != ".." else {
2675+
errors.append(
2676+
DiagnosticError(
2677+
node: literal,
2678+
message: "Relative JavaScript module specifiers are not supported: '\(path)'. "
2679+
+ "Use a '/'-prefixed path for a file in this target (e.g. '/Modules/utils.mjs'), "
2680+
+ "or a bare specifier for an external module (e.g. 'node:path')."
2681+
)
2682+
)
2683+
return nil
2684+
}
25912685
if importedModulePathNodes[path] == nil {
25922686
importedModulePathNodes[path] = Syntax(literal)
25932687
}
@@ -2645,7 +2739,9 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
26452739
return nil
26462740
}
26472741

2648-
let jsName = AttributeChecker.extractJSName(from: jsSetter)
2742+
let extractedJSName = extractJSName(from: jsSetter)
2743+
validateDefaultExportUsage(extractedJSName, from: nil, node: node, isSetter: true)
2744+
let jsName = extractedJSName?.memberName
26492745
let parameters = node.signature.parameterClause.parameters
26502746

26512747
guard let firstParam = parameters.first else {
@@ -2779,10 +2875,16 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
27792875
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
27802876
if AttributeChecker.hasJSClassAttribute(node.attributes) {
27812877
let attribute = AttributeChecker.firstJSClassAttribute(node.attributes)
2782-
let jsName = attribute.flatMap(AttributeChecker.extractJSName)
2878+
let extractedJSName = attribute.flatMap { extractJSName(from: $0) }
27832879
let from = attribute.flatMap { extractJSImportFrom(from: $0) }
2880+
validateDefaultExportUsage(extractedJSName, from: from, node: node)
27842881
let accessLevel = Self.bridgeAccessLevel(from: node.modifiers)
2785-
enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel)
2882+
enterJSClass(
2883+
node.name.text,
2884+
jsName: extractedJSName?.memberName,
2885+
from: from,
2886+
accessLevel: accessLevel
2887+
)
27862888
}
27872889
return .visitChildren
27882890
}
@@ -2796,10 +2898,16 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
27962898
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
27972899
if AttributeChecker.hasJSClassAttribute(node.attributes) {
27982900
let attribute = AttributeChecker.firstJSClassAttribute(node.attributes)
2799-
let jsName = attribute.flatMap(AttributeChecker.extractJSName)
2901+
let extractedJSName = attribute.flatMap { extractJSName(from: $0) }
28002902
let from = attribute.flatMap { extractJSImportFrom(from: $0) }
2903+
validateDefaultExportUsage(extractedJSName, from: from, node: node)
28012904
let accessLevel = Self.bridgeAccessLevel(from: node.modifiers)
2802-
enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel)
2905+
enterJSClass(
2906+
node.name.text,
2907+
jsName: extractedJSName?.memberName,
2908+
from: from,
2909+
accessLevel: accessLevel
2910+
)
28032911
}
28042912
return .visitChildren
28052913
}
@@ -2990,8 +3098,10 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
29903098
}
29913099

29923100
let baseName = SwiftToSkeleton.normalizeIdentifier(node.name.text)
2993-
let jsName = AttributeChecker.extractJSName(from: jsFunction)
3101+
let extractedJSName = extractJSName(from: jsFunction)
29943102
let from = extractJSImportFrom(from: jsFunction)
3103+
validateDefaultExportUsage(extractedJSName, from: from, node: node)
3104+
let jsName = extractedJSName?.memberName
29953105
let name = baseName
29963106

29973107
let parameters = parseParameters(from: node.signature.parameterClause)
@@ -3043,12 +3153,13 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
30433153
return nil
30443154
}
30453155
let propertyName = SwiftToSkeleton.normalizeIdentifier(identifier.identifier.text)
3046-
let jsName = AttributeChecker.extractJSName(from: jsGetter)
3156+
let extractedJSName = extractJSName(from: jsGetter)
30473157
let from = extractJSImportFrom(from: jsGetter)
3158+
validateDefaultExportUsage(extractedJSName, from: from, node: node)
30483159
let accessLevel = Self.bridgeAccessLevel(from: node.modifiers)
30493160
return ImportedGetterSkeleton(
30503161
name: propertyName,
3051-
jsName: jsName,
3162+
jsName: extractedJSName?.memberName,
30523163
from: from,
30533164
type: propertyType,
30543165
documentation: nil,

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)

0 commit comments

Comments
 (0)