Skip to content

Commit 5ce3050

Browse files
authored
add JS-snippet support using from: .module("/path/to/js-module.js") syntax (#792)
1 parent b4b8027 commit 5ce3050

33 files changed

Lines changed: 1892 additions & 81 deletions

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ let package = Package(
198198
"bridge-js.global.d.ts",
199199
"Generated/JavaScript",
200200
"JavaScript",
201+
"Modules",
201202
],
202203
swiftSettings: [
203204
.enableExperimentalFeature("Extern")

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public final class SwiftToSkeleton {
2323

2424
private var sourceFiles: [(sourceFile: SourceFileSyntax, inputFilePath: String)] = []
2525
private var usedExternalModules = Set<String>()
26+
private let javaScriptModuleExists: (String) throws -> Bool
27+
private var validatedJavaScriptModulePaths = Set<String>()
2628

2729
/// Non-fatal diagnostics collected during `finalize()`. These do not fail the build.
2830
public private(set) var warnings: [(file: String, diagnostic: DiagnosticError)] = []
@@ -32,12 +34,14 @@ public final class SwiftToSkeleton {
3234
moduleName: String,
3335
exposeToGlobal: Bool,
3436
externalModuleIndex: ExternalModuleIndex,
35-
identityMode: String? = nil
37+
identityMode: String? = nil,
38+
javaScriptModuleExists: @escaping (String) throws -> Bool = { _ in false }
3639
) {
3740
self.progress = progress
3841
self.moduleName = moduleName
3942
self.exposeToGlobal = exposeToGlobal
4043
self.identityMode = identityMode
44+
self.javaScriptModuleExists = javaScriptModuleExists
4145
self.typeDeclResolver = TypeDeclResolver()
4246
self.externalModuleIndex = externalModuleIndex
4347

@@ -90,6 +94,57 @@ public final class SwiftToSkeleton {
9094
)
9195
importCollector.walk(sourceFile)
9296

97+
let importOrigins =
98+
importCollector.importedFunctions.compactMap(\.from)
99+
+ importCollector.importedTypes.compactMap(\.from)
100+
+ importCollector.importedGlobalGetters.compactMap(\.from)
101+
let modulePaths = Set(importOrigins.compactMap(\.modulePath))
102+
for path in modulePaths.sorted() {
103+
if validatedJavaScriptModulePaths.contains(path) {
104+
continue
105+
}
106+
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+
}
117+
guard !path.split(separator: "/").contains("..") else {
118+
importCollector.errors.append(
119+
DiagnosticError(
120+
node: pathNode,
121+
message: "JavaScript module paths must not contain '..': '\(path)'."
122+
)
123+
)
124+
continue
125+
}
126+
let lowercasedPath = path.lowercased()
127+
guard lowercasedPath.hasSuffix(".js") || lowercasedPath.hasSuffix(".mjs") else {
128+
importCollector.errors.append(
129+
DiagnosticError(
130+
node: pathNode,
131+
message: "JavaScript modules must use a '.js' or '.mjs' extension: '\(path)'."
132+
)
133+
)
134+
continue
135+
}
136+
guard try javaScriptModuleExists(path) else {
137+
importCollector.errors.append(
138+
DiagnosticError(
139+
node: pathNode,
140+
message: "JavaScript module file was not found at '\(path)'."
141+
)
142+
)
143+
continue
144+
}
145+
validatedJavaScriptModulePaths.insert(path)
146+
}
147+
93148
let exportErrors = exportCollector.errors.filter { $0.severity == .error }
94149
let importErrorsFatal = importCollector.errors.filter {
95150
$0.severity == .error && !$0.message.contains("Unsupported type '")
@@ -2413,6 +2468,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
24132468
var importedFunctions: [ImportedFunctionSkeleton] = []
24142469
var importedTypes: [ImportedTypeSkeleton] = []
24152470
var importedGlobalGetters: [ImportedGetterSkeleton] = []
2471+
var importedModulePathNodes: [String: Syntax] = [:]
24162472
var errors: [DiagnosticError] = []
24172473

24182474
private let inputFilePath: String
@@ -2507,22 +2563,41 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
25072563
}
25082564
return nil
25092565
}
2566+
}
2567+
2568+
private func extractJSImportFrom(from attribute: AttributeSyntax) -> JSImportFrom? {
2569+
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self),
2570+
let argument = arguments.first(where: { $0.label?.text == "from" })
2571+
else {
2572+
return nil
2573+
}
25102574

2511-
/// Extracts the `from` argument value from an attribute, if present.
2512-
static func extractJSImportFrom(from attribute: AttributeSyntax) -> JSImportFrom? {
2513-
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
2575+
if let call = argument.expression.as(FunctionCallExprSyntax.self),
2576+
call.calledExpression.trimmedDescription.split(separator: ".").last == "module"
2577+
{
2578+
guard call.arguments.count == 1,
2579+
let pathExpression = call.arguments.first?.expression,
2580+
let literal = pathExpression.as(StringLiteralExprSyntax.self),
2581+
let path = literal.representedLiteralValue
2582+
else {
2583+
errors.append(
2584+
DiagnosticError(
2585+
node: call.arguments.first?.expression ?? argument.expression,
2586+
message: "JavaScript module path must be a string literal."
2587+
)
2588+
)
25142589
return nil
25152590
}
2516-
for argument in arguments {
2517-
guard argument.label?.text == "from" else { continue }
2518-
2519-
// Accept `.global`, `JSImportFrom.global`, etc.
2520-
let description = argument.expression.trimmedDescription
2521-
let caseName = description.split(separator: ".").last.map(String.init) ?? description
2522-
return JSImportFrom(rawValue: caseName)
2591+
if importedModulePathNodes[path] == nil {
2592+
importedModulePathNodes[path] = Syntax(literal)
25232593
}
2524-
return nil
2594+
return .module(path)
25252595
}
2596+
2597+
// Accept `.global`, `JSImportFrom.global`, etc.
2598+
let description = argument.expression.trimmedDescription
2599+
let caseName = description.split(separator: ".").last.map(String.init) ?? description
2600+
return caseName == "global" ? .global : nil
25262601
}
25272602

25282603
// MARK: - Validation Helpers
@@ -2705,7 +2780,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
27052780
if AttributeChecker.hasJSClassAttribute(node.attributes) {
27062781
let attribute = AttributeChecker.firstJSClassAttribute(node.attributes)
27072782
let jsName = attribute.flatMap(AttributeChecker.extractJSName)
2708-
let from = attribute.flatMap(AttributeChecker.extractJSImportFrom)
2783+
let from = attribute.flatMap { extractJSImportFrom(from: $0) }
27092784
let accessLevel = Self.bridgeAccessLevel(from: node.modifiers)
27102785
enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel)
27112786
}
@@ -2722,7 +2797,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
27222797
if AttributeChecker.hasJSClassAttribute(node.attributes) {
27232798
let attribute = AttributeChecker.firstJSClassAttribute(node.attributes)
27242799
let jsName = attribute.flatMap(AttributeChecker.extractJSName)
2725-
let from = attribute.flatMap(AttributeChecker.extractJSImportFrom)
2800+
let from = attribute.flatMap { extractJSImportFrom(from: $0) }
27262801
let accessLevel = Self.bridgeAccessLevel(from: node.modifiers)
27272802
enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel)
27282803
}
@@ -2916,7 +2991,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
29162991

