@expo/expo-modules-macros-plugin 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -0,0 +1,390 @@
1
+ import SwiftSyntax
2
+
3
+ /**
4
+ A `@JS func` collected for **direct JSI binding**. Instead of describing the function with a
5
+ `Function(...)` / `AsyncFunction(...)` DSL entry that the runtime interprets per call,
6
+ `@ExpoModule` synthesizes a `_decorateModule` that binds each such function into the module's JS object
7
+ via the closure-taking `JavaScriptObject.setProperty(_:)`, with the decode-call-encode body
8
+ inlined into the closure. This omits the `[Any]`/`toTuple` dynamic-call path: every argument is
9
+ decoded individually by its static type.
10
+
11
+ The receiver is the module's real `self` (a module is a singleton instance), so the body calls
12
+ `self.<name>(...)` directly and ignores the JS `this`. An `async` `@JS func` produces an `async`
13
+ closure body and is installed through the async `setProperty(_:)` overload (so JS gets a promise).
14
+ */
15
+ internal struct JSFunction {
16
+ let swiftName: String
17
+ let jsName: String
18
+ let parameters: [FunctionParameterSyntax]
19
+ /// The declared return type as written, or `nil` when the function returns `Void`/nothing.
20
+ let returnType: String?
21
+ let isThrowing: Bool
22
+ let isAsync: Bool
23
+
24
+ init(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) {
25
+ self.swiftName = funcDecl.name.text
26
+ self.jsName = jsNameArgument(of: attribute) ?? funcDecl.name.text
27
+ self.parameters = Array(funcDecl.signature.parameterClause.parameters)
28
+
29
+ let declaredReturnType = funcDecl.signature.returnClause?.type
30
+ self.returnType = isVoidType(declaredReturnType) ? nil : declaredReturnType?.trimmedDescription
31
+
32
+ let effectSpecifiers = funcDecl.signature.effectSpecifiers
33
+ self.isThrowing = effectSpecifiers?.throwsClause?.throwsSpecifier != nil
34
+ self.isAsync = effectSpecifiers?.asyncSpecifier != nil
35
+ }
36
+
37
+ /// The number of leading parameters that must always be supplied: the total minus the maximal
38
+ /// trailing run of *omittable* parameters (each having a default value or an optional type). A
39
+ /// non-omittable parameter part-way through stops the run, since arguments are positional — a
40
+ /// required parameter after an omittable one forces the earlier one to be supplied too.
41
+ private var requiredArgumentCount: Int {
42
+ var required = parameters.count
43
+ for parameter in parameters.reversed() {
44
+ guard isOmittable(parameter) else {
45
+ break
46
+ }
47
+ required -= 1
48
+ }
49
+ return required
50
+ }
51
+
52
+ /// The decode-call-encode statements that form the host-function body, indented with the given
53
+ /// prefix. An arity guard (an exact check when every parameter is required, otherwise a range
54
+ /// check) throwing `Exceptions.ArgumentsRangeMismatch`; then the decode of the always-present
55
+ /// required prefix (primitives via a direct typed accessor like `asDouble()` on a zero-copy
56
+ /// `arguments.unownedValue(at:)`, others via `getDynamicType().cast(...)`); then the call and
57
+ /// result encode (primitives via `toJavaScriptValue(in:)`, others via `castToJS(...)`). When a
58
+ /// trailing run of parameters is omittable the call branches on `arguments.count`, decoding only
59
+ /// the slots that branch actually has — `arguments[i]` traps past `count`, so a slot the caller
60
+ /// didn't pass is never indexed.
61
+ private func bodyStatements(indent: String) -> String {
62
+ let required = requiredArgumentCount
63
+ let maximum = parameters.count
64
+ var lines: [String] = []
65
+
66
+ if required == maximum {
67
+ lines.append(
68
+ """
69
+ guard arguments.count == \(maximum) else {
70
+ throw Exceptions.ArgumentsRangeMismatch((functionName: "\(jsName)", received: arguments.count, required: \(required), maximum: \(maximum)))
71
+ }
72
+ """)
73
+ } else {
74
+ lines.append(
75
+ """
76
+ guard arguments.count >= \(required) && arguments.count <= \(maximum) else {
77
+ throw Exceptions.ArgumentsRangeMismatch((functionName: "\(jsName)", received: arguments.count, required: \(required), maximum: \(maximum)))
78
+ }
79
+ """)
80
+ }
81
+
82
+ // Decode the required prefix once — these slots are present in every accepted arity, so the
83
+ // decode is shared rather than repeated per branch.
84
+ for index in 0..<required {
85
+ lines.append(decodeStatement(at: index))
86
+ }
87
+
88
+ if required == maximum {
89
+ // No omittable trailing run: a single flat call with every argument decoded.
90
+ lines.append(contentsOf: callAndEncodeLines(arity: maximum, decodingFrom: required))
91
+ } else {
92
+ // One call shape per accepted arity. Each branch decodes only the trailing slots it has and
93
+ // fills the rest (defaulted params drop their label so Swift applies the default; optional
94
+ // params are passed `nil`). A value-returning function binds the result from a `switch`
95
+ // expression and encodes once after it; a no-return one calls inline in a `switch` statement
96
+ // and returns `.undefined`.
97
+ if returnType != nil {
98
+ lines.append("let result = switch arguments.count {")
99
+ } else {
100
+ lines.append("switch arguments.count {")
101
+ }
102
+ for arity in required...maximum {
103
+ let label = arity == maximum ? "default:" : "case \(arity):"
104
+ lines.append(label)
105
+ for index in required..<arity {
106
+ lines.append(" " + decodeStatement(at: index))
107
+ }
108
+ lines.append(" \(callExpression(arity: arity))")
109
+ }
110
+ lines.append("}")
111
+ lines.append(contentsOf: encodeResultLines())
112
+ }
113
+
114
+ return lines
115
+ .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
116
+ .map { indent + $0 }
117
+ .joined(separator: "\n")
118
+ }
119
+
120
+ /// `let arg<index> = …` decoding the slot at `index` by its static type: a primitive through a
121
+ /// direct typed accessor on a borrowed `JavaScriptUnownedValue` (no owning value, no `jsi::Value`
122
+ /// copy, no `getDynamicType()` allocation, no `Any` boxing, no force-cast — still validating and
123
+ /// throwing `TypeError` on a mismatch), any other type through the dynamic converter (which needs
124
+ /// an owning value, so it indexes the buffer directly).
125
+ private func decodeStatement(at index: Int) -> String {
126
+ let type = parameters[index].type.trimmedDescription
127
+ if let accessor = fastDecodeAccessor(for: type) {
128
+ return "let arg\(index) = try arguments.unownedValue(at: \(index)).\(accessor)()"
129
+ }
130
+ return "let arg\(index) = try \(type).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(type)"
131
+ }
132
+
133
+ /// The `self.<name>(...)` call for the given arity. Slots `0..<arity` are passed their decoded
134
+ /// `arg<i>`; a trailing optional-without-default slot that this arity omits is passed `nil`; a
135
+ /// trailing defaulted slot that this arity omits is dropped entirely so Swift applies its default.
136
+ private func callExpression(arity: Int) -> String {
137
+ var callArguments: [String] = []
138
+ for (index, parameter) in parameters.enumerated() {
139
+ let label = parameter.firstName.text
140
+ let value: String?
141
+ if index < arity {
142
+ value = "arg\(index)"
143
+ } else if hasDefaultValue(parameter) {
144
+ // Omitted defaulted slot: drop it from the call so Swift fills in the default.
145
+ value = nil
146
+ } else {
147
+ // Omitted optional-without-default slot: pass `nil`.
148
+ value = "nil"
149
+ }
150
+ guard let value else {
151
+ continue
152
+ }
153
+ callArguments.append(label == "_" ? value : "\(label): \(value)")
154
+ }
155
+ let tryKeyword = (isThrowing || isAsync) ? "try " : ""
156
+ let awaitKeyword = isAsync ? "await " : ""
157
+ return "\(tryKeyword)\(awaitKeyword)self.\(swiftName)(\(callArguments.joined(separator: ", ")))"
158
+ }
159
+
160
+ /// The flat (single-arity) call-and-encode lines used when no trailing parameter is omittable:
161
+ /// `let result = self.f(...)` then the return encode (or the no-return `self.f(...)` + `.undefined`).
162
+ private func callAndEncodeLines(arity: Int, decodingFrom: Int) -> [String] {
163
+ var lines: [String] = []
164
+ for index in decodingFrom..<arity {
165
+ lines.append(decodeStatement(at: index))
166
+ }
167
+ if returnType != nil {
168
+ lines.append("let result = \(callExpression(arity: arity))")
169
+ lines.append(contentsOf: encodeResultLines())
170
+ } else {
171
+ lines.append(callExpression(arity: arity))
172
+ lines.append("return .undefined")
173
+ }
174
+ return lines
175
+ }
176
+
177
+ /// Encode the `result` local back to JS and return it: a primitive through `toJavaScriptValue(in:)`
178
+ /// (the typed `JavaScriptRepresentable` conversion — no `Any`, no dynamic-type allocation), any
179
+ /// other type through the dynamic converter. A no-return function returns `.undefined` instead.
180
+ private func encodeResultLines() -> [String] {
181
+ guard let returnType else {
182
+ return ["return .undefined"]
183
+ }
184
+ if fastDecodeAccessor(for: returnType) != nil {
185
+ return ["return result.toJavaScriptValue(in: runtime)"]
186
+ }
187
+ return ["return try \(returnType).getDynamicType().castToJS(result, appContext: appContext, in: runtime)"]
188
+ }
189
+
190
+ /// The `setProperty` statement that installs this function on the JS object. The decode-call-encode
191
+ /// body is inlined directly into the closure passed to the closure-taking `setProperty` overload
192
+ /// (which creates the host function under the hood) — no separate named binding. For an `async`
193
+ /// function the body `await`s the call, which selects the async `setProperty` overload (so JS
194
+ /// receives a promise).
195
+ ///
196
+ /// Capture mirrors core's `SyncFunctionDefinition.build`: `self` (the module) is captured
197
+ /// **strong** — the host-function closure is what keeps the native callable alive for as long as
198
+ /// JS can invoke it; its lifetime is bounded by the JS VM's garbage collection of the object.
199
+ /// `appContext` is captured **weak** (and guarded) so it doesn't form a real retain cycle through
200
+ /// the app context. When no argument or return value goes through the dynamic-type converter the
201
+ /// body never references `appContext`, so the capture and guard are omitted to avoid the
202
+ /// unused-capture warning.
203
+ var decorateStatements: String {
204
+ if usesAppContext {
205
+ return """
206
+ object.setProperty("\(jsName)") { [weak appContext, self] this, arguments in
207
+ guard let appContext else {
208
+ throw Exceptions.AppContextLost()
209
+ }
210
+ \(bodyStatements(indent: " "))
211
+ }
212
+ """
213
+ }
214
+ return """
215
+ object.setProperty("\(jsName)") { [self] this, arguments in
216
+ \(bodyStatements(indent: " "))
217
+ }
218
+ """
219
+ }
220
+
221
+ /// True when the host-function body references `appContext` — i.e. some parameter or the return
222
+ /// type lacks a fast accessor and decodes/encodes through `getDynamicType()`, which threads
223
+ /// `appContext` in.
224
+ private var usesAppContext: Bool {
225
+ if parameters.contains(where: { fastDecodeAccessor(for: $0.type.trimmedDescription) == nil }) {
226
+ return true
227
+ }
228
+ if let returnType, fastDecodeAccessor(for: returnType) == nil {
229
+ return true
230
+ }
231
+ return false
232
+ }
233
+ }
234
+
235
+ /// A `@JS var` collected for **direct JSI binding**. Instead of describing the property with a
236
+ /// `Property(...)` DSL entry, `@ExpoModule` synthesizes a get/set accessor into the module's JS
237
+ /// object inside `_decorateModule`: it builds a descriptor object (`enumerable` + `get`, and `set`
238
+ /// when the property is settable) and installs it with `object.defineProperty(name, descriptor:)`,
239
+ /// mirroring core's `PropertyDefinition.buildDescriptor`. The `get`/`set` host functions are
240
+ /// installed the same way `@JS func`s are — the closure-taking `setProperty(_:)` overload, with the
241
+ /// read/write body inlined into the closure.
242
+ ///
243
+ /// The receiver is the module's real `self`, so the getter reads `self.<name>` and the setter writes
244
+ /// `self.<name> = …` directly, ignoring the JS `this`. Decode/encode of the value reuse the same
245
+ /// static-type fast path as functions (primitives through a direct typed accessor / `toJavaScriptValue`,
246
+ /// other types through the `getDynamicType()` converter).
247
+ internal struct JSProperty {
248
+ let swiftName: String
249
+ let jsName: String
250
+ /// The property's value type as written, or `nil` when it couldn't be inferred (no annotation and
251
+ /// no literal default). When `nil` the getter still works (the encode infers from `self.<name>`)
252
+ /// but the setter uses an untyped closure parameter.
253
+ let valueType: String?
254
+ /// Whether the property is settable from JS: `true` for a stored `var` or a computed `var` with an
255
+ /// explicit `set` accessor; `false` for a getter-only computed `var` or a `let`.
256
+ let isSettable: Bool
257
+
258
+ /// The statements that install this property's accessor on the JS object, indented for the
259
+ /// `_decorateModule` body. Builds a descriptor object (`enumerable` + `get`, and `set` when
260
+ /// settable) via the closure-taking `setProperty(_:)` overload — with the read/write body inlined
261
+ /// into each closure — and installs it with `object.defineProperty(name, descriptor:)`. Capture
262
+ /// matches the function bindings: `self` strong, `appContext` weak + guarded — and, like functions,
263
+ /// the `appContext` capture + guard are omitted from an accessor whose body never references it (a
264
+ /// primitive value, decoded/encoded without the dynamic converter), to avoid the unused-capture
265
+ /// warning. Getter and setter are gated independently.
266
+ var decorateStatements: String {
267
+ let descriptorName = "\(swiftName)Descriptor"
268
+ // A primitive value type encodes/decodes without `getDynamicType()`, so its accessor body never
269
+ // references `appContext`. `nil` (untyped) goes through the dynamic-less `toJavaScriptValue`
270
+ // getter, which also doesn't use it.
271
+ let usesAppContext = valueType.map { fastDecodeAccessor(for: $0) == nil } ?? false
272
+ var lines: [String] = []
273
+
274
+ lines.append("let \(descriptorName) = runtime.createObject()")
275
+ lines.append("\(descriptorName).setProperty(\"enumerable\", value: true)")
276
+
277
+ // Getter: read `self.<name>` and encode the result back to JS.
278
+ let getEncode: String
279
+ if let valueType, fastDecodeAccessor(for: valueType) != nil {
280
+ getEncode = "return self.\(swiftName).toJavaScriptValue(in: runtime)"
281
+ } else if let valueType {
282
+ getEncode =
283
+ "return try \(valueType).getDynamicType().castToJS(self.\(swiftName), appContext: appContext, in: runtime)"
284
+ } else {
285
+ // No known type: fall back to converting whatever `self.<name>` is. This only happens when the
286
+ // declaration has neither an annotation nor a literal default, which is rare for a stored var.
287
+ getEncode = "return self.\(swiftName).toJavaScriptValue(in: runtime)"
288
+ }
289
+ lines.append(accessorClosure(descriptorName, "get", usesAppContext: usesAppContext, body: getEncode))
290
+
291
+ // Setter: decode argument 0 by the static type and write `self.<name>`. A typed setter needs a
292
+ // known value type; when the type couldn't be inferred the property is bound getter-only (a
293
+ // settable var with neither an annotation nor a literal default is rare and can't be decoded).
294
+ if isSettable, let valueType {
295
+ let setDecode: String
296
+ if let accessor = fastDecodeAccessor(for: valueType) {
297
+ setDecode = "self.\(swiftName) = try arguments.unownedValue(at: 0).\(accessor)()"
298
+ } else {
299
+ setDecode =
300
+ "self.\(swiftName) = try \(valueType).getDynamicType().cast(jsValue: arguments[0], appContext: appContext) as! \(valueType)"
301
+ }
302
+ lines.append(
303
+ accessorClosure(descriptorName, "set", usesAppContext: usesAppContext, body: "\(setDecode)\nreturn .undefined"))
304
+ }
305
+
306
+ lines.append("object.defineProperty(\"\(jsName)\", descriptor: \(descriptorName))")
307
+
308
+ return lines
309
+ .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
310
+ .map { " " + $0 }
311
+ .joined(separator: "\n")
312
+ }
313
+
314
+ /// One `descriptor.setProperty("get"/"set") { … }` accessor entry. Captures `self` strong and, when
315
+ /// `usesAppContext`, `appContext` weak + guarded (matching the function bindings); otherwise the
316
+ /// capture and guard are omitted so a primitive accessor doesn't warn on an unused capture.
317
+ private func accessorClosure(
318
+ _ descriptorName: String, _ key: String, usesAppContext: Bool, body: String
319
+ ) -> String {
320
+ // Indent each line of a (possibly multi-line) body to sit one level inside the closure, aligned
321
+ // with the `guard`; a bare `\(body)` interpolation would only indent the first line.
322
+ let indentedBody = body
323
+ .split(separator: "\n", omittingEmptySubsequences: false)
324
+ .map { " \($0)" }
325
+ .joined(separator: "\n")
326
+ if usesAppContext {
327
+ return """
328
+ \(descriptorName).setProperty("\(key)") { [weak appContext, self] this, arguments in
329
+ guard let appContext else {
330
+ throw Exceptions.AppContextLost()
331
+ }
332
+ \(indentedBody)
333
+ }
334
+ """
335
+ }
336
+ return """
337
+ \(descriptorName).setProperty("\(key)") { [self] this, arguments in
338
+ \(indentedBody)
339
+ }
340
+ """
341
+ }
342
+ }
343
+
344
+ /// The single generated function that decorates the module's JS object. Core supplies the object;
345
+ /// this binds every `@JS func` (via an inlined `setProperty` closure) and every `@JS var` (via a
346
+ /// `defineProperty` accessor) into it. Mirrors core's `ObjectDefinition.decorate(object:)`, including
347
+ /// its `borrowing` object parameter (it mutates through the reference without reassigning or taking
348
+ /// ownership). Named `_decorateModule` with the leading-underscore convention for synthesized members
349
+ /// the **runtime calls by name**; the `ExpoModule` suffix names the `@ExpoModule` macro it came from (a
350
+ /// shared object's counterpart is `_decorateSharedObject`).
351
+ internal func buildDecorateJavaScriptObject(functions: [JSFunction], properties: [JSProperty]) -> DeclSyntax {
352
+ let functionBody = functions.map { $0.decorateStatements }
353
+ let propertyBody = properties.map { $0.decorateStatements }
354
+ let body = (functionBody + propertyBody).joined(separator: "\n")
355
+ return """
356
+ @JavaScriptActor
357
+ public func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws {
358
+ \(raw: body)
359
+ }
360
+ """
361
+ }
362
+
363
+ /// The throwing `JavaScriptUnownedValue` accessor that decodes the given primitive type directly,
364
+ /// bypassing the dynamic-type converter (`asDouble()` for `Double`, etc.). Returns `nil` for
365
+ /// types without a dedicated accessor — arrays, records, optionals, shared objects, other numeric
366
+ /// widths — which decode through `getDynamicType().cast(...)`.
367
+ private func fastDecodeAccessor(for type: String) -> String? {
368
+ switch type {
369
+ case "Bool":
370
+ return "asBool"
371
+ case "Int":
372
+ return "asInt"
373
+ case "Double":
374
+ return "asDouble"
375
+ case "String":
376
+ return "asString"
377
+ default:
378
+ return nil
379
+ }
380
+ }
381
+
382
+ /// True when a return clause is absent or written as `Void` / `()` — i.e. the function returns
383
+ /// nothing JS-visible, so the binding returns `.undefined`.
384
+ private func isVoidType(_ type: TypeSyntax?) -> Bool {
385
+ guard let type else {
386
+ return true
387
+ }
388
+ let text = type.trimmedDescription
389
+ return text == "Void" || text == "()"
390
+ }
@@ -0,0 +1,330 @@
1
+ import SwiftDiagnostics
2
+ import SwiftSyntax
3
+ import SwiftSyntaxMacros
4
+
5
+ /// Accessor macro applied to a function-typed `var` on a module or shared object, turning it into a
6
+ /// typed JavaScript event. A function-typed `var` can't be a stored property without an initializer,
7
+ /// so the macro expands it into a computed getter returning a closure that dispatches by name into
8
+ /// the `EventEmitter` `emit` overloads (core conforms both `BaseModule` and `SharedObject` to that
9
+ /// protocol, so `self.emit` resolves on each):
10
+ ///
11
+ /// @Event
12
+ /// var onProgress: (ProgressEvent) -> Void
13
+ /// // expands to:
14
+ /// var onProgress: (ProgressEvent) -> Void {
15
+ /// get {
16
+ /// { [weak self] payload in self?.emit(event: "progress", payload: payload) }
17
+ /// }
18
+ /// }
19
+ ///
20
+ /// A no-payload event (`() -> Void`) dispatches through the dedicated `emit(event:)` overload.
21
+ /// The JS event name defaults to the property name with the conventional `on` prefix stripped
22
+ /// (see `defaultEventName(for:)`); `@Event("customName")` overrides it verbatim.
23
+ ///
24
+ /// The closure captures `self` **weakly**: it's usually invoked inline (`self.onProgress(…)`), but an
25
+ /// author may store it or hand it to a delegate, and a strong capture would then extend the module's
26
+ /// lifetime. After the emitter deallocates the closure silently no-ops, which matches what `emit`
27
+ /// already does once the runtime is gone.
28
+ ///
29
+ /// The synthesized property is deliberately **not** isolated to `@JavaScriptActor`, unlike `@JS`
30
+ /// members: `emit` is itself non-isolated and schedules the dispatch onto the JS thread internally,
31
+ /// so the event is callable from any thread or isolation with no actor hop at the call site. It is
32
+ /// also self-contained: `@ExpoModule`/`@SharedObject` neither collect `@Event` members nor register
33
+ /// their names anywhere.
34
+ ///
35
+ /// `@Event(sync: true)` opts into **synchronous dispatch**: the closure calls `emitSync` (inline
36
+ /// conversion + dispatch, no scheduling) instead of `emit`, and `@ExpoModule`/`@SharedObject` stamp
37
+ /// the member `@JavaScriptActor` so the compiler forces the call site onto the JS thread, the
38
+ /// inverse of the async default. The isolation is on the property access, so it guards the inline
39
+ /// `self.onTick(…)` usage; a closure stored or handed off escapes it, after which `emitSync` runs
40
+ /// wherever the caller invokes it.
41
+ ///
42
+ /// As a **peer**, the macro emits a never-called conformance assertion (see
43
+ /// `TypeConformanceAssertion.swift`) checking that the payload type is JS-convertible and that the
44
+ /// enclosing type conforms to `EventEmitter`, so both failure modes surface as clear conformance
45
+ /// errors on the user's own declaration.
46
+ public struct EventMacro: AccessorMacro {
47
+ public static func expansion(
48
+ of node: AttributeSyntax,
49
+ providingAccessorsOf declaration: some DeclSyntaxProtocol,
50
+ in context: some MacroExpansionContext
51
+ ) throws -> [AccessorDeclSyntax] {
52
+ let event = try validatedEvent(of: node, on: declaration)
53
+ // The closure's parameter and return types are inferred from the property's declared type
54
+ // through the getter, so the body never has to spell the payload type. A sync event calls
55
+ // `emitSync` (inline dispatch, JS thread only); the default calls the scheduling `emit`.
56
+ let emitMethod = event.isSync ? "emitSync" : "emit"
57
+ let closure = event.hasPayload
58
+ ? "{ [weak self] payload in self?.\(emitMethod)(event: \"\(event.jsName)\", payload: payload) }"
59
+ : "{ [weak self] in self?.\(emitMethod)(event: \"\(event.jsName)\") }"
60
+ return [
61
+ """
62
+ get {
63
+ \(raw: closure)
64
+ }
65
+ """
66
+ ]
67
+ }
68
+ }
69
+
70
+ extension EventMacro: PeerMacro {
71
+ public static func expansion(
72
+ of node: AttributeSyntax,
73
+ providingPeersOf declaration: some DeclSyntaxProtocol,
74
+ in context: some MacroExpansionContext
75
+ ) throws -> [DeclSyntax] {
76
+ // Diagnostics are owned by the accessor expansion; an invalid declaration silently emits no
77
+ // peer here so each error is reported once.
78
+ guard let event = try? validatedEvent(of: node, on: declaration) else {
79
+ return []
80
+ }
81
+ // One nested helper asserts everything in a single call: the payload type is JS-convertible
82
+ // (the `P` parameter, dropped for no-payload events and known-conforming primitives) and the
83
+ // enclosing type can emit (the `E` parameter, always present since any `@Event` dispatches
84
+ // through `self.emit`). Named after the member so the member shows up in either diagnostic,
85
+ // and asserting the enclosing type by its spelled name so the conformance error names the
86
+ // user's type rather than 'Self'.
87
+ let name = event.swiftName
88
+ let payload = event.payloadType.flatMap(assertableBoundaryType)
89
+ let owner = enclosingTypeName(in: context) ?? "Self"
90
+ let helper = payload != nil
91
+ ? "func \(name)<P: \(jsConvertibleProtocolName), E: \(eventEmitterProtocolName)>(_: P.Type, _: E.Type) {}"
92
+ : "func \(name)<E: \(eventEmitterProtocolName)>(_: E.Type) {}"
93
+ let call = payload.map { "\(name)(\($0).self, \(owner).self)" } ?? "\(name)(\(owner).self)"
94
+ return [
95
+ """
96
+ private func _assertTypesConformance_\(raw: name)() {
97
+ \(raw: helper)
98
+ \(raw: call)
99
+ }
100
+ """
101
+ ]
102
+ }
103
+ }
104
+
105
+ /// What the expansions need to know about a validated `@Event` declaration: the property name, the
106
+ /// JS event name (after an `@Event("…")` override), and the payload type when the function type
107
+ /// takes one.
108
+ private struct EventMember {
109
+ let swiftName: String
110
+ let jsName: String
111
+ /// The payload type as written, or `nil` for a no-payload `() -> Void` event.
112
+ let payloadType: String?
113
+ /// Whether the event dispatches synchronously (`@Event(sync: true)`) via `emitSync`.
114
+ let isSync: Bool
115
+
116
+ var hasPayload: Bool {
117
+ return payloadType != nil
118
+ }
119
+ }
120
+
121
+ /// Validates the declaration `@Event` is attached to and reads the event out of it. The checks
122
+ /// mirror what the expansion relies on: a single-binding instance `var` (the macro synthesizes a
123
+ /// computed getter, so `let`, accessors, and initializers are all incompatible) whose type is a
124
+ /// function type returning `Void` with at most one payload parameter.
125
+ private func validatedEvent(
126
+ of node: AttributeSyntax,
127
+ on declaration: some DeclSyntaxProtocol
128
+ ) throws -> EventMember {
129
+ guard let varDecl = declaration.as(VariableDeclSyntax.self) else {
130
+ throw MacroExpansionErrorMessage("@Event can only be applied to a property")
131
+ }
132
+ if varDecl.attributes.firstAttribute(named: "JS") != nil {
133
+ throw MacroExpansionErrorMessage(
134
+ "@Event and @JS cannot be combined on the same property; an event is exposed to JS on its own, so remove one of the attributes")
135
+ }
136
+ // The compiler also rejects accessor macros on a `let`, but with a generic message; this one says
137
+ // what to do instead and carries the fix-it doing it. The synthesized property is getter-only, so
138
+ // switching to `var` loses nothing.
139
+ if varDecl.bindingSpecifier.tokenKind == .keyword(.let) {
140
+ throw letBindingDiagnostic(for: varDecl)
141
+ }
142
+ if varDecl.modifiers.contains(where: isTypeLevelModifier) {
143
+ throw MacroExpansionErrorMessage(
144
+ "@Event must be an instance property; events are emitted from a module or shared object instance.")
145
+ }
146
+ guard varDecl.bindings.count == 1, let binding = varDecl.bindings.first,
147
+ let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else {
148
+ throw MacroExpansionErrorMessage(
149
+ "@Event must be applied to a single named property; declare each event separately")
150
+ }
151
+ if binding.initializer != nil {
152
+ throw MacroExpansionErrorMessage(
153
+ "@Event property cannot have an initial value; the macro synthesizes the closure")
154
+ }
155
+ if binding.accessorBlock != nil {
156
+ throw MacroExpansionErrorMessage(
157
+ "@Event property cannot declare its own accessors; the macro synthesizes the getter")
158
+ }
159
+ guard let functionType = underlyingFunctionType(of: binding.typeAnnotation?.type) else {
160
+ throw MacroExpansionErrorMessage(
161
+ "@Event property must declare a function type, such as '(Payload) -> Void' or '() -> Void'")
162
+ }
163
+ guard isVoidReturn(functionType.returnClause.type) else {
164
+ throw MacroExpansionErrorMessage(
165
+ "@Event function type must return 'Void'; an event dispatches to JS and has no return value")
166
+ }
167
+ guard functionType.parameters.count <= 1 else {
168
+ throw MacroExpansionErrorMessage(
169
+ "@Event function type takes at most one payload parameter; combine multiple values into a single record")
170
+ }
171
+
172
+ let swiftName = identifier.identifier.text
173
+ return EventMember(
174
+ swiftName: swiftName,
175
+ jsName: jsNameArgument(of: node) ?? defaultEventName(for: swiftName),
176
+ payloadType: functionType.parameters.first?.type.trimmedDescription,
177
+ isSync: boolArgument(of: node, label: "sync") == true
178
+ )
179
+ }
180
+
181
+ // MARK: - Default event name
182
+
183
+ /// The default JS event name for a property: the Swift name with the conventional `on` prefix
184
+ /// stripped and the remainder decapitalized (`onStatusChange` → `statusChange`). The two sides
185
+ /// idiomatically want different names: the Swift property reads as invoking a handler
186
+ /// (`self.onStatusChange(…)`) and the prefix keeps it from colliding with a state property
187
+ /// (`status`), while JS listens by bare name (`addListener("statusChange")`, the Node/DOM idiom
188
+ /// that module and shared-object events follow). Names without the prefix (`statusChange`,
189
+ /// `online`) pass through verbatim, and an explicit `@Event("name")` override is never
190
+ /// transformed — that's also the escape hatch for legacy `onX` wire names.
191
+ private func defaultEventName(for swiftName: String) -> String {
192
+ guard swiftName.hasPrefix("on") else {
193
+ return swiftName
194
+ }
195
+ let rest = swiftName.dropFirst(2)
196
+ guard let first = rest.first, first.isUppercase else {
197
+ return swiftName
198
+ }
199
+ return decapitalized(String(rest))
200
+ }
201
+
202
+ /// Lowercases the leading uppercase run the way Swift's API importer does: a single leading
203
+ /// capital is lowercased (`StatusChange` → `statusChange`); a longer acronym run keeps its last
204
+ /// capital when a lowercase letter follows it, since that capital starts the next word
205
+ /// (`URLChange` → `urlChange`, `URL` → `url`).
206
+ private func decapitalized(_ name: String) -> String {
207
+ let runEnd = name.firstIndex { !$0.isUppercase } ?? name.endIndex
208
+ if name[..<runEnd].count > 1 && runEnd != name.endIndex {
209
+ let lastCapital = name.index(before: runEnd)
210
+ return name[..<lastCapital].lowercased() + name[lastCapital...]
211
+ }
212
+ return name[..<runEnd].lowercased() + name[runEnd...]
213
+ }
214
+
215
+ // MARK: - The `let` diagnostic
216
+
217
+ /// The error for `@Event let`, attached to the `let` keyword itself and carrying a fix-it that
218
+ /// replaces it with `var`. Unlike the other checks (plain thrown messages located at the attribute),
219
+ /// this one is a structured `Diagnostic` so Xcode can offer the one-click fix.
220
+ private func letBindingDiagnostic(for varDecl: VariableDeclSyntax) -> DiagnosticsError {
221
+ let specifier = varDecl.bindingSpecifier
222
+ let fixIt = FixIt(
223
+ message: EventFixItMessage("Replace 'let' with 'var'", id: "event-let-to-var"),
224
+ changes: [
225
+ // Rewriting just the token's kind keeps its surrounding trivia (indentation, the space
226
+ // before the property name) intact.
227
+ .replace(
228
+ oldNode: Syntax(specifier),
229
+ newNode: Syntax(specifier.with(\.tokenKind, .keyword(.var)))
230
+ )
231
+ ]
232
+ )
233
+ let message = EventDiagnosticMessage(
234
+ "@Event must be applied to a 'var': it expands into a computed property, which a 'let' cannot be. The synthesized property is read-only anyway.",
235
+ id: "event-on-let"
236
+ )
237
+ return DiagnosticsError(diagnostics: [
238
+ Diagnostic(node: specifier, message: message, fixIts: [fixIt])
239
+ ])
240
+ }
241
+
242
+ private struct EventDiagnosticMessage: DiagnosticMessage {
243
+ let message: String
244
+ let diagnosticID: MessageID
245
+ let severity: DiagnosticSeverity = .error
246
+
247
+ init(_ message: String, id: String) {
248
+ self.message = message
249
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
250
+ }
251
+ }
252
+
253
+ private struct EventFixItMessage: FixItMessage {
254
+ let message: String
255
+ let fixItID: MessageID
256
+
257
+ init(_ message: String, id: String) {
258
+ self.message = message
259
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
260
+ }
261
+ }
262
+
263
+ // MARK: - Declaration shape helpers
264
+
265
+ /// The spelled name of the innermost type declaration enclosing the macro, read from the lexical
266
+ /// context, so the emitter assertion can name the user's type in the conformance diagnostic
267
+ /// ("requires that 'MyModule' conform to 'EventEmitter'" instead of "'Self'"). Returns `nil` (the
268
+ /// caller falls back to `Self`) when there's no enclosing type, or when it's a generic type
269
+ /// declaration: `Foo.self` isn't valid for an unbound generic, while `Self` works anywhere. An
270
+ /// extension can't be detected as generic syntactically (see the extension case below).
271
+ private func enclosingTypeName(in context: some MacroExpansionContext) -> String? {
272
+ for scope in context.lexicalContext {
273
+ if let classDecl = scope.as(ClassDeclSyntax.self) {
274
+ return classDecl.genericParameterClause == nil ? classDecl.name.text : nil
275
+ }
276
+ if let structDecl = scope.as(StructDeclSyntax.self) {
277
+ return structDecl.genericParameterClause == nil ? structDecl.name.text : nil
278
+ }
279
+ if let actorDecl = scope.as(ActorDeclSyntax.self) {
280
+ return actorDecl.genericParameterClause == nil ? actorDecl.name.text : nil
281
+ }
282
+ if let extensionDecl = scope.as(ExtensionDeclSyntax.self) {
283
+ // An extension carries no generic-parameter clause of its own, so a bare extended type
284
+ // (`extension Box`) is indistinguishable from a non-generic one (`extension Foo`); both read
285
+ // as a plain identifier here. A written bound form (`extension Box<Int>`) is valid as `.self`,
286
+ // and the common non-generic case keeps its spelled name in the diagnostic. The unguarded gap
287
+ // is `extension <Generic>` with the parameters omitted, where the spelled name is an unbound
288
+ // generic invalid as `.self`; events on generic types in an extension are rare enough that the
289
+ // resulting compile error is an acceptable price for naming the user's type everywhere else.
290
+ return extensionDecl.extendedType.trimmedDescription
291
+ }
292
+ }
293
+ return nil
294
+ }
295
+
296
+ private func isTypeLevelModifier(_ modifier: DeclModifierSyntax) -> Bool {
297
+ return modifier.name.tokenKind == .keyword(.static) || modifier.name.tokenKind == .keyword(.class)
298
+ }
299
+
300
+ /// The function type underlying a property's type annotation, unwrapping attributes
301
+ /// (`@Sendable (P) -> Void`) and single-element parentheses (`((P) -> Void)`). Returns `nil` when
302
+ /// the annotation is missing or isn't a function type, including an optional function type:
303
+ /// an event is always present, never `nil`.
304
+ private func underlyingFunctionType(of type: TypeSyntax?) -> FunctionTypeSyntax? {
305
+ guard let type else {
306
+ return nil
307
+ }
308
+ if let attributed = type.as(AttributedTypeSyntax.self) {
309
+ return underlyingFunctionType(of: attributed.baseType)
310
+ }
311
+ if let tuple = type.as(TupleTypeSyntax.self),
312
+ tuple.elements.count == 1, let element = tuple.elements.first, element.firstName == nil {
313
+ return underlyingFunctionType(of: element.type)
314
+ }
315
+ return type.as(FunctionTypeSyntax.self)
316
+ }
317
+
318
+ /// True when the function type's return is written as `Void` / `()`. Function types always carry an
319
+ /// explicit return clause, so unlike a function declaration there's no "absent" case. A module-qualified
320
+ /// `Swift.Void` and redundant parentheses (`(Void)`, `(())`) are accepted too, so a valid void return
321
+ /// written one of those ways isn't rejected with a misleading "must return 'Void'" diagnostic.
322
+ private func isVoidReturn(_ type: TypeSyntax) -> Bool {
323
+ // Peel single-element, unlabeled parentheses: `(Void)` and `(())` are the same type as their content.
324
+ if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1,
325
+ let element = tuple.elements.first, element.firstName == nil {
326
+ return isVoidReturn(element.type)
327
+ }
328
+ let text = type.trimmedDescription
329
+ return text == "Void" || text == "()" || text == "Swift.Void"
330
+ }
@@ -6,11 +6,13 @@ import SwiftSyntaxMacros
6
6
  Macro applied to a module class. It plays three roles, each implemented in its own
