diff --git a/Sources/NodeAPI/NodeIterator.swift b/Sources/NodeAPI/NodeIterator.swift new file mode 100644 index 0000000..60240c9 --- /dev/null +++ b/Sources/NodeAPI/NodeIterator.swift @@ -0,0 +1,50 @@ +extension Sequence where Element: NodeValueConvertible { + @NodeActor public func nodeIterator() -> NodeIterator { + NodeIterator(self.lazy.map { $0 as NodeValueConvertible }.makeIterator()) + } +} + +public final class NodeIterator: NodeClass { + public struct Result: NodeValueConvertible, NodeValueCreatable { + public typealias ValueType = NodeObject + + let value: NodeValueConvertible? + let done: Bool? + + public func nodeValue() throws -> any NodeValue { + let obj = try NodeObject() + if let value = value { + try obj["value"].set(to: value) + } + if let done = done { + try obj["done"].set(to: done) + } + return obj + } + + public static func from(_ value: ValueType) throws -> Self { + Self( + value: try value.get("value"), + done: try value.get("done").as(Bool.self) + ) + } + } + + public static let properties: NodeClassPropertyList = [ + "next": NodeMethod(next), + ] + + private var iterator: any IteratorProtocol + public init(_ iterator: any IteratorProtocol) { + self.iterator = iterator + } + + public func next() -> Result { + if let value = iterator.next() { + return Result(value: value, done: false) + } else { + return Result(value: nil, done: true) + } + } +} + diff --git a/Sources/NodeAPI/NodeSymbol.swift b/Sources/NodeAPI/NodeSymbol.swift index 5e2a4fc..0160105 100644 --- a/Sources/NodeAPI/NodeSymbol.swift +++ b/Sources/NodeAPI/NodeSymbol.swift @@ -1,11 +1,40 @@ @_implementationOnly import CNodeAPI public final class NodeSymbol: NodePrimitive, NodeName { - @_spi(NodeAPI) public let base: NodeValueBase @_spi(NodeAPI) public init(_ base: NodeValueBase) { self.base = base } + + public static func global(for name: String) throws -> NodeSymbol { + let ctx = NodeContext.current + let env = ctx.environment + let symbol = try env.global.Symbol.for(name) + if let nonNullSymbol = try symbol.nodeValue().as(NodeSymbol.self) { + return nonNullSymbol + } else { + throw NodeAPIError(.genericFailure, message: "globalThis.Symbol.for('\(name)') is not a symbol") + } + } + + public static func deferredGlobal(for name: String) -> NodeDeferredName { + NodeDeferredName { try global(for: name) } + } + + public static func wellKnown(propertyName name: String) throws -> NodeSymbol { + let ctx = NodeContext.current + let env = ctx.environment + let property = try env.global.Symbol[name].as(NodeSymbol.self) + if let property = property { + return property + } else { + throw NodeAPIError(.genericFailure, message: "globalThis.Symbol.\(name) is not a symbol") + } + } + + public static func deferredWellKnown(propertyName name: String) -> NodeDeferredName { + NodeDeferredName { try wellKnown(propertyName: name) } + } public init(description: String? = nil) throws { let ctx = NodeContext.current @@ -15,5 +44,14 @@ public final class NodeSymbol: NodePrimitive, NodeName { try env.check(napi_create_symbol(env.raw, descRaw, &result)) self.base = NodeValueBase(raw: result, in: ctx) } - } + +extension NodeSymbol { + /// Allows implementing the Iterable protocol for an object. + /// This method is called by the `for-of` statement or destructuring like `[...obj]`. + public static var iterator: NodeDeferredName { deferredWellKnown(propertyName: "iterator") } + + /// This symbol allows customizing how NodeJS formats objects in console.log. + /// See https://nodejs.org/api/util.html#custom-inspection-functions-on-objects + public static var utilInspectCustom: NodeDeferredName { deferredGlobal(for: "nodejs.util.inspect.custom")} +} \ No newline at end of file diff --git a/Sources/NodeAPI/NodeValue.swift b/Sources/NodeAPI/NodeValue.swift index 35fa249..bf7e399 100644 --- a/Sources/NodeAPI/NodeValue.swift +++ b/Sources/NodeAPI/NodeValue.swift @@ -116,6 +116,22 @@ public struct NodeDeferredValue: NodeValueConvertible, Sendable { } } +// Utility for APIs that take NodeValueConvertible: useful when you +// want to defer NodeValue creation to the API, for example for +// accessing global or well-known Symbols. +public struct NodeDeferredName: NodeValueConvertible, Sendable, NodeName { + let wrapper: @Sendable @NodeActor () throws -> NodeValue + + // thread-safe + public init(_ wrapper: @escaping @Sendable @NodeActor () throws -> NodeValue) { + self.wrapper = wrapper + } + + @NodeActor public func nodeValue() throws -> NodeValue { + try wrapper() + } +} + public protocol AnyNodeValueCreatable { @NodeActor static func from(_ value: NodeValue) throws -> Self? } @@ -166,7 +182,7 @@ extension NodeCallable { @discardableResult public func dynamicallyCall(withArguments args: [NodeValueConvertible]) throws -> AnyNodeValue { guard let fn = try self.as(NodeFunction.self) else { - throw NodeAPIError(.functionExpected, message: "Cannot call a non-function") + throw NodeAPIError(.functionExpected, message: "Cannot call a non-function: \(try debugDescription())") } return try fn.call(on: receiver, args) } @@ -174,6 +190,14 @@ extension NodeCallable { public var new: NodeCallableConstructor { NodeCallableConstructor(callable: self) } + + internal func debugDescription() throws -> String { + let actual = "\(try self.nodeValue()) (\(self))" + if let dynamicProperty = self as? NodeObject.DynamicProperty { + return "\(receiver).\(dynamicProperty.key) is \(actual)" + } + return "is \(actual)" + } } // this type exists due to the aforementioned callAsFunction bug @@ -182,7 +206,7 @@ extension NodeCallable { let callable: NodeCallable public func dynamicallyCall(withArguments args: [NodeValueConvertible]) throws -> NodeObject { guard let fn = try callable.as(NodeFunction.self) else { - throw NodeAPIError(.functionExpected, message: "Cannot call a non-function") + throw NodeAPIError(.functionExpected, message: "Cannot call a non-function as constructor: \(try callable.debugDescription())") } return try fn.construct(withArguments: args) } diff --git a/Sources/NodeAPI/Sugar.swift b/Sources/NodeAPI/Sugar.swift index b3398f8..04ca6f9 100644 --- a/Sources/NodeAPI/Sugar.swift +++ b/Sources/NodeAPI/Sugar.swift @@ -199,3 +199,7 @@ public macro NodeMethod(_: NodePropertyAttributes = .defaultMethod) @attached(peer, names: prefixed(`$`)) public macro NodeProperty(_: NodePropertyAttributes = .defaultProperty) = #externalMacro(module: "NodeAPIMacros", type: "NodePropertyMacro") + +@attached(peer, names: prefixed(`$`)) +public macro NodeName(_: NodeName) + = #externalMacro(module: "NodeAPIMacros", type: "NodeNameMacro") diff --git a/Sources/NodeAPIMacros/Diagnostics.swift b/Sources/NodeAPIMacros/Diagnostics.swift index 5568cd1..e92937b 100644 --- a/Sources/NodeAPIMacros/Diagnostics.swift +++ b/Sources/NodeAPIMacros/Diagnostics.swift @@ -37,4 +37,8 @@ extension DiagnosticMessage where Self == NodeDiagnosticMessage { static var expectedInit: Self { .init("@NodeConstructor can only be applied to an initializer") } + + static var expectedName: Self { + .init("@NodeName must have a name provided") + } } diff --git a/Sources/NodeAPIMacros/NodeClassMacro.swift b/Sources/NodeAPIMacros/NodeClassMacro.swift index bd5d2af..ddd62cb 100644 --- a/Sources/NodeAPIMacros/NodeClassMacro.swift +++ b/Sources/NodeAPIMacros/NodeClassMacro.swift @@ -31,9 +31,11 @@ struct NodeClassMacro: ExtensionMacro { } else { nil as TokenSyntax? } + if let identifier = identifier?.trimmed { + let key = member.decl.attributes?.findAttribute(named: "NodeName")?.nodeAttributes ?? "\(literal: identifier.text)" as ExprSyntax DictionaryElementSyntax( - key: "\(literal: identifier.text)" as ExprSyntax, + key: key, value: "$\(identifier)" as ExprSyntax ) } diff --git a/Sources/NodeAPIMacros/NodeNameMacro.swift b/Sources/NodeAPIMacros/NodeNameMacro.swift new file mode 100644 index 0000000..89a4c01 --- /dev/null +++ b/Sources/NodeAPIMacros/NodeNameMacro.swift @@ -0,0 +1,18 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +struct NodeNameMacro: PeerMacro { + static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let _ = node.nodeAttributes else { + context.diagnose(.init(node: Syntax(node), message: .expectedName)) + return [] + } + + // Processed by NodeClassMacro. + return [] + } +} diff --git a/Sources/NodeAPIMacros/Plugin.swift b/Sources/NodeAPIMacros/Plugin.swift index 04064e6..49d5491 100644 --- a/Sources/NodeAPIMacros/Plugin.swift +++ b/Sources/NodeAPIMacros/Plugin.swift @@ -8,5 +8,6 @@ import SwiftSyntaxMacros NodeConstructorMacro.self, NodeClassMacro.self, NodeModuleMacro.self, + NodeNameMacro.self, ] } diff --git a/Sources/NodeAPIMacros/SyntaxUtils.swift b/Sources/NodeAPIMacros/SyntaxUtils.swift index 7b51080..3e91189 100644 --- a/Sources/NodeAPIMacros/SyntaxUtils.swift +++ b/Sources/NodeAPIMacros/SyntaxUtils.swift @@ -1,14 +1,31 @@ import SwiftSyntax +extension DeclSyntax { + var attributes: AttributeListSyntax? { + if let function = self.as(FunctionDeclSyntax.self) { + function.attributes + } else if let property = self.as(VariableDeclSyntax.self) { + property.attributes + } else { + nil + } + } +} + extension AttributeListSyntax { - func hasAttribute(named name: String) -> Bool { - contains { + func findAttribute(named name: String) -> AttributeSyntax? { + lazy.compactMap { if case let .attribute(value) = $0 { - value.attributeName.as(IdentifierTypeSyntax.self)?.name.trimmed.text == name - } else { - false + if value.attributeName.as(IdentifierTypeSyntax.self)?.name.trimmed.text == name { + return value + } } - } + return nil + }.first + } + + func hasAttribute(named name: String) -> Bool { + findAttribute(named: name) != nil } } diff --git a/Tests/NodeAPIMacrosTests/NodeClassMacroTests.swift b/Tests/NodeAPIMacrosTests/NodeClassMacroTests.swift index 889aaae..b469a25 100644 --- a/Tests/NodeAPIMacrosTests/NodeClassMacroTests.swift +++ b/Tests/NodeAPIMacrosTests/NodeClassMacroTests.swift @@ -62,6 +62,42 @@ final class NodeClassMacroTests: XCTestCase { } } + func testCustomName() { + assertMacro { + #""" + @NodeClass final class Foo { + @NodeName("q") + @NodeProperty var x = 5 + @NodeName(NodeSymbol.someGlobalSymbol) + @NodeProperty var y = 6 + var z = 7 + + @NodeMethod func foo() {} + func bar() {} + @NodeMethod func baz() {} + } + """# + } expansion: { + """ + final class Foo { + @NodeName("q") + @NodeProperty var x = 5 + @NodeName(NodeSymbol.someGlobalSymbol) + @NodeProperty var y = 6 + var z = 7 + + @NodeMethod func foo() {} + func bar() {} + @NodeMethod func baz() {} + } + + extension Foo { + @NodeActor public static let properties: NodeClassPropertyList = ["q": $x, NodeSymbol.someGlobalSymbol: $y, "foo": $foo, "baz": $baz] + } + """ + } + } + func testNonClass() { assertMacro { #""" @@ -98,6 +134,7 @@ final class NodeClassMacroTests: XCTestCase { @NodeProperty(.enumerable) var y = "hello" var z = 7 + @NodeName("longerFooName") @NodeMethod func foo(_ x: String) async throws { throw SomeError(x) } @@ -127,6 +164,7 @@ final class NodeClassMacroTests: XCTestCase { = NodeProperty(attributes: .enumerable, \_NodeSelf.y) var z = 7 + @NodeName("longerFooName") func foo(_ x: String) async throws { throw SomeError(x) } @@ -157,7 +195,7 @@ final class NodeClassMacroTests: XCTestCase { } extension Foo { - @NodeActor public static let properties: NodeClassPropertyList = ["x": $x, "y": $y, "foo": $foo, "baz": $baz] + @NodeActor public static let properties: NodeClassPropertyList = ["x": $x, "y": $y, "longerFooName": $foo, "baz": $baz] } """# } diff --git a/test/index.js b/test/index.js index cb03cb5..22517df 100644 --- a/test/index.js +++ b/test/index.js @@ -5,7 +5,10 @@ const { spawnSync } = require("child_process"); process.chdir(__dirname); function usage() { - console.log("Usage: test [all|suite ]"); + console.log("Usage: test [all | suite | _suite ]"); + console.log(" all: clean & run all tests"); + console.log(" suite: clean & run suite with given name"); + console.log(" _suite: run suite with given name without cleaning"); process.exit(1); } diff --git a/test/suites/Test/Test.swift b/test/suites/Test/Test.swift index 761ceac..fc75778 100644 --- a/test/suites/Test/Test.swift +++ b/test/suites/Test/Test.swift @@ -58,4 +58,22 @@ final class File: NodeClass { } } -#NodeModule(exports: ["File": File.deferredConstructor]) +final class SomeIterable: NodeClass { + typealias Element = String + + static let properties: NodeClassPropertyList = [ + NodeSymbol.iterator: NodeMethod(nodeIterator), + ] + + static let construct = NodeConstructor(SomeIterable.init(_:)) + init(_ args: NodeArguments) throws { } + + private let values: [String] = ["one", "two", "three"] + + func nodeIterator() throws -> NodeIterator { + values.nodeIterator() + } + +} + +#NodeModule(exports: ["File": File.deferredConstructor, "SomeIterable": SomeIterable.deferredConstructor]) diff --git a/test/suites/Test/index.js b/test/suites/Test/index.js index e2b6d4d..4c3c7b4 100644 --- a/test/suites/Test/index.js +++ b/test/suites/Test/index.js @@ -1,6 +1,6 @@ const assert = require("assert"); -const { File } = require("../../.build/Test.node"); +const { File, SomeIterable } = require("../../.build/Test.node"); assert.strictEqual(File.default().filename, "default.txt") @@ -23,3 +23,13 @@ file.unlink(); assert.strictEqual(file.reply("hi"), "You said hi"); assert.strictEqual(file.reply(null), "You said nothing"); assert.strictEqual(file.reply(undefined), "You said nothing"); + +const iterable = new SomeIterable() +const expected = ["one", "two", "three"] +assert.deepStrictEqual(Array.from(iterable), expected) +assert.deepStrictEqual([...iterable], expected) +let index = 0 +for (const item of iterable) { + assert.strictEqual(item, expected[index]) + index++ +} \ No newline at end of file