Skip to content
Open
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
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ let package = Package(
"bridge-js.global.d.ts",
"Generated/JavaScript",
"JavaScript",
"Modules",
],
swiftSettings: [
.enableExperimentalFeature("Extern")
Expand Down
1 change: 1 addition & 0 deletions Plugins/BridgeJS/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ let package = Package(
dependencies: [
"BridgeJSCore",
"BridgeJSLink",
"BridgeJSBuildPlugin",
"TS2Swift",
.product(name: "SwiftParser", package: "swift-syntax"),
.product(name: "SwiftSyntax", package: "swift-syntax"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ struct BridgeJSBuildPlugin: BuildToolPlugin {
.map(\.url)

let configFile = pathToConfigFile(target: target)
var inputFiles: [URL] = inputSwiftFiles
let inputJavaScriptFiles = discoverJavaScriptModuleFiles(in: target.directoryURL)
var inputFiles: [URL] = inputSwiftFiles + inputJavaScriptFiles
if FileManager.default.fileExists(atPath: configFile.path) {
inputFiles.append(configFile)
}
Expand Down Expand Up @@ -77,7 +78,7 @@ struct BridgeJSBuildPlugin: BuildToolPlugin {
}

let allSwiftFiles = inputSwiftFiles + pluginGeneratedSwiftFiles
arguments.append(contentsOf: allSwiftFiles.map(\.path))
arguments.append(contentsOf: (allSwiftFiles + inputJavaScriptFiles).map(\.path))

return .buildCommand(
displayName: "Generate BridgeJS code",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ extension BridgeJSCommandPlugin.Context {
!$0.url.path.hasPrefix(generatedDirectory.path + "/")
}.map(\.url.path)
)
generateArguments.append(contentsOf: discoverJavaScriptModuleFiles(in: target.directoryURL).map(\.path))
generateArguments.append(contentsOf: remainingArguments)

try runBridgeJSTool(arguments: generateArguments)
Expand Down
94 changes: 77 additions & 17 deletions Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public final class SwiftToSkeleton {

private var sourceFiles: [(sourceFile: SourceFileSyntax, inputFilePath: String)] = []
private var usedExternalModules = Set<String>()
private let javaScriptModuleSource: (String) throws -> String?
private var importedJSModules: [String: ImportedJSModule] = [:]

/// Non-fatal diagnostics collected during `finalize()`. These do not fail the build.
public private(set) var warnings: [(file: String, diagnostic: DiagnosticError)] = []
Expand All @@ -32,12 +34,14 @@ public final class SwiftToSkeleton {
moduleName: String,
exposeToGlobal: Bool,
externalModuleIndex: ExternalModuleIndex,
identityMode: String? = nil
identityMode: String? = nil,
javaScriptModuleSource: @escaping (String) throws -> String? = { _ in nil }
) {
self.progress = progress
self.moduleName = moduleName
self.exposeToGlobal = exposeToGlobal
self.identityMode = identityMode
self.javaScriptModuleSource = javaScriptModuleSource
self.typeDeclResolver = TypeDeclResolver()
self.externalModuleIndex = externalModuleIndex

Expand Down Expand Up @@ -90,6 +94,38 @@ public final class SwiftToSkeleton {
)
importCollector.walk(sourceFile)

let importOrigins =
importCollector.importedFunctions.compactMap(\.from)
+ importCollector.importedTypes.compactMap(\.from)
+ importCollector.importedGlobalGetters.compactMap(\.from)
let modulePaths = Set(importOrigins.compactMap(\.modulePath))
for path in modulePaths.sorted() {
if importedJSModules[path] != nil {
continue
}
let pathNode = importCollector.importedModulePathNodes[path] ?? Syntax(sourceFile)
guard path.hasPrefix("/") else {
importCollector.errors.append(
DiagnosticError(
node: pathNode,
message: "JavaScript module paths must start with '/' to indicate the Swift target root: "
+ "'\(path)'."
)
)
continue
}
guard let source = try javaScriptModuleSource(path) else {
importCollector.errors.append(
DiagnosticError(
node: pathNode,
message: "JavaScript module file was not found at '\(path)'."
)
)
continue
}
importedJSModules[path] = ImportedJSModule(path: path, source: source)
}

let exportErrors = exportCollector.errors.filter { $0.severity == .error }
let importErrorsFatal = importCollector.errors.filter {
$0.severity == .error && !$0.message.contains("Unsupported type '")
Expand Down Expand Up @@ -128,7 +164,11 @@ public final class SwiftToSkeleton {
throw BridgeJSCoreDiagnosticError(diagnostics: diagnostics)
}
let importedSkeleton: ImportedModuleSkeleton? = {
let module = ImportedModuleSkeleton(children: importedFiles)
let modules = importedJSModules.values.sorted { $0.path < $1.path }
let module = ImportedModuleSkeleton(
children: importedFiles,
modules: modules.isEmpty ? nil : modules
)
if module.children.allSatisfy({ $0.functions.isEmpty && $0.types.isEmpty && $0.globalGetters.isEmpty }) {
return nil
}
Expand Down Expand Up @@ -2413,6 +2453,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
var importedFunctions: [ImportedFunctionSkeleton] = []
var importedTypes: [ImportedTypeSkeleton] = []
var importedGlobalGetters: [ImportedGetterSkeleton] = []
var importedModulePathNodes: [String: Syntax] = [:]
var errors: [DiagnosticError] = []

private let inputFilePath: String
Expand Down Expand Up @@ -2507,22 +2548,41 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
}
return nil
}
}

/// Extracts the `from` argument value from an attribute, if present.
static func extractJSImportFrom(from attribute: AttributeSyntax) -> JSImportFrom? {
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
private func extractJSImportFrom(from attribute: AttributeSyntax) -> JSImportFrom? {
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self),
let argument = arguments.first(where: { $0.label?.text == "from" })
else {
return nil
}

if let call = argument.expression.as(FunctionCallExprSyntax.self),
call.calledExpression.trimmedDescription.split(separator: ".").last == "module"
{
guard call.arguments.count == 1,
let pathExpression = call.arguments.first?.expression,
let literal = pathExpression.as(StringLiteralExprSyntax.self),
let path = literal.representedLiteralValue
else {
errors.append(
DiagnosticError(
node: call.arguments.first?.expression ?? argument.expression,
message: "JavaScript module path must be a string literal."
)
)
return nil
}
for argument in arguments {
guard argument.label?.text == "from" else { continue }

// Accept `.global`, `JSImportFrom.global`, etc.
let description = argument.expression.trimmedDescription
let caseName = description.split(separator: ".").last.map(String.init) ?? description
return JSImportFrom(rawValue: caseName)
if importedModulePathNodes[path] == nil {
importedModulePathNodes[path] = Syntax(literal)
}
return nil
return .module(path)
}

// Accept `.global`, `JSImportFrom.global`, etc.
let description = argument.expression.trimmedDescription
let caseName = description.split(separator: ".").last.map(String.init) ?? description
return caseName == "global" ? .global : nil
}

// MARK: - Validation Helpers
Expand Down Expand Up @@ -2705,7 +2765,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
if AttributeChecker.hasJSClassAttribute(node.attributes) {
let attribute = AttributeChecker.firstJSClassAttribute(node.attributes)
let jsName = attribute.flatMap(AttributeChecker.extractJSName)
let from = attribute.flatMap(AttributeChecker.extractJSImportFrom)
let from = attribute.flatMap { extractJSImportFrom(from: $0) }
let accessLevel = Self.bridgeAccessLevel(from: node.modifiers)
enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel)
}
Expand All @@ -2722,7 +2782,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
if AttributeChecker.hasJSClassAttribute(node.attributes) {
let attribute = AttributeChecker.firstJSClassAttribute(node.attributes)
let jsName = attribute.flatMap(AttributeChecker.extractJSName)
let from = attribute.flatMap(AttributeChecker.extractJSImportFrom)
let from = attribute.flatMap { extractJSImportFrom(from: $0) }
let accessLevel = Self.bridgeAccessLevel(from: node.modifiers)
enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel)
}
Expand Down Expand Up @@ -2916,7 +2976,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {

let baseName = SwiftToSkeleton.normalizeIdentifier(node.name.text)
let jsName = AttributeChecker.extractJSName(from: jsFunction)
let from = AttributeChecker.extractJSImportFrom(from: jsFunction)
let from = extractJSImportFrom(from: jsFunction)
let name = baseName

let parameters = parseParameters(from: node.signature.parameterClause)
Expand Down Expand Up @@ -2969,7 +3029,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
}
let propertyName = SwiftToSkeleton.normalizeIdentifier(identifier.identifier.text)
let jsName = AttributeChecker.extractJSName(from: jsGetter)
let from = AttributeChecker.extractJSImportFrom(from: jsGetter)
let from = extractJSImportFrom(from: jsGetter)
let accessLevel = Self.bridgeAccessLevel(from: node.modifiers)
return ImportedGetterSkeleton(
name: propertyName,
Expand Down
44 changes: 36 additions & 8 deletions Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public struct BridgeJSLink {
let enableLifetimeTracking: Bool = false
private let namespaceBuilder = NamespaceBuilder()
private let intrinsicRegistry = JSIntrinsicRegistry()
private let importedModuleRegistry = ImportedJSModuleRegistry()

public init(
skeletons: [BridgeJSSkeleton] = [],
Expand Down Expand Up @@ -1077,6 +1078,11 @@ public struct BridgeJSLink {
let printer = CodeFragmentPrinter(header: header)
printer.nextLine()

printer.write(lines: importedModuleRegistry.importLines)
if !importedModuleRegistry.importLines.isEmpty {
printer.nextLine()
}

printer.write(lines: data.topLevelTypeLines)

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

public func link() throws -> (outputJs: String, outputDts: String) {
public func link() throws -> BridgeJSLinkOutput {
intrinsicRegistry.reset()
try importedModuleRegistry.configure(skeletons: skeletons)
intrinsicRegistry.classNamespaces = skeletons.reduce(into: [:]) { result, unified in
guard let skeleton = unified.exported else { return }
for klass in skeleton.classes {
Expand All @@ -1233,7 +1240,11 @@ public struct BridgeJSLink {
let data = try collectLinkData()
let outputJs = try generateJavaScript(data: data)
let outputDts = generateTypeScript(data: data)
return (outputJs, outputDts)
return BridgeJSLinkOutput(
outputJs: outputJs,
outputDts: outputDts,
modules: importedModuleRegistry.artifacts
)
}

private func enumHelperAssignments() -> CodeFragmentPrinter {
Expand Down Expand Up @@ -1586,7 +1597,7 @@ public struct BridgeJSLink {
return "\"\(Self.escapeForJavaScriptStringLiteral(name))\""
}

fileprivate static func escapeForJavaScriptStringLiteral(_ string: String) -> String {
static func escapeForJavaScriptStringLiteral(_ string: String) -> String {
string
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
Expand Down Expand Up @@ -3460,7 +3471,10 @@ extension BridgeJSLink {
try thunkBuilder.liftParameter(param: param)
}
let jsName = function.jsName ?? function.name
let importRootExpr = function.from == .global ? "globalThis" : "imports"
let importRootExpr = try importedModuleRegistry.namespaceExpression(
swiftModuleName: importObjectBuilder.moduleName,
from: function.from
)

try thunkBuilder.call(name: jsName, fromObjectExpr: importRootExpr)
let funcLines = thunkBuilder.renderFunction(name: function.abiName(context: nil))
Expand All @@ -3484,7 +3498,10 @@ extension BridgeJSLink {
intrinsicRegistry: intrinsicRegistry
)
let jsName = getter.jsName ?? getter.name
let importRootExpr = getter.from == .global ? "globalThis" : "imports"
let importRootExpr = try importedModuleRegistry.namespaceExpression(
swiftModuleName: importObjectBuilder.moduleName,
from: getter.from
)
try thunkBuilder.getImportProperty(
name: jsName,
fromObjectExpr: importRootExpr,
Expand Down Expand Up @@ -3539,7 +3556,11 @@ extension BridgeJSLink {
}
for method in type.staticMethods {
let abiName = method.abiName(context: type, operation: "static")
let (js, dts) = try renderImportedStaticMethod(context: type, method: method)
let (js, dts) = try renderImportedStaticMethod(
swiftModuleName: importObjectBuilder.moduleName,
context: type,
method: method
)
importObjectBuilder.assignToImportObject(name: abiName, function: js)
importObjectBuilder.appendDts(dts)
}
Expand Down Expand Up @@ -3583,7 +3604,10 @@ extension BridgeJSLink {
for param in constructor.parameters {
try thunkBuilder.liftParameter(param: param)
}
let importRootExpr = type.from == .global ? "globalThis" : "imports"
let importRootExpr = try importedModuleRegistry.namespaceExpression(
swiftModuleName: importObjectBuilder.moduleName,
from: type.from
)
try thunkBuilder.callConstructor(
jsName: type.jsName ?? type.name,
swiftTypeName: type.name,
Expand Down Expand Up @@ -3627,6 +3651,7 @@ extension BridgeJSLink {
}

func renderImportedStaticMethod(
swiftModuleName: String,
context: ImportedTypeSkeleton,
method: ImportedFunctionSkeleton
) throws -> (js: [String], dts: [String]) {
Expand All @@ -3638,7 +3663,10 @@ extension BridgeJSLink {
for param in method.parameters {
try thunkBuilder.liftParameter(param: param)
}
let importRootExpr = context.from == .global ? "globalThis" : "imports"
let importRootExpr = try importedModuleRegistry.namespaceExpression(
swiftModuleName: swiftModuleName,
from: context.from
)
let constructorExpr = ImportedThunkBuilder.propertyAccessExpr(
objectExpr: importRootExpr,
propertyName: context.jsName ?? context.name
Expand Down
21 changes: 21 additions & 0 deletions Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
public struct BridgeJSLinkOutput: Sendable {
public struct Module: Equatable, Sendable {
public let relativePath: String
public let source: String

init(relativePath: String, source: String) {
self.relativePath = relativePath
self.source = source
}
}

public let outputJs: String
public let outputDts: String
public let modules: [Module]

init(outputJs: String, outputDts: String, modules: [Module]) {
self.outputJs = outputJs
self.outputDts = outputDts
self.modules = modules
}
}
Loading
Loading