Skip to content

Support implementing Symbol-based EcmaScript protocols like Iterable #28

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions Sources/NodeAPI/NodeIterator.swift
Original file line number Diff line number Diff line change
@@ -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<NodeValueConvertible>
public init(_ iterator: any IteratorProtocol<NodeValueConvertible>) {
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)
}
}
}

42 changes: 40 additions & 2 deletions Sources/NodeAPI/NodeSymbol.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")}
}
28 changes: 26 additions & 2 deletions Sources/NodeAPI/NodeValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
Expand Down Expand Up @@ -166,14 +182,22 @@ 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)
}

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)"
}
Comment on lines +196 to +198
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it would be better for DynamicProperty to implement CustomStringRepresentable in like "(obj).(key)", so that stringifying a DynamicProperty anywhere prints the full path 🤔

return "is \(actual)"
}
}

// this type exists due to the aforementioned callAsFunction bug
Expand All @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/NodeAPI/Sugar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
4 changes: 4 additions & 0 deletions Sources/NodeAPIMacros/Diagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
4 changes: 3 additions & 1 deletion Sources/NodeAPIMacros/NodeClassMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down
18 changes: 18 additions & 0 deletions Sources/NodeAPIMacros/NodeNameMacro.swift
Original file line number Diff line number Diff line change
@@ -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 []
}
}
1 change: 1 addition & 0 deletions Sources/NodeAPIMacros/Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ import SwiftSyntaxMacros
NodeConstructorMacro.self,
NodeClassMacro.self,
NodeModuleMacro.self,
NodeNameMacro.self,
]
}
29 changes: 23 additions & 6 deletions Sources/NodeAPIMacros/SyntaxUtils.swift
Original file line number Diff line number Diff line change
@@ -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
}
}

Expand Down
40 changes: 39 additions & 1 deletion Tests/NodeAPIMacrosTests/NodeClassMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
#"""
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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]
}
"""#
}
Expand Down
5 changes: 4 additions & 1 deletion test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ const { spawnSync } = require("child_process");
process.chdir(__dirname);

function usage() {
console.log("Usage: test [all|suite <suite name>]");
console.log("Usage: test [all | suite <suite name> | _suite <suite name>]");
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);
}

Expand Down
20 changes: 19 additions & 1 deletion test/suites/Test/Test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

}
Comment on lines +61 to +77
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this code prefer the macro?


#NodeModule(exports: ["File": File.deferredConstructor, "SomeIterable": SomeIterable.deferredConstructor])
12 changes: 11 additions & 1 deletion test/suites/Test/index.js
Original file line number Diff line number Diff line change
@@ -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")

Expand All @@ -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++
}