expo-modules-macros 0.25.0 → 0.27.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 (48) hide show
  1. package/AGENTS.md +8 -8
  2. package/README.md +40 -3
  3. package/apple/ExpoModulesMacros +0 -0
  4. package/apple/Package.swift +10 -4
  5. package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/CLI.swift +45 -2
  6. package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +120 -0
  7. package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +183 -0
  8. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ExportedSurface.swift +3 -2
  9. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ResolveRefs.swift +9 -0
  10. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/SurfaceVisitor.swift +10 -3
  11. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/TupleTypeAliases.swift +126 -0
  12. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/TypeNode.swift +47 -4
  13. package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/SourceScan.swift +8 -2
  14. package/apple/Sources/ExpoModulesMacros/Expand/MacroExpander.swift +484 -0
  15. package/apple/Sources/ExpoModulesMacros/{EventMacro.swift → Macros/EventMacro.swift} +23 -10
  16. package/apple/Sources/ExpoModulesMacros/{ExpoModuleMacro.swift → Macros/ExpoModuleMacro.swift} +13 -1
  17. package/apple/Sources/ExpoModulesMacros/{JSMacro.swift → Macros/JSMacro.swift} +71 -54
  18. package/apple/Sources/ExpoModulesMacros/{RecordMacro.swift → Macros/RecordMacro.swift} +18 -5
  19. package/apple/Sources/ExpoModulesMacros/{SharedObjectMacro.swift → Macros/SharedObjectMacro.swift} +13 -1
  20. package/apple/Sources/ExpoModulesMacros/{UnionMacro.swift → Macros/UnionMacro.swift} +16 -5
  21. package/apple/Sources/ExpoModulesMacros/Plugin.swift +9 -14
  22. package/apple/Sources/ExpoModulesMacros/ProvidedMacros.swift +31 -0
  23. package/apple/Sources/ExpoModulesMacros/{ClosureArguments.swift → Support/ClosureArguments.swift} +30 -4
  24. package/apple/Sources/ExpoModulesMacros/{DecorateModuleBuilder.swift → Support/DecorateModuleBuilder.swift} +5 -1
  25. package/apple/Sources/ExpoModulesMacros/{MacroHelpers.swift → Support/MacroHelpers.swift} +11 -4
  26. package/apple/Sources/ExpoModulesMacros/Support/TupleTypes.swift +272 -0
  27. package/apple/Sources/ExpoModulesMacros/Support/TypeAliases.swift +75 -0
  28. package/apple/Sources/ExpoModulesMacros/{TypeConformanceAssertion.swift → Support/TypeConformanceAssertion.swift} +104 -41
  29. package/build/index.d.ts +31 -1
  30. package/build/index.js +34 -0
  31. package/build/types.d.ts +77 -2
  32. package/build/types.js +4 -3
  33. package/package.json +5 -5
  34. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/Availability.swift +0 -0
  35. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ScanExports.swift +0 -0
  36. /package/apple/Sources/{ExpoModulesScanner/Modules → ExpoModulesCLI/Commands/ScanModules}/ScanModules.swift +0 -0
  37. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/Detection.swift +0 -0
  38. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/DetectionVisitor.swift +0 -0
  39. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/ScanBuildConfiguration.swift +0 -0
  40. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/StandardStreams.swift +0 -0
  41. /package/apple/Sources/ExpoModulesMacros/{ExpoModulesOptimizedMacro.swift → Macros/ExpoModulesOptimizedMacro.swift} +0 -0
  42. /package/apple/Sources/ExpoModulesMacros/{ExpoViewMacro.swift → Macros/ExpoViewMacro.swift} +0 -0
  43. /package/apple/Sources/ExpoModulesMacros/{ViewPropsMacro.swift → Macros/ViewPropsMacro.swift} +0 -0
  44. /package/apple/Sources/ExpoModulesMacros/{FreeFormTypes.swift → Support/FreeFormTypes.swift} +0 -0
  45. /package/apple/Sources/ExpoModulesMacros/{JSConstructor.swift → Support/JSConstructor.swift} +0 -0
  46. /package/apple/Sources/ExpoModulesMacros/{JSNameCollisions.swift → Support/JSNameCollisions.swift} +0 -0
  47. /package/apple/Sources/ExpoModulesMacros/{OptimizedFunctionHelpers.swift → Support/OptimizedFunctionHelpers.swift} +0 -0
  48. /package/apple/Sources/ExpoModulesMacros/{Receiver.swift → Support/Receiver.swift} +0 -0
