expo-modules-macros 0.21.0 → 0.23.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 (41) hide show
  1. package/.github/workflows/linux.yml +8 -5
  2. package/.github/workflows/publish.yml +26 -11
  3. package/.github/workflows/swift-format.yml +79 -0
  4. package/.github/workflows/swift.yml +7 -4
  5. package/.github/workflows/windows.yml +8 -5
  6. package/AGENTS.md +16 -9
  7. package/README.md +20 -5
  8. package/apple/ExpoModulesMacros +0 -0
  9. package/apple/Package.resolved +3 -3
  10. package/apple/Package.swift +1 -2
  11. package/apple/Sources/ExpoModulesMacros/ClosureArguments.swift +39 -14
  12. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +144 -30
  13. package/apple/Sources/ExpoModulesMacros/EventMacro.swift +29 -15
  14. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +63 -68
  15. package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +2 -1
  16. package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +2 -61
  17. package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +6 -3
  18. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +184 -48
  19. package/apple/Sources/ExpoModulesMacros/JSNameCollisions.swift +26 -6
  20. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +172 -43
  21. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +286 -222
  22. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +49 -56
  23. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +57 -27
  24. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +23 -9
  25. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +10 -5
  26. package/apple/Sources/ExpoModulesScanner/CLI.swift +3 -1
  27. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +11 -7
  28. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +2 -1
  29. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +10 -7
  30. package/apple/Sources/ExpoModulesScanner/Exports/Availability.swift +6 -3
  31. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +20 -4
  32. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +18 -5
  33. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +8 -3
  34. package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +179 -46
  35. package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +28 -8
  36. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +8 -2
  37. package/build/index.d.ts +1 -1
  38. package/build/index.js +3 -3
  39. package/build/types.d.ts +32 -2
  40. package/build/types.js +1 -1
  41. package/package.json +8 -5
@@ -1,23 +1,20 @@
1
1
  import SwiftSyntax
2
2
 
3
- /**
4
- Reads the first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork".
5
- Returns nil if the attribute has no arguments or the first argument is not a string literal.
6
- */
3
+ /// Reads the first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork".
4
+ /// Returns nil if the attribute has no arguments or the first argument is not a string literal.
7
5
  internal func jsNameArgument(of attribute: AttributeSyntax) -> String? {
8
6
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
9
7
  let first = args.first,
10
8
  let str = first.expression.as(StringLiteralExprSyntax.self),
11
- let segment = str.segments.first?.as(StringSegmentSyntax.self) else {
9
+ let segment = str.segments.first?.as(StringSegmentSyntax.self)
10
+ else {
12
11
  return nil
13
12
  }
14
13
  return segment.content.text
15
14
  }
16
15
 
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
- */
16
+ /// Reads a labeled boolean-literal argument of an attribute, e.g. `@Event(sync: true)` -> true.
17
+ /// Returns nil if the attribute has no argument with that label or its value isn't a boolean literal.
21
18
  internal func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
22
19
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
23
20
  return nil
@@ -44,7 +41,8 @@ internal func hasJSOption(_ attribute: AttributeSyntax, named option: String) ->
44
41
  return true
45
42
  }
46
43
  if let array = arg.expression.as(ArrayExprSyntax.self),
47
- array.elements.contains(where: { namesOption($0.expression, option) }) {
44
+ array.elements.contains(where: { namesOption($0.expression, option) })
45
+ {
48
46
  return true
49
47
  }
50
48
  }
@@ -69,6 +67,64 @@ internal func isConcurrentJSMember(_ decl: DeclSyntaxProtocol) -> Bool {
69
67
  return hasJSOption(attribute, named: "concurrent")
70
68
  }
71
69
 