29172992
let baseName = SwiftToSkeleton.normalizeIdentifier(node.name.text)
29182993
let jsName = AttributeChecker.extractJSName(from: jsFunction)
2919-
let from = AttributeChecker.extractJSImportFrom(from: jsFunction)
2994+
let from = extractJSImportFrom(from: jsFunction)
29202995
let name = baseName
29212996

29222997
let parameters = parseParameters(from: node.signature.parameterClause)
@@ -2969,7 +3044,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
29693044
}
29703045
let propertyName = SwiftToSkeleton.normalizeIdentifier(identifier.identifier.text)
29713046
let jsName = AttributeChecker.extractJSName(from: jsGetter)
2972-
let from = AttributeChecker.extractJSImportFrom(from: jsGetter)
3047+
let from = extractJSImportFrom(from: jsGetter)
29733048
let accessLevel = Self.bridgeAccessLevel(from: node.modifiers)
29743049
return ImportedGetterSkeleton(
29753050
name: propertyName,

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public struct BridgeJSLink {
1616
let enableLifetimeTracking: Bool = false
1717
private let namespaceBuilder = NamespaceBuilder()
1818
private let intrinsicRegistry = JSIntrinsicRegistry()
19+
private let importedModuleRegistry = ImportedJSModuleRegistry()
1920

2021
public init(
2122
skeletons: [BridgeJSSkeleton] = [],
@@ -40,10 +41,12 @@ public struct BridgeJSLink {
4041
return configIdentityMode == "pointer"
4142
}
4243

43-
mutating func addSkeletonFile(data: Data) throws {
44+
@discardableResult
45+
mutating func addSkeletonFile(data: Data) throws -> BridgeJSSkeleton {
4446
do {
4547
let unified = try JSONDecoder().decode(BridgeJSSkeleton.self, from: data)
4648
skeletons.append(unified)
49+
return unified
4750
} catch {
4851
struct SkeletonDecodingError: Error, CustomStringConvertible {
4952
let description: String
@@ -1077,6 +1080,11 @@ public struct BridgeJSLink {
10771080
let printer = CodeFragmentPrinter(header: header)
10781081
printer.nextLine()
10791082

1083+
printer.write(lines: importedModuleRegistry.importLines)
1084+
if !importedModuleRegistry.importLines.isEmpty {
1085+
printer.nextLine()
1086+
}
1087+
10801088
printer.write(lines: data.topLevelTypeLines)
10811089

10821090
let exportedSkeletons = skeletons.compactMap(\.exported)
@@ -1222,6 +1230,7 @@ public struct BridgeJSLink {
12221230

12231231
public func link() throws -> (outputJs: String, outputDts: String) {
12241232
intrinsicRegistry.reset()
1233+
importedModuleRegistry.configure(skeletons: skeletons)
12251234
intrinsicRegistry.classNamespaces = skeletons.reduce(into: [:]) { result, unified in
12261235
guard let skeleton = unified.exported else { return }
12271236
for klass in skeleton.classes {
@@ -1586,7 +1595,7 @@ public struct BridgeJSLink {
15861595
return "\"\(Self.escapeForJavaScriptStringLiteral(name))\""
15871596
}
15881597

1589-
fileprivate static func escapeForJavaScriptStringLiteral(_ string: String) -> String {
1598+
static func escapeForJavaScriptStringLiteral(_ string: String) -> String {
15901599
string
15911600
.replacingOccurrences(of: "\\", with: "\\\\")
15921601
.replacingOccurrences(of: "\"", with: "\\\"")
@@ -3460,7 +3469,10 @@ extension BridgeJSLink {
34603469
try thunkBuilder.liftParameter(param: param)
34613470
}
34623471
let jsName = function.jsName ?? function.name
3463-
let importRootExpr = function.from == .global ? "globalThis" : "imports"
3472+
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3473+
swiftModuleName: importObjectBuilder.moduleName,
3474+
from: function.from
3475+
)
34643476

34653477
try thunkBuilder.call(name: jsName, fromObjectExpr: importRootExpr)
34663478
let funcLines = thunkBuilder.renderFunction(name: function.abiName(context: nil))
@@ -3484,7 +3496,10 @@ extension BridgeJSLink {
34843496
intrinsicRegistry: intrinsicRegistry
34853497
)
34863498
let jsName = getter.jsName ?? getter.name
3487-
let importRootExpr = getter.from == .global ? "globalThis" : "imports"
3499+
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3500+
swiftModuleName: importObjectBuilder.moduleName,
3501+
from: getter.from
3502+
)
34883503
try thunkBuilder.getImportProperty(
34893504
name: jsName,
34903505
fromObjectExpr: importRootExpr,
@@ -3539,7 +3554,11 @@ extension BridgeJSLink {
35393554
}
35403555
for method in type.staticMethods {
35413556
let abiName = method.abiName(context: type, operation: "static")
3542-
let (js, dts) = try renderImportedStaticMethod(context: type, method: method)
3557+
let (js, dts) = try renderImportedStaticMethod(
3558+
swiftModuleName: importObjectBuilder.moduleName,
3559+
context: type,
3560+
method: method
3561+
)
35433562
importObjectBuilder.assignToImportObject(name: abiName, function: js)
35443563
importObjectBuilder.appendDts(dts)
35453564
}
@@ -3583,7 +3602,10 @@ extension BridgeJSLink {
35833602
for param in constructor.parameters {
35843603
try thunkBuilder.liftParameter(param: param)
35853604
}
3586-
let importRootExpr = type.from == .global ? "globalThis" : "imports"
3605+
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3606+
swiftModuleName: importObjectBuilder.moduleName,
3607+
from: type.from
3608+
)
35873609
try thunkBuilder.callConstructor(
35883610
jsName: type.jsName ?? type.name,
35893611
swiftTypeName: type.name,
@@ -3627,6 +3649,7 @@ extension BridgeJSLink {
36273649
}
36283650

36293651
func renderImportedStaticMethod(
3652+
swiftModuleName: String,
36303653
context: ImportedTypeSkeleton,
36313654
method: ImportedFunctionSkeleton
36323655
) throws -> (js: [String], dts: [String]) {
@@ -3638,7 +3661,10 @@ extension BridgeJSLink {
36383661
for param in method.parameters {
36393662
try thunkBuilder.liftParameter(param: param)
36403663
}
3641-
let importRootExpr = context.from == .global ? "globalThis" : "imports"
3664+
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3665+
swiftModuleName: swiftModuleName,
3666+
from: context.from
3667+
)
36423668
let constructorExpr = ImportedThunkBuilder.propertyAccessExpr(
36433669
objectExpr: importRootExpr,
36443670
propertyName: context.jsName ?? context.name
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#if canImport(BridgeJSSkeleton)
2+
import BridgeJSSkeleton
3+
#endif
4+
5+
final class ImportedJSModuleRegistry {
6+
struct Reference: Hashable {
7+
let swiftModuleName: String
8+
let path: String
9+
10+
var relativeOutputPath: String {
11+
"bridge-js-modules/\(swiftModuleName)\(path)"
12+
}
13+
}
14+
15+
private var aliases: [Reference: String] = [:]
16+
private(set) var references: [Reference] = []
17+
18+
func configure(skeletons: [BridgeJSSkeleton]) {
19+
aliases.removeAll(keepingCapacity: true)
20+
references = Self.collectReferences(skeletons: skeletons)
21+
for (index, reference) in references.enumerated() {
22+
aliases[reference] = "__bjs_imported_module_\(index)"
23+
}
24+
}
25+
26+
static func collectReferences(skeletons: [BridgeJSSkeleton]) -> [Reference] {
27+
var references = Set<Reference>()
28+
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+
}
37+
}
38+
}
39+
return references.sorted {
40+
($0.swiftModuleName, $0.path) < ($1.swiftModuleName, $1.path)
41+
}
42+
}
43+
44+
func namespaceExpression(swiftModuleName: String, from: JSImportFrom?) throws -> String {
45+
switch from {
46+
case nil:
47+
return "imports"
48+
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 {
53+
throw BridgeJSLinkError(
54+
message: "Missing JavaScript module \(swiftModuleName)\(path)"
55+
)
56+
}
57+
return alias
58+
}
59+
}
60+
61+
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)\";"
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)