@@ -37,21 +37,43 @@ public struct JSMacro: PeerMacro {
37
37
  diagnoseConcurrentOption(of: node, on: declaration, in: context)
38
38
  diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
39
39
 
40
- guard let member = boundaryMember(of: declaration),
41
- let assertion = directionalConformanceAssertion(
42
- name: member.name,
43
- decodableTypes: member.decodableTypes,
44
- encodableType: member.encodableType,
45
- callbackArgumentTypes: member.callbackArgumentTypes,
46
- isStatic: member.isStatic
47
- )
48
- else {
40
+ // In an `@ExpoModule` or `@SharedObject` class, the class macro emits the assertion instead: it sees
41
+ // the type aliases declared in the class body, which this macro can't (see `TupleTypeAliases`).
42
+ guard !isInBindingClass(context), let assertion = jsConformanceAssertion(for: declaration) else {
49
43
  return []
50
44
  }
51
45
  return [assertion]
52
46
  }
53
47
  }
54
48
 
49
+ /// The conformance-assertion peer for a `@JS` member, or `nil` when the member has no type to assert.
50
+ private func jsConformanceAssertion(for declaration: some DeclSyntaxProtocol) -> DeclSyntax? {
51
+ guard let member = boundaryMember(of: declaration) else {
52
+ return nil
53
+ }
54
+ return directionalConformanceAssertion(name: member.name, types: member.types, isStatic: member.isStatic)
55
+ }
56
+
57
+ /// The conformance assertion of a `@JS` member, for an `@ExpoModule` or `@SharedObject` class to fold
58
+ /// into its `_assertTypesConformance()` (see `classConformanceAssertions`), or `nil` when the member has
59
+ /// no type to assert.
60
+ internal func jsConformanceAssertionBody(for declaration: some DeclSyntaxProtocol) -> MemberConformanceAssertion? {
61
+ guard let member = boundaryMember(of: declaration) else {
62
+ return nil
63
+ }
64
+ return memberConformanceAssertion(name: member.name, types: member.types)
65
+ }
66
+
67
+ /// True when the member is declared directly in an `@ExpoModule` or `@SharedObject` class, whose macro
68
+ /// binds it.
69
+ private func isInBindingClass(_ context: some MacroExpansionContext) -> Bool {
70
+ guard let classDecl = context.lexicalContext.first?.as(ClassDeclSyntax.self) else {
71
+ return false
72
+ }
73
+ return classDecl.attributes.firstAttribute(named: "ExpoModule") != nil
74
+ || classDecl.attributes.firstAttribute(named: "SharedObject") != nil
75
+ }
76
+
55
77
  /// Emits the diagnostic for `@JS(.concurrent)` on a member that can't take it. The option maps to
56
78
  /// Swift's `@concurrent`, which requires an `async` function: a synchronous member has nowhere to
57
79
  /// suspend, and a property or initializer can't be async at all. Diagnosing here points at the
@@ -323,28 +345,24 @@ private struct JSFixItMessage: FixItMessage {
323
345
  }
324
346
  }
325
347
 
326
- /// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer unique
327
- /// among siblings), the boundary types split by conversion direction, and whether the member is
328
- /// type-level. Arguments (and a settable property's incoming value) are decoded; return values (and a
329
- /// property's outgoing value) are encoded, so each is asserted against the protocol for its direction.
348
+ /// What an assertion needs about a `@JS` member: a name (which names the helper), the boundary types in
349
+ /// signature order, each with the protocol for the direction it travels and its argument label, and
350
+ /// whether the member is type-level. Arguments (and a settable property's incoming value) are decoded;
351
+ /// return values (and a property's outgoing value) are encoded, so each is asserted against the
352
+ /// protocol for its direction.
330
353
  private struct BoundaryMember {
331
354
  let name: String
332
- /// Types decoded from JS: function/constructor arguments, a settable property's value type, and the
333
- /// return types of closure arguments.
334
- let decodableTypes: [String]
335
- /// The single type encoded to JS: a function's return type, or a property's value type on read;
336
- /// `nil` when the member produces nothing JS-visible (a `Void` function).
337
- let encodableType: String?
338
- /// The parameter types of closure arguments, encoded to JS when native code calls the closure.
339
- var callbackArgumentTypes: [String] = []
355
+ /// The types crossing the boundary, in signature order.
356
+ let types: [AssertedType]
340
357
  /// True for `static`/`class` members, so the peer is emitted in the same metatype context.
341
358
  let isStatic: Bool
342
359
  }