70
+ /// The `.withAsync("name")` option of a `@JS` attribute, e.g. `@JS(.withAsync("loadAsync"))` or
71
+ /// `@JS("load", [.withAsync("loadAsync")])`, or `nil` when the attribute doesn't list it. When the
72
+ /// option is listed more than once, the first one counts and `@JS` reports the others.
73
+ internal func asyncVariantOption(of attribute: AttributeSyntax) -> FunctionCallExprSyntax? {
74
+ return asyncVariantOptions(of: attribute).first
75
+ }
76
+
77
+ /// Every `.withAsync(...)` option of a `@JS` attribute, in source order. Like `hasJSOption`, it scans
78
+ /// the arguments and the elements of an array literal.
79
+ internal func asyncVariantOptions(of attribute: AttributeSyntax) -> [FunctionCallExprSyntax] {
80
+ guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
81
+ return []
82
+ }
83
+ return args.flatMap { arg -> [FunctionCallExprSyntax] in
84
+ if let array = arg.expression.as(ArrayExprSyntax.self) {
85
+ return array.elements.compactMap { asyncOptionCall($0.expression) }
86
+ }
87
+ return asyncOptionCall(arg.expression).map { [$0] } ?? []
88
+ }
89
+ }
90
+
91
+ /// The expression as a `.withAsync(...)` call (or `JSOptions.withAsync(...)`), or `nil` for anything
92
+ /// else.
93
+ private func asyncOptionCall(_ expression: ExprSyntax) -> FunctionCallExprSyntax? {
94
+ guard let call = expression.as(FunctionCallExprSyntax.self),
95
+ let member = call.calledExpression.as(MemberAccessExprSyntax.self),
96
+ member.declName.baseName.text == "withAsync"
97
+ else {
98
+ return nil
99
+ }
100
+ return call
101
+ }
102
+
103
+ /// The JavaScript name a `.withAsync("name")` option gives the promise-returning variant, with the
104
+ /// token of its string literal, where a name collision is reported. `nil` when the argument isn't a
105
+ /// single plain string literal, which `@JS` reports.
106
+ internal func asyncVariantName(of option: FunctionCallExprSyntax) -> (name: String, token: TokenSyntax)? {
107
+ guard option.arguments.count == 1,
108
+ let argument = option.arguments.first,
109
+ argument.label == nil,
110
+ let literal = argument.expression.as(StringLiteralExprSyntax.self),
111
+ literal.segments.count == 1,
112
+ let segment = literal.segments.first?.as(StringSegmentSyntax.self)
113
+ else {
114
+ return nil
115
+ }
116
+ return (segment.content.text, segment.content)
117
+ }
118
+
119
+ /// True if the `@JS`-marked declaration lists `.withAsync("name")`. Such a function is left
120
+ /// unstamped, because its promise-returning variant calls it on the concurrent pool.
121
+ internal func hasAsyncVariant(_ decl: DeclSyntaxProtocol) -> Bool {
122
+ guard let attribute = memberAttributes(of: decl).firstAttribute(named: "JS") else {
123
+ return false
124
+ }
125
+ return asyncVariantOption(of: attribute) != nil
126
+ }
127
+
72
128
  /// True if the type is written as an optional: `T?`, `T!`, or the explicit `Optional<T>`. Used to
73
129
  /// decide argument requiredness (an optional parameter may be omitted) and record-field nullability.
