expo-modules-macros 0.21.0 → 0.22.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.
Files changed (34) hide show
  1. package/.github/workflows/swift-format.yml +76 -0
  2. package/AGENTS.md +9 -4
  3. package/README.md +15 -2
  4. package/apple/ExpoModulesMacros +0 -0
  5. package/apple/Package.resolved +3 -3
  6. package/apple/Package.swift +1 -2
  7. package/apple/Sources/ExpoModulesMacros/ClosureArguments.swift +39 -14
  8. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +113 -26
  9. package/apple/Sources/ExpoModulesMacros/EventMacro.swift +29 -15
  10. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +53 -45
  11. package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +2 -1
  12. package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +2 -61
  13. package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +6 -3
  14. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +180 -44
  15. package/apple/Sources/ExpoModulesMacros/JSNameCollisions.swift +26 -6
  16. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +163 -42
  17. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +285 -221
  18. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +45 -32
  19. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +57 -27
  20. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +23 -9
  21. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +10 -5
  22. package/apple/Sources/ExpoModulesScanner/CLI.swift +3 -1
  23. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +11 -7
  24. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +2 -1
  25. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +10 -7
  26. package/apple/Sources/ExpoModulesScanner/Exports/Availability.swift +6 -3
  27. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +3 -2
  28. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +15 -5
  29. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +8 -3
  30. package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +127 -45
  31. package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +2 -1
  32. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +8 -2
  33. package/build/types.d.ts +7 -1
  34. package/package.json +7 -5
@@ -35,6 +35,7 @@ public struct JSMacro: PeerMacro {
35
35
  diagnoseFreeFormTypes(in: declaration, in: context)
36
36
  diagnoseClosureTypes(in: declaration, in: context)
37
37
  diagnoseConcurrentOption(of: node, on: declaration, in: context)
38
+ diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
38
39
 
39
40
  guard let member = boundaryMember(of: declaration),
40
41
  let assertion = directionalConformanceAssertion(
@@ -43,7 +44,8 @@ public struct JSMacro: PeerMacro {
43
44
  encodableType: member.encodableType,
44
45
  callbackArgumentTypes: member.callbackArgumentTypes,
45
46
  isStatic: member.isStatic
46
- ) else {
47
+ )
48
+ else {
47
49
  return []
48
50
  }
49
51
  return [assertion]
@@ -74,7 +76,9 @@ private func diagnoseConcurrentOption(
74
76
  id: "js-concurrent-requires-async",
75
77
  severity: .error
76
78
  ),
77
- fixIts: [insertAsyncFixIt(for: funcDecl)]))
79
+ fixIts: [insertAsyncFixIt(for: funcDecl)]
80
+ )
81
+ )
78
82
  return
79
83
  }