7
7
  extension below:
8
8
 
9
- - `MemberMacro`: scans the class body for `@JS`-marked declarations and synthesizes a
10
- framework-internal `_synthesizedDefinition()` returning `[AnyDefinition]`, which
11
- `expo-modules-core` calls automatically and merges into the module's definition. When
12
- the class doesn't already inherit `Module`/`BaseModule`, it also synthesizes the
13
- `appContext` storage and `init(appContext:)` those base classes would have provided.
9
+ - `MemberMacro`: binds `@JS`-marked members directly into the module's JS object via the
10
+ synthesized `_decorateModule`, and emits the resolved module name as a non-optional
11
+ `_jsName` static (no `Name(…)` DSL element). It also synthesizes a
12
+ framework-internal `_synthesizedDefinition()` returning `[AnyDefinition]` (now carrying
13
+ only nested `classes` entries) that `expo-modules-core` merges into the module's
14
+ definition. When the class doesn't already inherit `Module`/`BaseModule`, it also
15
+ synthesizes the `appContext` storage and `init(appContext:)` those base classes provide.
14
16
  - `MemberAttributeMacro`: stamps `@JavaScriptActor` on `@JS` sync members and
15
17
  `@ModuleDefinitionBuilder` on a `definition()` method.
16
18
  - `ExtensionMacro`: adds the `AnyModule` conformance when the class doesn't inherit it.