74
130
  internal func isOptionalType(_ type: TypeSyntax) -> Bool {
@@ -129,12 +185,82 @@ internal func optionalWrappedType(_ type: TypeSyntax) -> TypeSyntax? {
129
185
  if let identifier = type.as(IdentifierTypeSyntax.self),
130
186
  identifier.name.text == "Optional",
131
187
  let argument = identifier.genericArgumentClause?.arguments.first,
132
- case .type(let wrapped) = argument.argument {
188
+ case .type(let wrapped) = argument.argument
189
+ {
133
190
  return wrapped
134
191
  }
135
192
  return nil
136
193
  }
137
194
 
195
+ /// The element type of `[T]` or `Array<T>`, or `nil` for any other type.
196
+ internal func arrayElementType(_ type: TypeSyntax) -> TypeSyntax? {
197
+ if let array = type.as(ArrayTypeSyntax.self) {
198
+ return array.element
199
+ }
200
+ guard let arguments = genericArguments(of: type, named: "Array"), arguments.count == 1 else {
201
+ return nil
202
+ }
203
+ return arguments[0]
204
+ }
205
+
206
+ /// The value type of `[String: V]` or `Dictionary<String, V>`, or `nil` for any other type, including a
207
+ /// dictionary with a different key type.
208
+ internal func stringKeyedDictionaryValueType(_ type: TypeSyntax) -> TypeSyntax? {
209
+ if let dictionary = type.as(DictionaryTypeSyntax.self) {
210
+ return isStringType(dictionary.key) ? dictionary.value : nil
211
+ }
212
+ guard let arguments = genericArguments(of: type, named: "Dictionary"), arguments.count == 2 else {
213
+ return nil
214
+ }
215
+ return isStringType(arguments[0]) ? arguments[1] : nil
216
+ }
217
+
218
+ /// The element type of `Set<T>`, or `nil` for any other type.
219
+ internal func setElementType(_ type: TypeSyntax) -> TypeSyntax? {
220
+ guard let arguments = genericArguments(of: type, named: "Set"), arguments.count == 1 else {
221
+ return nil
222
+ }
223
+ return arguments[0]
224
+ }
225
+
226
+ /// The type arguments of a generic type spelled `<name><…>` (or `Swift.<name><…>`), or `nil` when the
227
+ /// type has another name or no type arguments.
228
+ private func genericArguments(of type: TypeSyntax, named name: String) -> [TypeSyntax]? {
229
+ let clause: GenericArgumentClauseSyntax?
230
+ if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == name {
231
+ clause = identifier.genericArgumentClause
232
+ } else if let member = type.as(MemberTypeSyntax.self),
233
+ member.name.text == name,
234
+ member.baseType.as(IdentifierTypeSyntax.self)?.name.text == "Swift"
235
+ {
236
+ clause = member.genericArgumentClause
237
+ } else {
238
+ return nil
239
+ }
240
+ guard let clause else {
241
+ return nil
242
+ }
243
+ var types: [TypeSyntax] = []
244
+ for argument in clause.arguments {
245
+ guard case .type(let type) = argument.argument else {
246
+ return nil
247
+ }
248
+ types.append(type)
249
+ }
250
+ return types
251
+ }
252
+
253
+ /// True when the type is `String` or `Swift.String`.
254
+ private func isStringType(_ type: TypeSyntax) -> Bool {
255
+ if let identifier = type.as(IdentifierTypeSyntax.self) {
256
+ return identifier.name.text == "String" && identifier.genericArgumentClause == nil
257
+ }
258
+ if let member = type.as(MemberTypeSyntax.self), member.name.text == "String" {
259
+ return member.baseType.as(IdentifierTypeSyntax.self)?.name.text == "Swift"
260
+ }
261
+ return false
262
+ }
263
+
138
264
  /// True when the modifiers make the member type-level (`static` or `class`). A shared object routes
139
265
  /// such members to its constructor (JS-static) and instance members to its prototype; the `@JS`
140
266
  /// conformance assertion also uses this to emit its peer in the matching metatype context.
@@ -144,6 +270,12 @@ internal func isTypeLevel(_ modifiers: DeclModifierListSyntax) -> Bool {
144
270
  }
145
271
  }
146
272
 
273
+ /// True for a `lazy` property. A lazy `@JS` property is read-only from JS: its getter stores the
274
+ /// first value as a data property on the JS object, which a setter couldn't write through.
275
+ internal func isLazy(_ modifiers: DeclModifierListSyntax) -> Bool {
276
+ return modifiers.contains { $0.name.tokenKind == .keyword(.lazy) }
277
+ }
278
+
147
279
  /// True if a trailing occurrence of this parameter may be omitted by the JS caller: it either has a
148
280
  /// default value (Swift applies it) or is an optional type (an absent slot becomes `nil`). The arity
149
281
  /// range and the per-arity call branches are derived from this.
@@ -158,26 +290,23 @@ internal func hasDefaultValue(_ parameter: FunctionParameterSyntax) -> Bool {
158
290
  return parameter.defaultValue != nil
159
291
  }
160
292
 
161
- /**
162
- True if the declaration is a `@Event(sync: true)` property. A sync event dispatches inline on the
163
- JS thread instead of scheduling, so `@ExpoModule`/`@SharedObject` stamp it with `@JavaScriptActor`,
164
- making "must be called on the JS thread" a compile-time guarantee at the call site. Async events
165
- (the default) are deliberately not stamped: their `emit` schedules onto the JS thread itself, so
166
- they stay callable from any thread.
167
- */
293
+ /// True if the declaration is a `@Event(sync: true)` property. A sync event dispatches inline on the
294
+ /// JS thread instead of scheduling, so `@ExpoModule`/`@SharedObject` stamp it with `@JavaScriptActor`,
295
+ /// making "must be called on the JS thread" a compile-time guarantee at the call site. Async events
296
+ /// (the default) are deliberately not stamped: their `emit` schedules onto the JS thread itself, so
297
+ /// they stay callable from any thread.
168
298
  internal func isSyncEventMember(_ decl: DeclSyntaxProtocol) -> Bool {
169
299
  guard let varDecl = decl.as(VariableDeclSyntax.self),
170
- let attribute = varDecl.attributes.firstAttribute(named: "Event") else {
300
+ let attribute = varDecl.attributes.firstAttribute(named: "Event")
301
+ else {
171
302
  return false
172
303
  }
173
304
  return boolArgument(of: attribute, label: "sync") == true
174
305
  }
175
306
 
176
- /**
177
- Reads a labeled array-literal argument of an attribute, e.g. `@ExpoModule(classes: [Foo.self, Bar.self])`,
178
- and returns the type names referenced (e.g. `["Foo", "Bar"]`). Each element must be a
179
- `<TypeName>.self` member-access expression; non-conforming elements are skipped silently.
180
- */
307
+ /// Reads a labeled array-literal argument of an attribute, e.g. `@ExpoModule(classes: [Foo.self, Bar.self])`,
308
+ /// and returns the type names referenced (e.g. `["Foo", "Bar"]`). Each element must be a
309
+ /// `<TypeName>.self` member-access expression; non-conforming elements are skipped silently.
181
310
  internal func classListArgument(of attribute: AttributeSyntax, label: String) -> [String] {
182
311
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
183
312
  return []
@@ -189,7 +318,8 @@ internal func classListArgument(of attribute: AttributeSyntax, label: String) ->
189
318
  return array.elements.compactMap { element -> String? in
190
319
  guard let memberAccess = element.expression.as(MemberAccessExprSyntax.self),
191
320
  memberAccess.declName.baseName.text == "self",
192
- let base = memberAccess.base else {
321
+ let base = memberAccess.base
322
+ else {
193
323
  return nil
194
324
  }
195
325
  // A bare `CardView.self` has a `DeclReferenceExprSyntax` base; a qualified
@@ -205,11 +335,9 @@ internal func classListArgument(of attribute: AttributeSyntax, label: String) ->
205
335
  return []
206
336
  }
207
337
 
208
- /**
209
- Returns true if the class's inheritance clause names any of the given identifiers.
210
- Matches by base identifier only, so `Module`, `ExpoModulesCore.Module`, and
211
- `Module<Foo>` all match an entry of "Module".
212
- */
338
+ /// Returns true if the class's inheritance clause names any of the given identifiers.
339
+ /// Matches by base identifier only, so `Module`, `ExpoModulesCore.Module`, and
340
+ /// `Module<Foo>` all match an entry of "Module".
213
341
  internal func inheritsFromAny(_ classDecl: ClassDeclSyntax, names: Set<String>) -> Bool {
214
342
  guard let inherited = classDecl.inheritanceClause?.inheritedTypes else {
215
343
  return false
@@ -222,10 +350,8 @@ internal func inheritsFromAny(_ classDecl: ClassDeclSyntax, names: Set<String>)
222
350
  return false
223
351
  }
224
352
 
225
- /**
226
- Returns the rightmost identifier of a type, e.g. `Foo` for `Foo`, `Foo` for
227
- `Module.Foo`, and nil for composed or generic shapes the macro doesn't need to handle.
228
- */
353
+ /// Returns the rightmost identifier of a type, e.g. `Foo` for `Foo`, `Foo` for
354
+ /// `Module.Foo`, and nil for composed or generic shapes the macro doesn't need to handle.
229
355
  internal func baseIdentifier(of type: TypeSyntax) -> String? {
230
356
  if let identifier = type.as(IdentifierTypeSyntax.self) {
231
357
  return identifier.name.text
@@ -236,10 +362,8 @@ internal func baseIdentifier(of type: TypeSyntax) -> String? {
236
362
  return nil
237
363
  }
238
364
 
239
- /**
240
- True if the declaration carries a `@JS` attribute. Works for functions, properties, and inits;
241
- returns false for any other decl kind.
242
- */
365
+ /// True if the declaration carries a `@JS` attribute. Works for functions, properties, and inits;
366
+ /// returns false for any other decl kind.
243
367
  internal func memberHasJSAttribute(_ decl: DeclSyntaxProtocol) -> Bool {
244
368
  if let funcDecl = decl.as(FunctionDeclSyntax.self) {
245
369
  return funcDecl.attributes.firstAttribute(named: "JS") != nil
@@ -256,6 +380,7 @@ internal func memberHasJSAttribute(_ decl: DeclSyntaxProtocol) -> Bool {
256
380
  /// Decides whether the macro should stamp `@JavaScriptActor` on a `@JS`-marked member.
257
381
  /// The macro defers to the user when they've already chosen an isolation:
258
382
  /// - the member opted out with `@JS(.concurrent)`
383
+ /// - the member lists `@JS(.withAsync("name"))`, whose variant runs it on the concurrent pool
259
384
  /// - the `nonisolated` modifier is present on the member
260
385
  /// - any attribute whose name matches a known global actor (`@MainActor`, `@JavaScriptActor`)
261
386
  /// or follows the `*Actor` naming convention is present on the member or its enclosing type
@@ -265,7 +390,7 @@ internal func shouldStampJavaScriptActor(
265
390
  on member: DeclSyntaxProtocol,
266
391
  enclosedBy enclosing: some DeclGroupSyntax
267
392
  ) -> Bool {
268
- if isConcurrentJSMember(member) {
393
+ if isConcurrentJSMember(member) || hasAsyncVariant(member) {
269
394
  return false
270
395
  }
271
396
 
@@ -312,7 +437,7 @@ internal func memberAttributes(of decl: DeclSyntaxProtocol) -> AttributeListSynt
312
437
  return AttributeListSyntax()
313
438
  }
314
439
 
315
- private func hasGlobalActorShape(_ element: AttributeListSyntax.Element) -> Bool {
440
+ internal func hasGlobalActorShape(_ element: AttributeListSyntax.Element) -> Bool {
316
441
  guard let attribute = element.as(AttributeSyntax.self) else {
317
442
  return false
318
443
  }
@@ -345,7 +470,8 @@ extension AttributeListSyntax {
345
470
  internal func firstAttribute(named name: String) -> AttributeSyntax? {
346
471
  for element in self {
347
472
  if let attr = element.as(AttributeSyntax.self),
348
- attr.attributeName.trimmedDescription == name {
473
+ attr.attributeName.trimmedDescription == name
474
+ {
349
475
  return attr
350
476
  }
351
477
  }
@@ -425,6 +551,7 @@ internal func collectProperties(
425
551
  let jsNameOverride = jsNameArgument(of: attribute)
426
552
  // A `let` is never settable; only `var` bindings can carry a setter.
427
553
  let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
554
+ let isLazy = isLazy(varDecl.modifiers)
428
555
 
429
556
  return varDecl.bindings.compactMap { binding in
430
557
  guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
@@ -433,7 +560,8 @@ internal func collectProperties(
433
560
  let swiftName = ident.identifier.text
434
561
  // Prefer the explicit annotation; recover the type from a literal default (`var x = false`)
435
562
  // when there's none. `nil` falls back to inference at the use site.
436
- let valueType = binding.typeAnnotation?.type.trimmedDescription
563
+ let valueType =
564
+ binding.typeAnnotation?.type.trimmedDescription
437
565
  ?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
438
566
  return JSProperty(
439
567
  swiftName: swiftName,
@@ -441,7 +569,8 @@ internal func collectProperties(
441
569
  valueType: valueType,
442
570
  // A literal-inferred type never mentions `Any`, so only an explicit annotation can be free-form.
443
571
  freeForm: binding.typeAnnotation.flatMap { freeFormConversion(of: $0.type) },
444
- isSettable: isVar && bindingIsSettable(binding),
572
+ isSettable: isVar && !isLazy && bindingIsSettable(binding),
573
+ isLazy: isLazy,
445
574
  nameToken: ident.identifier
446
575
  )
447
576
  }