80
84
  context.diagnose(
@@ -84,7 +88,108 @@ private func diagnoseConcurrentOption(
84
88
  "'.concurrent' applies only to an 'async' @JS function, not to a property or initializer.",
85
89
  id: "js-concurrent-requires-function",
86
90
  severity: .error
87
- )))
91
+ )
92
+ )
93
+ )
94
+ }
95
+
96
+ /// Emits the diagnostics for `@JS(.withAsync("name"))` on a member that can't take it. The option
97
+ /// adds a promise-returning variant that calls the function on the concurrent pool, so it needs a
98
+ /// synchronous function without a global actor, and the variant's JavaScript name as a string literal.
99
+ private func diagnoseAsyncVariantOption(
100
+ of node: AttributeSyntax,
101
+ on declaration: some DeclSyntaxProtocol,
102
+ in context: some MacroExpansionContext
103
+ ) {
104
+ guard let option = asyncVariantOption(of: node) else {
105
+ return
106
+ }
107
+ guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
108
+ context.diagnose(
109
+ Diagnostic(
110
+ node: node,
111
+ message: JSDiagnosticMessage(
112
+ "'.withAsync' applies only to a synchronous @JS function, not to a property or initializer.",
113
+ id: "js-async-variant-requires-function",
114
+ severity: .error
115
+ )
116
+ )
117
+ )
118
+ return
119
+ }
120
+ if funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil {
121
+ context.diagnose(
122
+ Diagnostic(
123
+ node: node,
124
+ message: JSDiagnosticMessage(
125
+ "'.withAsync' adds a promise-returning variant to a synchronous function, and this function is already 'async'. Remove '.withAsync', and use '.concurrent' to run the function off the JavaScript thread.",
126
+ id: "js-async-variant-on-async-function",
127
+ severity: .error
128
+ )
129
+ )
130
+ )
131
+ return
132
+ }
133
+ for duplicate in asyncVariantOptions(of: node).dropFirst() {
134
+ context.diagnose(
135
+ Diagnostic(
136
+ node: duplicate,
137
+ message: JSDiagnosticMessage(
138
+ "'.withAsync' can be listed only once: a function gets one promise-returning variant.",
139
+ id: "js-async-variant-duplicate",
140
+ severity: .error
141
+ )
142
+ )
143
+ )
144
+ }
145
+ if asyncVariantName(of: option) == nil {
146
+ context.diagnose(
147
+ Diagnostic(
148
+ node: option,
149
+ message: JSDiagnosticMessage(
150
+ "'.withAsync' takes the JavaScript name of the variant as a string literal, for example '.withAsync(\"loadAsync\")'.",
151
+ id: "js-async-variant-name",
152
+ severity: .error
153
+ )
154
+ )
155
+ )
156
+ }
157
+ if let isolation = globalActorIsolation(of: funcDecl, in: context) {
158
+ context.diagnose(
159
+ Diagnostic(
160
+ node: node,
161
+ message: JSDiagnosticMessage(
162
+ isolation.enclosingTypeName.map {
163
+ "'.withAsync' calls the function on the concurrent thread pool, so the function can't inherit '\(isolation.attribute)' from '\($0)'. Mark the function 'nonisolated'."
164
+ }
165
+ ?? "'.withAsync' calls the function on the concurrent thread pool, so the function can't be isolated to '\(isolation.attribute)'.",
166
+ id: "js-async-variant-isolated",
167
+ severity: .error
168
+ )
169
+ )
170
+ )
171
+ }
172
+ }
173
+
174
+ /// The global actor a function is isolated to, written on the function itself or inherited from its
175
+ /// enclosing type, or `nil` when it has none or is `nonisolated`. `enclosingTypeName` is set when the
176
+ /// isolation is inherited.
177
+ private func globalActorIsolation(
178
+ of funcDecl: FunctionDeclSyntax,
179
+ in context: some MacroExpansionContext
180
+ ) -> (attribute: String, enclosingTypeName: String?)? {
181
+ if let attribute = funcDecl.attributes.first(where: hasGlobalActorShape) {
182
+ return (attribute.trimmedDescription, nil)
183
+ }
184
+ if funcDecl.modifiers.contains(where: { $0.name.text == "nonisolated" }) {
185
+ return nil
186
+ }
187
+ guard let enclosing = context.lexicalContext.first,
188
+ let attribute = enclosing.asProtocol(WithAttributesSyntax.self)?.attributes.first(where: hasGlobalActorShape)
189
+ else {
190
+ return nil
191
+ }
192
+ return (attribute.trimmedDescription, enclosing.asProtocol(NamedDeclSyntax.self)?.name.text)
88
193
  }
89
194
 
90
195
  /// The fix-it offered alongside the synchronous-function diagnostic: insert `async` into the
@@ -144,7 +249,9 @@ private func diagnoseParameterSpecifiers(
144
249
  "A @JS parameter can't be '\(specifier)': its argument is a value decoded from JavaScript, which can't be written back or carry an isolation.",
145
250
  id: "js-parameter-specifier",
146
251
  severity: .error
147
- )))
252
+ )
253
+ )
254
+ )
148
255
  }
149
256
  }
150
257
 
