Skip to content

Commit bb25870

Browse files
committed
added .module("path/to/file.js") support for imports
1 parent 0e35c39 commit bb25870

36 files changed

Lines changed: 1727 additions & 44 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/Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ let package = Package(
5757
dependencies: [
5858
"BridgeJSCore",
5959
"BridgeJSLink",
60+
"BridgeJSBuildPlugin",
6061
"TS2Swift",
6162
.product(name: "SwiftParser", package: "swift-syntax"),
6263
.product(name: "SwiftSyntax", package: "swift-syntax"),

Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ struct BridgeJSBuildPlugin: BuildToolPlugin {
2727
.map(\.url)
2828

2929
let configFile = pathToConfigFile(target: target)
30-
var inputFiles: [URL] = inputSwiftFiles
30+
let inputJavaScriptFiles = discoverJavaScriptModuleFiles(in: target.directoryURL)
31+
var inputFiles: [URL] = inputSwiftFiles + inputJavaScriptFiles
3132
if FileManager.default.fileExists(atPath: configFile.path) {
3233
inputFiles.append(configFile)
3334
}
@@ -77,7 +78,7 @@ struct BridgeJSBuildPlugin: BuildToolPlugin {
7778
}
7879

7980
let allSwiftFiles = inputSwiftFiles + pluginGeneratedSwiftFiles
80-
arguments.append(contentsOf: allSwiftFiles.map(\.path))
81+
arguments.append(contentsOf: (allSwiftFiles + inputJavaScriptFiles).map(\.path))
8182

8283
return .buildCommand(
8384
displayName: "Generate BridgeJS code",

Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ extension BridgeJSCommandPlugin.Context {
184184
!$0.url.path.hasPrefix(generatedDirectory.path + "/")
185185
}.map(\.url.path)
186186
)
187+
generateArguments.append(contentsOf: discoverJavaScriptModuleFiles(in: target.directoryURL).map(\.path))
187188
generateArguments.append(contentsOf: remainingArguments)
188189

189190
try runBridgeJSTool(arguments: generateArguments)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../BridgeJSPluginUtilities

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 41 additions & 3 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 javaScriptModuleSource: (String) throws -> String?
27+
private var importedJSModules: [String: ImportedJSModule] = [:]
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+
javaScriptModuleSource: @escaping (String) throws -> String? = { _ in nil }
3639
) {
3740
self.progress = progress
3841
self.moduleName = moduleName
3942
self.exposeToGlobal = exposeToGlobal
4043
self.identityMode = identityMode
44+
self.javaScriptModuleSource = javaScriptModuleSource
4145
self.typeDeclResolver = TypeDeclResolver()
4246
self.externalModuleIndex = externalModuleIndex
4347

@@ -90,6 +94,27 @@ 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 importedJSModules[path] != nil {
104+
continue
105+
}
106+
guard let source = try javaScriptModuleSource(path) else {
107+
importCollector.errors.append(
108+
DiagnosticError(
109+
node: sourceFile,
110+
message: "JavaScript module file was not found at '\(path)'."
111+
)
112+
)
113+
continue
114+
}
115+
importedJSModules[path] = ImportedJSModule(path: path, source: source)
116+
}
117+
93118
let exportErrors = exportCollector.errors.filter { $0.severity == .error }
94119
let importErrorsFatal = importCollector.errors.filter {
95120
$0.severity == .error && !$0.message.contains("Unsupported type '")
@@ -128,7 +153,11 @@ public final class SwiftToSkeleton {
128153
throw BridgeJSCoreDiagnosticError(diagnostics: diagnostics)
129154
}
130155
let importedSkeleton: ImportedModuleSkeleton? = {
131-
let module = ImportedModuleSkeleton(children: importedFiles)
156+
let modules = importedJSModules.values.sorted { $0.path < $1.path }
157+
let module = ImportedModuleSkeleton(
158+
children: importedFiles,
159+
modules: modules.isEmpty ? nil : modules
160+
)
132161
if module.children.allSatisfy({ $0.functions.isEmpty && $0.types.isEmpty && $0.globalGetters.isEmpty }) {
133162
return nil
134163
}
@@ -2516,10 +2545,19 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
25162545
for argument in arguments {
25172546
guard argument.label?.text == "from" else { continue }
25182547

2548+
if let call = argument.expression.as(FunctionCallExprSyntax.self),
2549+
call.calledExpression.trimmedDescription.split(separator: ".").last == "module",
2550+
call.arguments.count == 1,
2551+
let literal = call.arguments.first?.expression.as(StringLiteralExprSyntax.self),
2552+
let path = literal.representedLiteralValue
2553+
{
2554+
return .module(path)
2555+
}
2556+
25192557
// Accept `.global`, `JSImportFrom.global`, etc.
25202558
let description = argument.expression.trimmedDescription
25212559
let caseName = description.split(separator: ".").last.map(String.init) ?? description
2522-
return JSImportFrom(rawValue: caseName)
2560+
return caseName == "global" ? .global : nil
25232561
}
25242562
return nil
25252563
}

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

Lines changed: 36 additions & 8 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] = [],
@@ -1077,6 +1078,11 @@ public struct BridgeJSLink {
10771078
let printer = CodeFragmentPrinter(header: header)
10781079
printer.nextLine()
10791080

1081+
printer.write(lines: importedModuleRegistry.importLines)
1082+
if !importedModuleRegistry.importLines.isEmpty {
1083+
printer.nextLine()
1084+
}
1085+
10801086
printer.write(lines: data.topLevelTypeLines)
10811087

10821088
let exportedSkeletons = skeletons.compactMap(\.exported)
@@ -1220,8 +1226,9 @@ public struct BridgeJSLink {
12201226
return printer.lines.joined(separator: "\n")
12211227
}
12221228

1223-
public func link() throws -> (outputJs: String, outputDts: String) {
1229+
public func link() throws -> BridgeJSLinkOutput {
12241230
intrinsicRegistry.reset()
1231+
try importedModuleRegistry.configure(skeletons: skeletons)
12251232
intrinsicRegistry.classNamespaces = skeletons.reduce(into: [:]) { result, unified in
12261233
guard let skeleton = unified.exported else { return }
12271234
for klass in skeleton.classes {
@@ -1233,7 +1240,11 @@ public struct BridgeJSLink {
12331240
let data = try collectLinkData()
12341241
let outputJs = try generateJavaScript(data: data)
12351242
let outputDts = generateTypeScript(data: data)
1236-
return (outputJs, outputDts)
1243+
return BridgeJSLinkOutput(
1244+
outputJs: outputJs,
1245+
outputDts: outputDts,
1246+
modules: importedModuleRegistry.artifacts
1247+
)
12371248
}
12381249

12391250
private func enumHelperAssignments() -> CodeFragmentPrinter {
@@ -1586,7 +1597,7 @@ public struct BridgeJSLink {
15861597
return "\"\(Self.escapeForJavaScriptStringLiteral(name))\""
15871598
}
15881599

1589-
fileprivate static func escapeForJavaScriptStringLiteral(_ string: String) -> String {
1600+
static func escapeForJavaScriptStringLiteral(_ string: String) -> String {
15901601
string
15911602
.replacingOccurrences(of: "\\", with: "\\\\")
15921603
.replacingOccurrences(of: "\"", with: "\\\"")
@@ -3460,7 +3471,10 @@ extension BridgeJSLink {
34603471
try thunkBuilder.liftParameter(param: param)
34613472
}
34623473
let jsName = function.jsName ?? function.name
3463-
let importRootExpr = function.from == .global ? "globalThis" : "imports"
3474+
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3475+
swiftModuleName: importObjectBuilder.moduleName,
3476+
from: function.from
3477+
)
34643478

34653479
try thunkBuilder.call(name: jsName, fromObjectExpr: importRootExpr)
34663480
let funcLines = thunkBuilder.renderFunction(name: function.abiName(context: nil))
@@ -3484,7 +3498,10 @@ extension BridgeJSLink {
34843498
intrinsicRegistry: intrinsicRegistry
34853499
)
34863500
let jsName = getter.jsName ?? getter.name
3487-
let importRootExpr = getter.from == .global ? "globalThis" : "imports"
3501+
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3502+
swiftModuleName: importObjectBuilder.moduleName,
3503+
from: getter.from
3504+
)
34883505
try thunkBuilder.getImportProperty(
34893506
name: jsName,
34903507
fromObjectExpr: importRootExpr,
@@ -3539,7 +3556,11 @@ extension BridgeJSLink {
35393556
}
35403557
for method in type.staticMethods {
35413558
let abiName = method.abiName(context: type, operation: "static")
3542-
let (js, dts) = try renderImportedStaticMethod(context: type, method: method)
3559+
let (js, dts) = try renderImportedStaticMethod(
3560+
swiftModuleName: importObjectBuilder.moduleName,
3561+
context: type,
3562+
method: method
3563+
)
35433564
importObjectBuilder.assignToImportObject(name: abiName, function: js)
35443565
importObjectBuilder.appendDts(dts)
35453566
}
@@ -3583,7 +3604,10 @@ extension BridgeJSLink {
35833604
for param in constructor.parameters {
35843605
try thunkBuilder.liftParameter(param: param)
35853606
}
3586-
let importRootExpr = type.from == .global ? "globalThis" : "imports"
3607+
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3608+
swiftModuleName: importObjectBuilder.moduleName,
3609+
from: type.from
3610+
)
35873611
try thunkBuilder.callConstructor(
35883612
jsName: type.jsName ?? type.name,
35893613
swiftTypeName: type.name,
@@ -3627,6 +3651,7 @@ extension BridgeJSLink {
36273651
}
36283652

36293653
func renderImportedStaticMethod(
3654+
swiftModuleName: String,
36303655
context: ImportedTypeSkeleton,
36313656
method: ImportedFunctionSkeleton
36323657
) throws -> (js: [String], dts: [String]) {
@@ -3638,7 +3663,10 @@ extension BridgeJSLink {
36383663
for param in method.parameters {
36393664
try thunkBuilder.liftParameter(param: param)
36403665
}
3641-
let importRootExpr = context.from == .global ? "globalThis" : "imports"
3666+
let importRootExpr = try importedModuleRegistry.namespaceExpression(
3667+
swiftModuleName: swiftModuleName,
3668+
from: context.from
3669+
)
36423670
let constructorExpr = ImportedThunkBuilder.propertyAccessExpr(
36433671
objectExpr: importRootExpr,
36443672
propertyName: context.jsName ?? context.name
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
public struct BridgeJSLinkOutput: Sendable {
2+
public struct Module: Equatable, Sendable {
3+
public let relativePath: String
4+
public let source: String
5+
6+
init(relativePath: String, source: String) {
7+
self.relativePath = relativePath
8+
self.source = source
9+
}
10+
}
11+
12+
public let outputJs: String
13+
public let outputDts: String
14+
public let modules: [Module]
15+
16+
init(outputJs: String, outputDts: String, modules: [Module]) {
17+
self.outputJs = outputJs
18+
self.outputDts = outputDts
19+
self.modules = modules
20+
}
21+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#if canImport(BridgeJSSkeleton)
2+
import BridgeJSSkeleton
3+
#endif
4+
5+
final class ImportedJSModuleRegistry {
6+
private struct Key: Hashable {
7+
let swiftModuleName: String
8+
let path: String
9+
}
10+
11+
private var aliases: [Key: String] = [:]
12+
private(set) var artifacts: [BridgeJSLinkOutput.Module] = []
13+
14+
func configure(skeletons: [BridgeJSSkeleton]) throws {
15+
aliases.removeAll(keepingCapacity: true)
16+
artifacts.removeAll(keepingCapacity: true)
17+
18+
var sources: [Key: String] = [:]
19+
for skeleton in skeletons {
20+
for module in skeleton.imported?.modules ?? [] {
21+
let key = Key(swiftModuleName: skeleton.moduleName, path: module.path)
22+
if let existing = sources[key], existing != module.source {
23+
throw BridgeJSLinkError(
24+
message: "Conflicting JavaScript module contents for \(skeleton.moduleName)/\(module.path)"
25+
)
26+
}
27+
sources[key] = module.source
28+
}
29+
}
30+
31+
for (index, entry) in sources.sorted(by: {
32+
($0.key.swiftModuleName, $0.key.path) < ($1.key.swiftModuleName, $1.key.path)
33+
}).enumerated() {
34+
let relativePath = "bridge-js-modules/\(entry.key.swiftModuleName)/\(entry.key.path)"
35+
aliases[entry.key] = "__bjs_imported_module_\(index)"
36+
artifacts.append(.init(relativePath: relativePath, source: entry.value))
37+
}
38+
}
39+
40+
func namespaceExpression(swiftModuleName: String, from: JSImportFrom?) throws -> String {
41+
switch from {
42+
case nil:
43+
return "imports"
44+
case .global:
45+
return "globalThis"
46+
case .module(let path):
47+
let key = Key(swiftModuleName: swiftModuleName, path: path)
48+
guard let alias = aliases[key] else {
49+
throw BridgeJSLinkError(
50+
message: "Missing embedded JavaScript module for \(swiftModuleName)/\(path)"
51+
)
52+
}
53+
return alias
54+
}
55+
}
56+
57+
var importLines: [String] {
58+
artifacts.enumerated().map { index, artifact in
59+
let path = BridgeJSLink.escapeForJavaScriptStringLiteral(artifact.relativePath)
60+
return "import * as __bjs_imported_module_\(index) from \"./\(path)\";"
61+
}
62+
}
63+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Foundation
2+
3+
/// JavaScript files must be declared as build-command inputs so SwiftPM reruns
4+
/// BridgeJS when a referenced module changes.
5+
func discoverJavaScriptModuleFiles(in directory: URL) -> [URL] {
6+
guard
7+
let enumerator = FileManager.default.enumerator(
8+
at: directory,
9+
includingPropertiesForKeys: nil,
10+
options: [.skipsHiddenFiles]
11+
)
12+
else { return [] }
13+
14+
return enumerator.compactMap { $0 as? URL }.filter(isJavaScriptModuleFile).sorted { $0.path < $1.path }
15+
}
16+
17+
func isJavaScriptModuleFile(_ file: URL) -> Bool {
18+
["js", "mjs"].contains(file.pathExtension.lowercased())
19+
}

0 commit comments

Comments
 (0)