@@ -39,14 +41,18 @@ public struct ExpoModuleMacro: MemberMacro {
39
41
  throw MacroExpansionErrorMessage("@ExpoModule can only be applied to a class")
40
42
  }
41
43
 
44
+ // The module name is fully resolved here (the explicit `@ExpoModule("…")` argument, else the
45
+ // class name) and emitted as a non-optional `_jsName` static below — so the
46
+ // macro no longer emits a `Name(…)` DSL entry. Core reads the static for the module name,
47
+ // ahead of its type-name fallback (which then only applies to non-macro DSL modules).
42
48
  let moduleName = jsNameArgument(of: node) ?? classDecl.name.text
43
- var entries: [String] = ["Name(\"\(moduleName)\")"]
49
+ var entries: [String] = []
44
50
 
45
- // `@JS func`s (sync and async) are bound directly into the JS object by the synthesized
46
- // `_decorateModule` rather than described with a `Function(...)` / `AsyncFunction(...)` DSL entry,
47
- // so they're collected here instead of appended to `entries`. Properties still go through
48
- // the DSL for now.
51
+ // `@JS func`s (sync and async) and `@JS var`s are bound directly into the JS object by the
52
+ // synthesized `_decorateModule` rather than described with a `Function(...)` / `Property(...)`
53
+ // DSL entry, so they're collected here instead of appended to `entries`.
49
54
  var functions: [JSFunction] = []
