diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift index fefcf40c..dfe161e9 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift @@ -123,10 +123,21 @@ 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) + + 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 { @@ -165,7 +176,8 @@ class ExportSwift { abiName: abiName, parameters: parameters, returnType: returnType, - effects: effects + effects: effects, + namespace: namespace ) } @@ -193,12 +205,40 @@ class ExportSwift { return Effects(isAsync: isAsync, isThrows: isThrows) } + private func extractNamespace( + from jsAttribute: AttributeSyntax + ) -> [String]? { + 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) + } + override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { guard node.attributes.hasJSAttribute() else { return .skipChildren } guard case .classBody(let name) = state else { diagnose(node: node, message: "@JS init must be inside a @JS class") return .skipChildren } + + if let jsAttribute = node.attributes.firstJSAttribute, + 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 { @@ -225,13 +265,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 +679,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..022c5cbb 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift @@ -62,6 +62,8 @@ struct BridgeJSLink { 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( @@ -83,10 +85,19 @@ 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) @@ -108,6 +119,36 @@ 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. @@ -169,15 +210,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) }) @@ -204,6 +243,102 @@ 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] + { + 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] = [] 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..32ea9791 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Namespaces.swift @@ -0,0 +1,34 @@ +@JS func plainFunction() -> String { "plain" } + +@JS(namespace: "MyModule.Utils") func namespacedFunction() -> String { "namespaced" } + +@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(namespace: "Utils.Converters") class Converter { + @JS init() {} + + @JS func toString(value: Int) -> String { + return String(value) + } +} + +@JS(namespace: "__Swift.Foundation") +class UUID { + @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..b2ccecc4 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.d.ts @@ -0,0 +1,72 @@ +// 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; + } + class UUID { + uuidString(): string; + } + } + } + namespace MyModule { + namespace Utils { + function namespacedFunction(): 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; +} +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; + namespacedFunction(): 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..dce99393 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/Namespaces.Export.js @@ -0,0 +1,157 @@ +// 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; + } + } + 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; + }, + 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 = {}; + } + 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; + globalThis.MyModule.Utils.namespacedFunction = exports.namespacedFunction; + + 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..2a6440f1 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.json @@ -0,0 +1,153 @@ +{ + "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", + "parameters" : [ + + ], + "returnType" : { + "string" : { + + } + } + } + ], + "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", + "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", + "parameters" : [ + + ], + "returnType" : { + "string" : { + + } + } + } + ], + "name" : "UUID", + "namespace" : [ + "__Swift", + "Foundation" + ] + } + ], + "functions" : [ + { + "abiName" : "bjs_plainFunction", + "effects" : { + "isAsync" : false, + "isThrows" : false + }, + "name" : "plainFunction", + "parameters" : [ + + ], + "returnType" : { + "string" : { + + } + } + }, + { + "abiName" : "bjs_namespacedFunction", + "effects" : { + "isAsync" : false, + "isThrows" : false + }, + "name" : "namespacedFunction", + "namespace" : [ + "MyModule", + "Utils" + ], + "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..fba15b29 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.swift @@ -0,0 +1,116 @@ +// 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_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 { + #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_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/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 bddd8c7c..dac264ff 100644 --- a/Sources/JavaScriptKit/Macros.swift +++ b/Sources/JavaScriptKit/Macros.swift @@ -24,12 +24,73 @@ /// } /// ``` /// +/// If you prefer to access through namespace-based syntax, you can use `namespace` parameter +/// +/// Example: +/// +/// ```swift +/// // Export a function to JavaScript with a custom namespace +/// @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(namespace: "Utils.Greeters") class Greeter { +/// var name: String +/// +/// @JS init(name: String) { +/// self.name = name +/// } +/// +/// @JS func greet() -> String { +/// return "Hello, " + self.name + "!" +/// } +/// +/// @JS 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" +/// ``` +/// /// 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() = Builtin.ExternalMacro +public macro JS(namespace: String? = nil) = Builtin.ExternalMacro 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()