From 1561681d7e111f49d326df382ab2ae38abe08737 Mon Sep 17 00:00:00 2001 From: Krzysztof Rodak Date: Thu, 7 Aug 2025 09:56:56 -0600 Subject: [PATCH 1/4] BridgeJS: Introduce extended macro and namespace information extraction and storing --- .../Sources/BridgeJSCore/ExportSwift.swift | 35 ++- .../Sources/BridgeJSLink/BridgeJSLink.swift | 288 ++++++++++++++---- .../BridgeJSSkeleton/BridgeJSSkeleton.swift | 3 + .../BridgeJSToolTests/Inputs/Namespaces.swift | 33 ++ .../BridgeJSLinkTests/Namespaces.Export.d.ts | 68 +++++ .../BridgeJSLinkTests/Namespaces.Export.js | 150 +++++++++ .../ExportSwiftTests/Namespaces.json | 173 +++++++++++ .../ExportSwiftTests/Namespaces.swift | 117 +++++++ Sources/JavaScriptKit/Macros.swift | 66 ++++ 9 files changed, 868 insertions(+), 65 deletions(-) create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.d.ts create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.js create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.json create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.swift diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift index fefcf40c..2dc16456 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift @@ -123,10 +123,13 @@ class ExportSwift { } private func visitFunction(node: FunctionDeclSyntax) -> ExportedFunction? { - guard node.attributes.hasJSAttribute() else { + guard let jsAttribute = node.attributes.firstJSAttribute else { return nil } + let name = node.name.text + let namespace = extractNamespace(from: jsAttribute) + var parameters: [Parameter] = [] for param in node.signature.parameterClause.parameters { guard let type = self.parent.lookupType(for: param.type) else { @@ -165,7 +168,8 @@ class ExportSwift { abiName: abiName, parameters: parameters, returnType: returnType, - effects: effects + effects: effects, + namespace: namespace ) } @@ -192,6 +196,17 @@ class ExportSwift { } return Effects(isAsync: isAsync, isThrows: isThrows) } + + private func extractNamespace( + from jsAttribute: AttributeSyntax + ) -> [String]? { + guard let arguments = jsAttribute.arguments?.as(LabeledExprListSyntax.self), + let firstArg = arguments.first?.expression.as(StringLiteralExprSyntax.self), + let namespaceString = firstArg.segments.first?.as(StringSegmentSyntax.self)?.content.text else { + return nil + } + return namespaceString.split(separator: ".").map(String.init) + } override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { guard node.attributes.hasJSAttribute() else { return .skipChildren } @@ -225,13 +240,17 @@ class ExportSwift { override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { let name = node.name.text + stateStack.push(state: .classBody(name: name)) - guard node.attributes.hasJSAttribute() else { return .skipChildren } + guard let jsAttribute = node.attributes.firstJSAttribute else { return .skipChildren } + + let namespace = extractNamespace(from: jsAttribute) exportedClassByName[name] = ExportedClass( name: name, constructor: nil, - methods: [] + methods: [], + namespace: namespace ) exportedClassNames.append(name) return .visitChildren @@ -635,9 +654,13 @@ class ExportSwift { extension AttributeListSyntax { fileprivate func hasJSAttribute() -> Bool { - return first(where: { + firstJSAttribute != nil + } + + fileprivate var firstJSAttribute: AttributeSyntax? { + first(where: { $0.as(AttributeSyntax.self)?.attributeName.trimmedDescription == "JS" - }) != nil + })?.as(AttributeSyntax.self) } } diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift index 4d9ba596..871ffe6b 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift @@ -6,7 +6,7 @@ struct BridgeJSLink { var exportedSkeletons: [ExportedSkeleton] = [] var importedSkeletons: [ImportedModuleSkeleton] = [] let sharedMemory: Bool - + init( exportedSkeletons: [ExportedSkeleton] = [], importedSkeletons: [ImportedModuleSkeleton] = [], @@ -16,17 +16,17 @@ struct BridgeJSLink { self.importedSkeletons = importedSkeletons self.sharedMemory = sharedMemory } - + mutating func addExportedSkeletonFile(data: Data) throws { let skeleton = try JSONDecoder().decode(ExportedSkeleton.self, from: data) exportedSkeletons.append(skeleton) } - + mutating func addImportedSkeletonFile(data: Data) throws { let skeletons = try JSONDecoder().decode(ImportedModuleSkeleton.self, from: data) importedSkeletons.append(skeletons) } - + let swiftHeapObjectClassDts = """ /// Represents a Swift heap object like a class instance or an actor instance. export interface SwiftHeapObject { @@ -36,7 +36,7 @@ struct BridgeJSLink { release(): void; } """ - + let swiftHeapObjectClassJs = """ /// Represents a Swift heap object like a class instance or an actor instance. class SwiftHeapObject { @@ -49,20 +49,22 @@ struct BridgeJSLink { }); this.registry.register(this, this.pointer); } - + release() { this.registry.unregister(this); this.deinit(this.pointer); } } """ - + func link() throws -> (outputJs: String, outputDts: String) { var exportsLines: [String] = [] var classLines: [String] = [] var dtsExportLines: [String] = [] var dtsClassLines: [String] = [] - + var namespacedFunctions: [ExportedFunction] = [] + var namespacedClasses: [ExportedClass] = [] + if exportedSkeletons.contains(where: { $0.classes.count > 0 }) { classLines.append( contentsOf: swiftHeapObjectClassJs.split(separator: "\n", omittingEmptySubsequences: false).map { @@ -75,7 +77,7 @@ struct BridgeJSLink { } ) } - + for skeleton in exportedSkeletons { for klass in skeleton.classes { let (jsType, dtsType, dtsExportEntry) = renderExportedClass(klass) @@ -83,17 +85,26 @@ struct BridgeJSLink { exportsLines.append("\(klass.name),") dtsExportLines.append(contentsOf: dtsExportEntry) dtsClassLines.append(contentsOf: dtsType) + + if klass.namespace != nil { + namespacedClasses.append(klass) + } } - + for function in skeleton.functions { var (js, dts) = renderExportedFunction(function: function) + + if function.namespace != nil { + namespacedFunctions.append(function) + } + js[0] = "\(function.name): " + js[0] js[js.count - 1] += "," exportsLines.append(contentsOf: js) dtsExportLines.append(contentsOf: dts) } } - + var importObjectBuilders: [ImportObjectBuilder] = [] for skeletonSet in importedSkeletons { let importObjectBuilder = ImportObjectBuilder(moduleName: skeletonSet.moduleName) @@ -107,21 +118,48 @@ struct BridgeJSLink { } importObjectBuilders.append(importObjectBuilder) } - + + let hasNamespacedItems = !namespacedFunctions.isEmpty || !namespacedClasses.isEmpty + + let exportsSection: String + if hasNamespacedItems { + let namespaceSetupCode = renderGlobalNamespace(namespacedFunctions: namespacedFunctions, namespacedClasses: namespacedClasses) + .map { $0.indent(count: 12) }.joined(separator: "\n") + exportsSection = """ + \(classLines.map { $0.indent(count: 12) }.joined(separator: "\n")) + const exports = { + \(exportsLines.map { $0.indent(count: 16) }.joined(separator: "\n")) + }; + + \(namespaceSetupCode) + + return exports; + }, + """ + } else { + exportsSection = """ + \(classLines.map { $0.indent(count: 12) }.joined(separator: "\n")) + return { + \(exportsLines.map { $0.indent(count: 16) }.joined(separator: "\n")) + }; + }, + """ + } + let outputJs = """ // 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; @@ -169,15 +207,13 @@ struct BridgeJSLink { /** @param {WebAssembly.Instance} instance */ createExports: (instance) => { const js = swift.memory.heap; - \(classLines.map { $0.indent(count: 12) }.joined(separator: "\n")) - return { - \(exportsLines.map { $0.indent(count: 16) }.joined(separator: "\n")) - }; - }, + \(exportsSection) } } """ + var dtsLines: [String] = [] + dtsLines.append(contentsOf: namespaceDeclarations()) dtsLines.append(contentsOf: dtsClassLines) dtsLines.append("export type Exports = {") dtsLines.append(contentsOf: dtsExportLines.map { $0.indent(count: 4) }) @@ -191,7 +227,7 @@ struct BridgeJSLink { // // To update this file, just rebuild your project or run // `swift package bridge-js`. - + \(dtsLines.joined(separator: "\n")) export function createInstantiator(options: { imports: Imports; @@ -203,17 +239,110 @@ struct BridgeJSLink { """ return (outputJs, outputDts) } - + + private func namespaceDeclarations() -> [String] { + var dtsLines: [String] = [] + var namespaceFunctions: [String: [ExportedFunction]] = [:] + var namespaceClasses: [String: [ExportedClass]] = [:] + + for skeleton in exportedSkeletons { + for function in skeleton.functions { + if let namespace = function.namespace { + let namespaceKey = namespace.joined(separator: ".") + if namespaceFunctions[namespaceKey] == nil { + namespaceFunctions[namespaceKey] = [] + } + namespaceFunctions[namespaceKey]?.append(function) + } + } + + for klass in skeleton.classes { + if let classNamespace = klass.namespace { + let namespaceKey = classNamespace.joined(separator: ".") + if namespaceClasses[namespaceKey] == nil { + namespaceClasses[namespaceKey] = [] + } + namespaceClasses[namespaceKey]?.append(klass) + } + } + } + + guard !namespaceFunctions.isEmpty || !namespaceClasses.isEmpty else { return dtsLines } + + dtsLines.append("export {};") + dtsLines.append("") + dtsLines.append("declare global {") + + let identBaseSize = 4 + + for (namespacePath, classes) in namespaceClasses.sorted(by: { $0.key < $1.key }) { + let parts = namespacePath.split(separator: ".").map(String.init) + + for i in 0.. String? { let call = "instance.exports.\(abiName)(\(parameterForwardings.joined(separator: ", ")))" var returnExpr: String? - + switch returnType { case .void: bodyLines.append("\(call);") @@ -268,13 +397,13 @@ struct BridgeJSLink { } return returnExpr } - + func callConstructor(abiName: String) -> String { let call = "instance.exports.\(abiName)(\(parameterForwardings.joined(separator: ", ")))" bodyLines.append("const ret = \(call);") return "ret" } - + func checkExceptionLines() -> [String] { guard effects.isThrows else { return [] @@ -289,7 +418,7 @@ struct BridgeJSLink { "}", ] } - + func renderFunction( name: String, parameters: [Parameter], @@ -311,11 +440,11 @@ struct BridgeJSLink { return funcLines } } - + private func renderTSSignature(parameters: [Parameter], returnType: BridgeType) -> String { return "(\(parameters.map { "\($0.name): \($0.type.tsType)" }.joined(separator: ", "))): \(returnType.tsType)" } - + func renderExportedFunction(function: ExportedFunction) -> (js: [String], dts: [String]) { let thunkBuilder = ExportedThunkBuilder(effects: function.effects) for param in function.parameters { @@ -333,19 +462,19 @@ struct BridgeJSLink { dtsLines.append( "\(function.name)\(renderTSSignature(parameters: function.parameters, returnType: function.returnType));" ) - + return (funcLines, dtsLines) } - + func renderExportedClass(_ klass: ExportedClass) -> (js: [String], dtsType: [String], dtsExportEntry: [String]) { var jsLines: [String] = [] var dtsTypeLines: [String] = [] var dtsExportEntryLines: [String] = [] - + dtsTypeLines.append("export interface \(klass.name) extends SwiftHeapObject {") dtsExportEntryLines.append("\(klass.name): {") jsLines.append("class \(klass.name) extends SwiftHeapObject {") - + if let constructor: ExportedConstructor = klass.constructor { let thunkBuilder = ExportedThunkBuilder(effects: constructor.effects) for param in constructor.parameters { @@ -360,13 +489,13 @@ struct BridgeJSLink { funcLines.append("super(\(returnExpr), instance.exports.bjs_\(klass.name)_deinit);".indent(count: 4)) funcLines.append("}") jsLines.append(contentsOf: funcLines.map { $0.indent(count: 4) }) - + dtsExportEntryLines.append( "new\(renderTSSignature(parameters: constructor.parameters, returnType: .swiftHeapObject(klass.name)));" .indent(count: 4) ) } - + for method in klass.methods { let thunkBuilder = ExportedThunkBuilder(effects: method.effects) thunkBuilder.lowerSelf() @@ -389,22 +518,63 @@ struct BridgeJSLink { ) } jsLines.append("}") - + dtsTypeLines.append("}") dtsExportEntryLines.append("}") - + return (jsLines, dtsTypeLines, dtsExportEntryLines) } - + + func renderGlobalNamespace(namespacedFunctions: [ExportedFunction], namespacedClasses: [ExportedClass]) -> [String] { + var lines: [String] = [] + var uniqueNamespaces: [String] = [] + var seen = Set() + + let functionNamespacePaths: Set<[String]> = Set(namespacedFunctions + .compactMap { $0.namespace }) + let classNamespacePaths: Set<[String]> = Set(namespacedClasses + .compactMap { $0.namespace }) + + let allNamespacePaths = functionNamespacePaths + .union(classNamespacePaths) + + allNamespacePaths.forEach { namespacePath in + namespacePath.makeIterator().enumerated().forEach { (index, _) in + let path = namespacePath[0...index].joined(separator: ".") + if seen.insert(path).inserted { + uniqueNamespaces.append(path) + } + } + } + + uniqueNamespaces.sorted().forEach { namespace in + lines.append("if (typeof globalThis.\(namespace) === 'undefined') {") + lines.append(" globalThis.\(namespace) = {};") + lines.append("}") + } + + namespacedClasses.forEach { klass in + let namespacePath: String = klass.namespace?.joined(separator: ".") ?? "" + lines.append("globalThis.\(namespacePath).\(klass.name) = exports.\(klass.name);") + } + + namespacedFunctions.forEach { function in + let namespacePath: String = function.namespace?.joined(separator: ".") ?? "" + lines.append("globalThis.\(namespacePath).\(function.name) = exports.\(function.name);") + } + + return lines + } + class ImportedThunkBuilder { var bodyLines: [String] = [] var parameterNames: [String] = [] var parameterForwardings: [String] = [] - + func liftSelf() { parameterNames.append("self") } - + func liftParameter(param: Parameter) { parameterNames.append(param.name) switch param.type { @@ -420,7 +590,7 @@ struct BridgeJSLink { parameterForwardings.append(param.name) } } - + func renderFunction( name: String, returnExpr: String?, @@ -444,7 +614,7 @@ struct BridgeJSLink { funcLines.append("}") return funcLines } - + func call(name: String, returnType: BridgeType) { let call = "options.imports.\(name)(\(parameterForwardings.joined(separator: ", ")))" if returnType == .void { @@ -453,12 +623,12 @@ struct BridgeJSLink { bodyLines.append("let ret = \(call);") } } - + func callConstructor(name: String) { let call = "new options.imports.\(name)(\(parameterForwardings.joined(separator: ", ")))" bodyLines.append("let ret = \(call);") } - + func callMethod(name: String, returnType: BridgeType) { let call = "swift.memory.getObject(self).\(name)(\(parameterForwardings.joined(separator: ", ")))" if returnType == .void { @@ -467,17 +637,17 @@ struct BridgeJSLink { bodyLines.append("let ret = \(call);") } } - + func callPropertyGetter(name: String, returnType: BridgeType) { let call = "swift.memory.getObject(self).\(name)" bodyLines.append("let ret = \(call);") } - + func callPropertySetter(name: String, returnType: BridgeType) { let call = "swift.memory.getObject(self).\(name) = \(parameterForwardings.joined(separator: ", "))" bodyLines.append("\(call);") } - + func lowerReturnValue(returnType: BridgeType) throws -> String? { switch returnType { case .void: @@ -496,28 +666,28 @@ struct BridgeJSLink { } } } - + class ImportObjectBuilder { var moduleName: String var importedLines: [String] = [] var dtsImportLines: [String] = [] - + init(moduleName: String) { self.moduleName = moduleName importedLines.append("const \(moduleName) = importObject[\"\(moduleName)\"] = {};") } - + func assignToImportObject(name: String, function: [String]) { var js = function js[0] = "\(moduleName)[\"\(name)\"] = " + js[0] importedLines.append(contentsOf: js) } - + func appendDts(_ lines: [String]) { dtsImportLines.append(contentsOf: lines) } } - + func renderImportedFunction( importObjectBuilder: ImportObjectBuilder, function: ImportedFunctionSkeleton @@ -540,7 +710,7 @@ struct BridgeJSLink { ) importObjectBuilder.assignToImportObject(name: function.abiName(context: nil), function: funcLines) } - + func renderImportedType( importObjectBuilder: ImportObjectBuilder, type: ImportedTypeSkeleton @@ -564,7 +734,7 @@ struct BridgeJSLink { ) importObjectBuilder.assignToImportObject(name: getterAbiName, function: js) importObjectBuilder.appendDts(dts) - + if !property.isReadonly { let setterAbiName = property.setterAbiName(context: type) let (js, dts) = try renderImportedProperty( @@ -588,7 +758,7 @@ struct BridgeJSLink { importObjectBuilder.appendDts(dts) } } - + func renderImportedConstructor( importObjectBuilder: ImportObjectBuilder, type: ImportedTypeSkeleton, @@ -614,7 +784,7 @@ struct BridgeJSLink { "}", ]) } - + func renderImportedProperty( property: ImportedPropertySkeleton, abiName: String, @@ -630,7 +800,7 @@ struct BridgeJSLink { ) return (funcLines, []) } - + func renderImportedMethod( context: ImportedTypeSkeleton, method: ImportedFunctionSkeleton diff --git a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift index 5bfcc414..56e88f92 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift @@ -29,18 +29,21 @@ struct ExportedFunction: Codable { var parameters: [Parameter] var returnType: BridgeType var effects: Effects + var namespace: [String]? } struct ExportedClass: Codable { var name: String var constructor: ExportedConstructor? var methods: [ExportedFunction] + var namespace: [String]? } struct ExportedConstructor: Codable { var abiName: String var parameters: [Parameter] var effects: Effects + var namespace: [String]? } struct ExportedSkeleton: Codable { diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift new file mode 100644 index 00000000..75f1db62 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift @@ -0,0 +1,33 @@ +@JS func plainFunction() -> String { "plain" } + +@JS("__Swift.Foundation") class Greeter { + var name: String + + @JS("__Swift.Foundation") init(name: String) { + self.name = name + } + + @JS("__Swift.Foundation") func greet() -> String { + return "Hello, " + self.name + "!" + } + + @JS("__Swift.Foundation") func changeName(name: String) { + self.name = name + } +} + +@JS("Utils.Converters") class Converter { + @JS("Utils.Converters") init() {} + + @JS("Utils.Converters") func toString(value: Int) -> String { + return String(value) + } +} + +@JS("__Swift.Foundation") +class UUID { + @JS("__Swift.Foundation") + @JS func uuidString() -> String { + Foundation.UUID().uuidString + } +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.d.ts new file mode 100644 index 00000000..0cab386a --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.d.ts @@ -0,0 +1,68 @@ +// 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 {}; + +declare global { + namespace Utils { + namespace Converters { + class Converter { + constructor(); + toString(value: number): string; + } + } + } + namespace __Swift { + namespace Foundation { + class Greeter { + constructor(name: string); + greet(): string; + changeName(name: string): void; + } + class UUID { + uuidString(): string; + } + } + } +} + +/// Represents a Swift heap object like a class instance or an actor instance. +export interface SwiftHeapObject { + /// Release the heap object. + /// + /// Note: Calling this method will release the heap object and it will no longer be accessible. + release(): void; +} +export interface Greeter extends SwiftHeapObject { + greet(): string; + changeName(name: string): void; +} +export interface Converter extends SwiftHeapObject { + toString(value: number): string; +} +export interface UUID extends SwiftHeapObject { + uuidString(): string; +} +export type Exports = { + Greeter: { + new(name: string): Greeter; + } + Converter: { + new(): Converter; + } + UUID: { + } + plainFunction(): string; +} +export type Imports = { +} +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/Namespaces.Export.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.js new file mode 100644 index 00000000..bda0340e --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.js @@ -0,0 +1,150 @@ +// 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; + return { + /** @param {WebAssembly.Imports} importObject */ + addImports: (importObject) => { + const bjs = {}; + importObject["bjs"] = bjs; + 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); + } + + }, + 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; + /// Represents a Swift heap object like a class instance or an actor instance. + class SwiftHeapObject { + constructor(pointer, deinit) { + this.pointer = pointer; + this.hasReleased = false; + this.deinit = deinit; + this.registry = new FinalizationRegistry((pointer) => { + deinit(pointer); + }); + this.registry.register(this, this.pointer); + } + + release() { + this.registry.unregister(this); + this.deinit(this.pointer); + } + } + class Greeter extends SwiftHeapObject { + constructor(name) { + const nameBytes = textEncoder.encode(name); + const nameId = swift.memory.retain(nameBytes); + const ret = instance.exports.bjs_Greeter_init(nameId, nameBytes.length); + swift.memory.release(nameId); + super(ret, instance.exports.bjs_Greeter_deinit); + } + greet() { + instance.exports.bjs_Greeter_greet(this.pointer); + const ret = tmpRetString; + tmpRetString = undefined; + return ret; + } + changeName(name) { + const nameBytes = textEncoder.encode(name); + const nameId = swift.memory.retain(nameBytes); + instance.exports.bjs_Greeter_changeName(this.pointer, nameId, nameBytes.length); + swift.memory.release(nameId); + } + } + class Converter extends SwiftHeapObject { + constructor() { + const ret = instance.exports.bjs_Converter_init(); + super(ret, instance.exports.bjs_Converter_deinit); + } + toString(value) { + instance.exports.bjs_Converter_toString(this.pointer, value); + const ret = tmpRetString; + tmpRetString = undefined; + return ret; + } + } + class UUID extends SwiftHeapObject { + uuidString() { + instance.exports.bjs_UUID_uuidString(this.pointer); + const ret = tmpRetString; + tmpRetString = undefined; + return ret; + } + } + const exports = { + Greeter, + Converter, + UUID, + plainFunction: function bjs_plainFunction() { + instance.exports.bjs_plainFunction(); + const ret = tmpRetString; + tmpRetString = undefined; + return ret; + }, + }; + + if (typeof globalThis.Utils === 'undefined') { + globalThis.Utils = {}; + } + if (typeof globalThis.Utils.Converters === 'undefined') { + globalThis.Utils.Converters = {}; + } + if (typeof globalThis.__Swift === 'undefined') { + globalThis.__Swift = {}; + } + if (typeof globalThis.__Swift.Foundation === 'undefined') { + globalThis.__Swift.Foundation = {}; + } + globalThis.__Swift.Foundation.Greeter = exports.Greeter; + globalThis.Utils.Converters.Converter = exports.Converter; + globalThis.__Swift.Foundation.UUID = exports.UUID; + + return exports; + }, + } +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.json new file mode 100644 index 00000000..0eda721e --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.json @@ -0,0 +1,173 @@ +{ + "classes" : [ + { + "constructor" : { + "abiName" : "bjs_Greeter_init", + "effects" : { + "isAsync" : false, + "isThrows" : false + }, + "parameters" : [ + { + "label" : "name", + "name" : "name", + "type" : { + "string" : { + + } + } + } + ] + }, + "methods" : [ + { + "abiName" : "bjs_Greeter_greet", + "effects" : { + "isAsync" : false, + "isThrows" : false + }, + "name" : "greet", + "namespace" : [ + "__Swift", + "Foundation" + ], + "parameters" : [ + + ], + "returnType" : { + "string" : { + + } + } + }, + { + "abiName" : "bjs_Greeter_changeName", + "effects" : { + "isAsync" : false, + "isThrows" : false + }, + "name" : "changeName", + "namespace" : [ + "__Swift", + "Foundation" + ], + "parameters" : [ + { + "label" : "name", + "name" : "name", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "void" : { + + } + } + } + ], + "name" : "Greeter", + "namespace" : [ + "__Swift", + "Foundation" + ] + }, + { + "constructor" : { + "abiName" : "bjs_Converter_init", + "effects" : { + "isAsync" : false, + "isThrows" : false + }, + "parameters" : [ + + ] + }, + "methods" : [ + { + "abiName" : "bjs_Converter_toString", + "effects" : { + "isAsync" : false, + "isThrows" : false + }, + "name" : "toString", + "namespace" : [ + "Utils", + "Converters" + ], + "parameters" : [ + { + "label" : "value", + "name" : "value", + "type" : { + "int" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + } + ], + "name" : "Converter", + "namespace" : [ + "Utils", + "Converters" + ] + }, + { + "methods" : [ + { + "abiName" : "bjs_UUID_uuidString", + "effects" : { + "isAsync" : false, + "isThrows" : false + }, + "name" : "uuidString", + "namespace" : [ + "__Swift", + "Foundation" + ], + "parameters" : [ + + ], + "returnType" : { + "string" : { + + } + } + } + ], + "name" : "UUID", + "namespace" : [ + "__Swift", + "Foundation" + ] + } + ], + "functions" : [ + { + "abiName" : "bjs_plainFunction", + "effects" : { + "isAsync" : false, + "isThrows" : false + }, + "name" : "plainFunction", + "parameters" : [ + + ], + "returnType" : { + "string" : { + + } + } + } + ] +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.swift new file mode 100644 index 00000000..7bc0ca00 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.swift @@ -0,0 +1,117 @@ +// 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`. + +@_spi(BridgeJS) import JavaScriptKit + +@_expose(wasm, "bjs_plainFunction") +@_cdecl("bjs_plainFunction") +public func _bjs_plainFunction() -> Void { + #if arch(wasm32) + var ret = plainFunction() + return ret.withUTF8 { ptr in + _swift_js_return_string(ptr.baseAddress, Int32(ptr.count)) + } + #else + fatalError("Only available on WebAssembly") + #endif +} + +@_expose(wasm, "bjs_Greeter_init") +@_cdecl("bjs_Greeter_init") +public func _bjs_Greeter_init(nameBytes: Int32, nameLen: Int32) -> UnsafeMutableRawPointer { + #if arch(wasm32) + let name = String(unsafeUninitializedCapacity: Int(nameLen)) { b in + _swift_js_init_memory(nameBytes, b.baseAddress.unsafelyUnwrapped) + return Int(nameLen) + } + let ret = Greeter(name: name) + return Unmanaged.passRetained(ret).toOpaque() + #else + fatalError("Only available on WebAssembly") + #endif +} + +@_expose(wasm, "bjs_Greeter_greet") +@_cdecl("bjs_Greeter_greet") +public func _bjs_Greeter_greet(_self: UnsafeMutableRawPointer) -> Void { + #if arch(wasm32) + var ret = Unmanaged.fromOpaque(_self).takeUnretainedValue().greet() + return ret.withUTF8 { ptr in + _swift_js_return_string(ptr.baseAddress, Int32(ptr.count)) + } + #else + fatalError("Only available on WebAssembly") + #endif +} + +@_expose(wasm, "bjs_Greeter_changeName") +@_cdecl("bjs_Greeter_changeName") +public func _bjs_Greeter_changeName(_self: UnsafeMutableRawPointer, nameBytes: Int32, nameLen: Int32) -> Void { + #if arch(wasm32) + let name = String(unsafeUninitializedCapacity: Int(nameLen)) { b in + _swift_js_init_memory(nameBytes, b.baseAddress.unsafelyUnwrapped) + return Int(nameLen) + } + Unmanaged.fromOpaque(_self).takeUnretainedValue().changeName(name: name) + #else + fatalError("Only available on WebAssembly") + #endif +} + +@_expose(wasm, "bjs_Greeter_deinit") +@_cdecl("bjs_Greeter_deinit") +public func _bjs_Greeter_deinit(pointer: UnsafeMutableRawPointer) { + Unmanaged.fromOpaque(pointer).release() +} + +@_expose(wasm, "bjs_Converter_init") +@_cdecl("bjs_Converter_init") +public func _bjs_Converter_init() -> UnsafeMutableRawPointer { + #if arch(wasm32) + let ret = Converter() + return Unmanaged.passRetained(ret).toOpaque() + #else + fatalError("Only available on WebAssembly") + #endif +} + +@_expose(wasm, "bjs_Converter_toString") +@_cdecl("bjs_Converter_toString") +public func _bjs_Converter_toString(_self: UnsafeMutableRawPointer, value: Int32) -> Void { + #if arch(wasm32) + var ret = Unmanaged.fromOpaque(_self).takeUnretainedValue().toString(value: Int(value)) + return ret.withUTF8 { ptr in + _swift_js_return_string(ptr.baseAddress, Int32(ptr.count)) + } + #else + fatalError("Only available on WebAssembly") + #endif +} + +@_expose(wasm, "bjs_Converter_deinit") +@_cdecl("bjs_Converter_deinit") +public func _bjs_Converter_deinit(pointer: UnsafeMutableRawPointer) { + Unmanaged.fromOpaque(pointer).release() +} + +@_expose(wasm, "bjs_UUID_uuidString") +@_cdecl("bjs_UUID_uuidString") +public func _bjs_UUID_uuidString(_self: UnsafeMutableRawPointer) -> Void { + #if arch(wasm32) + var ret = Unmanaged.fromOpaque(_self).takeUnretainedValue().uuidString() + return ret.withUTF8 { ptr in + _swift_js_return_string(ptr.baseAddress, Int32(ptr.count)) + } + #else + fatalError("Only available on WebAssembly") + #endif +} + +@_expose(wasm, "bjs_UUID_deinit") +@_cdecl("bjs_UUID_deinit") +public func _bjs_UUID_deinit(pointer: UnsafeMutableRawPointer) { + Unmanaged.fromOpaque(pointer).release() +} \ No newline at end of file diff --git a/Sources/JavaScriptKit/Macros.swift b/Sources/JavaScriptKit/Macros.swift index bddd8c7c..75642939 100644 --- a/Sources/JavaScriptKit/Macros.swift +++ b/Sources/JavaScriptKit/Macros.swift @@ -33,3 +33,69 @@ /// - Important: This feature is still experimental. No API stability is guaranteed, and the API may change in future releases. @attached(peer) public macro JS() = Builtin.ExternalMacro + +/// A macro that exposes Swift functions, classes, and methods to JavaScript. +/// Additionally defines namespaces defined by `namespace` parameter +/// +/// Apply this macro to Swift declarations that you want to make callable from JavaScript: +/// +/// ```swift +/// // Export a function to JavaScript with a custom namespace +/// @JS("__Swift.Foundation.UUID") public func create() -> String { +/// UUID().uuidString +/// } +/// +/// // Export a class with a custom namespace (note that each method needs to specify the namespace) +/// @JS("Utils.Greeters") class Greeter { +/// var name: String +/// +/// @JS("Utils.Greeters") init(name: String) { +/// self.name = name +/// } +/// +/// @JS("Utils.Greeters") func greet() -> String { +/// return "Hello, " + self.name + "!" +/// } +/// +/// @JS("Utils.Greeters") func changeName(name: String) { +/// self.name = name +/// } +/// } +/// ``` +/// And the corresponding TypeScript declaration will be generated as: +/// ```javascript +/// declare global { +/// namespace Utils { +/// namespace Greeters { +/// class Greeter { +/// constructor(name: string); +/// greet(): string; +/// changeName(name: string): void; +/// } +/// } +/// } +/// namespace __Swift { +/// namespace Foundation { +/// namespace UUID { +/// function create(): string; +/// } +/// } +/// } +/// } +/// ``` +/// The above Swift class will be accessible in JavaScript as: +/// ```javascript +/// const greeter = new globalThis.Utils.Greeters.Greeter("World"); +/// console.log(greeter.greet()); // "Hello, World!" +/// greeter.changeName("JavaScript"); +/// console.log(greeter.greet()); // "Hello, JavaScript!" +/// +/// const uuid = new globalThis.__Swift.Foundation.UUID.create(); // "1A83F0E0-F7F2-4FD1-8873-01A68CF79AF4" +/// ``` +/// +/// - Parameter namespace: A dot-separated string that defines the namespace hierarchy in JavaScript. +/// Each segment becomes a nested object in the resulting JavaScript structure. +/// +/// - Important: This feature is still experimental. No API stability is guaranteed, and the API may change in future releases. +@attached(peer) +public macro JS(_ namespace: String) = Builtin.ExternalMacro From e91edce4b53d05b5559a84458eee10f46fcb08e1 Mon Sep 17 00:00:00 2001 From: Krzysztof Rodak Date: Thu, 14 Aug 2025 18:11:31 +0200 Subject: [PATCH 2/4] BridgeJS: fix: unify macros to avoid duplication --- Sources/JavaScriptKit/Macros.swift | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/Sources/JavaScriptKit/Macros.swift b/Sources/JavaScriptKit/Macros.swift index 75642939..43c399c5 100644 --- a/Sources/JavaScriptKit/Macros.swift +++ b/Sources/JavaScriptKit/Macros.swift @@ -24,27 +24,16 @@ /// } /// ``` /// -/// When you build your project with the BridgeJS plugin, these declarations will be -/// accessible from JavaScript, and TypeScript declaration files (`.d.ts`) will be -/// automatically generated to provide type safety. -/// -/// For detailed usage information, see the article . +/// If you prefer to access through namespace-based syntax, you can use `namespace` parameter /// -/// - Important: This feature is still experimental. No API stability is guaranteed, and the API may change in future releases. -@attached(peer) -public macro JS() = Builtin.ExternalMacro - -/// A macro that exposes Swift functions, classes, and methods to JavaScript. -/// Additionally defines namespaces defined by `namespace` parameter -/// -/// Apply this macro to Swift declarations that you want to make callable from JavaScript: +/// Example: /// /// ```swift /// // Export a function to JavaScript with a custom namespace /// @JS("__Swift.Foundation.UUID") public func create() -> String { /// UUID().uuidString /// } -/// +/// /// // Export a class with a custom namespace (note that each method needs to specify the namespace) /// @JS("Utils.Greeters") class Greeter { /// var name: String @@ -52,11 +41,11 @@ public macro JS() = Builtin.ExternalMacro /// @JS("Utils.Greeters") init(name: String) { /// self.name = name /// } -/// +/// /// @JS("Utils.Greeters") func greet() -> String { /// return "Hello, " + self.name + "!" /// } -/// +/// /// @JS("Utils.Greeters") func changeName(name: String) { /// self.name = name /// } @@ -93,9 +82,15 @@ public macro JS() = Builtin.ExternalMacro /// const uuid = new globalThis.__Swift.Foundation.UUID.create(); // "1A83F0E0-F7F2-4FD1-8873-01A68CF79AF4" /// ``` /// +/// When you build your project with the BridgeJS plugin, these declarations will be +/// accessible from JavaScript, and TypeScript declaration files (`.d.ts`) will be +/// automatically generated to provide type safety. +/// +/// For detailed usage information, see the article . +/// /// - Parameter namespace: A dot-separated string that defines the namespace hierarchy in JavaScript. /// Each segment becomes a nested object in the resulting JavaScript structure. /// /// - Important: This feature is still experimental. No API stability is guaranteed, and the API may change in future releases. @attached(peer) -public macro JS(_ namespace: String) = Builtin.ExternalMacro +public macro JS(namespace: String? = nil) = Builtin.ExternalMacro From 5ca3335bc5a083fa58709a6885d7bdd848af6814 Mon Sep 17 00:00:00 2001 From: Krzysztof Rodak Date: Thu, 14 Aug 2025 18:12:56 +0200 Subject: [PATCH 3/4] BridgeJS: fix: don't allow for other than top-levels namespace macros --- .../Sources/BridgeJSCore/ExportSwift.swift | 19 ++++++ .../BridgeJSToolTests/Inputs/Namespaces.swift | 13 ++-- .../BridgeJSLinkTests/Namespaces.Export.d.ts | 8 ++- .../BridgeJSLinkTests/Namespaces.Export.js | 19 ++++-- .../ExportSwiftTests/Namespaces.json | 60 +++++++------------ .../ExportSwiftTests/Namespaces.swift | 27 ++++----- Sources/JavaScriptKit/Macros.swift | 8 +-- 7 files changed, 82 insertions(+), 72 deletions(-) diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift index 2dc16456..6503281b 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift @@ -130,6 +130,14 @@ class ExportSwift { let name = node.name.text let namespace = extractNamespace(from: jsAttribute) + if namespace != nil, case .classBody = state { + diagnose( + node: jsAttribute, + message: "Namespace is only needed in top-level declaration", + hint: "Remove the namespace from @JS attribute or move this function to top-level" + ) + } + var parameters: [Parameter] = [] for param in node.signature.parameterClause.parameters { guard let type = self.parent.lookupType(for: param.type) else { @@ -214,6 +222,17 @@ class ExportSwift { diagnose(node: node, message: "@JS init must be inside a @JS class") return .skipChildren } + + if let jsAttribute = node.attributes.firstJSAttribute, + let namespace = extractNamespace(from: jsAttribute), + namespace != nil { + diagnose( + node: jsAttribute, + message: "Namespace is not supported for initializer declarations", + hint: "Remove the namespace from @JS attribute" + ) + } + var parameters: [Parameter] = [] for param in node.signature.parameterClause.parameters { guard let type = self.parent.lookupType(for: param.type) else { diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift index 75f1db62..50e275cb 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift @@ -1,32 +1,33 @@ @JS func plainFunction() -> String { "plain" } +@JS("MyModule.Utils") func namespacedFunction() -> String { "namespaced" } + @JS("__Swift.Foundation") class Greeter { var name: String - @JS("__Swift.Foundation") init(name: String) { + @JS init(name: String) { self.name = name } - @JS("__Swift.Foundation") func greet() -> String { + @JS func greet() -> String { return "Hello, " + self.name + "!" } - @JS("__Swift.Foundation") func changeName(name: String) { + func changeName(name: String) { self.name = name } } @JS("Utils.Converters") class Converter { - @JS("Utils.Converters") init() {} + @JS init() {} - @JS("Utils.Converters") func toString(value: Int) -> String { + @JS func toString(value: Int) -> String { return String(value) } } @JS("__Swift.Foundation") class UUID { - @JS("__Swift.Foundation") @JS func uuidString() -> String { Foundation.UUID().uuidString } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.d.ts index 0cab386a..b2ccecc4 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.d.ts +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.d.ts @@ -20,13 +20,17 @@ declare global { class Greeter { constructor(name: string); greet(): string; - changeName(name: string): void; } class UUID { uuidString(): string; } } } + namespace MyModule { + namespace Utils { + function namespacedFunction(): string; + } + } } /// Represents a Swift heap object like a class instance or an actor instance. @@ -38,7 +42,6 @@ export interface SwiftHeapObject { } export interface Greeter extends SwiftHeapObject { greet(): string; - changeName(name: string): void; } export interface Converter extends SwiftHeapObject { toString(value: number): string; @@ -56,6 +59,7 @@ export type Exports = { UUID: { } plainFunction(): string; + namespacedFunction(): string; } export type Imports = { } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.js index bda0340e..dce99393 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.js +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.js @@ -89,12 +89,6 @@ export async function createInstantiator(options, swift) { tmpRetString = undefined; return ret; } - changeName(name) { - const nameBytes = textEncoder.encode(name); - const nameId = swift.memory.retain(nameBytes); - instance.exports.bjs_Greeter_changeName(this.pointer, nameId, nameBytes.length); - swift.memory.release(nameId); - } } class Converter extends SwiftHeapObject { constructor() { @@ -126,8 +120,20 @@ export async function createInstantiator(options, swift) { tmpRetString = undefined; return ret; }, + namespacedFunction: function bjs_namespacedFunction() { + instance.exports.bjs_namespacedFunction(); + const ret = tmpRetString; + tmpRetString = undefined; + return ret; + }, }; + if (typeof globalThis.MyModule === 'undefined') { + globalThis.MyModule = {}; + } + if (typeof globalThis.MyModule.Utils === 'undefined') { + globalThis.MyModule.Utils = {}; + } if (typeof globalThis.Utils === 'undefined') { globalThis.Utils = {}; } @@ -143,6 +149,7 @@ export async function createInstantiator(options, swift) { globalThis.__Swift.Foundation.Greeter = exports.Greeter; globalThis.Utils.Converters.Converter = exports.Converter; globalThis.__Swift.Foundation.UUID = exports.UUID; + globalThis.MyModule.Utils.namespacedFunction = exports.namespacedFunction; return exports; }, diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.json index 0eda721e..2a6440f1 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.json +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.json @@ -27,44 +27,12 @@ "isThrows" : false }, "name" : "greet", - "namespace" : [ - "__Swift", - "Foundation" - ], "parameters" : [ ], "returnType" : { "string" : { - } - } - }, - { - "abiName" : "bjs_Greeter_changeName", - "effects" : { - "isAsync" : false, - "isThrows" : false - }, - "name" : "changeName", - "namespace" : [ - "__Swift", - "Foundation" - ], - "parameters" : [ - { - "label" : "name", - "name" : "name", - "type" : { - "string" : { - - } - } - } - ], - "returnType" : { - "void" : { - } } } @@ -94,10 +62,6 @@ "isThrows" : false }, "name" : "toString", - "namespace" : [ - "Utils", - "Converters" - ], "parameters" : [ { "label" : "value", @@ -131,10 +95,6 @@ "isThrows" : false }, "name" : "uuidString", - "namespace" : [ - "__Swift", - "Foundation" - ], "parameters" : [ ], @@ -162,6 +122,26 @@ "name" : "plainFunction", "parameters" : [ + ], + "returnType" : { + "string" : { + + } + } + }, + { + "abiName" : "bjs_namespacedFunction", + "effects" : { + "isAsync" : false, + "isThrows" : false + }, + "name" : "namespacedFunction", + "namespace" : [ + "MyModule", + "Utils" + ], + "parameters" : [ + ], "returnType" : { "string" : { diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.swift index 7bc0ca00..fba15b29 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.swift @@ -19,6 +19,19 @@ public func _bjs_plainFunction() -> Void { #endif } +@_expose(wasm, "bjs_namespacedFunction") +@_cdecl("bjs_namespacedFunction") +public func _bjs_namespacedFunction() -> Void { + #if arch(wasm32) + var ret = namespacedFunction() + return ret.withUTF8 { ptr in + _swift_js_return_string(ptr.baseAddress, Int32(ptr.count)) + } + #else + fatalError("Only available on WebAssembly") + #endif +} + @_expose(wasm, "bjs_Greeter_init") @_cdecl("bjs_Greeter_init") public func _bjs_Greeter_init(nameBytes: Int32, nameLen: Int32) -> UnsafeMutableRawPointer { @@ -47,20 +60,6 @@ public func _bjs_Greeter_greet(_self: UnsafeMutableRawPointer) -> Void { #endif } -@_expose(wasm, "bjs_Greeter_changeName") -@_cdecl("bjs_Greeter_changeName") -public func _bjs_Greeter_changeName(_self: UnsafeMutableRawPointer, nameBytes: Int32, nameLen: Int32) -> Void { - #if arch(wasm32) - let name = String(unsafeUninitializedCapacity: Int(nameLen)) { b in - _swift_js_init_memory(nameBytes, b.baseAddress.unsafelyUnwrapped) - return Int(nameLen) - } - Unmanaged.fromOpaque(_self).takeUnretainedValue().changeName(name: name) - #else - fatalError("Only available on WebAssembly") - #endif -} - @_expose(wasm, "bjs_Greeter_deinit") @_cdecl("bjs_Greeter_deinit") public func _bjs_Greeter_deinit(pointer: UnsafeMutableRawPointer) { diff --git a/Sources/JavaScriptKit/Macros.swift b/Sources/JavaScriptKit/Macros.swift index 43c399c5..7adb053f 100644 --- a/Sources/JavaScriptKit/Macros.swift +++ b/Sources/JavaScriptKit/Macros.swift @@ -34,19 +34,19 @@ /// UUID().uuidString /// } /// -/// // Export a class with a custom namespace (note that each method needs to specify the namespace) +/// // Export a class with a custom namespace (note that only top level macro needs to specify the namespace) /// @JS("Utils.Greeters") class Greeter { /// var name: String /// -/// @JS("Utils.Greeters") init(name: String) { +/// @JS init(name: String) { /// self.name = name /// } /// -/// @JS("Utils.Greeters") func greet() -> String { +/// @JS func greet() -> String { /// return "Hello, " + self.name + "!" /// } /// -/// @JS("Utils.Greeters") func changeName(name: String) { +/// @JS func changeName(name: String) { /// self.name = name /// } /// } From e7017023c2525d68ca41cc0111f128c29838792d Mon Sep 17 00:00:00 2001 From: Krzysztof Rodak Date: Thu, 14 Aug 2025 18:26:41 +0200 Subject: [PATCH 4/4] BridgeJS: fix: adding name for namespace parameter BridgeJS: fix: run swiftformat BridgeJS: chore: extend docc documentaion with namespace example --- .../Sources/BridgeJSCore/ExportSwift.swift | 34 ++- .../Sources/BridgeJSLink/BridgeJSLink.swift | 256 +++++++++--------- .../BridgeJSToolTests/Inputs/Namespaces.swift | 16 +- .../Articles/Exporting-Swift-to-JavaScript.md | 90 ++++++ Sources/JavaScriptKit/Macros.swift | 4 +- .../JSClosure+AsyncTests.swift | 6 +- 6 files changed, 257 insertions(+), 149 deletions(-) diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift index 6503281b..dfe161e9 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift @@ -126,10 +126,10 @@ class ExportSwift { guard let jsAttribute = node.attributes.firstJSAttribute else { return nil } - + let name = node.name.text let namespace = extractNamespace(from: jsAttribute) - + if namespace != nil, case .classBody = state { diagnose( node: jsAttribute, @@ -137,7 +137,7 @@ class ExportSwift { hint: "Remove the namespace from @JS attribute or move this function to top-level" ) } - + var parameters: [Parameter] = [] for param in node.signature.parameterClause.parameters { guard let type = self.parent.lookupType(for: param.type) else { @@ -204,15 +204,21 @@ class ExportSwift { } return Effects(isAsync: isAsync, isThrows: isThrows) } - + private func extractNamespace( from jsAttribute: AttributeSyntax ) -> [String]? { - guard let arguments = jsAttribute.arguments?.as(LabeledExprListSyntax.self), - let firstArg = arguments.first?.expression.as(StringLiteralExprSyntax.self), - let namespaceString = firstArg.segments.first?.as(StringSegmentSyntax.self)?.content.text else { - return nil + guard let arguments = jsAttribute.arguments?.as(LabeledExprListSyntax.self) else { + return nil } + + guard let namespaceArg = arguments.first(where: { $0.label?.text == "namespace" }), + let stringLiteral = namespaceArg.expression.as(StringLiteralExprSyntax.self), + let namespaceString = stringLiteral.segments.first?.as(StringSegmentSyntax.self)?.content.text + else { + return nil + } + return namespaceString.split(separator: ".").map(String.init) } @@ -222,17 +228,17 @@ class ExportSwift { diagnose(node: node, message: "@JS init must be inside a @JS class") return .skipChildren } - + if let jsAttribute = node.attributes.firstJSAttribute, - let namespace = extractNamespace(from: jsAttribute), - namespace != nil { + extractNamespace(from: jsAttribute) != nil + { diagnose( node: jsAttribute, message: "Namespace is not supported for initializer declarations", hint: "Remove the namespace from @JS attribute" ) } - + var parameters: [Parameter] = [] for param in node.signature.parameterClause.parameters { guard let type = self.parent.lookupType(for: param.type) else { @@ -259,7 +265,7 @@ class ExportSwift { override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { let name = node.name.text - + stateStack.push(state: .classBody(name: name)) guard let jsAttribute = node.attributes.firstJSAttribute else { return .skipChildren } @@ -675,7 +681,7 @@ extension AttributeListSyntax { fileprivate func hasJSAttribute() -> Bool { firstJSAttribute != nil } - + fileprivate var firstJSAttribute: AttributeSyntax? { first(where: { $0.as(AttributeSyntax.self)?.attributeName.trimmedDescription == "JS" diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift index 871ffe6b..022c5cbb 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift @@ -6,7 +6,7 @@ struct BridgeJSLink { var exportedSkeletons: [ExportedSkeleton] = [] var importedSkeletons: [ImportedModuleSkeleton] = [] let sharedMemory: Bool - + init( exportedSkeletons: [ExportedSkeleton] = [], importedSkeletons: [ImportedModuleSkeleton] = [], @@ -16,17 +16,17 @@ struct BridgeJSLink { self.importedSkeletons = importedSkeletons self.sharedMemory = sharedMemory } - + mutating func addExportedSkeletonFile(data: Data) throws { let skeleton = try JSONDecoder().decode(ExportedSkeleton.self, from: data) exportedSkeletons.append(skeleton) } - + mutating func addImportedSkeletonFile(data: Data) throws { let skeletons = try JSONDecoder().decode(ImportedModuleSkeleton.self, from: data) importedSkeletons.append(skeletons) } - + let swiftHeapObjectClassDts = """ /// Represents a Swift heap object like a class instance or an actor instance. export interface SwiftHeapObject { @@ -36,7 +36,7 @@ struct BridgeJSLink { release(): void; } """ - + let swiftHeapObjectClassJs = """ /// Represents a Swift heap object like a class instance or an actor instance. class SwiftHeapObject { @@ -49,14 +49,14 @@ struct BridgeJSLink { }); this.registry.register(this, this.pointer); } - + release() { this.registry.unregister(this); this.deinit(this.pointer); } } """ - + func link() throws -> (outputJs: String, outputDts: String) { var exportsLines: [String] = [] var classLines: [String] = [] @@ -64,7 +64,7 @@ struct BridgeJSLink { var dtsClassLines: [String] = [] var namespacedFunctions: [ExportedFunction] = [] var namespacedClasses: [ExportedClass] = [] - + if exportedSkeletons.contains(where: { $0.classes.count > 0 }) { classLines.append( contentsOf: swiftHeapObjectClassJs.split(separator: "\n", omittingEmptySubsequences: false).map { @@ -77,7 +77,7 @@ struct BridgeJSLink { } ) } - + for skeleton in exportedSkeletons { for klass in skeleton.classes { let (jsType, dtsType, dtsExportEntry) = renderExportedClass(klass) @@ -85,26 +85,26 @@ struct BridgeJSLink { exportsLines.append("\(klass.name),") dtsExportLines.append(contentsOf: dtsExportEntry) dtsClassLines.append(contentsOf: dtsType) - + if klass.namespace != nil { namespacedClasses.append(klass) } } - + for function in skeleton.functions { var (js, dts) = renderExportedFunction(function: function) - + if function.namespace != nil { namespacedFunctions.append(function) } - + js[0] = "\(function.name): " + js[0] js[js.count - 1] += "," exportsLines.append(contentsOf: js) dtsExportLines.append(contentsOf: dts) } } - + var importObjectBuilders: [ImportObjectBuilder] = [] for skeletonSet in importedSkeletons { let importObjectBuilder = ImportObjectBuilder(moduleName: skeletonSet.moduleName) @@ -118,48 +118,51 @@ struct BridgeJSLink { } importObjectBuilders.append(importObjectBuilder) } - + let hasNamespacedItems = !namespacedFunctions.isEmpty || !namespacedClasses.isEmpty - + let exportsSection: String if hasNamespacedItems { - let namespaceSetupCode = renderGlobalNamespace(namespacedFunctions: namespacedFunctions, namespacedClasses: namespacedClasses) - .map { $0.indent(count: 12) }.joined(separator: "\n") + let namespaceSetupCode = renderGlobalNamespace( + namespacedFunctions: namespacedFunctions, + namespacedClasses: namespacedClasses + ) + .map { $0.indent(count: 12) }.joined(separator: "\n") exportsSection = """ - \(classLines.map { $0.indent(count: 12) }.joined(separator: "\n")) - const exports = { - \(exportsLines.map { $0.indent(count: 16) }.joined(separator: "\n")) - }; - - \(namespaceSetupCode) - - return exports; - }, - """ + \(classLines.map { $0.indent(count: 12) }.joined(separator: "\n")) + const exports = { + \(exportsLines.map { $0.indent(count: 16) }.joined(separator: "\n")) + }; + + \(namespaceSetupCode) + + return exports; + }, + """ } else { exportsSection = """ - \(classLines.map { $0.indent(count: 12) }.joined(separator: "\n")) - return { - \(exportsLines.map { $0.indent(count: 16) }.joined(separator: "\n")) - }; - }, - """ + \(classLines.map { $0.indent(count: 12) }.joined(separator: "\n")) + return { + \(exportsLines.map { $0.indent(count: 16) }.joined(separator: "\n")) + }; + }, + """ } - + let outputJs = """ // 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; @@ -211,7 +214,7 @@ struct BridgeJSLink { } } """ - + var dtsLines: [String] = [] dtsLines.append(contentsOf: namespaceDeclarations()) dtsLines.append(contentsOf: dtsClassLines) @@ -227,7 +230,7 @@ struct BridgeJSLink { // // To update this file, just rebuild your project or run // `swift package bridge-js`. - + \(dtsLines.joined(separator: "\n")) export function createInstantiator(options: { imports: Imports; @@ -239,12 +242,12 @@ struct BridgeJSLink { """ return (outputJs, outputDts) } - + private func namespaceDeclarations() -> [String] { var dtsLines: [String] = [] var namespaceFunctions: [String: [ExportedFunction]] = [:] var namespaceClasses: [String: [ExportedClass]] = [:] - + for skeleton in exportedSkeletons { for function in skeleton.functions { if let namespace = function.namespace { @@ -255,7 +258,7 @@ struct BridgeJSLink { namespaceFunctions[namespaceKey]?.append(function) } } - + for klass in skeleton.classes { if let classNamespace = klass.namespace { let namespaceKey = classNamespace.joined(separator: ".") @@ -266,83 +269,86 @@ struct BridgeJSLink { } } } - + guard !namespaceFunctions.isEmpty || !namespaceClasses.isEmpty else { return dtsLines } - + dtsLines.append("export {};") dtsLines.append("") dtsLines.append("declare global {") - + let identBaseSize = 4 - + for (namespacePath, classes) in namespaceClasses.sorted(by: { $0.key < $1.key }) { let parts = namespacePath.split(separator: ".").map(String.init) - + for i in 0.. String? { let call = "instance.exports.\(abiName)(\(parameterForwardings.joined(separator: ", ")))" var returnExpr: String? - + switch returnType { case .void: bodyLines.append("\(call);") @@ -397,13 +403,13 @@ struct BridgeJSLink { } return returnExpr } - + func callConstructor(abiName: String) -> String { let call = "instance.exports.\(abiName)(\(parameterForwardings.joined(separator: ", ")))" bodyLines.append("const ret = \(call);") return "ret" } - + func checkExceptionLines() -> [String] { guard effects.isThrows else { return [] @@ -418,7 +424,7 @@ struct BridgeJSLink { "}", ] } - + func renderFunction( name: String, parameters: [Parameter], @@ -440,11 +446,11 @@ struct BridgeJSLink { return funcLines } } - + private func renderTSSignature(parameters: [Parameter], returnType: BridgeType) -> String { return "(\(parameters.map { "\($0.name): \($0.type.tsType)" }.joined(separator: ", "))): \(returnType.tsType)" } - + func renderExportedFunction(function: ExportedFunction) -> (js: [String], dts: [String]) { let thunkBuilder = ExportedThunkBuilder(effects: function.effects) for param in function.parameters { @@ -462,19 +468,19 @@ struct BridgeJSLink { dtsLines.append( "\(function.name)\(renderTSSignature(parameters: function.parameters, returnType: function.returnType));" ) - + return (funcLines, dtsLines) } - + func renderExportedClass(_ klass: ExportedClass) -> (js: [String], dtsType: [String], dtsExportEntry: [String]) { var jsLines: [String] = [] var dtsTypeLines: [String] = [] var dtsExportEntryLines: [String] = [] - + dtsTypeLines.append("export interface \(klass.name) extends SwiftHeapObject {") dtsExportEntryLines.append("\(klass.name): {") jsLines.append("class \(klass.name) extends SwiftHeapObject {") - + if let constructor: ExportedConstructor = klass.constructor { let thunkBuilder = ExportedThunkBuilder(effects: constructor.effects) for param in constructor.parameters { @@ -489,13 +495,13 @@ struct BridgeJSLink { funcLines.append("super(\(returnExpr), instance.exports.bjs_\(klass.name)_deinit);".indent(count: 4)) funcLines.append("}") jsLines.append(contentsOf: funcLines.map { $0.indent(count: 4) }) - + dtsExportEntryLines.append( "new\(renderTSSignature(parameters: constructor.parameters, returnType: .swiftHeapObject(klass.name)));" .indent(count: 4) ) } - + for method in klass.methods { let thunkBuilder = ExportedThunkBuilder(effects: method.effects) thunkBuilder.lowerSelf() @@ -518,26 +524,32 @@ struct BridgeJSLink { ) } jsLines.append("}") - + dtsTypeLines.append("}") dtsExportEntryLines.append("}") - + return (jsLines, dtsTypeLines, dtsExportEntryLines) } - - func renderGlobalNamespace(namespacedFunctions: [ExportedFunction], namespacedClasses: [ExportedClass]) -> [String] { + + func renderGlobalNamespace(namespacedFunctions: [ExportedFunction], namespacedClasses: [ExportedClass]) -> [String] + { var lines: [String] = [] var uniqueNamespaces: [String] = [] var seen = Set() - let functionNamespacePaths: Set<[String]> = Set(namespacedFunctions - .compactMap { $0.namespace }) - let classNamespacePaths: Set<[String]> = Set(namespacedClasses - .compactMap { $0.namespace }) - - let allNamespacePaths = functionNamespacePaths + let functionNamespacePaths: Set<[String]> = Set( + namespacedFunctions + .compactMap { $0.namespace } + ) + let classNamespacePaths: Set<[String]> = Set( + namespacedClasses + .compactMap { $0.namespace } + ) + + let allNamespacePaths = + functionNamespacePaths .union(classNamespacePaths) - + allNamespacePaths.forEach { namespacePath in namespacePath.makeIterator().enumerated().forEach { (index, _) in let path = namespacePath[0...index].joined(separator: ".") @@ -546,35 +558,35 @@ struct BridgeJSLink { } } } - + uniqueNamespaces.sorted().forEach { namespace in lines.append("if (typeof globalThis.\(namespace) === 'undefined') {") lines.append(" globalThis.\(namespace) = {};") lines.append("}") } - + namespacedClasses.forEach { klass in let namespacePath: String = klass.namespace?.joined(separator: ".") ?? "" lines.append("globalThis.\(namespacePath).\(klass.name) = exports.\(klass.name);") } - + namespacedFunctions.forEach { function in let namespacePath: String = function.namespace?.joined(separator: ".") ?? "" lines.append("globalThis.\(namespacePath).\(function.name) = exports.\(function.name);") } - + return lines } - + class ImportedThunkBuilder { var bodyLines: [String] = [] var parameterNames: [String] = [] var parameterForwardings: [String] = [] - + func liftSelf() { parameterNames.append("self") } - + func liftParameter(param: Parameter) { parameterNames.append(param.name) switch param.type { @@ -590,7 +602,7 @@ struct BridgeJSLink { parameterForwardings.append(param.name) } } - + func renderFunction( name: String, returnExpr: String?, @@ -614,7 +626,7 @@ struct BridgeJSLink { funcLines.append("}") return funcLines } - + func call(name: String, returnType: BridgeType) { let call = "options.imports.\(name)(\(parameterForwardings.joined(separator: ", ")))" if returnType == .void { @@ -623,12 +635,12 @@ struct BridgeJSLink { bodyLines.append("let ret = \(call);") } } - + func callConstructor(name: String) { let call = "new options.imports.\(name)(\(parameterForwardings.joined(separator: ", ")))" bodyLines.append("let ret = \(call);") } - + func callMethod(name: String, returnType: BridgeType) { let call = "swift.memory.getObject(self).\(name)(\(parameterForwardings.joined(separator: ", ")))" if returnType == .void { @@ -637,17 +649,17 @@ struct BridgeJSLink { bodyLines.append("let ret = \(call);") } } - + func callPropertyGetter(name: String, returnType: BridgeType) { let call = "swift.memory.getObject(self).\(name)" bodyLines.append("let ret = \(call);") } - + func callPropertySetter(name: String, returnType: BridgeType) { let call = "swift.memory.getObject(self).\(name) = \(parameterForwardings.joined(separator: ", "))" bodyLines.append("\(call);") } - + func lowerReturnValue(returnType: BridgeType) throws -> String? { switch returnType { case .void: @@ -666,28 +678,28 @@ struct BridgeJSLink { } } } - + class ImportObjectBuilder { var moduleName: String var importedLines: [String] = [] var dtsImportLines: [String] = [] - + init(moduleName: String) { self.moduleName = moduleName importedLines.append("const \(moduleName) = importObject[\"\(moduleName)\"] = {};") } - + func assignToImportObject(name: String, function: [String]) { var js = function js[0] = "\(moduleName)[\"\(name)\"] = " + js[0] importedLines.append(contentsOf: js) } - + func appendDts(_ lines: [String]) { dtsImportLines.append(contentsOf: lines) } } - + func renderImportedFunction( importObjectBuilder: ImportObjectBuilder, function: ImportedFunctionSkeleton @@ -710,7 +722,7 @@ struct BridgeJSLink { ) importObjectBuilder.assignToImportObject(name: function.abiName(context: nil), function: funcLines) } - + func renderImportedType( importObjectBuilder: ImportObjectBuilder, type: ImportedTypeSkeleton @@ -734,7 +746,7 @@ struct BridgeJSLink { ) importObjectBuilder.assignToImportObject(name: getterAbiName, function: js) importObjectBuilder.appendDts(dts) - + if !property.isReadonly { let setterAbiName = property.setterAbiName(context: type) let (js, dts) = try renderImportedProperty( @@ -758,7 +770,7 @@ struct BridgeJSLink { importObjectBuilder.appendDts(dts) } } - + func renderImportedConstructor( importObjectBuilder: ImportObjectBuilder, type: ImportedTypeSkeleton, @@ -784,7 +796,7 @@ struct BridgeJSLink { "}", ]) } - + func renderImportedProperty( property: ImportedPropertySkeleton, abiName: String, @@ -800,7 +812,7 @@ struct BridgeJSLink { ) return (funcLines, []) } - + func renderImportedMethod( context: ImportedTypeSkeleton, method: ImportedFunctionSkeleton diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift index 50e275cb..32ea9791 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift @@ -1,34 +1,34 @@ @JS func plainFunction() -> String { "plain" } -@JS("MyModule.Utils") func namespacedFunction() -> String { "namespaced" } +@JS(namespace: "MyModule.Utils") func namespacedFunction() -> String { "namespaced" } -@JS("__Swift.Foundation") class Greeter { +@JS(namespace: "__Swift.Foundation") class Greeter { var name: String @JS init(name: String) { self.name = name } - + @JS func greet() -> String { return "Hello, " + self.name + "!" } - + func changeName(name: String) { self.name = name } } -@JS("Utils.Converters") class Converter { +@JS(namespace: "Utils.Converters") class Converter { @JS init() {} - + @JS func toString(value: Int) -> String { return String(value) } } -@JS("__Swift.Foundation") +@JS(namespace: "__Swift.Foundation") class UUID { @JS func uuidString() -> String { - Foundation.UUID().uuidString + Foundation.UUID().uuidString } } diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/Exporting-Swift-to-JavaScript.md b/Sources/JavaScriptKit/Documentation.docc/Articles/Exporting-Swift-to-JavaScript.md index 08504c08..6ce30772 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/Exporting-Swift-to-JavaScript.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/Exporting-Swift-to-JavaScript.md @@ -162,3 +162,93 @@ export type Exports = { } } ``` + +## Using Namespaces + +The `@JS` macro supports organizing your exported Swift code into namespaces using dot-separated strings. This allows you to create hierarchical structures in JavaScript that mirror your Swift code organization. + +### Functions with Namespaces + +You can export functions to specific namespaces by providing a namespace parameter: + +```swift +import JavaScriptKit + +// Export a function to a custom namespace +@JS(namespace: "MyModule.Utils") func namespacedFunction() -> String { + return "namespaced" +} +``` + +This function will be accessible in JavaScript through its namespace hierarchy: + +```javascript +// Access the function through its namespace +const result = globalThis.MyModule.Utils.namespacedFunction(); +console.log(result); // "namespaced" +``` + +The generated TypeScript declaration will reflect the namespace structure: + +```typescript +declare global { + namespace MyModule { + namespace Utils { + function namespacedFunction(): string; + } + } +} +``` + +### Classes with Namespaces + +For classes, you only need to specify the namespace on the top-level class declaration. All exported methods within the class will be part of that namespace: + +```swift +import JavaScriptKit + +@JS(namespace: "__Swift.Foundation") class Greeter { + var name: String + + @JS init(name: String) { + self.name = name + } + + @JS func greet() -> String { + return "Hello, " + self.name + "!" + } + + func changeName(name: String) { + self.name = name + } +} +``` + +In JavaScript, this class is accessible through its namespace: + +```javascript +// Create instances through namespaced constructors +const greeter = new globalThis.__Swift.Foundation.Greeter("World"); +console.log(greeter.greet()); // "Hello, World!" +``` + +The generated TypeScript declaration will organize the class within its namespace: + +```typescript +declare global { + namespace __Swift { + namespace Foundation { + class Greeter { + constructor(name: string); + greet(): string; + } + } + } +} + +export interface Greeter extends SwiftHeapObject { + greet(): string; +} +``` + +Using namespaces can be preferable for projects with many global functions, as they help prevent naming collisions. Namespaces also provide intuitive hierarchies for organizing your exported Swift code, and they do not affect the code generated by `@JS` declarations without namespaces. diff --git a/Sources/JavaScriptKit/Macros.swift b/Sources/JavaScriptKit/Macros.swift index 7adb053f..dac264ff 100644 --- a/Sources/JavaScriptKit/Macros.swift +++ b/Sources/JavaScriptKit/Macros.swift @@ -30,12 +30,12 @@ /// /// ```swift /// // Export a function to JavaScript with a custom namespace -/// @JS("__Swift.Foundation.UUID") public func create() -> String { +/// @JS(namespace: "__Swift.Foundation.UUID") public func create() -> String { /// UUID().uuidString /// } /// /// // Export a class with a custom namespace (note that only top level macro needs to specify the namespace) -/// @JS("Utils.Greeters") class Greeter { +/// @JS(namespace: "Utils.Greeters") class Greeter { /// var name: String /// /// @JS init(name: String) { diff --git a/Tests/JavaScriptEventLoopTests/JSClosure+AsyncTests.swift b/Tests/JavaScriptEventLoopTests/JSClosure+AsyncTests.swift index e3c19a8e..db093e54 100644 --- a/Tests/JavaScriptEventLoopTests/JSClosure+AsyncTests.swift +++ b/Tests/JavaScriptEventLoopTests/JSClosure+AsyncTests.swift @@ -72,7 +72,7 @@ class JSClosureAsyncTests: XCTestCase { )!.value() XCTAssertEqual(result, 42.0) } - + func testAsyncOneshotClosureWithPriority() async throws { let priority = UnsafeSendableBox(nil) let closure = JSOneshotClosure.async(priority: .high) { _ in @@ -83,7 +83,7 @@ class JSClosureAsyncTests: XCTestCase { XCTAssertEqual(result, 42.0) XCTAssertEqual(priority.value, .high) } - + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) func testAsyncOneshotClosureWithTaskExecutor() async throws { let executor = AnyTaskExecutor() @@ -93,7 +93,7 @@ class JSClosureAsyncTests: XCTestCase { let result = try await JSPromise(from: closure.function!())!.value() XCTAssertEqual(result, 42.0) } - + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) func testAsyncOneshotClosureWithTaskExecutorPreference() async throws { let executor = AnyTaskExecutor()