Skip to content
Merged
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
238 changes: 133 additions & 105 deletions apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift

Large diffs are not rendered by default.

52 changes: 0 additions & 52 deletions apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

60 changes: 60 additions & 0 deletions apple/Sources/ExpoModulesMacros/JSConstructor.swift
Original file line number Diff line number Diff line change
@@ -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 <Type>(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: " "))
}
"""
}
}
73 changes: 73 additions & 0 deletions apple/Sources/ExpoModulesMacros/MacroHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
58 changes: 58 additions & 0 deletions apple/Sources/ExpoModulesMacros/Receiver.swift
Original file line number Diff line number Diff line change
@@ -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] " : ""
}
}
}
Loading
Loading