343
360
 
344
- /// Reads the boundary member off a `@JS` declaration, splitting its types by conversion direction. A
345
- /// function contributes its parameter types (decodable) and its return type when non-Void (encodable);
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?`,
361
+ /// Reads the boundary member off a `@JS` declaration, with each type's conversion direction. A function
362
+ /// contributes its parameter types (decodable) under their argument labels and its return type when
363
+ /// non-Void (encodable) under `returning`; an initializer contributes its parameter types; a property
364
+ /// contributes its value type, unlabeled, as decodable when settable (the setter) and as encodable (the
365
+ /// getter). Composed types (`[Int]`, `String?`,
348
366
  /// …) are kept verbatim, their conditional conformances transitively constraining the elements. A type
349
367
  /// that mentions `Any` is left out: it converts through the free-form methods, or is diagnosed by
350
368
  /// `diagnoseFreeFormTypes`. Returns `nil` for declaration kinds `@JS` doesn't read types from, a
@@ -352,30 +370,28 @@ private struct BoundaryMember {
352
370
  /// mentions `Any`.
353
371
  private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> BoundaryMember? {
354
372
  if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
355
- let argumentTypes = argumentBoundaryTypes(of: funcDecl.signature.parameterClause.parameters)
356
- let returnType = funcDecl.signature.returnClause?.type
357
- let encodableType = returnType.flatMap {
358
- isVoidType($0) || containsFunctionType($0) || mentionsFreeFormAny($0) ? nil : $0.trimmedDescription
373
+ var types = argumentBoundaryTypes(of: funcDecl.signature.parameterClause.parameters)
374
+ if let returnType = funcDecl.signature.returnClause?.type,
375
+ !isVoidType(returnType) && !containsFunctionType(returnType) && !mentionsFreeFormAny(returnType)
376
+ {
377
+ types.append(
378
+ AssertedType(
379
+ label: "returning",
380
+ type: returnType.trimmedDescription,
381
+ constraint: javaScriptEncodableProtocolName
382
+ )
383
+ )
359
384
  }
360
- return BoundaryMember(
361
- name: funcDecl.name.text,
362
- decodableTypes: argumentTypes.decodable,
363
- encodableType: encodableType,
364
- callbackArgumentTypes: argumentTypes.callbackArguments,
365
- isStatic: isTypeLevel(funcDecl.modifiers)
366
- )
385
+ return BoundaryMember(name: funcDecl.name.text, types: types, isStatic: isTypeLevel(funcDecl.modifiers))
367
386
  }
368
387
 
369
388
  if let initDecl = declaration.as(InitializerDeclSyntax.self) {
370
389
  // A constructor decodes its arguments like a function does, and the instance it returns isn't
371
390
  // encoded. The name is escaped because it also names the assertion's nested helper, and `init` is
372
391
  // a keyword.
373
- let argumentTypes = argumentBoundaryTypes(of: initDecl.signature.parameterClause.parameters)
374
392
  return BoundaryMember(
375
393
  name: "`init`",
376
- decodableTypes: argumentTypes.decodable,
377
- encodableType: nil,
378
- callbackArgumentTypes: argumentTypes.callbackArguments,
394
+ types: argumentBoundaryTypes(of: initDecl.signature.parameterClause.parameters),
379
395
  isStatic: false
380
396
  )
381
397
  }
@@ -395,10 +411,11 @@ private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> Boundary
395
411
  let typeText = type.trimmedDescription
396
412
  let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
397
413
  let isSettable = isVar && !isLazy(varDecl.modifiers) && bindingIsSettable(binding)
414
+ let decodable = AssertedType(label: nil, type: typeText, constraint: javaScriptDecodableProtocolName)
415
+ let encodable = AssertedType(label: nil, type: typeText, constraint: javaScriptEncodableProtocolName)
398
416
  return BoundaryMember(
399
417
  name: identifier.identifier.text,
400
- decodableTypes: isSettable ? [typeText] : [],
401
- encodableType: typeText,
418
+ types: (isSettable ? [decodable] : []) + [encodable],
402
419
  isStatic: isTypeLevel(varDecl.modifiers)
403
420
  )
404
421
  }
@@ -406,18 +423,16 @@ private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> Boundary
406
423
  return nil
407
424
  }
408
425
 
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]) {
426
+ /// The boundary types of a function's or constructor's arguments, under their argument labels: an
427
+ /// argument is decoded from JS, and a closure argument instead contributes its parameter types (encoded
428
+ /// when native code calls it) and its return type (decoded).
429
+ private func argumentBoundaryTypes(of parameters: FunctionParameterListSyntax) -> [AssertedType] {
415
430
  // A closure argument moves data the other way: JS receives its parameters and returns its result.
416
431
  // A closure in any other position is diagnosed by `diagnoseClosureTypes` and left out here, since
417
432
  // its metatype isn't a valid expression in the assertion.
418
- var decodableTypes: [String] = []
419
- var callbackArgumentTypes: [String] = []
433
+ var types: [AssertedType] = []
420
434
  for parameter in parameters {
435
+ let label = parameter.firstName.tokenKind == .wildcard ? nil : parameter.firstName.text
421
436
  // The specifier isn't part of the asserted type, and a parameter with one the binding can't honor
422
437
  // is reported by `diagnoseParameterSpecifiers` and not asserted.
423
438
  let specified = SpecifiedType(parameter.type)
@@ -426,23 +441,25 @@ private func argumentBoundaryTypes(
426
441
  }
427
442
  guard let closure = JSClosureType(specified.type) else {
428
443
  if !containsFunctionType(specified.type) && !mentionsFreeFormAny(specified.type) {
429
- decodableTypes.append(specified.type.trimmedDescription)
444
+ let type = specified.type.trimmedDescription
445
+ types.append(AssertedType(label: label, type: type, constraint: javaScriptDecodableProtocolName))
430
446
  }
431
447
  continue
432
448
  }
433
449
  // A closure type that mentions `Any` is reported by `diagnoseClosureTypes` instead.
434
450
  for parameter in closure.parameters
435
451
  where !containsFunctionType(parameter.type) && !mentionsFreeFormAny(parameter.type) {
436
- callbackArgumentTypes.append(parameter.type.trimmedDescription)
452
+ let type = parameter.type.trimmedDescription
453
+ types.append(AssertedType(label: label, type: type, constraint: javaScriptEncodableProtocolName))
437
454
  }
438
455
  let closureReturnType = closure.functionType.returnClause.type
439
456
  if let returnType = closure.returnType,
440
457
  !containsFunctionType(closureReturnType) && !mentionsFreeFormAny(closureReturnType)
441
458
  {
442
- decodableTypes.append(returnType)
459
+ types.append(AssertedType(label: label, type: returnType, constraint: javaScriptDecodableProtocolName))
443
460
  }
444
461
  }
445
- return (decodableTypes, callbackArgumentTypes)
462
+ return types
446
463
  }
447
464
 
448
465
  /// True when a return clause is written as `Void` / `()` — nothing crosses the boundary, so it needs
@@ -423,10 +423,17 @@ private func dictionaryReadLines(properties: [RecordProperty]) -> [String] {
423
423
  let entry = "entry\(index)"
424
424
  let exprType = expressionType(property.type)
425
425
  let isBareAny = property.freeForm?.isBareAny == true
426
- let cast =
427
- isBareAny
428
- ? entry
429
- : "try \(exprType).getDynamicType().cast(\(entry), appContext: appContext) as! \(exprType)"
426
+ let cast: String
427
+ if isBareAny {
428
+ cast = entry
429
+ } else if tupleConversion(of: property.type) != nil {
430
+ // A type that holds a tuple has no dynamic type, so the dictionary value must already be of the
431
+ // property's type.
432
+ cast =
433
+ "try { () throws -> \(exprType) in guard let value = \(entry) as? \(exprType) else { throw Conversions.ConvertingException<\(exprType)>(\(entry)) }; return value }()"
434
+ } else {
435
+ cast = "try \(exprType).getDynamicType().cast(\(entry), appContext: appContext) as! \(exprType)"
436
+ }
430
437
  lines.append(" let \(entry) = dictionary[\"\(property.key)\"]")
431
438
  if property.isRequired {
432
439
  lines.append(" guard let \(entry) else {")
@@ -544,7 +551,13 @@ private func objectWriteLines(properties: [RecordProperty], of record: String, c
544
551
  encode = "try JavaScriptValue.encodeAny(\(value) as Any, in: runtime)"
545
552
  case nil:
546
553
  let propName = propNameArgument(for: property, at: index, cached: cachesPropNames)
547
- return " try object.setProperty(\(propName), encoding: \(value), in: runtime)"
554
+ // A type that holds a tuple has no `encode(_:into:in:)`, so the tuple methods encode it into the
555
+ // property's slot.
556
+ if let tuple = tupleConversion(of: property.type) {
557
+ return
558
+ " try object.setProperty(\(propName)) { slot, runtime in try \(tuple.encodeCall(of: value, into: "slot")) }"
559
+ }
560
+ return " try object.setProperty(\(propName), encoding: \(value))"
548
561
  }
549
562
  let propName = propNameArgument(for: property, at: index, cached: cachesPropNames)
550
563
  return " object.setProperty(\(propName), value: \(encode))"
@@ -50,9 +50,15 @@ public struct SharedObjectMacro: MemberMacro {
50
50
  var staticFunctions: [JSFunction] = []
51
51
  var staticProperties: [JSProperty] = []
52
52
  var constructor: JSConstructor?
53
+ var assertions: [MemberConformanceAssertion] = []
54
+ let aliases = TupleTypeAliases(in: classDecl.memberBlock)
53
55
 
54
56
  for member in classDecl.memberBlock.members {
55
- let decl = member.decl
57
+ // The types of a `@JS` member are read with the class's tuple aliases resolved.
58
+ let decl = aliases.resolving(member.decl)
59
+ if memberHasJSAttribute(decl), let assertion = jsConformanceAssertionBody(for: decl) {
60
+ assertions.append(assertion)
61
+ }
56
62
 
57
63
  if let initDecl = decl.as(InitializerDeclSyntax.self),
58
64
  initDecl.attributes.firstAttribute(named: "JS") != nil
@@ -129,6 +135,12 @@ public struct SharedObjectMacro: MemberMacro {
129
135
  emitted.append(constructor.buildConstructor(typeName: typeName))
130
136
  }
131
137
 
138
+ // The conformance assertions of the `@JS` members, which `@JS` leaves to this macro (see
139
+ // `jsConformanceAssertionBody`).
140
+ if let assertion = classConformanceAssertions(assertions) {
141
+ emitted.append(assertion)
142
+ }
143
+
132
144
  return emitted
133
145
  }
134
146
  }
@@ -270,9 +270,9 @@ private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> Unio
270
270
  private func decodeMethod(union: UnionType) -> DeclSyntax {
271
271
  var lines: [String] = [" let kind = JavaScriptValueKinds(of: value)"]
272
272
  for unionCase in union.cases {
273
- let payloadType = expressionType(unionCase.payloadType)
273
+ let conversion = payloadConversion(unionCase)
274
274
  lines.append(
275
- " if \(payloadType).decodableKinds.contains(kind), let payload = try? \(payloadType).decode(value, in: runtime) {"
275
+ " if \(conversion.decodableKindsExpression).contains(kind), let payload = try? \(conversion.decodeExpression(of: "value")) {"
276
276
  )
277
277
  lines.append(" return \(unionCase.construction)")
278
278
  lines.append(" }")
@@ -293,7 +293,7 @@ private func decodeMethod(union: UnionType) -> DeclSyntax {
293
293
  /// `decodableKinds`: the union of the payloads' kinds, so a union nested in another union, or wrapped
294
294
  /// in an optional, is skipped as precisely as its payloads are.
295
295
  private func decodableKindsProperty(union: UnionType) -> DeclSyntax {
296
- let kinds = union.cases.map { "\(expressionType($0.payloadType)).decodableKinds" }
296
+ let kinds = union.cases.map { payloadConversion($0).decodableKindsExpression }
297
297
  // An option set's array literal is the union of its elements, which reads like the case list. The
298
298
  // optimizer folds it to a constant after specialization, the same as a chain of `union(_:)` calls.
299
299
  let expression = kinds.count == 1 ? kinds[0] : "[\(kinds.joined(separator: ", "))]"
@@ -311,7 +311,7 @@ private func encodeMethod(union: UnionType) -> DeclSyntax {
311
311
  var lines: [String] = [" switch value {"]
312
312
  for unionCase in union.cases {
313
313
  lines.append(" case \(unionCase.pattern):")
314
- lines.append(" return try \(expressionType(unionCase.payloadType)).encode(payload, in: runtime)")
314
+ lines.append(" return try \(payloadConversion(unionCase).encodeExpression(of: "payload"))")
315
315
  }
316
316
  lines.append(" }")
317
317
  let body = lines.joined(separator: "\n")
@@ -331,7 +331,12 @@ private func encodeIntoSlotMethod(union: UnionType) -> DeclSyntax {
331
331
  var lines: [String] = [" switch value {"]
332
332
  for unionCase in union.cases {
333
333
  lines.append(" case \(unionCase.pattern):")
334
- lines.append(" try \(expressionType(unionCase.payloadType)).encode(payload, into: slot, in: runtime)")
334
+ if let tuple = tupleConversion(of: unionCase.payloadType) {
335
+ // A payload that holds a tuple has no `encode(_:into:in:)`, so the tuple methods encode it into the slot.
336
+ lines.append(" try \(tuple.encodeCall(of: "payload", into: "slot"))")
337
+ } else {
338
+ lines.append(" try \(expressionType(unionCase.payloadType)).encode(payload, into: slot, in: runtime)")
339
+ }
335
340
  }
336
341
  lines.append(" }")
337
342
  let body = lines.joined(separator: "\n")
@@ -439,3 +444,9 @@ private struct UnionFixItMessage: FixItMessage {
439
444
  self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
440
445
  }
441
446
  }
447
+
448
+ /// How a case's payload converts: through the tuple methods when it holds a tuple (see
449
+ /// `TupleConversion`), and through its own `decode` / `encode` otherwise.
450
+ private func payloadConversion(_ unionCase: UnionCase) -> TupleConversion {
451
+ return tupleConversion(of: unionCase.payloadType) ?? .conforming(type: unionCase.payloadType)
452
+ }
@@ -1,4 +1,4 @@
1
- import ExpoModulesScanner
1
+ import ExpoModulesCLI
2
2
  import SwiftCompilerPlugin
3
3
  import SwiftSyntaxMacros
4
4
 
@@ -11,23 +11,14 @@ import CRT
11
11
  #endif
12
12
 
13
13
  struct ExpoModulesMacrosPlugin: CompilerPlugin {
14
- let providingMacros: [Macro.Type] = [
15
- OptimizedFunctionAttachedMacro.self,
16
- JSMacro.self,
17
- EventMacro.self,
18
- ExpoModuleMacro.self,
19
- SharedObjectMacro.self,
20
- RecordMacro.self,
21
- UnionMacro.self,
22
- ViewPropsMacro.self,
23
- ExpoViewMacro.self,
24
- ]
14
+ let providingMacros: [Macro.Type] = ProvidedMacro.all.map(\.type)
25
15
  }
26
16
 
27
17
  /// The executable doubles as the scanner CLI. The compiler always launches a plugin executable
28
18
  /// without arguments and speaks the plugin protocol over stdin, so any argument means a scanner
29
19
  /// invocation (`ExpoModulesMacros scan-modules <path>...`); with none, this starts the plugin
30
- /// server exactly as `@main` on the `CompilerPlugin` type would.
20
+ /// server exactly as `@main` on the `CompilerPlugin` type would. The CLI gets `expandMacros`, so
21
+ /// `expand` runs the macros of this executable.
31
22
  @main
32
23
  enum EntryPoint {
33
24
  static func main() throws {
@@ -35,7 +26,11 @@ enum EntryPoint {
35
26
  if arguments.isEmpty {
36
27
  try ExpoModulesMacrosPlugin.main()
37
28
  } else {
38
- exit(ScannerCLI.run(arguments: arguments))
29
+ let expander = MacroExpander(
30
+ attributeNames: Set(ProvidedMacro.all.map(\.name)),
31
+ expand: expandMacros
32
+ )
33
+ exit(ScannerCLI.run(arguments: arguments, expander: expander))
39
34
  }
40
35
  }
41
36
  }
@@ -0,0 +1,31 @@
1
+ import SwiftSyntax
2
+ import SwiftSyntaxMacros
3
+
4
+ /// A macro that the plugin provides, with the attribute name and the extension conformances of its
5
+ /// declaration in `expo-modules-core`.
6
+ struct ProvidedMacro {
7
+ let name: String
8
+ let type: Macro.Type
9
+ var conformances: [TypeSyntax] = []
10
+
11
+ /// Every macro of the plugin. The compiler gets the types from `providingMacros`, and `expand`
12
+ /// matches the attributes by name and passes the same conformances as the compiler, so both read
13
+ /// this list.
14
+ static var all: [ProvidedMacro] {
15
+ [
16
+ ProvidedMacro(name: "OptimizedFunction", type: OptimizedFunctionAttachedMacro.self),
17
+ ProvidedMacro(name: "JS", type: JSMacro.self),
18
+ ProvidedMacro(name: "Event", type: EventMacro.self),
19
+ ProvidedMacro(name: "ExpoModule", type: ExpoModuleMacro.self, conformances: ["AnyModule"]),
20
+ ProvidedMacro(name: "SharedObject", type: SharedObjectMacro.self),
21
+ ProvidedMacro(name: "Record", type: RecordMacro.self, conformances: ["Record"]),
22
+ ProvidedMacro(
23
+ name: "Union",
24
+ type: UnionMacro.self,
25
+ conformances: ["JavaScriptDecodable", "JavaScriptEncodable"]
26
+ ),
27
+ ProvidedMacro(name: "ViewProps", type: ViewPropsMacro.self, conformances: ["AnyViewProps"]),
28
+ ProvidedMacro(name: "ExpoView", type: ExpoViewMacro.self),
29
+ ]
30
+ }
31
+ }
@@ -146,13 +146,20 @@ private func closureLiteralLines(_ closure: JSClosureType, callback: String) ->
146
146
  // parameters instead would send non-`Sendable` values to the `@JavaScriptActor` closure, which
147
147
  // Swift 6 rejects. The closure captures nothing, so it needs no allocation. Without parameters
148
148
  // there is nothing to emplace, and `invokeAsync` takes the parameters variadically.
149
- let emplaces = !closure.isAsync && !parameters.isEmpty
149
+ //
150
+ // A result type that holds a tuple can't be passed as `returning:`, so a sync closure returning one
151
+ // decodes it through a `decoding:` closure, which only the `emplacing` variant takes. With no
152
+ // parameters it emplaces nothing.
153
+ let returnTuple = closure.returnType.flatMap { tupleConversion(of: $0) }
154
+ let emplaces = !closure.isAsync && (!parameters.isEmpty || returnTuple != nil)
150
155
  var arguments = parameters
151
156
  if emplaces {
152
157
  let values = parameters.count == 1 ? "p0" : "(" + parameters.joined(separator: ", ") + ")"
153
158
  arguments = [values, "argumentCount: \(parameters.count)"]
154
159
  }
155
- if let returnType = closure.returnType {
160
+ if let returnTuple {
161
+ arguments.append("decoding: \(elementClosure(returning: returnTuple.decodeExpression(of: "value")))")
162
+ } else if let returnType = closure.returnType {
156
163
  arguments.append("returning: \(expressionType(returnType)).self")
157
164
  }
158
165
  let argumentList = arguments.joined(separator: ", ")
@@ -168,9 +175,16 @@ private func closureLiteralLines(_ closure: JSClosureType, callback: String) ->
168
175
 
169
176
  if emplaces {
170
177
  var lines = [header, " \(call) { _values, _arguments, _runtime in"]
171
- for index in parameters.indices {
178
+ for (index, parameter) in closure.parameters.enumerated() {
172
179
  let value = parameters.count == 1 ? "_values" : "_values.\(index)"
173
- lines.append(" try _arguments.emplace(\(value), at: \(index), in: _runtime)")
180
+ // A parameter that holds a tuple has no `encode(_:into:in:)`, so the tuple methods encode it into the
181
+ // argument's slot.
182
+ if let tuple = tupleConversion(of: parameter.type) {
183
+ let encode = tuple.encodeCall(of: value, into: "slot", in: "_runtime")
184
+ lines.append(" try _arguments.emplace(at: \(index)) { slot in try \(encode) }")
185
+ } else {
186
+ lines.append(" try _arguments.emplace(\(value), at: \(index), in: _runtime)")
187
+ }
174
188
  }
175
189
  return lines + [" }", "}"]
176
190
  }
@@ -336,6 +350,18 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
336
350
  )
337
351
  )
338
352
  )
353
+ } else if closure.isAsync && tupleConversion(of: type) != nil {
354
+ // `invokeAsync` takes its arguments and result as `JavaScriptEncodable` / `JavaScriptDecodable`
355
+ // types, and a type that holds a tuple can't conform.
356
+ context.diagnose(
357
+ Diagnostic(
358
+ node: type,
359
+ message: ClosureDiagnosticMessage(
360
+ "An async @JS closure argument can't take or return a tuple. Use a @Record type, or a closure that isn't async.",
361
+ id: "js-closure-async-tuple"
362
+ )
363
+ )
364
+ )
339
365
  }
340
366
  }
341
367
 
@@ -499,13 +499,17 @@ private let resultSlotClosureParameters =
499
499
  + "result: borrowing JavaScriptValue.Slot, runtime: borrowing JavaScriptRuntime)"
500
500
 
501
501
  /// The statement that encodes `valueExpression` of a boundary type into the result slot `result`. A type
502
- /// that mentions `Any` has no `encode(_:into:in:)`, so its free-form encode result is moved into the slot.
502
+ /// that mentions `Any` has no `encode(_:into:in:)`, so its free-form encode result is moved into the slot,
503
+ /// and a type that holds a tuple is encoded into the slot by the tuple methods.
503
504
  private func encodeIntoSlotStatement(_ type: String, freeForm: FreeFormConversion?, of valueExpression: String)
504
505
  -> String
505
506
  {
506
507
  if freeForm != nil {
507
508
  return "result.emplace(try \(encodeCall(type, freeForm: freeForm, of: valueExpression)))"
508
509
  }
510
+ if let tuple = tupleConversion(of: type) {
511
+ return "try \(tuple.encodeCall(of: valueExpression, into: "result"))"
512
+ }
509
513
  return "try \(expressionType(type)).encode(\(valueExpression), into: result, in: runtime)"
510
514
  }
511
515
 
@@ -493,23 +493,30 @@ internal func expressionType(_ type: String) -> String {
493
493
 
494
494
  /// The decode expression for a boundary type read from `valueExpression` (a `JavaScriptUnownedValue`),
495
495
  /// as it appears after `try`. A type that mentions `Any` can't conform to `JavaScriptDecodable`, so it
496
- /// decodes through the `JavaScriptValue` free-form methods its `freeForm` conversion names; every other
497
- /// type decodes through its own static `decode`, spelled in expression position (`T!` rewritten to
498
- /// `T?`).
496
+ /// decodes through the `JavaScriptValue` free-form methods its `freeForm` conversion names, and a tuple
497
+ /// through the `JavaScriptValue` tuple methods (see `TupleConversion`); every other type decodes through
498
+ /// its own static `decode`, spelled in expression position (`T!` rewritten to `T?`).
499
499
  internal func decodeCall(_ type: String, freeForm: FreeFormConversion?, from valueExpression: String) -> String {
500
500
  if let freeForm {
501
501
  return freeForm.decodeExpression(of: valueExpression, as: expressionType(type))
502
502
  }
503
+ if let tuple = tupleConversion(of: type) {
504
+ return tuple.decodeExpression(of: valueExpression)
505
+ }
503
506
  return "\(expressionType(type)).decode(\(valueExpression), in: runtime)"
504
507
  }
505
508
 
506
509
  /// The encode expression for the native value `valueExpression` of a boundary type, as it appears
507
510
  /// after `try`. The counterpart of `decodeCall`: a type that mentions `Any` encodes through the
508
- /// `JavaScriptValue` free-form methods, and every other type through its own static `encode`.
511
+ /// `JavaScriptValue` free-form methods, a tuple through the `JavaScriptValue` tuple methods, and every
512
+ /// other type through its own static `encode`.
509
513
  internal func encodeCall(_ type: String, freeForm: FreeFormConversion?, of valueExpression: String) -> String {
510
514
  if let freeForm {
511
515
  return freeForm.encodeExpression(of: valueExpression)
512
516
  }
517
+ if let tuple = tupleConversion(of: type) {
518
+ return tuple.encodeExpression(of: valueExpression)
519
+ }
513
520
  return "\(expressionType(type)).encode(\(valueExpression), in: runtime)"
514
521
  }
515
522