From 4216b3e3f6408a010c5ae9f1934f60f02dffd310 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Mon, 8 Jun 2026 11:04:27 +0200 Subject: [PATCH] Bind `@SharedObject` members directly into the JS object via `_decorateSharedObject` Move `@SharedObject`'s `@JS func`s, `@JS var`s, and the `@JS init` off the DSL `Class { Function/Property/Constructor }` path and bind them directly into the shared object's JS object, the same direct-JSI strategy `@ExpoModule` already uses for modules. `@SharedObject` now synthesizes two static entry points alongside `_synthesizedClassDefinition()`: - `_decorateSharedObject(prototype:in:appContext:)` binds each `@JS func` (inlined `setProperty` closure) and `@JS var` (a `defineProperty` get/set accessor) onto the class prototype. The first parameter is named `prototype` (not `object` as on `_decorateModule`) because core hands in the shared class prototype, not an instance. Because a shared object has a distinct native instance behind each JS object, the bindings are static and recover the typed receiver from the JS `this` per call (`try SharedObject.native(from: this.asObject(in: runtime), as: .self)`, bound to a `_self` local) rather than capturing a singleton `self`. - `_constructSharedObject(this:arguments:in:appContext:)` decodes the `@JS init` arguments and returns a freshly built instance. The decode/encode fast path, arity handling (range-based check throwing `Exceptions.ArgumentsRangeMismatch`), and unowned-`this` closures are shared with the module path. To express both receivers, `JSFunction`/`JSProperty` are parameterized by a `Receiver` (module `self` vs. shared-object `_self`): the module bindings keep capturing `self` strong and ignore `this`; the shared-object bindings capture nothing of the instance and recover `this`. The `_self` local is named with a leading underscore so it never collides with a user member (e.g. a `var owner`). `collectProperties` moves to `MacroHelpers` since both macros now use it. The `Class` block keeps only non-`@JS` definitions (none today), so it's empty when every member is `@JS`. --- .../DecorateModuleBuilder.swift | 238 ++++++----- .../ExpoModulesMacros/ExpoModuleMacro.swift | 52 --- .../ExpoModulesMacros/JSConstructor.swift | 60 +++ .../ExpoModulesMacros/MacroHelpers.swift | 73 ++++ .../Sources/ExpoModulesMacros/Receiver.swift | 58 +++ .../ExpoModulesMacros/SharedObjectMacro.swift | 119 ++---- .../SharedObjectMacroTests.swift | 394 ++++++++++++++++-- 7 files changed, 723 insertions(+), 271 deletions(-) create mode 100644 apple/Sources/ExpoModulesMacros/JSConstructor.swift create mode 100644 apple/Sources/ExpoModulesMacros/Receiver.swift diff --git a/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift b/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift index ee9d61b..338f75c 100644 --- a/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +++ b/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift @@ -1,17 +1,15 @@ import SwiftSyntax -/** - A `@JS func` collected for **direct JSI binding**. Instead of describing the function with a - `Function(...)` / `AsyncFunction(...)` DSL entry that the runtime interprets per call, - `@ExpoModule` synthesizes a `_decorateModule` that binds each such function into the module's JS object - via the closure-taking `JavaScriptObject.setProperty(_:)`, with the decode-call-encode body - inlined into the closure. This omits the `[Any]`/`toTuple` dynamic-call path: every argument is - decoded individually by its static type. - - The receiver is the module's real `self` (a module is a singleton instance), so the body calls - `self.(...)` directly and ignores the JS `this`. An `async` `@JS func` produces an `async` - closure body and is installed through the async `setProperty(_:)` overload (so JS gets a promise). - */ +/// A `@JS func` collected for **direct JSI binding**. Instead of describing the function with a +/// `Function(...)` / `AsyncFunction(...)` DSL entry that the runtime interprets per call, the enclosing +/// macro synthesizes a decorator (`_decorateModule` / `_decorateSharedObject`) that binds each such +/// function into the JS object via the closure-taking `JavaScriptObject.setProperty(_:)`, with the +/// decode-call-encode body inlined into the closure. This omits the `[Any]`/`toTuple` dynamic-call path: +/// every argument is decoded individually by its static type. +/// +/// The receiver (see `Receiver`) is the module's `self` for a module binding, or the per-call `_self` +/// unwrapped from the JS `this` for a shared-object binding. An `async` `@JS func` produces an `async` +/// closure body and is installed through the async `setProperty(_:)` overload (so JS gets a promise). internal struct JSFunction { let swiftName: String let jsName: String @@ -50,19 +48,23 @@ internal struct JSFunction { } /// The decode-call-encode statements that form the host-function body, indented with the given - /// prefix. An arity guard (an exact check when every parameter is required, otherwise a range - /// check) throwing `Exceptions.ArgumentsRangeMismatch`; then the decode of the always-present - /// required prefix (primitives via a direct typed accessor like `asDouble()` on a zero-copy - /// `arguments.unownedValue(at:)`, others via `getDynamicType().cast(...)`); then the call and - /// result encode (primitives via `toJavaScriptValue(in:)`, others via `castToJS(...)`). When a - /// trailing run of parameters is omittable the call branches on `arguments.count`, decoding only - /// the slots that branch actually has — `arguments[i]` traps past `count`, so a slot the caller - /// didn't pass is never indexed. - private func bodyStatements(indent: String) -> String { + /// prefix. Receiver unwrap (shared objects only), then an arity guard (an exact check when every + /// parameter is required, otherwise a range check) throwing `Exceptions.ArgumentsRangeMismatch`; + /// then the decode of the always-present required prefix (primitives via a direct typed accessor + /// like `asDouble()` on a zero-copy `arguments.unownedValue(at:)`, others via + /// `getDynamicType().cast(...)`); then the call and result encode (primitives via + /// `toJavaScriptValue(in:)`, others via `castToJS(...)`). When a trailing run of parameters is + /// omittable the call branches on `arguments.count`, decoding only the slots that branch actually + /// has — `arguments[i]` traps past `count`, so a slot the caller didn't pass is never indexed. + private func bodyStatements(receiver: Receiver, indent: String) -> String { let required = requiredArgumentCount let maximum = parameters.count var lines: [String] = [] + if let unwrap = receiver.unwrapStatement { + lines.append(unwrap) + } + if required == maximum { lines.append( """ @@ -87,7 +89,7 @@ internal struct JSFunction { if required == maximum { // No omittable trailing run: a single flat call with every argument decoded. - lines.append(contentsOf: callAndEncodeLines(arity: maximum, decodingFrom: required)) + lines.append(contentsOf: callAndEncodeLines(receiver: receiver, arity: maximum, decodingFrom: required)) } else { // One call shape per accepted arity. Each branch decodes only the trailing slots it has and // fills the rest (defaulted params drop their label so Swift applies the default; optional @@ -105,7 +107,7 @@ internal struct JSFunction { for index in required..(...)` call for the given arity. Slots `0...(...)` call for the given arity. Slots `0..`; a trailing optional-without-default slot that this arity omits is passed `nil`; a /// trailing defaulted slot that this arity omits is dropped entirely so Swift applies its default. - private func callExpression(arity: Int) -> String { + private func callExpression(receiver: Receiver, arity: Int) -> String { var callArguments: [String] = [] for (index, parameter) in parameters.enumerated() { let label = parameter.firstName.text @@ -155,21 +157,22 @@ internal struct JSFunction { } let tryKeyword = (isThrowing || isAsync) ? "try " : "" let awaitKeyword = isAsync ? "await " : "" - return "\(tryKeyword)\(awaitKeyword)self.\(swiftName)(\(callArguments.joined(separator: ", ")))" + return "\(tryKeyword)\(awaitKeyword)\(receiver.callee).\(swiftName)(\(callArguments.joined(separator: ", ")))" } /// The flat (single-arity) call-and-encode lines used when no trailing parameter is omittable: - /// `let result = self.f(...)` then the return encode (or the no-return `self.f(...)` + `.undefined`). - private func callAndEncodeLines(arity: Int, decodingFrom: Int) -> [String] { + /// `let result = .f(...)` then the return encode (or the no-return `.f(...)` + + /// `.undefined`). + private func callAndEncodeLines(receiver: Receiver, arity: Int, decodingFrom: Int) -> [String] { var lines: [String] = [] for index in decodingFrom.. String { + // Synchronous `@JS` bindings bind through the unowned-`this` `setProperty` overload, which hands + // `this` in as a borrowed `JavaScriptUnownedValue` instead of allocating an owning + // `JavaScriptValue` and forming its `weak`-runtime reference on every call. A module ignores + // `this`; a shared object unwraps it (still borrowed). The first parameter is typed `borrowing // JavaScriptUnownedValue` to select that (otherwise `@_disfavoredOverload`) overload — which // requires the *parenthesized, fully typed* parameter list, since Swift rejects a type annotation // on a shorthand `{ [capture] name, name in }` parameter. Async functions keep the untyped // shorthand and the owning-`this` overload: there is no unowned-`this` async variant and the buffer // escapes into the task anyway. - let captures = usesAppContext ? "[weak appContext, self]" : "[self]" + let captures = receiver.captureClause(usesAppContext: usesAppContext) let parameters = isAsync ? "this, arguments" : "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)" + let object = receiver.decoratedObject if usesAppContext { return """ - object.setProperty("\(jsName)") { \(captures) \(parameters) in + \(object).setProperty("\(jsName)") { \(captures)\(parameters) in guard let appContext else { throw Exceptions.AppContextLost() } - \(bodyStatements(indent: " ")) + \(bodyStatements(receiver: receiver, indent: " ")) } """ } return """ - object.setProperty("\(jsName)") { \(captures) \(parameters) in - \(bodyStatements(indent: " ")) + \(object).setProperty("\(jsName)") { \(captures)\(parameters) in + \(bodyStatements(receiver: receiver, indent: " ")) } """ } @@ -249,17 +254,18 @@ internal struct JSFunction { } /// A `@JS var` collected for **direct JSI binding**. Instead of describing the property with a -/// `Property(...)` DSL entry, `@ExpoModule` synthesizes a get/set accessor into the module's JS -/// object inside `_decorateModule`: it builds a descriptor object (`enumerable` + `get`, and `set` -/// when the property is settable) and installs it with `object.defineProperty(name, descriptor:)`, -/// mirroring core's `PropertyDefinition.buildDescriptor`. The `get`/`set` host functions are -/// installed the same way `@JS func`s are — the closure-taking `setProperty(_:)` overload, with the -/// read/write body inlined into the closure. +/// `Property(...)` DSL entry, the enclosing macro synthesizes a get/set accessor into the JS object +/// inside its decorator (`_decorateModule` / `_decorateSharedObject`): it builds a descriptor object +/// (`enumerable` + `get`, and `set` when the property is settable) and installs it with +/// `object.defineProperty(name, descriptor:)`, mirroring core's `PropertyDefinition.buildDescriptor`. +/// The `get`/`set` host functions are installed the same way `@JS func`s are — the closure-taking +/// `setProperty(_:)` overload, with the read/write body inlined into the closure. /// -/// The receiver is the module's real `self`, so the getter reads `self.` and the setter writes -/// `self. = …` directly, ignoring the JS `this`. Decode/encode of the value reuse the same -/// static-type fast path as functions (primitives through a direct typed accessor / `toJavaScriptValue`, -/// other types through the `getDynamicType()` converter). +/// The receiver (see `Receiver`) is the module's `self` for a module binding, or the per-call `_self` +/// unwrapped from the JS `this` for a shared object. The getter reads `.` and the setter +/// writes `. = …`. Decode/encode of the value reuse the same static-type fast path as +/// functions (primitives through a direct typed accessor / `toJavaScriptValue`, other types through +/// the `getDynamicType()` converter). internal struct JSProperty { let swiftName: String let jsName: String @@ -272,55 +278,66 @@ internal struct JSProperty { let isSettable: Bool /// The statements that install this property's accessor on the JS object, indented for the - /// `_decorateModule` body. Builds a descriptor object (`enumerable` + `get`, and `set` when - /// settable) via the closure-taking `setProperty(_:)` overload — with the read/write body inlined - /// into each closure — and installs it with `object.defineProperty(name, descriptor:)`. Capture - /// matches the function bindings: `self` strong, `appContext` weak + guarded — and, like functions, - /// the `appContext` capture + guard are omitted from an accessor whose body never references it (a - /// primitive value, decoded/encoded without the dynamic converter), to avoid the unused-capture - /// warning. Getter and setter are gated independently. - var decorateStatements: String { + /// decorator body. Builds a descriptor object (`enumerable` + `get`, and `set` when settable) via + /// the closure-taking `setProperty(_:)` overload — with the read/write body inlined into each + /// closure — and installs it with `object.defineProperty(name, descriptor:)`. Capture matches the + /// function bindings: a module captures `self` strong, a shared object captures nothing of the + /// instance; `appContext` is weak + guarded — and, like functions, the `appContext` capture + guard + /// are omitted from an accessor whose body never references it (a primitive value, decoded/encoded + /// without the dynamic converter), to avoid the unused-capture warning. Getter and setter are gated + /// independently. + func decorateStatements(receiver: Receiver) -> String { let descriptorName = "\(swiftName)Descriptor" + let callee = receiver.callee + let object = receiver.decoratedObject // A primitive value type encodes/decodes without `getDynamicType()`, so its accessor body never // references `appContext`. `nil` (untyped) goes through the dynamic-less `toJavaScriptValue` // getter, which also doesn't use it. let usesAppContext = valueType.map { fastDecodeAccessor(for: $0) == nil } ?? false + // A shared object's accessors unwrap the JS `this` into `_self` before reading/writing; a module + // reads `self` directly. The unwrap leads each accessor body. + let unwrap = receiver.unwrapStatement.map { "\($0)\n" } ?? "" var lines: [String] = [] lines.append("let \(descriptorName) = runtime.createObject()") lines.append("\(descriptorName).setProperty(\"enumerable\", value: true)") - // Getter: read `self.` and encode the result back to JS. + // Getter: read `.` and encode the result back to JS. let getEncode: String if let valueType, fastDecodeAccessor(for: valueType) != nil { - getEncode = "return self.\(swiftName).toJavaScriptValue(in: runtime)" + getEncode = "return \(callee).\(swiftName).toJavaScriptValue(in: runtime)" } else if let valueType { getEncode = - "return try \(expressionType(valueType)).getDynamicType().castToJS(self.\(swiftName), appContext: appContext, in: runtime)" + "return try \(expressionType(valueType)).getDynamicType().castToJS(\(callee).\(swiftName), appContext: appContext, in: runtime)" } else { - // No known type: fall back to converting whatever `self.` is. This only happens when the - // declaration has neither an annotation nor a literal default, which is rare for a stored var. - getEncode = "return self.\(swiftName).toJavaScriptValue(in: runtime)" + // No known type: fall back to converting whatever `.` is. This only happens when + // the declaration has neither an annotation nor a literal default, which is rare for a stored + // var. + getEncode = "return \(callee).\(swiftName).toJavaScriptValue(in: runtime)" } - lines.append(accessorClosure(descriptorName, "get", usesAppContext: usesAppContext, body: getEncode)) + lines.append( + accessorClosure( + descriptorName, "get", receiver: receiver, usesAppContext: usesAppContext, body: "\(unwrap)\(getEncode)")) - // Setter: decode argument 0 by the static type and write `self.`. A typed setter needs a - // known value type; when the type couldn't be inferred the property is bound getter-only (a + // Setter: decode argument 0 by the static type and write `.`. A typed setter needs + // a known value type; when the type couldn't be inferred the property is bound getter-only (a // settable var with neither an annotation nor a literal default is rare and can't be decoded). if isSettable, let valueType { let setDecode: String if let accessor = fastDecodeAccessor(for: valueType) { - setDecode = "self.\(swiftName) = try arguments.unownedValue(at: 0).\(accessor)()" + setDecode = "\(callee).\(swiftName) = try arguments.unownedValue(at: 0).\(accessor)()" } else { let exprType = expressionType(valueType) setDecode = - "self.\(swiftName) = try \(exprType).getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! \(exprType)" + "\(callee).\(swiftName) = try \(exprType).getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! \(exprType)" } lines.append( - accessorClosure(descriptorName, "set", usesAppContext: usesAppContext, body: "\(setDecode)\nreturn .undefined")) + accessorClosure( + descriptorName, "set", receiver: receiver, usesAppContext: usesAppContext, + body: "\(unwrap)\(setDecode)\nreturn .undefined")) } - lines.append("object.defineProperty(\"\(jsName)\", descriptor: \(descriptorName))") + lines.append("\(object).defineProperty(\"\(jsName)\", descriptor: \(descriptorName))") return lines .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) } @@ -328,26 +345,29 @@ internal struct JSProperty { .joined(separator: "\n") } - /// One `descriptor.setProperty("get"/"set") { … }` accessor entry. Captures `self` strong and, when - /// `usesAppContext`, `appContext` weak + guarded (matching the function bindings); otherwise the - /// capture and guard are omitted so a primitive accessor doesn't warn on an unused capture. + /// One `descriptor.setProperty("get"/"set") { … }` accessor entry. The capture list follows the + /// receiver (a module captures `self` strong; a shared object captures nothing of the instance) and, + /// when `usesAppContext`, adds `appContext` weak + guarded (matching the function bindings); + /// otherwise the guard is omitted so a primitive accessor doesn't warn on an unused capture. private func accessorClosure( - _ descriptorName: String, _ key: String, usesAppContext: Bool, body: String + _ descriptorName: String, _ key: String, receiver: Receiver, usesAppContext: Bool, body: String ) -> String { + let captures = receiver.captureClause(usesAppContext: usesAppContext) // Indent each line of a (possibly multi-line) body to sit one level inside the closure, aligned // with the `guard`; a bare `\(body)` interpolation would only indent the first line. let indentedBody = body .split(separator: "\n", omittingEmptySubsequences: false) .map { " \($0)" } .joined(separator: "\n") - // Property `get`/`set` accessors are always synchronous and never decode `this`, so they bind - // through the unowned-`this` `setProperty` overload like sync functions. The parameter list is - // parenthesized and fully typed because Swift rejects a type annotation on a shorthand closure - // parameter; the explicit `borrowing JavaScriptUnownedValue` selects the unowned-`this` overload. + // Property `get`/`set` accessors are always synchronous, so they bind through the unowned-`this` + // `setProperty` overload like sync functions. The parameter list is parenthesized and fully typed + // because Swift rejects a type annotation on a shorthand closure parameter; the explicit + // `borrowing JavaScriptUnownedValue` selects the unowned-`this` overload. A module ignores `this`; + // a shared object unwraps it in the body. let parameters = "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)" if usesAppContext { return """ - \(descriptorName).setProperty("\(key)") { [weak appContext, self] \(parameters) in + \(descriptorName).setProperty("\(key)") { \(captures)\(parameters) in guard let appContext else { throw Exceptions.AppContextLost() } @@ -356,24 +376,31 @@ internal struct JSProperty { """ } return """ - \(descriptorName).setProperty("\(key)") { [self] \(parameters) in + \(descriptorName).setProperty("\(key)") { \(captures)\(parameters) in \(indentedBody) } """ } } +/// The body shared by both decorators: every `@JS func` bound via an inlined `setProperty` closure +/// and every `@JS var` via a `defineProperty` accessor, joined for the function body. The `receiver` +/// selects how each binding reaches its Swift value (module `self` vs. shared-object `_self`). +private func decorateBody(functions: [JSFunction], properties: [JSProperty], receiver: Receiver) -> String { + let functionBody = functions.map { $0.decorateStatements(receiver: receiver) } + let propertyBody = properties.map { $0.decorateStatements(receiver: receiver) } + return (functionBody + propertyBody).joined(separator: "\n") +} + /// The single generated function that decorates the module's JS object. Core supplies the object; /// this binds every `@JS func` (via an inlined `setProperty` closure) and every `@JS var` (via a /// `defineProperty` accessor) into it. Mirrors core's `ObjectDefinition.decorate(object:)`, including /// its `borrowing` object parameter (it mutates through the reference without reassigning or taking /// ownership). Named `_decorateModule` with the leading-underscore convention for synthesized members /// the **runtime calls by name**; the `ExpoModule` suffix names the `@ExpoModule` macro it came from (a -/// shared object's counterpart is `_decorateSharedObject`). +/// shared object's counterpart is `_decorateSharedObject`). The bindings call into the module `self`. internal func buildDecorateJavaScriptObject(functions: [JSFunction], properties: [JSProperty]) -> DeclSyntax { - let functionBody = functions.map { $0.decorateStatements } - let propertyBody = properties.map { $0.decorateStatements } - let body = (functionBody + propertyBody).joined(separator: "\n") + let body = decorateBody(functions: functions, properties: properties, receiver: .module) return """ @JavaScriptActor public func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { @@ -382,23 +409,24 @@ internal func buildDecorateJavaScriptObject(functions: [JSFunction], properties: """ } -/// The throwing `JavaScriptUnownedValue` accessor that decodes the given primitive type directly, -/// bypassing the dynamic-type converter (`asDouble()` for `Double`, etc.). Returns `nil` for -/// types without a dedicated accessor — arrays, records, optionals, shared objects, other numeric -/// widths — which decode through `getDynamicType().cast(...)`. -private func fastDecodeAccessor(for type: String) -> String? { - switch type { - case "Bool": - return "asBool" - case "Int": - return "asInt" - case "Double": - return "asDouble" - case "String": - return "asString" - default: - return nil - } +/// The shared-object counterpart of `_decorateModule`. Core supplies the class `prototype`; this binds +/// every `@JS func` and `@JS var` of the given shared-object type onto it. Because a shared object has a +/// distinct native instance behind each JS object, the bindings are **static** and recover the typed +/// receiver from the JS `this` per call (`try SharedObject.native(from: this.asObject(in: runtime), as: .self)`) +/// rather than capturing a singleton `self`. The first parameter is `prototype` (not `object` as on +/// `_decorateModule`) because it's the shared class prototype, not an instance. The constructor is +/// bound separately (see `JSConstructor.buildConstructor`). Only emitted when the type has at least one +/// `@JS func`/`var`. +internal func buildDecorateSharedObject( + functions: [JSFunction], properties: [JSProperty], typeName: String +) -> DeclSyntax { + let body = decorateBody(functions: functions, properties: properties, receiver: .sharedObject(typeName: typeName)) + return """ + @JavaScriptActor + public static func _decorateSharedObject(prototype: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + \(raw: body) + } + """ } /// True when a return clause is absent or written as `Void` / `()` — i.e. the function returns diff --git a/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift b/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift index 602d99d..e3c2e7a 100644 --- a/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +++ b/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift @@ -244,55 +244,3 @@ private func hasAppContextInitializer(_ classDecl: ClassDeclSyntax) -> Bool { } return false } - -// MARK: - Member builders - -private func collectProperties( - varDecl: VariableDeclSyntax, - attribute: AttributeSyntax -) -> [JSProperty] { - let jsNameOverride = jsNameArgument(of: attribute) - // A `let` is never settable; only `var` bindings can carry a setter. - let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var) - - return varDecl.bindings.compactMap { binding in - guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else { - return nil - } - let swiftName = ident.identifier.text - // Prefer the explicit annotation; recover the type from a literal default (`var x = false`) - // when there's none. `nil` falls back to inference at the use site. - let valueType = binding.typeAnnotation?.type.trimmedDescription - ?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) } - return JSProperty( - swiftName: swiftName, - jsName: jsNameOverride ?? swiftName, - valueType: valueType, - isSettable: isVar && bindingIsSettable(binding) - ) - } -} - -/// Whether a `var` binding is settable from JS. A stored property (no accessor block) is settable; -/// a computed property is settable only when it declares an explicit `set` accessor. A getter-only -/// computed property (`{ get }` or a single getter body) stays read-only. `willSet`/`didSet` -/// observers imply stored storage, which is also settable. -private func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool { - guard let accessorBlock = binding.accessorBlock else { - return true - } - switch accessorBlock.accessors { - case .accessors(let accessors): - return accessors.contains { accessor in - switch accessor.accessorSpecifier.tokenKind { - case .keyword(.set), .keyword(.willSet), .keyword(.didSet): - return true - default: - return false - } - } - case .getter: - return false - } -} - diff --git a/apple/Sources/ExpoModulesMacros/JSConstructor.swift b/apple/Sources/ExpoModulesMacros/JSConstructor.swift new file mode 100644 index 0000000..736f655 --- /dev/null +++ b/apple/Sources/ExpoModulesMacros/JSConstructor.swift @@ -0,0 +1,60 @@ +import SwiftSyntax + +/// A `@JS init` collected for direct JSI binding. A shared-object type has at most one (JS classes +/// have a single constructor). Instead of a `Constructor { … }` DSL entry, the macro synthesizes a +/// static `_constructSharedObject(...)` that decodes the JS arguments and returns a fresh instance; +/// unlike the method/property bindings it produces the native instance rather than recovering one. +internal struct JSConstructor { + let parameters: [FunctionParameterSyntax] + + init(initDecl: InitializerDeclSyntax) { + self.parameters = Array(initDecl.signature.parameterClause.parameters) + } + + /// The body statements, indented with `indent`: arity guard, per-argument decode (primitives via a + /// typed accessor, others via the dynamic converter), then `return (label: arg0, …)`. + private func bodyStatements(typeName: String, indent: String) -> String { + var lines: [String] = [] + + lines.append( + """ + guard arguments.count == \(parameters.count) else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "\(typeName)", received: arguments.count, required: \(parameters.count), maximum: \(parameters.count))) + } + """) + + var callArguments: [String] = [] + for (index, parameter) in parameters.enumerated() { + let type = parameter.type.trimmedDescription + + if let accessor = fastDecodeAccessor(for: type) { + lines.append("let arg\(index) = try arguments.unownedValue(at: \(index)).\(accessor)()") + } else { + let exprType = expressionType(type) + lines.append( + "let arg\(index) = try \(exprType).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(exprType)") + } + + let label = parameter.firstName.text + callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)") + } + + lines.append("return \(typeName)(\(callArguments.joined(separator: ", ")))") + + return lines + .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) } + .map { indent + $0 } + .joined(separator: "\n") + } + + /// The static `_constructSharedObject` entry point the runtime calls to build an instance from JS + /// arguments, returning the concrete type. `this`/`appContext` may go unreferenced, which is harmless. + func buildConstructor(typeName: String) -> DeclSyntax { + return """ + @JavaScriptActor + public static func _constructSharedObject(this: JavaScriptValue, arguments: borrowing JavaScriptValuesBuffer, in runtime: JavaScriptRuntime, appContext: AppContext) throws -> \(raw: typeName) { + \(raw: bodyStatements(typeName: typeName, indent: " ")) + } + """ + } +} diff --git a/apple/Sources/ExpoModulesMacros/MacroHelpers.swift b/apple/Sources/ExpoModulesMacros/MacroHelpers.swift index 6a9104b..0ae43bc 100644 --- a/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +++ b/apple/Sources/ExpoModulesMacros/MacroHelpers.swift @@ -256,3 +256,76 @@ internal func expressionType(_ type: String) -> String { } return type.dropLast() + "?" } + +// MARK: - @JS property collection + +/// Collects the `@JS var` bindings of a declaration into `JSProperty` values for direct JSI binding. +/// Shared between `@ExpoModule` and `@SharedObject` — the resulting properties are receiver-agnostic; +/// the decorator that emits them picks the receiver (module `self` vs. shared-object `_self`). +internal func collectProperties( + varDecl: VariableDeclSyntax, + attribute: AttributeSyntax +) -> [JSProperty] { + let jsNameOverride = jsNameArgument(of: attribute) + // A `let` is never settable; only `var` bindings can carry a setter. + let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var) + + return varDecl.bindings.compactMap { binding in + guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else { + return nil + } + let swiftName = ident.identifier.text + // Prefer the explicit annotation; recover the type from a literal default (`var x = false`) + // when there's none. `nil` falls back to inference at the use site. + let valueType = binding.typeAnnotation?.type.trimmedDescription + ?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) } + return JSProperty( + swiftName: swiftName, + jsName: jsNameOverride ?? swiftName, + valueType: valueType, + isSettable: isVar && bindingIsSettable(binding) + ) + } +} + +/// Whether a `var` binding is settable from JS. A stored property (no accessor block) is settable; +/// a computed property is settable only when it declares an explicit `set` accessor. A getter-only +/// computed property (`{ get }` or a single getter body) stays read-only. `willSet`/`didSet` +/// observers imply stored storage, which is also settable. +private func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool { + guard let accessorBlock = binding.accessorBlock else { + return true + } + switch accessorBlock.accessors { + case .accessors(let accessors): + return accessors.contains { accessor in + switch accessor.accessorSpecifier.tokenKind { + case .keyword(.set), .keyword(.willSet), .keyword(.didSet): + return true + default: + return false + } + } + case .getter: + return false + } +} + +/// The throwing `JavaScriptUnownedValue` accessor that decodes the given primitive type directly +/// (`asDouble()` for `Double`, etc.), bypassing the dynamic-type converter. Returns `nil` for types +/// without a dedicated accessor (arrays, records, optionals, shared objects, other numeric widths), +/// which decode through `getDynamicType().cast(...)`. +func fastDecodeAccessor(for type: String) -> String? { + switch type { + case "Bool": + return "asBool" + case "Int": + return "asInt" + case "Double": + return "asDouble" + case "String": + return "asString" + default: + return nil + } +} diff --git a/apple/Sources/ExpoModulesMacros/Receiver.swift b/apple/Sources/ExpoModulesMacros/Receiver.swift new file mode 100644 index 0000000..29c8940 --- /dev/null +++ b/apple/Sources/ExpoModulesMacros/Receiver.swift @@ -0,0 +1,58 @@ +import SwiftSyntax + +/// Where a directly-bound closure gets the Swift value it calls into. A module is a singleton, so its +/// bindings call `self` and ignore the JS `this`; a shared object has a distinct native instance per JS +/// object, so its bindings recover the typed receiver from `this`. +internal enum Receiver { + /// The module singleton; the closure captures `self` strong. + case module + /// A shared object of the given concrete type; the closure captures nothing and recovers the receiver + /// from `this` per call. + case sharedObject(typeName: String) + + /// The expression the body calls members on: `self` for a module, `_self` (bound by `unwrapStatement`) + /// for a shared object. The leading underscore avoids colliding with a user member like `var owner`. + var callee: String { + switch self { + case .module: + return "self" + case .sharedObject: + return "_self" + } + } + + /// The JS object the decorator binds members onto, matching its first parameter: `object` for a + /// module (its own JS object), `prototype` for a shared object (the shared class prototype). + var decoratedObject: String { + switch self { + case .module: + return "object" + case .sharedObject: + return "prototype" + } + } + + /// The leading body line binding the receiver, or `nil` for a module (it reads `self` directly). For a + /// shared object, `native(from:as:)` recovers the typed instance from the borrowed `this`, throwing on + /// a foreign object or a type mismatch. + var unwrapStatement: String? { + switch self { + case .module: + return nil + case .sharedObject(let typeName): + return "let _self = try SharedObject.native(from: this.asObject(in: runtime), as: \(typeName).self)" + } + } + + /// The capture-clause fragment (with a trailing space, or empty when nothing is captured). A module + /// captures `self` strong; a shared object captures nothing of the instance. `appContext`, when used, + /// is captured weak in both cases. + func captureClause(usesAppContext: Bool) -> String { + switch self { + case .module: + return usesAppContext ? "[weak appContext, self] " : "[self] " + case .sharedObject: + return usesAppContext ? "[weak appContext] " : "" + } + } +} diff --git a/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift b/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift index 0f97f51..0984865 100644 --- a/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +++ b/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift @@ -41,35 +41,38 @@ public struct SharedObjectMacro: MemberMacro { let typeName = classDecl.name.text let jsName = jsNameArgument(of: node) ?? typeName - var entries: [String] = [] - var sawConstructor = false + // `@JS func`s/`var`s and the `@JS init` are bound directly into the shared object's JS object by + // the synthesized `_decorateSharedObject` / `_constructSharedObject` rather than described with a + // `Function(...)` / `Property(...)` / `Constructor { … }` DSL entry, so they're collected here + // instead of appended to the `Class` block. The block keeps only non-`@JS` definitions (none are + // collected today), so it's empty when every member is `@JS`. + let entries: [String] = [] + var functions: [JSFunction] = [] + var properties: [JSProperty] = [] + var constructor: JSConstructor? for member in classDecl.memberBlock.members { let decl = member.decl if let initDecl = decl.as(InitializerDeclSyntax.self), initDecl.attributes.firstAttribute(named: "JS") != nil { - if sawConstructor { + if constructor != nil { throw MacroExpansionErrorMessage( "@SharedObject classes can have at most one @JS initializer; JavaScript classes have a single constructor.") } - sawConstructor = true - entries.append(buildConstructorEntry(initDecl: initDecl, typeName: typeName)) + constructor = JSConstructor(initDecl: initDecl) continue } if let funcDecl = decl.as(FunctionDeclSyntax.self), let attribute = funcDecl.attributes.firstAttribute(named: "JS") { - entries.append( - buildClassFunctionEntry(funcDecl: funcDecl, attribute: attribute, typeName: typeName)) + functions.append(JSFunction(funcDecl: funcDecl, attribute: attribute)) continue } if let varDecl = decl.as(VariableDeclSyntax.self), let attribute = varDecl.attributes.firstAttribute(named: "JS") { - entries.append( - contentsOf: buildClassPropertyEntries( - varDecl: varDecl, attribute: attribute, typeName: typeName)) + properties.append(contentsOf: collectProperties(varDecl: varDecl, attribute: attribute)) } } @@ -78,13 +81,27 @@ public struct SharedObjectMacro: MemberMacro { ? " return Class(\"\(jsName)\", \(typeName).self) {\n }" : " return Class(\"\(jsName)\", \(typeName).self) {\n\(lines)\n }" - let method: DeclSyntax = """ + var emitted: [DeclSyntax] = [ + """ public static func _synthesizedClassDefinition() -> ClassDefinition { \(raw: body) } """ + ] + + // Direct JSI binding: one `_decorateSharedObject` that binds each `@JS func`/`var` onto the JS + // object (unwrapping the per-call receiver from `this`), and a `_constructSharedObject` that + // builds an instance from the `@JS init` arguments. Each is emitted only when it has something + // to do. + if !functions.isEmpty || !properties.isEmpty { + emitted.append( + buildDecorateSharedObject(functions: functions, properties: properties, typeName: typeName)) + } + if let constructor { + emitted.append(constructor.buildConstructor(typeName: typeName)) + } - return [method] + return emitted } } @@ -111,81 +128,3 @@ extension SharedObjectMacro: MemberAttributeMacro { private func inheritsFromSharedObject(_ classDecl: ClassDeclSyntax) -> Bool { return inheritsFromAny(classDecl, names: ["SharedObject"]) } - -// MARK: - Class-scope entry builders - -private func buildClassFunctionEntry( - funcDecl: FunctionDeclSyntax, - attribute: AttributeSyntax, - typeName: String -) -> String { - let swiftName = funcDecl.name.text - let jsName = jsNameArgument(of: attribute) ?? swiftName - let effects = funcDecl.signature.effectSpecifiers - let isAsync = effects?.asyncSpecifier != nil - let isThrowing = effects?.throwsClause?.throwsSpecifier != nil - let dslEntry = isAsync ? "AsyncFunction" : "Function" - - let params = funcDecl.signature.parameterClause.parameters - let closureParamList: String - let callArgList: String - if params.isEmpty { - closureParamList = "(this: \(typeName))" - callArgList = "" - } else { - let typedParams = params.enumerated().map { index, param in - "_ arg\(index): \(param.type.trimmedDescription)" - }.joined(separator: ", ") - closureParamList = "(this: \(typeName), \(typedParams))" - - callArgList = params.enumerated().map { index, param in - let label = param.firstName.text - return label == "_" ? "arg\(index)" : "\(label): arg\(index)" - }.joined(separator: ", ") - } - - let awaitKeyword = isAsync ? "await " : "" - let tryKeyword = (isAsync || isThrowing) ? "try " : "" - let callExpr = "\(tryKeyword)\(awaitKeyword)this.\(swiftName)(\(callArgList))" - - return "\(dslEntry)(\"\(jsName)\") { \(closureParamList) in \(callExpr) }" -} - -private func buildClassPropertyEntries( - varDecl: VariableDeclSyntax, - attribute: AttributeSyntax, - typeName: String -) -> [String] { - let jsNameOverride = jsNameArgument(of: attribute) - - return varDecl.bindings.compactMap { binding in - guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else { - return nil - } - let swiftName = ident.identifier.text - let jsName = jsNameOverride ?? swiftName - return "Property(\"\(jsName)\") { (this: \(typeName)) in this.\(swiftName) }" - } -} - -private func buildConstructorEntry( - initDecl: InitializerDeclSyntax, - typeName: String -) -> String { - let params = initDecl.signature.parameterClause.parameters - - if params.isEmpty { - return "Constructor { \(typeName)() }" - } - - let argList = params.enumerated().map { index, param in - "_ arg\(index): \(param.type.trimmedDescription)" - }.joined(separator: ", ") - - let callArgs = params.enumerated().map { index, param in - let label = param.firstName.text - return label == "_" ? "arg\(index)" : "\(label): arg\(index)" - }.joined(separator: ", ") - - return "Constructor { (\(argList)) in \(typeName)(\(callArgs)) }" -} diff --git a/apple/Tests/ExpoModulesMacrosTests/SharedObjectMacroTests.swift b/apple/Tests/ExpoModulesMacrosTests/SharedObjectMacroTests.swift index f25d018..d925094 100644 --- a/apple/Tests/ExpoModulesMacrosTests/SharedObjectMacroTests.swift +++ b/apple/Tests/ExpoModulesMacrosTests/SharedObjectMacroTests.swift @@ -102,7 +102,7 @@ struct SharedObjectMacroTests { } @Test - func `Sync method emits a class-scope Function entry`() { + func `Sync method binds via _decorateSharedObject, unwrapping the receiver from this`() { assertExpansion( """ @SharedObject @@ -118,9 +118,22 @@ struct SharedObjectMacroTests { public static func _synthesizedClassDefinition() -> ClassDefinition { return Class("Cache", Cache.self) { - Function("get") { (this: Cache, _ arg0: String) in - this.get(arg0) + } + } + + @JavaScriptActor + public static func _decorateSharedObject(prototype: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + prototype.setProperty("get") { [weak appContext] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + guard let appContext else { + throw Exceptions.AppContextLost() } + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + guard arguments.count == 1 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "get", received: arguments.count, required: 1, maximum: 1)) + } + let arg0 = try arguments.unownedValue(at: 0).asString() + let result = _self.get(arg0) + return try String?.getDynamicType().castToJS(result, appContext: appContext, in: runtime) } } } @@ -129,7 +142,7 @@ struct SharedObjectMacroTests { } @Test - func `Async method emits an AsyncFunction entry`() { + func `Async method binds through the async setProperty overload`() { assertExpansion( """ @SharedObject @@ -144,9 +157,18 @@ struct SharedObjectMacroTests { public static func _synthesizedClassDefinition() -> ClassDefinition { return Class("Cache", Cache.self) { - AsyncFunction("loadAsync") { (this: Cache) in - try await this.load() + } + } + + @JavaScriptActor + public static func _decorateSharedObject(prototype: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + prototype.setProperty("loadAsync") { this, arguments in + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + guard arguments.count == 0 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "loadAsync", received: arguments.count, required: 0, maximum: 0)) } + try await _self.load() + return .undefined } } } @@ -155,7 +177,7 @@ struct SharedObjectMacroTests { } @Test - func `An optional property type is unwrapped to its core type in the assertion`() { + func `A non-primitive property routes through the dynamic converter and unwraps the receiver`() { assertExpansion( """ @SharedObject @@ -177,10 +199,29 @@ struct SharedObjectMacroTests { public static func _synthesizedClassDefinition() -> ClassDefinition { return Class("Cache", Cache.self) { - Property("owner") { (this: Cache) in - this.owner + } + } + + @JavaScriptActor + public static func _decorateSharedObject(prototype: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + let ownerDescriptor = runtime.createObject() + ownerDescriptor.setProperty("enumerable", value: true) + ownerDescriptor.setProperty("get") { [weak appContext] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + guard let appContext else { + throw Exceptions.AppContextLost() } + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + return try SomeType?.getDynamicType().castToJS(_self.owner, appContext: appContext, in: runtime) } + ownerDescriptor.setProperty("set") { [weak appContext] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + guard let appContext else { + throw Exceptions.AppContextLost() + } + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + _self.owner = try SomeType?.getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! SomeType? + return .undefined + } + prototype.defineProperty("owner", descriptor: ownerDescriptor) } } """ @@ -188,7 +229,7 @@ struct SharedObjectMacroTests { } @Test - func `Property emits a class-scope Property entry that takes the owner`() { + func `A primitive computed property binds a get-only accessor, unwrapping the receiver`() { assertExpansion( """ @SharedObject @@ -204,18 +245,26 @@ struct SharedObjectMacroTests { public static func _synthesizedClassDefinition() -> ClassDefinition { return Class("Cache", Cache.self) { - Property("size") { (this: Cache) in - this.size - } } } + + @JavaScriptActor + public static func _decorateSharedObject(prototype: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + let sizeDescriptor = runtime.createObject() + sizeDescriptor.setProperty("enumerable", value: true) + sizeDescriptor.setProperty("get") { (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + return _self.size.toJavaScriptValue(in: runtime) + } + prototype.defineProperty("size", descriptor: sizeDescriptor) + } } """ ) } @Test - func `@JS init becomes a Constructor block`() { + func `@JS init binds a _constructSharedObject that builds the instance from JS arguments`() { assertExpansion( """ @SharedObject @@ -231,18 +280,24 @@ struct SharedObjectMacroTests { public static func _synthesizedClassDefinition() -> ClassDefinition { return Class("Cache", Cache.self) { - Constructor { (_ arg0: String) in - Cache(name: arg0) - } } } + + @JavaScriptActor + public static func _constructSharedObject(this: JavaScriptValue, arguments: borrowing JavaScriptValuesBuffer, in runtime: JavaScriptRuntime, appContext: AppContext) throws -> Cache { + guard arguments.count == 1 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "Cache", received: arguments.count, required: 1, maximum: 1)) + } + let arg0 = try arguments.unownedValue(at: 0).asString() + return Cache(name: arg0) + } } """ ) } @Test - func `Mixed members: init + method + property all flow into the Class block`() { + func `Mixed members: init constructs, method and property decorate`() { assertExpansion( """ @SharedObject @@ -268,15 +323,306 @@ struct SharedObjectMacroTests { public static func _synthesizedClassDefinition() -> ClassDefinition { return Class("Cache", Cache.self) { - Constructor { (_ arg0: String) in - Cache(name: arg0) + } + } + + @JavaScriptActor + public static func _decorateSharedObject(prototype: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + prototype.setProperty("get") { [weak appContext] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + guard let appContext else { + throw Exceptions.AppContextLost() + } + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + guard arguments.count == 1 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "get", received: arguments.count, required: 1, maximum: 1)) + } + let arg0 = try arguments.unownedValue(at: 0).asString() + let result = _self.get(arg0) + return try String?.getDynamicType().castToJS(result, appContext: appContext, in: runtime) + } + let sizeDescriptor = runtime.createObject() + sizeDescriptor.setProperty("enumerable", value: true) + sizeDescriptor.setProperty("get") { (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + return _self.size.toJavaScriptValue(in: runtime) + } + prototype.defineProperty("size", descriptor: sizeDescriptor) + } + + @JavaScriptActor + public static func _constructSharedObject(this: JavaScriptValue, arguments: borrowing JavaScriptValuesBuffer, in runtime: JavaScriptRuntime, appContext: AppContext) throws -> Cache { + guard arguments.count == 1 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "Cache", received: arguments.count, required: 1, maximum: 1)) + } + let arg0 = try arguments.unownedValue(at: 0).asString() + return Cache(name: arg0) + } + } + """ + ) + } + + @Test + func `Trailing defaulted parameter branches the call through the unwrapped receiver`() { + assertExpansion( + """ + @SharedObject + final class Cache: SharedObject { + @JS + func resize(width: Int, height: Int = 100) -> Bool { true } + } + """, + expandedSource: """ + final class Cache: SharedObject { + @JavaScriptActor + func resize(width: Int, height: Int = 100) -> Bool { true } + + public static func _synthesizedClassDefinition() -> ClassDefinition { + return Class("Cache", Cache.self) { + } + } + + @JavaScriptActor + public static func _decorateSharedObject(prototype: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + prototype.setProperty("resize") { (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + guard arguments.count >= 1 && arguments.count <= 2 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "resize", received: arguments.count, required: 1, maximum: 2)) + } + let arg0 = try arguments.unownedValue(at: 0).asInt() + let result = switch arguments.count { + case 1: + _self.resize(width: arg0) + default: + let arg1 = try arguments.unownedValue(at: 1).asInt() + _self.resize(width: arg0, height: arg1) + } + return result.toJavaScriptValue(in: runtime) + } + } + } + """ + ) + } + + @Test + func `Settable stored property binds a getter and setter through the unwrapped receiver`() { + assertExpansion( + """ + @SharedObject + final class Cache: SharedObject { + @JS + var name: String = "" + } + """, + expandedSource: """ + final class Cache: SharedObject { + @JavaScriptActor + var name: String = "" + + public static func _synthesizedClassDefinition() -> ClassDefinition { + return Class("Cache", Cache.self) { + } + } + + @JavaScriptActor + public static func _decorateSharedObject(prototype: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + let nameDescriptor = runtime.createObject() + nameDescriptor.setProperty("enumerable", value: true) + nameDescriptor.setProperty("get") { (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + return _self.name.toJavaScriptValue(in: runtime) + } + nameDescriptor.setProperty("set") { (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + _self.name = try arguments.unownedValue(at: 0).asString() + return .undefined + } + prototype.defineProperty("name", descriptor: nameDescriptor) + } + } + """ + ) + } + + @Test + func `No-argument void method calls through and returns undefined`() { + assertExpansion( + """ + @SharedObject + final class Cache: SharedObject { + @JS + func clear() {} + } + """, + expandedSource: """ + final class Cache: SharedObject { + @JavaScriptActor + func clear() {} + + public static func _synthesizedClassDefinition() -> ClassDefinition { + return Class("Cache", Cache.self) { + } + } + + @JavaScriptActor + public static func _decorateSharedObject(prototype: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + prototype.setProperty("clear") { (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + guard arguments.count == 0 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "clear", received: arguments.count, required: 0, maximum: 0)) + } + _self.clear() + return .undefined + } + } + } + """ + ) + } + + @Test + func `No-argument init constructs without an arity guard body`() { + assertExpansion( + """ + @SharedObject + final class Cache: SharedObject { + @JS + init() {} + } + """, + expandedSource: """ + final class Cache: SharedObject { + @JavaScriptActor + init() {} + + public static func _synthesizedClassDefinition() -> ClassDefinition { + return Class("Cache", Cache.self) { + } + } + + @JavaScriptActor + public static func _constructSharedObject(this: JavaScriptValue, arguments: borrowing JavaScriptValuesBuffer, in runtime: JavaScriptRuntime, appContext: AppContext) throws -> Cache { + guard arguments.count == 0 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "Cache", received: arguments.count, required: 0, maximum: 0)) + } + return Cache() + } + } + """ + ) + } + + @Test + func `Non-primitive constructor argument decodes through the dynamic converter`() { + assertExpansion( + """ + @SharedObject + final class Cache: SharedObject { + @JS + init(config: MyRecord) {} + } + """, + expandedSource: """ + final class Cache: SharedObject { + @JavaScriptActor + init(config: MyRecord) {} + + public static func _synthesizedClassDefinition() -> ClassDefinition { + return Class("Cache", Cache.self) { + } + } + + @JavaScriptActor + public static func _constructSharedObject(this: JavaScriptValue, arguments: borrowing JavaScriptValuesBuffer, in runtime: JavaScriptRuntime, appContext: AppContext) throws -> Cache { + guard arguments.count == 1 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "Cache", received: arguments.count, required: 1, maximum: 1)) + } + let arg0 = try MyRecord.getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! MyRecord + return Cache(config: arg0) + } + } + """ + ) + } + + @Test + func `Implicitly-unwrapped constructor argument normalizes to optional in the cast expression`() { + assertExpansion( + """ + @SharedObject + final class Cache: SharedObject { + @JS + init(config: MyRecord!) {} + } + """, + expandedSource: """ + final class Cache: SharedObject { + @JavaScriptActor + init(config: MyRecord!) {} + + public static func _synthesizedClassDefinition() -> ClassDefinition { + return Class("Cache", Cache.self) { + } + } + + @JavaScriptActor + public static func _constructSharedObject(this: JavaScriptValue, arguments: borrowing JavaScriptValuesBuffer, in runtime: JavaScriptRuntime, appContext: AppContext) throws -> Cache { + guard arguments.count == 1 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "Cache", received: arguments.count, required: 1, maximum: 1)) + } + let arg0 = try MyRecord?.getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! MyRecord? + return Cache(config: arg0) + } + } + """ + ) + } + + @Test + func `Implicitly-unwrapped method argument and return normalize to optional through the receiver`() { + assertExpansion( + """ + @SharedObject + final class Cache: SharedObject { + @JS + func resolve(_ input: MyRecord!) -> MyRecord! { input } + } + """, + expandedSource: """ + final class Cache: SharedObject { + @JavaScriptActor + func resolve(_ input: MyRecord!) -> MyRecord! { input } + + private func _assertTypesConformance_resolve() { + func resolve(_: T.Type) { + } + resolve(MyRecord.self) + } + + public static func _synthesizedClassDefinition() -> ClassDefinition { + return Class("Cache", Cache.self) { + } + } + + @JavaScriptActor + public static func _decorateSharedObject(prototype: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws { + prototype.setProperty("resolve") { [weak appContext] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + guard let appContext else { + throw Exceptions.AppContextLost() } - Function("get") { (this: Cache, _ arg0: String) in - this.get(arg0) + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + guard arguments.count >= 0 && arguments.count <= 1 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "resolve", received: arguments.count, required: 0, maximum: 1)) } - Property("size") { (this: Cache) in - this.size + let result = switch arguments.count { + case 0: + _self.resolve(nil) + default: + let arg0 = try MyRecord?.getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! MyRecord? + _self.resolve(arg0) } + return try MyRecord?.getDynamicType().castToJS(result, appContext: appContext, in: runtime) } } }