expo-modules-macros 0.15.0 → 0.16.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.
package/README.md CHANGED
@@ -18,7 +18,7 @@ This package is not meant to be installed directly. It is a dependency of `expo-
18
18
  # Macros
19
19
 
20
20
  - **`@ExpoModule(_ name: String? = nil, classes: [Any.Type] = [])`** on a class. Turns the class into a module: binds its `@JS` members into the module's JavaScript object and resolves the module name from the argument, falling back to the class name. It also synthesizes everything inheriting from `Module` used to provide, so a module class can carry any superclass, or none.
21
- - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. The macro also checks that every type crossing the boundary is convertible in the direction it travels, so a bad type is reported on the author's own declaration rather than on the enclosing type. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it.
21
+ - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. The macro also checks that every type crossing the boundary is convertible in the direction it travels, so a bad type is reported on the author's own declaration rather than on the enclosing type. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. A function or initializer can take a closure argument, such as `(Point) throws -> Size` or `(String) async throws -> Data`. JavaScript passes a function for it, and native code can store the closure and call it from any thread. A closure that throws or returns a value blocks the caller until JavaScript returns, and an `async` closure suspends instead and awaits a promise that the function returns. A closure that returns a value must be `throws`, so that an error from JavaScript can reach the caller. A non-throwing `Void` closure returns before JavaScript reads its arguments, so don't change a class instance after passing it to one.
22
22
  - **`@Event(_ name: String? = nil, sync: Bool = false)`** on a function-typed `var`. Expands the property into a closure that emits the event, so calling the property sends it to JavaScript with the closure's parameter as the payload. The JS name defaults to the property name with a leading `on` stripped. `sync: true` dispatches inline instead of asynchronously.
23
23
  - **`@SharedObject(_ name: String? = nil)`** on a `SharedObject` subclass. Collects the class's `@JS` members into a class definition that a module exposes through `@ExpoModule(classes:)`.
24
24
  - **`@Record()`** on a record type. Treats every non-static, non-private, non-computed stored property as a field, with no per-field wrapper, and synthesizes the memberwise initializer, the conversions in both directions, and the `Record` conformance. Requiredness is inferred: a default value makes a field optional, an optional type makes it nullable and optional.
Binary file
@@ -0,0 +1,405 @@
1
+ import SwiftDiagnostics
2
+ import SwiftSyntax
3
+ import SwiftSyntaxMacros
4
+
5
+ /// A closure parameter of a `@JS` function or initializer, read from its declared type. JS passes a
6
+ /// function for it, and the binding wraps that function in a native closure of the declared type.
7
+ ///
8
+ /// A function type can't conform to `JavaScriptDecodable`, so a closure parameter doesn't decode
9
+ /// through `decode` like other arguments. The data also moves the other way: the closure's parameters
10
+ /// are encoded when native code calls it, and its return value is decoded from what the JS function
11
+ /// returns.
12
+ internal struct JSClosureType {
13
+ /// The function type as written, under any attributes, parentheses and optional wrapper.
14
+ let functionType: FunctionTypeSyntax
15
+ /// The attributes written on the function type, such as `@escaping` or `@Sendable`.
16
+ let attributes: [AttributeSyntax]
17
+ /// The closure's parameters. Each one is encoded to JS when the closure is called.
18
+ let parameters: [Parameter]
19
+ /// The return type as written, or `nil` for `Void`. It is decoded from the JS function's result.
20
+ let returnType: String?
21
+ let isAsync: Bool
22
+ let isThrowing: Bool
23
+ /// True for an optional closure (`((Int) -> Void)?`): JS `null` and `undefined` become `nil`.
24
+ let isOptional: Bool
25
+
26
+ /// Returns `nil` when the type isn't a closure, or is a closure nested in another type such as an
27
+ /// array. One optional layer is accepted around the function type.
28
+ init?(_ type: TypeSyntax) {
29
+ var type = unparenthesized(type)
30
+ var isOptional = false
31
+ if let wrapped = optionalWrappedType(type) {
32
+ isOptional = true
33
+ type = unparenthesized(wrapped)
34
+ }
35
+ var attributes: [AttributeSyntax] = []
36
+ if let attributed = type.as(AttributedTypeSyntax.self) {
37
+ attributes = attributed.attributes.compactMap { $0.as(AttributeSyntax.self) }
38
+ type = unparenthesized(attributed.baseType)
39
+ }
40
+ guard let functionType = type.as(FunctionTypeSyntax.self) else {
41
+ return nil
42
+ }
43
+ self.functionType = functionType
44
+ self.attributes = attributes
45
+ self.parameters = functionType.parameters.map(Parameter.init)
46
+ let returnType = functionType.returnClause.type
47
+ self.returnType = isVoidReturnType(returnType) ? nil : returnType.trimmedDescription
48
+ self.isAsync = functionType.effectSpecifiers?.asyncSpecifier != nil
49
+ self.isThrowing = functionType.effectSpecifiers?.throwsClause != nil
50
+ self.isOptional = isOptional
51
+ }
52
+
53
+ /// The type of the wrapper closure as the binding declares it. `@escaping` is left out, since it is
54
+ /// only valid on a parameter.
55
+ var wrapperTypeText: String {
56
+ let kept = attributes.filter { attributeName($0) != "escaping" }.map { $0.trimmedDescription + " " }
57
+ let text = kept.joined() + functionType.trimmedDescription
58
+ return isOptional ? "(\(text))?" : text
59
+ }
60
+
61
+ /// One parameter of the closure type.
62
+ struct Parameter {
63
+ /// The parameter's type without its specifiers, such as `borrowing` or `inout`.
64
+ let type: TypeSyntax
65
+ /// The specifiers written before the type, such as `borrowing`, `sending` or `inout`.
66
+ let specifiers: [String]
67
+ /// True for a variadic parameter (`Int...`).
68
+ let isVariadic: Bool
69
+
70
+ init(_ element: TupleTypeElementSyntax) {
71
+ if let attributed = element.type.as(AttributedTypeSyntax.self), !attributed.specifiers.isEmpty {
72
+ self.specifiers = attributed.specifiers.map(\.trimmedDescription)
73
+ self.type =
74
+ attributed.attributes.isEmpty
75
+ ? attributed.baseType : TypeSyntax(attributed.with(\.specifiers, []))
76
+ } else {
77
+ self.specifiers = []
78
+ self.type = element.type
79
+ }
80
+ self.isVariadic = element.ellipsis != nil
81
+ }
82
+
83
+ /// The first specifier the wrapper can't support, such as `inout` or `isolated`.
84
+ var unsupportedSpecifier: String? {
85
+ return specifiers.first { !supportedParameterSpecifiers.contains($0) }
86
+ }
87
+ }
88
+ }
89
+
90
+ /// The ownership specifiers a closure parameter may carry. The wrapper passes each parameter on to the
91
+ /// callback, so the ownership convention doesn't change what it does.
92
+ private let supportedParameterSpecifiers: Set<String> = ["borrowing", "consuming", "sending", "__shared", "__owned"]
93
+
94
+ /// The statements that bind `name` to the native value of a `@JS` argument read from
95
+ /// `valueExpression` (a `JavaScriptUnownedValue`). A closure gets a wrapper around the JS function,
96
+ /// and every other type decodes through `decodeCall`.
97
+ internal func argumentDecodeStatements(
98
+ type: TypeSyntax,
99
+ into name: String,
100
+ from valueExpression: String
101
+ ) -> [String] {
102
+ guard let closure = JSClosureType(type) else {
103
+ return ["let \(name) = try \(decodeCall(type.trimmedDescription, from: valueExpression))"]
104
+ }
105
+ return closureArgumentStatements(closure, into: name, from: valueExpression)
106
+ }
107
+
108
+ /// Binds `name` to a closure that calls the JS function in `valueExpression`. The
109
+ /// `JavaScriptCallback` keeps the JS function alive and does the thread hops, so the closure can be
110
+ /// stored and called from any thread.
111
+ private func closureArgumentStatements(
112
+ _ closure: JSClosureType,
113
+ into name: String,
114
+ from valueExpression: String
115
+ ) -> [String] {
116
+ let callback = "\(name)Callback"
117
+ let wrapper = closureLiteralLines(closure, callback: callback)
118
+
119
+ guard closure.isOptional else {
120
+ var lines = ["let \(callback) = try JavaScriptCallback.decode(\(valueExpression), in: runtime)"]
121
+ lines.append("let \(name): \(closure.wrapperTypeText) = " + wrapper[0])
122
+ lines.append(contentsOf: wrapper.dropFirst())
123
+ return lines
124
+ }
125
+
126
+ var lines = [
127
+ "let \(name): \(closure.wrapperTypeText)",
128
+ "if let \(callback) = try JavaScriptCallback.decodeIfPresent(\(valueExpression), in: runtime) {",
129
+ " \(name) = " + wrapper[0],
130
+ ]
131
+ lines.append(contentsOf: wrapper.dropFirst().map { " " + $0 })
132
+ lines.append("} else {")
133
+ lines.append(" \(name) = nil")
134
+ lines.append("}")
135
+ return lines
136
+ }
137
+
138
+ /// The closure literal `{ p0, p1 in … }` that passes its parameters to the `JavaScriptCallback`,
139
+ /// which encodes them, calls the function and decodes the result. The closure's effects pick the
140
+ /// method:
141
+ /// - a non-throwing `Void` closure doesn't wait for JS (`invokeDetached`);
142
+ /// - a sync closure that throws blocks until JS returns (`invokeBlocking`);
143
+ /// - an `async` closure suspends instead (`invokeAsync`), and the callback awaits a returned promise.
144
+ private func closureLiteralLines(_ closure: JSClosureType, callback: String) -> [String] {
145
+ // `@Sendable` keeps the literal from taking the binding's `@JavaScriptActor` isolation: native code
146
+ // may call it from any thread, and it captures only the callback.
147
+ let parameters = closure.parameters.indices.map { "p\($0)" }
148
+ let header = parameters.isEmpty ? "{ @Sendable in" : "{ @Sendable \(parameters.joined(separator: ", ")) in"
149
+
150
+ var arguments = parameters
151
+ if let returnType = closure.returnType {
152
+ arguments.append("returning: \(expressionType(returnType)).self")
153
+ }
154
+ let argumentList = arguments.joined(separator: ", ")
155
+
156
+ let call: String
157
+ if closure.isAsync {
158
+ call = "try await \(callback).invokeAsync(\(argumentList))"
159
+ } else if closure.isThrowing || closure.returnType != nil {
160
+ call = "try \(callback).invokeBlocking(\(argumentList))"
161
+ } else {
162
+ call = "\(callback).invokeDetached(\(argumentList))"
163
+ }
164
+
165
+ guard closure.isAsync && !closure.isThrowing else {
166
+ return [header, " " + call, "}"]
167
+ }
168
+ // The closure can't throw, so an error from JS goes to the callback's error reporting.
169
+ return [
170
+ header,
171
+ " do {",
172
+ " " + call,
173
+ " } catch {",
174
+ " \(callback).reportError(error)",
175
+ " }",
176
+ "}",
177
+ ]
178
+ }
179
+
180
+ // MARK: - Diagnostics
181
+
182
+ /// Emits the closure diagnostics for a `@JS` declaration. A closure is supported only as a parameter
183
+ /// of a function or initializer, where JS passes a function in. Every other position would need a
184
+ /// native closure encoded to a JS function, which isn't supported.
185
+ internal func diagnoseClosureTypes(
186
+ in declaration: some DeclSyntaxProtocol,
187
+ in context: some MacroExpansionContext
188
+ ) {
189
+ if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
190
+ diagnoseClosureParameters(funcDecl.signature.parameterClause.parameters, in: context)
191
+ if let returnType = funcDecl.signature.returnClause?.type, let nested = firstFunctionType(in: returnType) {
192
+ context.diagnose(
193
+ Diagnostic(
194
+ node: nested,
195
+ message: ClosureDiagnosticMessage(
196
+ "A @JS function can't return a closure: a native closure can't be encoded to a JavaScript function.",
197
+ id: "js-closure-return"
198
+ )))
199
+ }
200
+ return
201
+ }
202
+ if let initDecl = declaration.as(InitializerDeclSyntax.self) {
203
+ diagnoseClosureParameters(initDecl.signature.parameterClause.parameters, in: context)
204
+ return
205
+ }
206
+ if let varDecl = declaration.as(VariableDeclSyntax.self),
207
+ let type = varDecl.bindings.first?.typeAnnotation?.type,
208
+ let nested = firstFunctionType(in: type) {
209
+ context.diagnose(
210
+ Diagnostic(
211
+ node: nested,
212
+ message: ClosureDiagnosticMessage(
213
+ "A @JS property can't have a closure type: a native closure can't be encoded to a JavaScript function.",
214
+ id: "js-closure-property"
215
+ )))
216
+ }
217
+ }
218
+
219
+ private func diagnoseClosureParameters(
220
+ _ parameters: FunctionParameterListSyntax,
221
+ in context: some MacroExpansionContext
222
+ ) {
223
+ for parameter in parameters {
224
+ guard let closure = JSClosureType(parameter.type) else {
225
+ if let nested = firstFunctionType(in: parameter.type) {
226
+ context.diagnose(
227
+ Diagnostic(
228
+ node: nested,
229
+ message: ClosureDiagnosticMessage(
230
+ "A closure in a @JS argument must be the argument's own type, optionally wrapped in an optional. Closures inside other types, such as arrays or dictionaries, aren't supported.",
231
+ id: "js-closure-nested-in-type"
232
+ )))
233
+ }
234
+ continue
235
+ }
236
+ diagnoseClosure(closure, in: context)
237
+ }
238
+ }
239
+
240
+ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExpansionContext) {
241
+ let functionType = closure.functionType
242
+
243
+ for attribute in closure.attributes {
244
+ let name = attributeName(attribute)
245
+ guard name != "escaping" && name != "Sendable" else {
246
+ continue
247
+ }
248
+ context.diagnose(
249
+ Diagnostic(
250
+ node: attribute,
251
+ message: ClosureDiagnosticMessage(
252
+ "'@\(name)' isn't supported on a @JS closure argument. The closure calls JavaScript through its own thread hop, so only '@escaping' and '@Sendable' are allowed.",
253
+ id: "js-closure-attribute"
254
+ )))
255
+ }
256
+
257
+ if let throwsClause = functionType.effectSpecifiers?.throwsClause, throwsClause.type != nil {
258
+ context.diagnose(
259
+ Diagnostic(
260
+ node: throwsClause,
261
+ message: ClosureDiagnosticMessage(
262
+ "A @JS closure argument can't use typed throws: the error from JavaScript can be any error. Write 'throws' without a type.",
263
+ id: "js-closure-typed-throws"
264
+ )))
265
+ }
266
+
267
+ for parameter in closure.parameters {
268
+ if let specifier = parameter.unsupportedSpecifier {
269
+ context.diagnose(
270
+ Diagnostic(
271
+ node: parameter.type,
272
+ message: ClosureDiagnosticMessage(
273
+ "A @JS closure argument can't take an '\(specifier)' parameter: its value is encoded and sent to JavaScript, which can't write it back or share its isolation.",
274
+ id: "js-closure-parameter-specifier"
275
+ )))
276
+ }
277
+ if parameter.isVariadic {
278
+ context.diagnose(
279
+ Diagnostic(
280
+ node: parameter.type,
281
+ message: ClosureDiagnosticMessage(
282
+ "A @JS closure argument can't take a variadic parameter. Use an array instead.",
283
+ id: "js-closure-variadic-parameter"
284
+ )))
285
+ }
286
+ }
287
+
288
+ // The closure's parameters are encoded and its result is decoded, so neither can be another
289
+ // closure or a free-form type.
290
+ let returnType = functionType.returnClause.type
291
+ for type in closure.parameters.map(\.type) + [returnType] {
292
+ if let nested = firstFunctionType(in: type) {
293
+ context.diagnose(
294
+ Diagnostic(
295
+ node: nested,
296
+ message: ClosureDiagnosticMessage(
297
+ "A @JS closure argument can't take or return another closure.",
298
+ id: "js-closure-nested"
299
+ )))
300
+ } else if isFreeFormBoundaryType(type.trimmedDescription) {
301
+ context.diagnose(
302
+ Diagnostic(
303
+ node: type,
304
+ message: ClosureDiagnosticMessage(
305
+ "A @JS closure argument can't use the free-form '\(type.trimmedDescription)'. Use a concrete type, or 'JavaScriptValue' to pass a JS value through unchanged.",
306
+ id: "js-closure-free-form"
307
+ )))
308
+ }
309
+ }
310
+
311
+ if closure.returnType != nil && !closure.isThrowing {
312
+ context.diagnose(
313
+ Diagnostic(
314
+ node: functionType,
315
+ message: ClosureDiagnosticMessage(
316
+ "A @JS closure argument that returns a value must be 'throws': when the JavaScript function throws, the closure has no value to return.",
317
+ id: "js-closure-requires-throws"
318
+ ),
319
+ fixIts: [insertThrowsFixIt(for: functionType)]))
320
+ }
321
+ }
322
+
323
+ /// The fix-it for a non-throwing closure that returns a value: insert `throws` after the parameter
324
+ /// list, or after `async` when the type has it. The inserted keyword takes over the trivia that
325
+ /// followed the token before it, so `(Int) -> Int` becomes `(Int) throws -> Int`.
326
+ private func insertThrowsFixIt(for functionType: FunctionTypeSyntax) -> FixIt {
327
+ var newType = functionType
328
+ if var effectSpecifiers = functionType.effectSpecifiers, let asyncSpecifier = effectSpecifiers.asyncSpecifier {
329
+ effectSpecifiers.throwsClause = ThrowsClauseSyntax(
330
+ throwsSpecifier: .keyword(.throws, trailingTrivia: asyncSpecifier.trailingTrivia))
331
+ effectSpecifiers.asyncSpecifier = asyncSpecifier.with(\.trailingTrivia, .space)
332
+ newType.effectSpecifiers = effectSpecifiers
333
+ } else {
334
+ newType.effectSpecifiers = TypeEffectSpecifiersSyntax(
335
+ throwsClause: ThrowsClauseSyntax(
336
+ throwsSpecifier: .keyword(.throws, trailingTrivia: functionType.rightParen.trailingTrivia)))
337
+ newType.rightParen = functionType.rightParen.with(\.trailingTrivia, .space)
338
+ }
339
+ return FixIt(
340
+ message: ClosureFixItMessage("Mark the closure 'throws'", id: "js-closure-insert-throws"),
341
+ changes: [.replace(oldNode: Syntax(functionType), newNode: Syntax(newType))]
342
+ )
343
+ }
344
+
345
+ // MARK: - Helpers
346
+
347
+ /// True when `type` is a function type or has one anywhere inside it.
348
+ internal func containsFunctionType(_ type: TypeSyntax) -> Bool {
349
+ return firstFunctionType(in: type) != nil
350
+ }
351
+
352
+ /// The first function type in `type` or anywhere inside it, in source order.
353
+ private func firstFunctionType(in type: some SyntaxProtocol) -> FunctionTypeSyntax? {
354
+ if let functionType = type.as(FunctionTypeSyntax.self) {
355
+ return functionType
356
+ }
357
+ for child in type.children(viewMode: .sourceAccurate) {
358
+ if let found = firstFunctionType(in: child) {
359
+ return found
360
+ }
361
+ }
362
+ return nil
363
+ }
364
+
365
+ /// The type inside any number of single-element, unlabeled parentheses: `((Int) -> Void)` is the
366
+ /// same type as `(Int) -> Void`.
367
+ private func unparenthesized(_ type: TypeSyntax) -> TypeSyntax {
368
+ if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1,
369
+ let element = tuple.elements.first, element.firstName == nil {
370
+ return unparenthesized(element.type)
371
+ }
372
+ return type
373
+ }
374
+
375
+ /// True when a closure's return type is `Void`, `()` or `Swift.Void`, also inside parentheses.
376
+ private func isVoidReturnType(_ type: TypeSyntax) -> Bool {
377
+ let type = unparenthesized(type)
378
+ let text = type.trimmedDescription
379
+ return text == "Void" || text == "()" || text == "Swift.Void"
380
+ }
381
+
382
+ private func attributeName(_ attribute: AttributeSyntax) -> String {
383
+ return attribute.attributeName.trimmedDescription
384
+ }
385
+
386
+ private struct ClosureDiagnosticMessage: DiagnosticMessage {
387
+ let message: String
388
+ let diagnosticID: MessageID
389
+ let severity: DiagnosticSeverity = .error
390
+
391
+ init(_ message: String, id: String) {
392
+ self.message = message
393
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
394
+ }
395
+ }
396
+
397
+ private struct ClosureFixItMessage: FixItMessage {
398
+ let message: String
399
+ let fixItID: MessageID
400
+
401
+ init(_ message: String, id: String) {
402
+ self.message = message
403
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
404
+ }
405
+ }
@@ -86,7 +86,7 @@ internal struct JSFunction {
86
86
  // Decode the required prefix once — these slots are present in every accepted arity, so the
87
87
  // decode is shared rather than repeated per branch.
88
88
  for index in 0..<required {
89
- lines.append(decodeStatement(at: index))
89
+ lines.append(contentsOf: decodeLines(at: index))
90
90
  }
91
91
 
92
92
  if required == maximum {
@@ -95,7 +95,7 @@ internal struct JSFunction {
95
95
  // body; a sync one calls and encodes directly.
96
96
  if isAsync {
97
97
  for index in required..<maximum {
98
- lines.append(decodeStatement(at: index))
98
+ lines.append(contentsOf: decodeLines(at: index))
99
99
  }
100
100
  lines.append(contentsOf: asyncBodyLines(receiver: receiver, arity: maximum))
101
101
  } else {
@@ -110,7 +110,7 @@ internal struct JSFunction {
110
110
  let label = arity == maximum ? "default:" : "case \(arity):"
111
111
  lines.append(label)
112
112
  for index in required..<arity {
113
- lines.append(" " + decodeStatement(at: index))
113
+ lines.append(contentsOf: decodeLines(at: index).map { " " + $0 })
114
114
  }
115
115
  lines.append(contentsOf: asyncBodyLines(receiver: receiver, arity: arity).map { " " + $0 })
116
116
  }
@@ -127,7 +127,7 @@ internal struct JSFunction {
127
127
  let label = arity == maximum ? "default:" : "case \(arity):"
128
128
  lines.append(label)
129
129
  for index in required..<arity {
130
- lines.append(" " + decodeStatement(at: index))
130
+ lines.append(contentsOf: decodeLines(at: index).map { " " + $0 })
131
131
  }
132
132
  let assignment = returnType != nil ? "result = " : ""
133
133
  lines.append(" \(assignment)\(callExpression(receiver: receiver, arity: arity))")
@@ -160,14 +160,17 @@ internal struct JSFunction {
160
160
  return lines
161
161
  }
162
162
 
163
- /// `let arg<index> = …` decoding the slot at `index` by its static type through
163
+ /// The lines binding `arg<index>`: a decode of the slot at `index` by its static type through
164
164
  /// `JavaScriptDecodable.decode` on the borrowed `JavaScriptUnownedValue` — no owning value, no
165
165
  /// `jsi::Value` copy, no `Any` boxing, no force-cast; it returns the concrete type directly. A
166
166
  /// primitive's `decode` is `@inlinable` and lowers to the same direct accessor a hand-rolled fast
167
- /// path would use.
168
- private func decodeStatement(at index: Int) -> String {
169
- let type = parameters[index].type.trimmedDescription
170
- return "let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))"
167
+ /// path would use. A closure parameter takes several lines instead, wrapping the JS function.
168
+ private func decodeLines(at index: Int) -> [String] {
169
+ return argumentDecodeStatements(
170
+ type: parameters[index].type,
171
+ into: "arg\(index)",
172
+ from: "arguments.unownedValue(at: \(index))"
173
+ )
171
174
  }
172
175
 
173
176
  /// The `<callee>.<name>(...)` call for the given arity. Slots `0..<arity` are passed their decoded
@@ -205,7 +208,7 @@ internal struct JSFunction {
205
208
  private func callAndEncodeLines(receiver: Receiver, arity: Int, decodingFrom: Int) -> [String] {
206
209
  var lines: [String] = []
207
210
  for index in decodingFrom..<arity {
208
- lines.append(decodeStatement(at: index))
211
+ lines.append(contentsOf: decodeLines(at: index))
209
212
  }
210
213
  if returnType != nil {
211
214
  lines.append("let result = \(callExpression(receiver: receiver, arity: arity))")
@@ -14,7 +14,7 @@ internal struct JSConstructor {
14
14
 
15
15
  /// The body statements, indented with `indent`: arity guard, per-argument decode through
16
16
  /// `JavaScriptDecodable.decode` on a zero-copy `arguments.unownedValue(at:)` (the arity guard proves
17
- /// each index is in bounds), then `return <Type>(label: arg0, …)`.
17
+ /// each index is in bounds) or a wrapper for a closure argument, then `return <Type>(label: arg0, …)`.
18
18
  private func bodyStatements(typeName: String, indent: String) -> String {
19
19
  var lines: [String] = []
20
20
 
@@ -27,8 +27,12 @@ internal struct JSConstructor {
27
27
 
28
28
  var callArguments: [String] = []
29
29
  for (index, parameter) in parameters.enumerated() {
30
- let type = parameter.type.trimmedDescription
31
- lines.append("let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))")
30
+ lines.append(
31
+ contentsOf: argumentDecodeStatements(
32
+ type: parameter.type,
33
+ into: "arg\(index)",
34
+ from: "arguments.unownedValue(at: \(index))"
35
+ ))
32
36
 
33
37
  let label = parameter.firstName.text
34
38
  callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
@@ -32,6 +32,7 @@ public struct JSMacro: PeerMacro {
32
32
  in context: some MacroExpansionContext
33
33
  ) throws -> [DeclSyntax] {
34
34
  diagnoseFreeFormTypes(in: declaration, in: context)
35
+ diagnoseClosureTypes(in: declaration, in: context)
35
36
  diagnoseConcurrentOption(of: node, on: declaration, in: context)
36
37
 
37
38
  guard let member = boundaryMember(of: declaration),
@@ -39,6 +40,7 @@ public struct JSMacro: PeerMacro {
39
40
  name: member.name,
40
41
  decodableTypes: member.decodableTypes,
41
42
  encodableType: member.encodableType,
43
+ callbackArgumentTypes: member.callbackArgumentTypes,
42
44
  isStatic: member.isStatic
43
45
  ) else {
44
46
  return []
@@ -237,11 +239,14 @@ private struct JSFixItMessage: FixItMessage {
237
239
  /// property's outgoing value) are encoded, so each is asserted against the protocol for its direction.
238
240
  private struct BoundaryMember {
239
241
  let name: String
240
- /// Types decoded from JS: function/constructor arguments, and a settable property's value type.
242
+ /// Types decoded from JS: function/constructor arguments, a settable property's value type, and the
243
+ /// return types of closure arguments.
241
244
  let decodableTypes: [String]
242
245
  /// The single type encoded to JS: a function's return type, or a property's value type on read;
243
246
  /// `nil` when the member produces nothing JS-visible (a `Void` function).
244
247
  let encodableType: String?
248
+ /// The parameter types of closure arguments, encoded to JS when native code calls the closure.
249
+ var callbackArgumentTypes: [String] = []
245
250
  /// True for `static`/`class` members, so the peer is emitted in the same metatype context.
246
251
  let isStatic: Bool
247
252
  }
@@ -255,13 +260,34 @@ private struct BoundaryMember {
255
260
  /// recover it).
256
261
  private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> BoundaryMember? {
257
262
  if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
258
- let decodableTypes = funcDecl.signature.parameterClause.parameters.map { $0.type.trimmedDescription }
263
+ // A closure argument moves data the other way: JS receives its parameters and returns its result.
264
+ // A closure in any other position is diagnosed by `diagnoseClosureTypes` and left out here, since
265
+ // its metatype isn't a valid expression in the assertion.
266
+ var decodableTypes: [String] = []
267
+ var callbackArgumentTypes: [String] = []
268
+ for parameter in funcDecl.signature.parameterClause.parameters {
269
+ guard let closure = JSClosureType(parameter.type) else {
270
+ if !containsFunctionType(parameter.type) {
271
+ decodableTypes.append(parameter.type.trimmedDescription)
272
+ }
273
+ continue
274
+ }
275
+ for parameter in closure.parameters where !containsFunctionType(parameter.type) {
276
+ callbackArgumentTypes.append(parameter.type.trimmedDescription)
277
+ }
278
+ if let returnType = closure.returnType, !containsFunctionType(closure.functionType.returnClause.type) {
279
+ decodableTypes.append(returnType)
280
+ }
281
+ }
259
282
  let returnType = funcDecl.signature.returnClause?.type
260
- let encodableType = returnType.flatMap { isVoidType($0) ? nil : $0.trimmedDescription }
283
+ let encodableType = returnType.flatMap {
284
+ isVoidType($0) || containsFunctionType($0) ? nil : $0.trimmedDescription
285
+ }
261
286
  return BoundaryMember(
262
287
  name: funcDecl.name.text,
263
288
  decodableTypes: decodableTypes,
264
289
  encodableType: encodableType,
290
+ callbackArgumentTypes: callbackArgumentTypes,
265
291
  isStatic: isTypeLevel(funcDecl.modifiers)
266
292
  )
267
293
  }
@@ -269,7 +295,8 @@ private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> Boundary
269
295
  if let varDecl = declaration.as(VariableDeclSyntax.self),
270
296
  let binding = varDecl.bindings.first,
271
297
  let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
272
- let type = binding.typeAnnotation?.type {
298
+ let type = binding.typeAnnotation?.type,
299
+ !containsFunctionType(type) {
273
300
  // A `let`, or a `var` with no setter, is read-only (encodable only). A settable `var` is also
274
301
  // decoded on write, so its value type is asserted in both directions.
275
302
  let typeText = type.trimmedDescription
@@ -66,24 +66,29 @@ internal struct ConformanceAssertion {
66
66
  /// direction.
67
67
  ///
68
68
  /// `decodableTypes` are the argument types (and a settable property's value type); `encodableType` is
69
- /// the single return type (or a property's value type on read), or `nil` when there's none. Primitives
70
- /// are dropped from each; when nothing is left in either, returns `nil` so the caller emits no peer.
71
- /// `isStatic` mirrors a `static`/`class` member so the peer sits in the right metatype context.
69
+ /// the single return type (or a property's value type on read), or `nil` when there's none.
70
+ /// `callbackArgumentTypes` are the parameter types of closure arguments, which are encoded when native
71
+ /// code calls the closure. Primitives are dropped from each; when nothing is left in any of them,
72
+ /// returns `nil` so the caller emits no peer. `isStatic` mirrors a `static`/`class` member so the peer
73
+ /// sits in the right metatype context.
72
74
  internal func directionalConformanceAssertion(
73
75
  name: String,
74
76
  decodableTypes: [String],
75
77
  encodableType: String?,
78
+ callbackArgumentTypes: [String] = [],
76
79
  isStatic: Bool
77
80
  ) -> DeclSyntax? {
78
81
  let decodables = distinctAssertableTypes(decodableTypes)
82
+ let callbackArguments = distinctAssertableTypes(callbackArgumentTypes)
79
83
  let encodable = encodableType.flatMap(assertableBoundaryType)
80
- guard !decodables.isEmpty || encodable != nil else {
84
+ guard !decodables.isEmpty || !callbackArguments.isEmpty || encodable != nil else {
81
85
  return nil
82
86
  }
83
87
 
84
88
  // Build the generic parameter list and the matching call arguments in lockstep: one `A0…` slot
85
- // constrained `JavaScriptDecodable` per argument, and a single `Return` slot constrained
86
- // `JavaScriptEncodable` for the return/getter value (there's only ever one, so it isn't indexed).
89
+ // constrained `JavaScriptDecodable` per argument, one `E0…` slot constrained `JavaScriptEncodable`
90
+ // per closure-argument parameter, and a single `Return` slot constrained `JavaScriptEncodable` for
91
+ // the return/getter value (there's only ever one, so it isn't indexed).
87
92
  var parameters: [String] = []
88
93
  var typeParameters: [String] = []
89
94
  var arguments: [String] = []
@@ -92,6 +97,11 @@ internal func directionalConformanceAssertion(
92
97
  typeParameters.append("_: A\(index).Type")
93
98
  arguments.append("\(type).self")
94
99
  }
100
+ for (index, type) in callbackArguments.enumerated() {
101
+ parameters.append("E\(index): \(javaScriptEncodableProtocolName)")
102
+ typeParameters.append("_: E\(index).Type")
103
+ arguments.append("\(type).self")
104
+ }
95
105
  if let encodable {
96
106
  parameters.append("Return: \(javaScriptEncodableProtocolName)")
97
107
  typeParameters.append("_: Return.Type")
@@ -170,10 +170,25 @@ struct ExportedModule: Encodable, Equatable {
170
170
  let properties: [ExportedProperty]
171
171
  let events: [ExportedEvent]
172
172
 
173
+ /// The classes listed in `@ExpoModule(classes: [...])`, in source order. Each is reachable from JS
174
+ /// as a member of the module.
175
+ let classes: [ExportedClass]
176
+
173
177
  /// Absolute source path, matching `scan-modules`.
174
178
  let file: String
175
179
  }
176
180
 
181
+ /// One entry of `@ExpoModule(classes: [...])`.
182
+ struct ExportedClass: Encodable, Equatable {
183
+ /// The Swift name as spelled in the argument, e.g. `Database` or `Outer.Statement`.
184
+ let name: String
185
+
186
+ /// The name of the module member JS reads the class from: the matching `@SharedObject`'s `jsName`.
187
+ /// `nil` when the scan found no `@SharedObject` with this name, for example a class defined
188
+ /// outside the scanned paths.
189
+ let jsName: String?
190
+ }
191
+
177
192
  /// A `@SharedObject` type: a JS class with an optional `@JS init` constructor plus its `@JS` members.
178
193
  struct ExportedSharedObject: Encodable, Equatable {
179
194
  /// The Swift class name.
@@ -307,8 +322,8 @@ struct ExportedSurface: Encodable, Equatable {
307
322
  /// Versioned independently of `scanModulesSchemaVersion`: the two commands serve different consumers
308
323
  /// and change for different reasons. Version 2 added `events` to modules and shared objects; version 3
309
324
  /// added `enums`; version 4 added `unions`; version 5 resolves refs, adding `refKind` and correcting
310
- /// an enum ref's `typeof`.
311
- let scanExportsSchemaVersion = 5
325
+ /// an enum ref's `typeof`; version 6 added `classes` to modules.
326
+ let scanExportsSchemaVersion = 6
312
327
 
313
328
  /// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from
314
329
  /// `ScanModulesResult` (different consumer: TS generation vs. autolinking).
@@ -23,12 +23,16 @@ struct RefIndex {
23
23
  /// `name` -> the kind declaring it, plus the JS category a ref to it crosses as.
24
24
  private var entries: [String: (kind: RefKind, jsType: JSType)] = [:]
25
25
 
26
+ /// A `@SharedObject`'s Swift name -> its JS name.
27
+ private var sharedObjectJSNames: [String: String] = [:]
28
+
26
29
  init(surface: ExportedSurface) {
27
30
  for record in surface.records {
28
31
  entries[record.name] = (.record, .object)
29
32
  }
30
33
  for sharedObject in surface.sharedObjects {
31
34
  entries[sharedObject.name] = (.sharedObject, .object)
35
+ sharedObjectJSNames[sharedObject.name] = sharedObject.jsName
32
36
  }
33
37
  for union in surface.unions {
34
38
  // A union crosses as whichever alternative matched, so its category is only meaningful per
@@ -42,18 +46,28 @@ struct RefIndex {
42
46
 
43
47
  /// How a ref to `name` should be reported, or `nil` when the scan declares no such type.
44
48
  func lookup(_ name: String) -> (kind: RefKind, jsType: JSType)? {
45
- if let entry = entries[name] {
46
- return entry
47
- }
48
- // A qualified use site (`Media.Status`) names the same type a bare declaration did. Fall back to
49
- // the trailing component, which is how the conformance and raw-type checks already match names.
50
- guard let trailing = name.split(separator: ".").last.map(String.init), trailing != name else {
51
- return nil
52
- }
53
- return entries[trailing]
49
+ lookupQualified(name, in: entries)
50
+ }
51
+
52
+ /// The JS name of the `@SharedObject` declared as `name`, or `nil` when the scan declares none.
53
+ func sharedObjectJSName(_ name: String) -> String? {
54
+ lookupQualified(name, in: sharedObjectJSNames)
54
55
  }
55
56
  }
56
57
 
58
+ /// The value for `name` in `table`, falling back to its trailing component. A qualified use site
59
+ /// (`Media.Status`) names the same type a bare declaration did, which is how the conformance and
60
+ /// raw-type checks already match names.
61
+ private func lookupQualified<Value>(_ name: String, in table: [String: Value]) -> Value? {
62
+ if let value = table[name] {
63
+ return value
64
+ }
65
+ guard let trailing = name.split(separator: ".").last.map(String.init), trailing != name else {
66
+ return nil
67
+ }
68
+ return table[trailing]
69
+ }
70
+
57
71
  /// The JS category a raw-value enum crosses as: its raw value's. A bare `Enumerable` conformance with
58
72
  /// no raw type has nothing to go on, so it stays an object.
59
73
  private func jsType(ofRawType rawType: TypeNode?) -> JSType {
@@ -114,6 +128,9 @@ extension ExportedSurface {
114
128
  functions: module.functions.map { $0.resolvingRefs(using: index) },
115
129
  properties: module.properties.map { $0.resolvingRefs(using: index) },
116
130
  events: module.events.map { $0.resolvingRefs(using: index) },
131
+ classes: module.classes.map {
132
+ ExportedClass(name: $0.name, jsName: index.sharedObjectJSName($0.name))
133
+ },
117
134
  file: module.file
118
135
  )
119
136
  },
@@ -78,6 +78,11 @@ final class SurfaceVisitor: SyntaxVisitor {
78
78
  functions: functions,
79
79
  properties: properties,
80
80
  events: events,
81
+ // `jsName` is filled in by `ExportedSurface.resolvingRefs()`, since the `@SharedObject` can
82
+ // be declared in another file.
83
+ classes: classListArgument(of: attribute, label: "classes").map {
84
+ ExportedClass(name: $0, jsName: nil)
85
+ },
81
86
  file: file
82
87
  ))
83
88
  return
@@ -590,6 +595,25 @@ extension AttributeListSyntax {
590
595
  }
591
596
  }
592
597
 
598
+ /// The type names in an attribute's `label: [A.self, B.self]` argument, in source order. A qualified
599
+ /// `Outer.A.self` keeps its full spelling. Returns an empty array when the argument is absent or isn't
600
+ /// an array literal. A local copy of the macros' helper (the macro target can't be imported here).
601
+ private func classListArgument(of attribute: AttributeSyntax, label: String) -> [String] {
602
+ guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
603
+ let array = args.first(where: { $0.label?.text == label })?.expression.as(ArrayExprSyntax.self) else {
604
+ return []
605
+ }
606
+ return array.elements.compactMap { element in
607
+ guard let memberAccess = element.expression.as(MemberAccessExprSyntax.self),
608
+ memberAccess.declName.baseName.text == "self",
609
+ let base = memberAccess.base,
610
+ base.is(DeclReferenceExprSyntax.self) || base.is(MemberAccessExprSyntax.self) else {
611
+ return nil
612
+ }
613
+ return base.trimmedDescription
614
+ }
615
+ }
616
+
593
617
  /// The node for a property/field value type: the annotation, else the literal default's inferred
594
618
  /// primitive (`var n = 1` -> `Int`), else `nil`.
595
619
  private func valueTypeNode(annotation: TypeSyntax?, initializer: ExprSyntax?) -> TypeNode? {
@@ -8,8 +8,8 @@ import SwiftSyntax
8
8
  /// never silently dropped.
9
9
 
10
10
  /// The runtime category mirroring JavaScript's `typeof`. A coarse companion to `TypeNode.kind`: every
11
- /// structured object kind (array, dictionary, promise, ref) reports `object`. `bigint`/`symbol` are
12
- /// included for completeness; the scanner doesn't produce them.
11
+ /// structured object kind (array, dictionary, promise, ref) reports `object`. `symbol` is included for
12
+ /// completeness; the scanner doesn't produce it.
13
13
  enum JSType: String, Encodable {
14
14
  case undefined
15
15
  case object
@@ -37,8 +37,8 @@ enum RefKind: String, Encodable {
37
37
  }
38
38
 
39
39
  indirect enum TypeNode: Equatable {
40
- /// `Bool`, `Int`, `Double`, `String`: the types core fast-decodes. `name` is the Swift spelling;
41
- /// `jsType` is `boolean`/`number`/`string`.
40
+ /// A Swift type that crosses as a JS primitive: `Bool`, `String`, or a numeric type. `name` is the
41
+ /// Swift spelling; `jsType` is `boolean`/`number`/`bigint`/`string`.
42
42
  case primitive(name: String, jsType: JSType)
43
43
 
44
44
  /// `T?` / `T!` / `Optional<T>`.
@@ -148,13 +148,27 @@ extension TypeNode: Encodable {
148
148
  }
149
149
  }
150
150
 
151
- /// The primitive Swift types with a dedicated JS mapping (the set core fast-decodes), each paired with
152
- /// its JS primitive. A bare name here is a `.primitive`; anything else is a `.ref`.
151
+ /// The Swift types that cross as a JS primitive, each paired with that primitive. A bare name here is
152
+ /// a `.primitive`; anything else is a `.ref`.
153
153
  private let primitiveJSTypes: [String: JSType] = [
154
154
  "Bool": .boolean,
155
+ "String": .string,
156
+ // These numeric types encode to a JS `number`.
155
157
  "Int": .number,
158
+ "Int8": .number,
159
+ "Int16": .number,
160
+ "Int32": .number,
161
+ "UInt": .number,
162
+ "UInt8": .number,
163
+ "UInt16": .number,
164
+ "UInt32": .number,
156
165
  "Double": .number,
157
- "String": .string,
166
+ "Float": .number,
167
+ "CGFloat": .number,
168
+ // The 64-bit fixed-width integers decode from a `number` or a `bigint`, and always encode to a
169
+ // `bigint`. The reported category is the encoded one.
170
+ "Int64": .bigint,
171
+ "UInt64": .bigint,
158
172
  ]
159
173
 
160
174
  /// Parses a `TypeSyntax` into a `TypeNode`: the single place Swift type syntax is interpreted.
@@ -183,6 +197,11 @@ func typeNode(from type: TypeSyntax) -> TypeNode {
183
197
  if let attributed = type.as(AttributedTypeSyntax.self) {
184
198
  return typeNode(from: attributed.baseType)
185
199
  }
200
+ // `(T)`: one unlabeled element in parentheses is the same type as `T`, as in `((Int) -> Void)?`.
201
+ if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1,
202
+ let element = tuple.elements.first, element.firstName == nil {
203
+ return typeNode(from: element.type)
204
+ }
186
205
  // A nominal type, possibly generic: `Int`, `Point`, `Optional<T>`, `Array<T>`, `Promise<T>`.
187
206
  if let identifier = type.as(IdentifierTypeSyntax.self) {
188
207
  return nominalNode(name: identifier.name.text, generics: identifier.genericArgumentClause)
package/build/types.d.ts CHANGED
@@ -112,9 +112,25 @@ export interface ExportedModule {
112
112
  functions: ExportedFunction[];
113
113
  properties: ExportedProperty[];
114
114
  events: ExportedEvent[];
115
+ /**
116
+ * The classes listed in `@ExpoModule(classes: [...])`, in source order. Each is reachable from JS
117
+ * as a member of the module.
118
+ */
119
+ classes: ExportedClass[];
115
120
  /** Absolute source path. */
116
121
  file: string;
117
122
  }
123
+ /** One entry of `@ExpoModule(classes: [...])`. */
124
+ export interface ExportedClass {
125
+ /** The Swift name as spelled in the argument, e.g. `Database` or `Outer.Statement`. */
126
+ name: string;
127
+ /**
128
+ * The name of the module member JS reads the class from: the matching shared object's `jsName`.
129
+ * Absent when the scan found no shared object with this name, for example a class defined outside
130
+ * the scanned paths.
131
+ */
132
+ jsName?: string;
133
+ }
118
134
  /** One `@Event var`: a typed event JS listens for by name, rather than calls. */
119
135
  export interface ExportedEvent {
120
136
  /** The Swift property name, e.g. `onStatusChange`. */
@@ -261,4 +277,4 @@ export interface ScanExportsResult {
261
277
  * as a clear error instead of silently misread fields.
262
278
  */
263
279
  export declare const SUPPORTED_SCAN_MODULES_SCHEMA_VERSION = 2;
264
- export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 5;
280
+ export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 6;
package/build/types.js CHANGED
@@ -12,4 +12,4 @@ exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = exports.SUPPORTED_SCAN_MODULES_S
12
12
  * as a clear error instead of silently misread fields.
13
13
  */
14
14
  exports.SUPPORTED_SCAN_MODULES_SCHEMA_VERSION = 2;
15
- exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 5;
15
+ exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 6;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-macros",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Swift macro plugin for Expo modules",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",