55
+ var properties: [JSProperty] = []
50
56
 
51
57
  for typeName in classListArgument(of: node, label: "classes") {
52
58
  entries.append("\(typeName)._synthesizedClassDefinition()")
@@ -63,15 +69,25 @@ public struct ExpoModuleMacro: MemberMacro {
63
69
 
64
70
  if let varDecl = decl.as(VariableDeclSyntax.self),
65
71
  let attribute = varDecl.attributes.firstAttribute(named: "JS") {
66
- entries.append(contentsOf: buildPropertyEntries(varDecl: varDecl, attribute: attribute))
72
+ properties.append(contentsOf: collectProperties(varDecl: varDecl, attribute: attribute))
67
73
  }
68
74
  }
69
75
 
70
- let lines = entries.map { " \($0)" }.joined(separator: ",\n")
71
- let body = " return [\n\(lines)\n ]"
76
+ let body: String
77
+ if entries.isEmpty {
78
+ body = " return []"
79
+ } else {
80
+ let lines = entries.map { " \($0)" }.joined(separator: ",\n")
81
+ body = " return [\n\(lines)\n ]"
82
+ }
72
83
 
73
84
  var emitted: [DeclSyntax] = []
74
85
 
86
+ // The fully-resolved module name as a non-optional stored constant. Core reads this
87
+ // instead of the retired `Name(…)` DSL element; it feeds both native registration and the
88
+ // JS object name, so they can't diverge.
89
+ emitted.append("public static let _jsName = \"\(raw: moduleName)\"")
90
+
75
91
  // `Module`/`BaseModule` already provide `appContext` storage and the
76
92
  // `init(appContext:)` requirement, so we only synthesize them for classes that
77
93
  // inherit from neither. Each is skipped individually if the user wrote their own,
@@ -100,11 +116,11 @@ public struct ExpoModuleMacro: MemberMacro {
100
116
  """
101
117
  emitted.append(method)
102
118
 
103
- // Direct JSI binding: one `_decorateModule` that binds each `@JS func` into the module's JS object,
104
- // with the decode-call-encode body inlined into each closure. Only emitted when there are
105
- // functions to bind.
106
- if !functions.isEmpty {
107
- emitted.append(buildDecorateJavaScriptObject(functions: functions))
119
+ // Direct JSI binding: one `_decorateModule` that binds each `@JS func` (inlined `setProperty`
120
+ // closure) and each `@JS var` (a `defineProperty` get/set accessor) into the module's JS object.
121
+ // Only emitted when there's at least one member to bind.
122
+ if !functions.isEmpty || !properties.isEmpty {
123
+ emitted.append(buildDecorateJavaScriptObject(functions: functions, properties: properties))
108
124
  }
109
125
 
110
126
  return emitted
@@ -127,7 +143,10 @@ extension ExpoModuleMacro: MemberAttributeMacro {
127
143
  // `@JS` sync members run on the JS thread; stamp `@JavaScriptActor` so isolation is
128
144
  // checked at compile time. Skipped when the member already chose an isolation
129
145
  // (`async`, `nonisolated`, or another global actor) — see `shouldStampJavaScriptActor`.
130
- if memberHasJSAttribute(member),
146
+ // `@Event(sync: true)` members get the stamp too: a sync event dispatches inline, so the
147
+ // isolation forces its call site onto the JS thread. Async events (the default) are
148
+ // deliberately left unstamped — their `emit` schedules onto the JS thread itself.
149
+ if memberHasJSAttribute(member) || isSyncEventMember(member),
131
150
  shouldStampJavaScriptActor(on: member, enclosedBy: declaration) {
132
151
  attributes.append("@JavaScriptActor")
133
152
  }
@@ -228,19 +247,52 @@ private func hasAppContextInitializer(_ classDecl: ClassDeclSyntax) -> Bool {
228
247
 
229
248
  // MARK: - Member builders
230
249
 
231
- private func buildPropertyEntries(
250
+ private func collectProperties(
232
251
  varDecl: VariableDeclSyntax,
233
252
  attribute: AttributeSyntax
234
- ) -> [String] {
253
+ ) -> [JSProperty] {
235
254
  let jsNameOverride = jsNameArgument(of: attribute)
255
+ // A `let` is never settable; only `var` bindings can carry a setter.
256
+ let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
236
257
 
237
258
  return varDecl.bindings.compactMap { binding in
238
259
  guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
239
260
  return nil
240
261
  }
241
262
  let swiftName = ident.identifier.text
242
- let jsName = jsNameOverride ?? swiftName
243
- return "Property(\"\(jsName)\") { self.\(swiftName) }"
263
+ // Prefer the explicit annotation; recover the type from a literal default (`var x = false`)
264
+ // when there's none. `nil` falls back to inference at the use site.
265
+ let valueType = binding.typeAnnotation?.type.trimmedDescription
266
+ ?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
267
+ return JSProperty(
268
+ swiftName: swiftName,
269
+ jsName: jsNameOverride ?? swiftName,
270
+ valueType: valueType,
271
+ isSettable: isVar && bindingIsSettable(binding)
272
+ )
273
+ }
274
+ }
275
+
276
+ /// Whether a `var` binding is settable from JS. A stored property (no accessor block) is settable;
277
+ /// a computed property is settable only when it declares an explicit `set` accessor. A getter-only
278
+ /// computed property (`{ get }` or a single getter body) stays read-only. `willSet`/`didSet`
279
+ /// observers imply stored storage, which is also settable.
280
+ private func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
281
+ guard let accessorBlock = binding.accessorBlock else {
282
+ return true
283
+ }
284
+ switch accessorBlock.accessors {
285
+ case .accessors(let accessors):
286
+ return accessors.contains { accessor in
287
+ switch accessor.accessorSpecifier.tokenKind {
288
+ case .keyword(.set), .keyword(.willSet), .keyword(.didSet):
289
+ return true
290
+ default:
291
+ return false
292
+ }
293
+ }
294
+ case .getter:
295
+ return false
244
296
  }
245
297
  }
246
298
 
@@ -14,6 +14,64 @@ internal func jsNameArgument(of attribute: AttributeSyntax) -> String? {
14
14
  return segment.content.text
15
15
  }
16
16
 
17
+ /**
18
+ Reads a labeled boolean-literal argument of an attribute, e.g. `@Event(sync: true)` -> true.
19
+ Returns nil if the attribute has no argument with that label or its value isn't a boolean literal.
20
+ */
21
+ internal func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
22
+ guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
23
+ return nil
24
+ }
25
+ for arg in args where arg.label?.text == label {
26
+ guard let literal = arg.expression.as(BooleanLiteralExprSyntax.self) else {
27
+ return nil
28
+ }
29
+ return literal.literal.tokenKind == .keyword(.true)
30
+ }
31
+ return nil
32
+ }
33
+
34
+ /// True if the type is written as an optional: `T?`, `T!`, or the explicit `Optional<T>`. Used to
35
+ /// decide argument requiredness (an optional parameter may be omitted) and record-field nullability.
36
+ internal func isOptionalType(_ type: TypeSyntax) -> Bool {
37
+ if type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
38
+ return true
39
+ }
40
+ if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional" {
41
+ return true
42
+ }
43
+ return false
44
+ }
45
+
46
+ /// True if a trailing occurrence of this parameter may be omitted by the JS caller: it either has a
47
+ /// default value (Swift applies it) or is an optional type (an absent slot becomes `nil`). The arity
48
+ /// range and the per-arity call branches are derived from this.
49
+ internal func isOmittable(_ parameter: FunctionParameterSyntax) -> Bool {
50
+ return hasDefaultValue(parameter) || isOptionalType(parameter.type)
51
+ }
52
+
53
+ /// True if the parameter declares a default value (`b: Int = 5`). An omitted defaulted slot is left
54
+ /// out of the call so Swift fills in the default, distinguishing it from an omitted optional slot
55
+ /// (passed `nil`).
56
+ internal func hasDefaultValue(_ parameter: FunctionParameterSyntax) -> Bool {
57
+ return parameter.defaultValue != nil
58
+ }
59
+
60
+ /**
61
+ True if the declaration is a `@Event(sync: true)` property. A sync event dispatches inline on the
62
+ JS thread instead of scheduling, so `@ExpoModule`/`@SharedObject` stamp it with `@JavaScriptActor`,
63
+ making "must be called on the JS thread" a compile-time guarantee at the call site. Async events
64
+ (the default) are deliberately not stamped: their `emit` schedules onto the JS thread itself, so
65
+ they stay callable from any thread.
66
+ */
67
+ internal func isSyncEventMember(_ decl: DeclSyntaxProtocol) -> Bool {
68
+ guard let varDecl = decl.as(VariableDeclSyntax.self),
69
+ let attribute = varDecl.attributes.firstAttribute(named: "Event") else {
70
+ return false
71
+ }
72
+ return boolArgument(of: attribute, label: "sync") == true
73
+ }
74
+
17
75
  /**
18
76
  Reads a labeled array-literal argument of an attribute, e.g. `@ExpoModule(classes: [Foo.self, Bar.self])`,
19
77
  and returns the type names referenced (e.g. `["Foo", "Bar"]`). Each element must be a
@@ -155,6 +213,27 @@ private func hasGlobalActorShape(_ element: AttributeListSyntax.Element) -> Bool
155
213
  return name.hasSuffix("Actor")
156
214
  }
157
215
 
216
+ /// The Swift default type of a literal expression — `String`, `Double`, `Int`, or `Bool` — or `nil`
217
+ /// when the expression isn't one of those literals. Used to recover a property's type when it has no
218
+ /// annotation but does have a literal default (`var name = "foo"` → `String`). This matches the type
219
+ /// Swift itself would infer for the same un-annotated declaration; expressions whose type a syntactic
220
+ /// macro can't know (function calls, collection literals, member access) return `nil`.
221
+ internal func inferredLiteralType(of expression: ExprSyntax) -> String? {
222
+ if expression.is(StringLiteralExprSyntax.self) {
223
+ return "String"
224
+ }
225
+ if expression.is(FloatLiteralExprSyntax.self) {
226
+ return "Double"
227
+ }
228
+ if expression.is(IntegerLiteralExprSyntax.self) {
229
+ return "Int"
230
+ }
231
+ if expression.is(BooleanLiteralExprSyntax.self) {
232
+ return "Bool"
233
+ }
234
+ return nil
235
+ }
236
+
158
237
  extension AttributeListSyntax {
159
238
  internal func firstAttribute(named name: String) -> AttributeSyntax? {
160
239
  for element in self {
@@ -6,6 +6,7 @@ struct ExpoModulesMacrosPlugin: CompilerPlugin {
6
6
  let providingMacros: [Macro.Type] = [
7
7
  OptimizedFunctionAttachedMacro.self,
8
8
  JSMacro.self,
9
+ EventMacro.self,
9
10
  ExpoModuleMacro.self,
10
11
  SharedObjectMacro.self,
11
12
  RecordMacro.self,
@@ -453,42 +453,6 @@ private func isExcludedByModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
453
453
  return false
454
454
  }
455
455
 
456
- /**
457
- The Swift default type of a literal expression — `String`, `Double`, `Int`, or `Bool` — or `nil`
458
- when the expression isn't one of those literals. Used to recover a property's type when it has no
459
- annotation but does have a literal default (`var name = "foo"` → `String`). This matches the type
460
- Swift itself would infer for the same un-annotated declaration; expressions whose type a syntactic
461
- macro can't know (function calls, collection literals, member access) return `nil`.
462
- */
463
- private func inferredLiteralType(of expression: ExprSyntax) -> String? {
464
- if expression.is(StringLiteralExprSyntax.self) {
465
- return "String"
466
- }
467
- if expression.is(FloatLiteralExprSyntax.self) {
468
- return "Double"
469
- }
470
- if expression.is(IntegerLiteralExprSyntax.self) {
471
- return "Int"
472
- }
473
- if expression.is(BooleanLiteralExprSyntax.self) {
474
- return "Bool"
475
- }
476
- return nil
477
- }
478
-
479
- /**
480
- True if the type syntax is optional: `T?`, `T!`, or the spelled-out `Optional<T>`.
481
- */
482
- private func isOptionalType(_ type: TypeSyntax) -> Bool {
483
- if type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
484
- return true
485
- }
486
- if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional" {
487
- return true
488
- }
489
- return false
490
- }
491
-
492
456
  /**
493
457
  True if the type's inheritance clause already lists a protocol with the given name.
494
458
  Matches either the bare identifier (`Record`) or a qualified member access ending in
@@ -95,7 +95,10 @@ extension SharedObjectMacro: MemberAttributeMacro {
95
95
  providingAttributesFor member: some DeclSyntaxProtocol,
96
96
  in context: some MacroExpansionContext
97
97
  ) throws -> [AttributeSyntax] {
98
- guard memberHasJSAttribute(member),
98
+ // `@Event(sync: true)` members are stamped alongside `@JS` ones: a sync event dispatches
99
+ // inline, so the isolation forces its call site onto the JS thread. Async events (the
100
+ // default) stay unstamped — their `emit` schedules onto the JS thread itself.
101
+ guard memberHasJSAttribute(member) || isSyncEventMember(member),
99
102
  shouldStampJavaScriptActor(on: member, enclosedBy: declaration) else {
100
103
  return []
101
104
  }
@@ -5,6 +5,11 @@ import SwiftSyntax
5
5
  /// asserts the conformance (`@JS`, `@Record`, …).
6
6
  internal let jsConvertibleProtocolName = "AnyArgument"
7
7
 
8
+ /// The protocol a type must conform to for `self.emit(event:…)` to resolve; core conforms
9
+ /// `BaseModule` and `SharedObject` to it. Asserted by `@Event` so attaching it to a type that can't
10
+ /// emit fails with a conformance diagnostic instead of an opaque "no member 'emit'" error.
11
+ internal let eventEmitterProtocolName = "EventEmitter"
12
+
8
13
  /// Types we never assert because they're statically known to conform and never reach the dynamic
9
14
  /// converter: the JS primitives. Asserting them would only add noise to the expansion. Kept here
10
15
  /// (rather than reusing the decode-path's `fastDecodeAccessor`) because "known-to-conform" is a
@@ -66,6 +71,15 @@ internal func typeConformanceAssertions(for assertions: [ConformanceAssertion])
66
71
  """
67
72
  }
68
73
 
74
+ /// The type to assert for a boundary type as written: trailing optional markers are unwrapped to the
75
+ /// core type, and a known-conforming primitive returns `nil` (nothing to assert). Shared with
76
+ /// `@Event`, which folds its single payload type into a combined assertion of its own shape rather
77
+ /// than reusing the whole body fragment below.
78
+ internal func assertableBoundaryType(_ type: String) -> String? {
79
+ let unwrapped = unwrappedOptional(type)
80
+ return knownConformingPrimitives.contains(unwrapped) ? nil : unwrapped
81
+ }
82
+
69
83
  /// The assertion's body fragment: a nested generic helper named after the member, plus one call per
70
84
  /// distinct non-primitive type. Nesting the helper keeps the constraint entirely local — no shared
71
85
  /// symbol, nothing to collide, nothing left in the type's namespace — and naming it after the member
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/expo-modules-macros-plugin",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Swift macro plugin for Expo modules",
5
5
  "license": "MIT",
6
6
  "author": "650 Industries, Inc.",
@@ -1,196 +0,0 @@
1
- import SwiftSyntax
2
-
3
- /**
4
- A `@JS func` collected for **direct JSI binding**. Instead of describing the function with a
5
- `Function(...)` / `AsyncFunction(...)` DSL entry that the runtime interprets per call,
6
- `@ExpoModule` synthesizes a `_decorateModule` that binds each such function into the module's JS object
7
- via the closure-taking `JavaScriptObject.setProperty(_:)`, with the decode-call-encode body
8
- inlined into the closure. This omits the `[Any]`/`toTuple` dynamic-call path: every argument is
9
- decoded individually by its static type.
10
-
11
- The receiver is the module's real `self` (a module is a singleton instance), so the body calls
12
- `self.<name>(...)` directly and ignores the JS `this`. An `async` `@JS func` produces an `async`
13
- closure body and is installed through the async `setProperty(_:)` overload (so JS gets a promise).
14
- */
15
- internal struct JSFunction {
16
- let swiftName: String
17
- let jsName: String
18
- let parameters: [FunctionParameterSyntax]
19
- /// The declared return type as written, or `nil` when the function returns `Void`/nothing.
20
- let returnType: String?
21
- let isThrowing: Bool
22
- let isAsync: Bool
23
-
24
- init(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) {
25
- self.swiftName = funcDecl.name.text
26
- self.jsName = jsNameArgument(of: attribute) ?? funcDecl.name.text
27
- self.parameters = Array(funcDecl.signature.parameterClause.parameters)
28
-
29
- let declaredReturnType = funcDecl.signature.returnClause?.type
30
- self.returnType = isVoidType(declaredReturnType) ? nil : declaredReturnType?.trimmedDescription
31
-
32
- let effectSpecifiers = funcDecl.signature.effectSpecifiers
33
- self.isThrowing = effectSpecifiers?.throwsClause?.throwsSpecifier != nil
34
- self.isAsync = effectSpecifiers?.asyncSpecifier != nil
35
- }
36
-
37
- /**
38
- The `#name` host-function body: a `@JavaScriptActor private func` matching the
39
- `createFunction` closure shape `(this, arguments) throws -> JavaScriptValue`, threading
40
- `appContext`/`runtime` in as parameters. It checks arity, decodes each argument by its static
41
- type — primitives through a direct typed accessor (`asDouble()`, …) on a borrowed
42
- `JavaScriptUnownedValue`, other types through the
43
- `T.getDynamicType()` converter — calls `self.<name>(...)`, and converts the result back to JS.
44
- */
45
- /// The decode-call-encode statements that form the host-function body, indented with the given
46
- /// prefix. Arity guard, then per-argument decode (primitives via a direct typed accessor like
47
- /// `asDouble()` on a zero-copy `arguments.unownedValue(at:)`, others via `getDynamicType().cast(...)`),
48
- /// the `self.<name>(...)` call, and the
49
- /// result encode (primitives via `toJavaScriptValue(in:)`, others via `castToJS(...)`).
50
- private func bodyStatements(indent: String) -> String {
51
- var lines: [String] = []
52
-
53
- lines.append(
54
- """
55
- guard arguments.count == \(parameters.count) else {
56
- throw Exception(name: "InvalidArgumentCount", description: "Function '\(jsName)' expects \(parameters.count) argument(s), but got \\(arguments.count)")
57
- }
58
- """)
59
-
60
- var callArguments: [String] = []
61
- for (index, parameter) in parameters.enumerated() {
62
- let type = parameter.type.trimmedDescription
63
-
64
- // Primitives decode through a direct typed accessor (`asDouble()`, etc.) on a borrowed
65
- // `JavaScriptUnownedValue` — no owning `JavaScriptValue` allocation, no `jsi::Value` copy, no
66
- // `getDynamicType()` allocation, no `Any` boxing, no force-cast — while still validating and
67
- // throwing `TypeError` on a mismatch. Other types fall back to the dynamic converter, which
68
- // needs an owning value, so they index the buffer directly.
69
- if let accessor = fastDecodeAccessor(for: type) {
70
- lines.append("let arg\(index) = try arguments.unownedValue(at: \(index)).\(accessor)()")
71
- } else {
72
- lines.append(
73
- "let arg\(index) = try \(type).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(type)")
74
- }
75
-
76
- let label = parameter.firstName.text
77
- callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
78
- }
79
-
80
- let tryKeyword = (isThrowing || isAsync) ? "try " : ""
81
- let awaitKeyword = isAsync ? "await " : ""
82
- let callExpression =
83
- "\(tryKeyword)\(awaitKeyword)self.\(swiftName)(\(callArguments.joined(separator: ", ")))"
84
-
85
- if let returnType {
86
- lines.append("let result = \(callExpression)")
87
- // Primitives encode through `toJavaScriptValue(in:)` (the typed `JavaScriptRepresentable`
88
- // conversion) — no `Any`, no dynamic-type allocation. Others go through the dynamic converter.
89
- if fastDecodeAccessor(for: returnType) != nil {
90
- lines.append("return result.toJavaScriptValue(in: runtime)")
91
- } else {
92
- lines.append("return try \(returnType).getDynamicType().castToJS(result, appContext: appContext, in: runtime)")
93
- }
94
- } else {
95
- lines.append(callExpression)
96
- lines.append("return .undefined")
97
- }
98
-
99
- return lines
100
- .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
101
- .map { indent + $0 }
102
- .joined(separator: "\n")
103
- }
104
-
105
- /// The `setProperty` statement that installs this function on the JS object. The decode-call-encode
106
- /// body is inlined directly into the closure passed to the closure-taking `setProperty` overload
107
- /// (which creates the host function under the hood) — no separate named binding. For an `async`
108
- /// function the body `await`s the call, which selects the async `setProperty` overload (so JS
109
- /// receives a promise).
110
- ///
111
- /// Capture mirrors core's `SyncFunctionDefinition.build`: `self` (the module) is captured
112
- /// **strong** — the host-function closure is what keeps the native callable alive for as long as
113
- /// JS can invoke it; its lifetime is bounded by the JS VM's garbage collection of the object.
114
- /// `appContext` is captured **weak** (and guarded) so it doesn't form a real retain cycle through
115
- /// the app context. When no argument or return value goes through the dynamic-type converter the
116
- /// body never references `appContext`, so the capture and guard are omitted to avoid the
117
- /// unused-capture warning.
118
- var decorateStatements: String {
119
- if usesAppContext {
120
- return """
121
- object.setProperty("\(jsName)") { [weak appContext, self] this, arguments in
122
- guard let appContext else {
123
- throw Exceptions.AppContextLost()
124
- }
125
- \(bodyStatements(indent: " "))
126
- }
127
- """
128
- }
129
- return """
130
- object.setProperty("\(jsName)") { [self] this, arguments in
131
- \(bodyStatements(indent: " "))
132
- }
133
- """
134
- }
135
-
136
- /// True when the host-function body references `appContext` — i.e. some parameter or the return
137
- /// type lacks a fast accessor and decodes/encodes through `getDynamicType()`, which threads
138
- /// `appContext` in.
139
- private var usesAppContext: Bool {
140
- if parameters.contains(where: { fastDecodeAccessor(for: $0.type.trimmedDescription) == nil }) {
141
- return true
142
- }
143
- if let returnType, fastDecodeAccessor(for: returnType) == nil {
144
- return true
145
- }
146
- return false
147
- }
148
- }
149
-
150
- /**
151
- The single generated function that decorates the module's JS object. Core supplies the object;
152
- this binds every `@JS func` into it via one inlined `setProperty` closure per function. Mirrors
153
- core's `ObjectDefinition.decorate(object:)`, including its `borrowing` object parameter (it
154
- mutates through the reference without reassigning or taking ownership). Named `_decorateModule`
155
- with the leading-underscore convention for synthesized members the **runtime calls by name**; the
156
- `ExpoModule` suffix names the `@ExpoModule` macro it came from (a shared object's counterpart is
157
- `_decorateSharedObject`).
158
- */
159
- internal func buildDecorateJavaScriptObject(functions: [JSFunction]) -> DeclSyntax {
160
- let body = functions.map { $0.decorateStatements }.joined(separator: "\n")
161
- return """
162
- @JavaScriptActor
163
- public func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws {
164
- \(raw: body)
165
- }
166
- """
167
- }
168
-
169
- /// The throwing `JavaScriptUnownedValue` accessor that decodes the given primitive type directly,
170
- /// bypassing the dynamic-type converter (`asDouble()` for `Double`, etc.). Returns `nil` for
171
- /// types without a dedicated accessor — arrays, records, optionals, shared objects, other numeric
172
- /// widths — which decode through `getDynamicType().cast(...)`.
173
- private func fastDecodeAccessor(for type: String) -> String? {
174
- switch type {
175
- case "Bool":
176
- return "asBool"
177
- case "Int":
178
- return "asInt"
179
- case "Double":
180
- return "asDouble"
181
- case "String":
182
- return "asString"
183
- default:
184
- return nil
185
- }
186
- }
187
-
188
- /// True when a return clause is absent or written as `Void` / `()` — i.e. the function returns
189
- /// nothing JS-visible, so the binding returns `.undefined`.
190
- private func isVoidType(_ type: TypeSyntax?) -> Bool {
191
- guard let type else {
192
- return true
193
- }
194
- let text = type.trimmedDescription
195
- return text == "Void" || text == "()"
196
- }