@@ -173,14 +280,16 @@ private func diagnoseFreeFormTypes(
173
280
  // A constructor decodes its arguments exactly like a function and has no return value.
174
281
  types = initDecl.signature.parameterClause.parameters.map { SpecifiedType($0.type).type }
175
282
  } else if let varDecl = declaration.as(VariableDeclSyntax.self),
176
- let type = varDecl.bindings.first?.typeAnnotation?.type {
283
+ let type = varDecl.bindings.first?.typeAnnotation?.type
284
+ {
177
285
  types = [type]
178
286
  }
179
287
 
180
288
  for type in types
181
289
  where mentionsFreeFormAny(type) && !isConvertibleFreeFormType(type) && !containsFunctionType(type) {
182
290
  context.diagnose(
183
- Diagnostic(node: type, message: unconvertibleFreeFormError(for: type.trimmedDescription)))
291
+ Diagnostic(node: type, message: unconvertibleFreeFormError(for: type.trimmedDescription))
292
+ )
184
293
  }
185
294
  }
186
295
 
@@ -234,61 +343,49 @@ private struct BoundaryMember {
234
343
 
235
344
  /// Reads the boundary member off a `@JS` declaration, splitting its types by conversion direction. A
236
345
  /// function contributes its parameter types (decodable) and its return type when non-Void (encodable);
237
- /// a property contributes its value type as encodable (the getter) and also as decodable when settable
238
- /// (the setter). Composed types (`[Int]`, `String?`, …) are kept verbatim, their conditional
239
- /// conformances transitively constraining the elements. A type that mentions `Any` is left out: it
240
- /// converts through the free-form methods, or is diagnosed by `diagnoseFreeFormTypes`. Returns `nil`
241
- /// for declaration kinds `@JS` doesn't read types from, a property whose type isn't spelled out (a
242
- /// syntactic macro can't recover it), or one whose type mentions `Any`.
346
+ /// an initializer contributes its parameter types; a property contributes its value type as encodable
347
+ /// (the getter) and also as decodable when settable (the setter). Composed types (`[Int]`, `String?`,
348
+ /// …) are kept verbatim, their conditional conformances transitively constraining the elements. A type
349
+ /// that mentions `Any` is left out: it converts through the free-form methods, or is diagnosed by
350
+ /// `diagnoseFreeFormTypes`. Returns `nil` for declaration kinds `@JS` doesn't read types from, a
351
+ /// property whose type isn't spelled out (a syntactic macro can't recover it), or one whose type
352
+ /// mentions `Any`.
243
353
  private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> BoundaryMember? {
244
354
  if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
245
- // A closure argument moves data the other way: JS receives its parameters and returns its result.
246
- // A closure in any other position is diagnosed by `diagnoseClosureTypes` and left out here, since
247
- // its metatype isn't a valid expression in the assertion.
248
- var decodableTypes: [String] = []
249
- var callbackArgumentTypes: [String] = []
250
- for parameter in funcDecl.signature.parameterClause.parameters {
251
- // The specifier isn't part of the asserted type, and a parameter with one the binding can't honor
252
- // is reported by `diagnoseParameterSpecifiers` and not asserted.
253
- let specified = SpecifiedType(parameter.type)
254
- guard specified.unsupportedSpecifier == nil else {
255
- continue
256
- }
257
- guard let closure = JSClosureType(specified.type) else {
258
- if !containsFunctionType(specified.type) && !mentionsFreeFormAny(specified.type) {
259
- decodableTypes.append(specified.type.trimmedDescription)
260
- }
261
- continue
262
- }
263
- // A closure type that mentions `Any` is reported by `diagnoseClosureTypes` instead.
264
- for parameter in closure.parameters
265
- where !containsFunctionType(parameter.type) && !mentionsFreeFormAny(parameter.type) {
266
- callbackArgumentTypes.append(parameter.type.trimmedDescription)
267
- }
268
- let closureReturnType = closure.functionType.returnClause.type
269
- if let returnType = closure.returnType,
270
- !containsFunctionType(closureReturnType) && !mentionsFreeFormAny(closureReturnType) {
271
- decodableTypes.append(returnType)
272
- }
273
- }
355
+ let argumentTypes = argumentBoundaryTypes(of: funcDecl.signature.parameterClause.parameters)
274
356
  let returnType = funcDecl.signature.returnClause?.type
275
357
  let encodableType = returnType.flatMap {
276
358
  isVoidType($0) || containsFunctionType($0) || mentionsFreeFormAny($0) ? nil : $0.trimmedDescription
277
359
  }
278
360
  return BoundaryMember(
279
361
  name: funcDecl.name.text,
280
- decodableTypes: decodableTypes,
362
+ decodableTypes: argumentTypes.decodable,
281
363
  encodableType: encodableType,
282
- callbackArgumentTypes: callbackArgumentTypes,
364
+ callbackArgumentTypes: argumentTypes.callbackArguments,
283
365
  isStatic: isTypeLevel(funcDecl.modifiers)
284
366
  )
285
367
  }
286
368
 
369
+ if let initDecl = declaration.as(InitializerDeclSyntax.self) {
370
+ // A constructor decodes its arguments like a function does, and the instance it returns isn't
371
+ // encoded. The name is escaped because it also names the assertion's nested helper, and `init` is
372
+ // a keyword.
373
+ let argumentTypes = argumentBoundaryTypes(of: initDecl.signature.parameterClause.parameters)
374
+ return BoundaryMember(
375
+ name: "`init`",
376
+ decodableTypes: argumentTypes.decodable,
377
+ encodableType: nil,
378
+ callbackArgumentTypes: argumentTypes.callbackArguments,
379
+ isStatic: false
380
+ )
381
+ }
382
+
287
383
  if let varDecl = declaration.as(VariableDeclSyntax.self),
288
384
  let binding = varDecl.bindings.first,
289
385
  let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
290
386
  let type = binding.typeAnnotation?.type,
291
- !containsFunctionType(type) {
387
+ !containsFunctionType(type)
388
+ {
292
389
  // A `let`, or a `var` with no setter, is read-only (encodable only). A settable `var` is also
293
390
  // decoded on write, so its value type is asserted in both directions. A type that mentions `Any`
294
391
  // converts through the free-form methods instead, so it isn't asserted at all.
@@ -309,6 +406,45 @@ private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> Boundary
309
406
  return nil
310
407
  }
311
408
 
409
+ /// The boundary types of a function's or constructor's arguments: an argument is decoded from JS, and
410
+ /// a closure argument instead contributes its parameter types (encoded when native code calls it) and
411
+ /// its return type (decoded).
412
+ private func argumentBoundaryTypes(
413
+ of parameters: FunctionParameterListSyntax
414
+ ) -> (decodable: [String], callbackArguments: [String]) {
415
+ // A closure argument moves data the other way: JS receives its parameters and returns its result.
416
+ // A closure in any other position is diagnosed by `diagnoseClosureTypes` and left out here, since
417
+ // its metatype isn't a valid expression in the assertion.
418
+ var decodableTypes: [String] = []
419
+ var callbackArgumentTypes: [String] = []
420
+ for parameter in parameters {
421
+ // The specifier isn't part of the asserted type, and a parameter with one the binding can't honor
422
+ // is reported by `diagnoseParameterSpecifiers` and not asserted.
423
+ let specified = SpecifiedType(parameter.type)
424
+ guard specified.unsupportedSpecifier == nil else {
425
+ continue
426
+ }
427
+ guard let closure = JSClosureType(specified.type) else {
428
+ if !containsFunctionType(specified.type) && !mentionsFreeFormAny(specified.type) {
429
+ decodableTypes.append(specified.type.trimmedDescription)
430
+ }
431
+ continue
432
+ }
433
+ // A closure type that mentions `Any` is reported by `diagnoseClosureTypes` instead.
434
+ for parameter in closure.parameters
435
+ where !containsFunctionType(parameter.type) && !mentionsFreeFormAny(parameter.type) {
436
+ callbackArgumentTypes.append(parameter.type.trimmedDescription)
437
+ }
438
+ let closureReturnType = closure.functionType.returnClause.type
439
+ if let returnType = closure.returnType,
440
+ !containsFunctionType(closureReturnType) && !mentionsFreeFormAny(closureReturnType)
441
+ {
442
+ decodableTypes.append(returnType)
443
+ }
444
+ }
445
+ return (decodableTypes, callbackArgumentTypes)
446
+ }
447
+
312
448
  /// True when a return clause is written as `Void` / `()` — nothing crosses the boundary, so it needs
313
449
  /// no conformance assertion. (A missing return clause never reaches here: `returnClause` is `nil`.)
314
450
  private func isVoidType(_ type: TypeSyntax) -> Bool {
@@ -13,9 +13,7 @@ internal func diagnoseJSNameCollisions(
13
13
  // Each member binds with its own `setProperty` or `defineProperty` call, so of two members with one
14
14
  // name, JavaScript would reach only one. Members are compared in source order, so the error lands on
15
15
  // every member after the first, whichever kind each one is.
16
- let members =
17
- functions.map { BoundMember(jsName: $0.jsName, kind: "function", nameToken: $0.nameToken) }
18
- + properties.map { BoundMember(jsName: $0.jsName, kind: "property", nameToken: $0.nameToken) }
16
+ let members = functions.map(BoundMember.init(function:)) + properties.map(BoundMember.init(property:))
19
17
  var firstMembers: [String: BoundMember] = [:]
20
18
 
21
19
  for member in members.sorted(by: { $0.nameToken.position < $1.nameToken.position }) {
@@ -23,25 +21,47 @@ internal func diagnoseJSNameCollisions(
23
21
  firstMembers[member.jsName] = member
24
22
  continue
25
23
  }
24
+ let renaming =
25
+ first.renaming == member.renaming ? first.renaming : "\(first.renaming) or \(member.renaming)"
26
26
  context.diagnose(
27
27
  Diagnostic(
28
28
  node: member.nameToken,
29
29
  message: JSNameCollisionMessage(
30
- "The JavaScript name '\(member.jsName)' is already bound by an earlier @JS \(first.kind). A JavaScript object holds one member per name, so JavaScript could reach only one of them. Give one of them another name with @JS(\"...\")."
30
+ "The JavaScript name '\(member.jsName)' is already bound by an earlier \(first.kind). A JavaScript object holds one member per name, so JavaScript could reach only one of them. Give one of them another name with \(renaming)."
31
31
  ),
32
32
  notes: [
33
33
  Note(
34
34
  node: Syntax(first.nameToken),
35
- message: JSNameCollisionNote("'\(member.jsName)' is first bound here"))
35
+ message: JSNameCollisionNote("'\(member.jsName)' is first bound here")
36
+ )
36
37
  ]
37
- ))
38
+ )
39
+ )
38
40
  }
39
41
  }
40
42
 
41
43
  private struct BoundMember {
42
44
  let jsName: String
45
+ /// How the message names the member, such as `@JS function`.
43
46
  let kind: String
47
+ /// The spelling that gives the member another JavaScript name.
48
+ let renaming: String
44
49
  let nameToken: TokenSyntax
50
+
51
+ init(function: JSFunction) {
52
+ // A `.withAsync` variant takes its JavaScript name from the option, not from `@JS("...")`.
53
+ self.jsName = function.jsName
54
+ self.kind = function.isAsyncVariant ? "'.withAsync' variant" : "@JS function"
55
+ self.renaming = function.isAsyncVariant ? ".withAsync(\"...\")" : "@JS(\"...\")"
56
+ self.nameToken = function.nameToken
57
+ }
58
+
59
+ init(property: JSProperty) {
60
+ self.jsName = property.jsName
61
+ self.kind = "@JS property"
62
+ self.renaming = "@JS(\"...\")"
63
+ self.nameToken = property.nameToken
64
+ }
45
65
  }
46
66
 
47
67
  private struct JSNameCollisionMessage: DiagnosticMessage {