diff --git a/Plugins/BridgeJS/Package.swift b/Plugins/BridgeJS/Package.swift index 63c74c53..a4385bd3 100644 --- a/Plugins/BridgeJS/Package.swift +++ b/Plugins/BridgeJS/Package.swift @@ -55,7 +55,7 @@ let package = Package( "BridgeJSLink", "TS2Swift", ], - exclude: ["__Snapshots__", "Inputs", "MultifileInputs"] + exclude: ["__Snapshots__", "Inputs", "MultifileInputs", "ImportMacroInputs"] ), .macro( name: "BridgeJSMacros", diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ImportSwiftMacros.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ImportSwiftMacros.swift index b4c5820d..db6b983e 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/ImportSwiftMacros.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ImportSwiftMacros.swift @@ -44,7 +44,8 @@ public final class ImportSwiftMacros { importedFiles.append( ImportedFileSkeleton( functions: collector.importedFunctions, - types: collector.importedTypes + types: collector.importedTypes, + globalGetters: collector.importedGlobalGetters ) ) } @@ -94,6 +95,7 @@ public final class ImportSwiftMacros { fileprivate final class APICollector: SyntaxAnyVisitor { var importedFunctions: [ImportedFunctionSkeleton] = [] var importedTypes: [ImportedTypeSkeleton] = [] + var importedGlobalGetters: [ImportedGetterSkeleton] = [] var errors: [DiagnosticError] = [] private let inputFilePath: String @@ -432,12 +434,9 @@ public final class ImportSwiftMacros { switch state { case .topLevel: - errors.append( - DiagnosticError( - node: node, - message: "@JSGetter is not supported at top-level. Use it only in @JSClass types." - ) - ) + if let getter = parseGetterSkeleton(node, enclosingTypeName: nil) { + importedGlobalGetters.append(getter) + } return .skipChildren case .jsClassBody(let typeName): diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift index 402e6e67..675708c4 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift @@ -36,6 +36,10 @@ public struct ImportTS { public func finalize() throws -> String? { var decls: [DeclSyntax] = [] for skeleton in self.skeleton.children { + for getter in skeleton.globalGetters { + let getterDecls = try renderSwiftGlobalGetter(getter, topLevelDecls: &decls) + decls.append(contentsOf: getterDecls) + } for function in skeleton.functions { let thunkDecls = try renderSwiftThunk(function, topLevelDecls: &decls) decls.append(contentsOf: thunkDecls) @@ -54,6 +58,24 @@ public struct ImportTS { return decls.map { $0.formatted(using: format).description }.joined(separator: "\n\n") } + func renderSwiftGlobalGetter( + _ getter: ImportedGetterSkeleton, + topLevelDecls: inout [DeclSyntax] + ) throws -> [DeclSyntax] { + let builder = CallJSEmission(moduleName: moduleName, abiName: getter.abiName(context: nil)) + try builder.call(returnType: getter.type) + try builder.liftReturnValue(returnType: getter.type) + topLevelDecls.append(builder.renderImportDecl()) + return [ + builder.renderThunkDecl( + name: "_$\(getter.name)_get", + parameters: [], + returnType: getter.type + ) + .with(\.leadingTrivia, Self.renderDocumentation(documentation: getter.documentation)) + ] + } + class CallJSEmission { let abiName: String let moduleName: String diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift index d6103f7d..1f55eb18 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift @@ -172,6 +172,9 @@ struct BridgeJSLink { guard let imported = unified.imported else { continue } let importObjectBuilder = ImportObjectBuilder(moduleName: unified.moduleName) for fileSkeleton in imported.children { + for getter in fileSkeleton.globalGetters { + try renderImportedGlobalGetter(importObjectBuilder: importObjectBuilder, getter: getter) + } for function in fileSkeleton.functions { try renderImportedFunction(importObjectBuilder: importObjectBuilder, function: function) } @@ -2142,6 +2145,31 @@ extension BridgeJSLink { body.write("\(call);") } + func getImportProperty(name: String, returnType: BridgeType) throws -> String? { + if returnType == .void { + throw BridgeJSLinkError(message: "Void is not supported for imported JS properties") + } + + let loweringFragment = try IntrinsicJSFragment.lowerReturn(type: returnType, context: context) + let expr = "imports[\"\(name)\"]" + + let returnExpr: String? + if loweringFragment.parameters.count == 0 { + body.write("\(expr);") + returnExpr = nil + } else { + let resultVariable = scope.variable("ret") + body.write("let \(resultVariable) = \(expr);") + returnExpr = resultVariable + } + + return try lowerReturnValue( + returnType: returnType, + returnExpr: returnExpr, + loweringFragment: loweringFragment + ) + } + private func lowerReturnValue( returnType: BridgeType, returnExpr: String?, @@ -2881,6 +2909,22 @@ extension BridgeJSLink { importObjectBuilder.assignToImportObject(name: function.abiName(context: nil), function: funcLines) } + func renderImportedGlobalGetter( + importObjectBuilder: ImportObjectBuilder, + getter: ImportedGetterSkeleton + ) throws { + let thunkBuilder = ImportedThunkBuilder() + let returnExpr = try thunkBuilder.getImportProperty(name: getter.name, returnType: getter.type) + let abiName = getter.abiName(context: nil) + let funcLines = thunkBuilder.renderFunction( + name: abiName, + returnExpr: returnExpr, + returnType: getter.type + ) + importObjectBuilder.appendDts(["readonly \(getter.name): \(getter.type.tsType);"]) + importObjectBuilder.assignToImportObject(name: abiName, function: funcLines) + } + func renderImportedType( importObjectBuilder: ImportObjectBuilder, type: ImportedTypeSkeleton diff --git a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift index e1815fbe..10f06802 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift @@ -634,7 +634,7 @@ public struct ImportedGetterSkeleton: Codable { self.functionName = functionName } - public func abiName(context: ImportedTypeSkeleton) -> String { + public func abiName(context: ImportedTypeSkeleton?) -> String { if let functionName = functionName { return ABINameGenerator.generateImportedABIName( baseName: functionName, @@ -669,7 +669,7 @@ public struct ImportedSetterSkeleton: Codable { self.functionName = functionName } - public func abiName(context: ImportedTypeSkeleton) -> String { + public func abiName(context: ImportedTypeSkeleton?) -> String { if let functionName = functionName { return ABINameGenerator.generateImportedABIName( baseName: functionName, @@ -713,10 +713,48 @@ public struct ImportedTypeSkeleton: Codable { public struct ImportedFileSkeleton: Codable { public let functions: [ImportedFunctionSkeleton] public let types: [ImportedTypeSkeleton] + /// Global-scope imported properties (e.g. `@JSGetter var console: JSConsole`) + public let globalGetters: [ImportedGetterSkeleton] + /// Global-scope imported properties (future use; not currently emitted by macros) + public let globalSetters: [ImportedSetterSkeleton] - public init(functions: [ImportedFunctionSkeleton], types: [ImportedTypeSkeleton]) { + public init( + functions: [ImportedFunctionSkeleton], + types: [ImportedTypeSkeleton], + globalGetters: [ImportedGetterSkeleton] = [], + globalSetters: [ImportedSetterSkeleton] = [] + ) { self.functions = functions self.types = types + self.globalGetters = globalGetters + self.globalSetters = globalSetters + } + + private enum CodingKeys: String, CodingKey { + case functions + case types + case globalGetters + case globalSetters + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.functions = try container.decode([ImportedFunctionSkeleton].self, forKey: .functions) + self.types = try container.decode([ImportedTypeSkeleton].self, forKey: .types) + self.globalGetters = try container.decodeIfPresent([ImportedGetterSkeleton].self, forKey: .globalGetters) ?? [] + self.globalSetters = try container.decodeIfPresent([ImportedSetterSkeleton].self, forKey: .globalSetters) ?? [] + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(functions, forKey: .functions) + try container.encode(types, forKey: .types) + if !globalGetters.isEmpty { + try container.encode(globalGetters, forKey: .globalGetters) + } + if !globalSetters.isEmpty { + try container.encode(globalSetters, forKey: .globalSetters) + } } } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift index 0485adfc..4cb46124 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift @@ -38,12 +38,21 @@ import Testing "Inputs" ) + static let importMacroInputsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("ImportMacroInputs") + static func collectInputs(extension: String) -> [String] { let fileManager = FileManager.default let inputs = try! fileManager.contentsOfDirectory(atPath: Self.inputsDirectory.path) return inputs.filter { $0.hasSuffix(`extension`) } } + static func collectImportMacroInputs() -> [String] { + let fileManager = FileManager.default + let inputs = try! fileManager.contentsOfDirectory(atPath: Self.importMacroInputsDirectory.path) + return inputs.filter { $0.hasSuffix(".swift") } + } + @Test(arguments: collectInputs(extension: ".swift")) func snapshotExport(input: String) throws { let url = Self.inputsDirectory.appendingPathComponent(input) @@ -101,6 +110,35 @@ import Testing try snapshot(bridgeJSLink: bridgeJSLink, name: name + ".Import") } + @Test(arguments: collectImportMacroInputs()) + func snapshotImportMacroInput(input: String) throws { + let url = Self.importMacroInputsDirectory.appendingPathComponent(input) + let name = url.deletingPathExtension().lastPathComponent + + let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) + let importSwift = ImportSwiftMacros(progress: .silent, moduleName: "TestModule") + importSwift.addSourceFile(sourceFile, "\(name).swift") + let importResult = try importSwift.finalize() + + var importTS = ImportTS(progress: .silent, moduleName: "TestModule") + for child in importResult.outputSkeleton.children { + importTS.addSkeleton(child) + } + let importSkeleton = importTS.skeleton + + var bridgeJSLink = BridgeJSLink(sharedMemory: false) + let unifiedSkeleton = BridgeJSSkeleton( + moduleName: "TestModule", + exported: nil, + imported: importSkeleton + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let unifiedData = try encoder.encode(unifiedSkeleton) + try bridgeJSLink.addSkeletonFile(data: unifiedData) + try snapshot(bridgeJSLink: bridgeJSLink, name: name + ".ImportMacros") + } + @Test(arguments: [ "Namespaces.swift", "StaticFunctions.swift", diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/GlobalGetter.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/GlobalGetter.swift new file mode 100644 index 00000000..fe3f7b06 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportMacroInputs/GlobalGetter.swift @@ -0,0 +1,6 @@ +@JSClass +struct JSConsole { + @JSFunction func log(_ message: String) throws(JSException) +} + +@JSGetter var console: JSConsole diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/GlobalGetter.ImportMacros.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/GlobalGetter.ImportMacros.d.ts new file mode 100644 index 00000000..312f5678 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/GlobalGetter.ImportMacros.d.ts @@ -0,0 +1,21 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +export interface JSConsole { + log(message: string): void; +} +export type Exports = { +} +export type Imports = { + readonly console: JSConsole; +} +export function createInstantiator(options: { + imports: Imports; +}, swift: any): Promise<{ + addImports: (importObject: WebAssembly.Imports) => void; + setInstance: (instance: WebAssembly.Instance) => void; + createExports: (instance: WebAssembly.Instance) => Exports; +}>; \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/GlobalGetter.ImportMacros.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/GlobalGetter.ImportMacros.js new file mode 100644 index 00000000..89aa3af9 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/GlobalGetter.ImportMacros.js @@ -0,0 +1,231 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +export async function createInstantiator(options, swift) { + let instance; + let memory; + let setException; + const textDecoder = new TextDecoder("utf-8"); + const textEncoder = new TextEncoder("utf-8"); + let tmpRetString; + let tmpRetBytes; + let tmpRetException; + let tmpRetOptionalBool; + let tmpRetOptionalInt; + let tmpRetOptionalFloat; + let tmpRetOptionalDouble; + let tmpRetOptionalHeapObject; + let tmpRetTag; + let tmpRetStrings = []; + let tmpRetInts = []; + let tmpRetF32s = []; + let tmpRetF64s = []; + let tmpParamInts = []; + let tmpParamF32s = []; + let tmpParamF64s = []; + let tmpRetPointers = []; + let tmpParamPointers = []; + const enumHelpers = {}; + const structHelpers = {}; + + let _exports = null; + let bjs = null; + + return { + /** + * @param {WebAssembly.Imports} importObject + */ + addImports: (importObject, importsContext) => { + bjs = {}; + importObject["bjs"] = bjs; + const imports = options.getImports(importsContext); + bjs["swift_js_return_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + tmpRetString = textDecoder.decode(bytes); + } + bjs["swift_js_init_memory"] = function(sourceId, bytesPtr) { + const source = swift.memory.getObject(sourceId); + const bytes = new Uint8Array(memory.buffer, bytesPtr); + bytes.set(source); + } + bjs["swift_js_make_js_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + return swift.memory.retain(textDecoder.decode(bytes)); + } + bjs["swift_js_init_memory_with_result"] = function(ptr, len) { + const target = new Uint8Array(memory.buffer, ptr, len); + target.set(tmpRetBytes); + tmpRetBytes = undefined; + } + bjs["swift_js_throw"] = function(id) { + tmpRetException = swift.memory.retainByRef(id); + } + bjs["swift_js_retain"] = function(id) { + return swift.memory.retainByRef(id); + } + bjs["swift_js_release"] = function(id) { + swift.memory.release(id); + } + bjs["swift_js_push_tag"] = function(tag) { + tmpRetTag = tag; + } + bjs["swift_js_push_int"] = function(v) { + tmpRetInts.push(v | 0); + } + bjs["swift_js_push_f32"] = function(v) { + tmpRetF32s.push(Math.fround(v)); + } + bjs["swift_js_push_f64"] = function(v) { + tmpRetF64s.push(v); + } + bjs["swift_js_push_string"] = function(ptr, len) { + const bytes = new Uint8Array(memory.buffer, ptr, len); + const value = textDecoder.decode(bytes); + tmpRetStrings.push(value); + } + bjs["swift_js_pop_param_int32"] = function() { + return tmpParamInts.pop(); + } + bjs["swift_js_pop_param_f32"] = function() { + return tmpParamF32s.pop(); + } + bjs["swift_js_pop_param_f64"] = function() { + return tmpParamF64s.pop(); + } + bjs["swift_js_push_pointer"] = function(pointer) { + tmpRetPointers.push(pointer); + } + bjs["swift_js_pop_param_pointer"] = function() { + return tmpParamPointers.pop(); + } + bjs["swift_js_return_optional_bool"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalBool = null; + } else { + tmpRetOptionalBool = value !== 0; + } + } + bjs["swift_js_return_optional_int"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalInt = null; + } else { + tmpRetOptionalInt = value | 0; + } + } + bjs["swift_js_return_optional_float"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalFloat = null; + } else { + tmpRetOptionalFloat = Math.fround(value); + } + } + bjs["swift_js_return_optional_double"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalDouble = null; + } else { + tmpRetOptionalDouble = value; + } + } + bjs["swift_js_return_optional_string"] = function(isSome, ptr, len) { + if (isSome === 0) { + tmpRetString = null; + } else { + const bytes = new Uint8Array(memory.buffer, ptr, len); + tmpRetString = textDecoder.decode(bytes); + } + } + bjs["swift_js_return_optional_object"] = function(isSome, objectId) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = swift.memory.getObject(objectId); + } + } + bjs["swift_js_return_optional_heap_object"] = function(isSome, pointer) { + if (isSome === 0) { + tmpRetOptionalHeapObject = null; + } else { + tmpRetOptionalHeapObject = pointer; + } + } + bjs["swift_js_get_optional_int_presence"] = function() { + return tmpRetOptionalInt != null ? 1 : 0; + } + bjs["swift_js_get_optional_int_value"] = function() { + const value = tmpRetOptionalInt; + tmpRetOptionalInt = undefined; + return value; + } + bjs["swift_js_get_optional_string"] = function() { + const str = tmpRetString; + tmpRetString = undefined; + if (str == null) { + return -1; + } else { + const bytes = textEncoder.encode(str); + tmpRetBytes = bytes; + return bytes.length; + } + } + bjs["swift_js_get_optional_float_presence"] = function() { + return tmpRetOptionalFloat != null ? 1 : 0; + } + bjs["swift_js_get_optional_float_value"] = function() { + const value = tmpRetOptionalFloat; + tmpRetOptionalFloat = undefined; + return value; + } + bjs["swift_js_get_optional_double_presence"] = function() { + return tmpRetOptionalDouble != null ? 1 : 0; + } + bjs["swift_js_get_optional_double_value"] = function() { + const value = tmpRetOptionalDouble; + tmpRetOptionalDouble = undefined; + return value; + } + bjs["swift_js_get_optional_heap_object_pointer"] = function() { + const pointer = tmpRetOptionalHeapObject; + tmpRetOptionalHeapObject = undefined; + return pointer || 0; + } + const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; + TestModule["bjs_console_get"] = function bjs_console_get() { + try { + let ret = imports["console"]; + return swift.memory.retain(ret); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_JSConsole_log"] = function bjs_JSConsole_log(self, message) { + try { + const messageObject = swift.memory.getObject(message); + swift.memory.release(message); + swift.memory.getObject(self).log(messageObject); + } catch (error) { + setException(error); + } + } + }, + setInstance: (i) => { + instance = i; + memory = instance.exports.memory; + + setException = (error) => { + instance.exports._swift_js_exception.value = swift.memory.retain(error) + } + }, + /** @param {WebAssembly.Instance} instance */ + createExports: (instance) => { + const js = swift.memory.heap; + const exports = { + }; + _exports = exports; + return exports; + }, + } +} \ No newline at end of file