expo-modules-macros 0.32.0 → 0.34.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 (24) hide show
  1. package/.github/workflows/macos.yml +16 -3
  2. package/AGENTS.md +7 -3
  3. package/Package.swift +10 -1
  4. package/README.md +3 -3
  5. package/apple/ExpoModulesMacros +0 -0
  6. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +124 -5
  7. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/InheritedMembers.swift +2 -1
  8. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ResolveRefs.swift +76 -17
  9. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ScanExports.swift +15 -8
  10. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +206 -21
  11. package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +14 -6
  12. package/apple/Sources/ExpoModulesCLI/Core/Detection.swift +6 -0
  13. package/apple/Sources/ExpoModulesCLI/Core/DetectionVisitor.swift +41 -2
  14. package/apple/Sources/ExpoModulesCLI/Core/SourceScan.swift +3 -2
  15. package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoViewMacro.swift +64 -30
  16. package/apple/Sources/ExpoModulesMacrosCore/Macros/JSMacro.swift +174 -2
  17. package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +176 -46
  18. package/apple/Sources/ExpoModulesMacrosCore/ProvidedMacros.swift +1 -1
  19. package/apple/Sources/ExpoModulesMacrosCore/Support/DecorateModuleBuilder.swift +17 -3
  20. package/apple/Sources/ExpoModulesMacrosCore/Support/JSConstructor.swift +11 -2
  21. package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +36 -5
  22. package/apple/Sources/ExpoModulesMacrosPlugin/ExpoModulesMacrosPlugin.swift +1 -0
  23. package/build/types.d.ts +58 -3
  24. package/package.json +5 -5
@@ -1,10 +1,10 @@
1
1
  import SwiftSyntax
2
2
 
3
- /// Extracts the full JS-exported surface of every `@ExpoModule`, `@SharedObject`, and `@Record` type:
4
- /// their `@JS` members and record properties. The deep counterpart to `DetectionVisitor`.
5
- /// Recognition is purely syntactic and re-reads what the macros read (the macro target can't be
6
- /// imported), so it stays in step with `JSFunction` / `JSProperty` / `JSConstructor` /
7
- /// `RecordProperty`.
3
+ /// Extracts the full JS-exported surface of every `@ExpoModule`, `@SharedObject`, `@Record`,
4
+ /// `@ExpoView`, and `@ViewProps` type: their `@JS` members, record properties, view props, and view
5
+ /// events. The deep counterpart to `DetectionVisitor`. Recognition is purely syntactic and re-reads
6
+ /// what the macros read (the macro target can't be imported), so it stays in step with `JSFunction` /
7
+ /// `JSProperty` / `JSConstructor` / `RecordProperty` / `validatedViewProps`.
8
8
  ///
9
9
  /// Types nested in another type's body, or in a file-scope `extension`, are reported under their
10
10
  /// qualified Swift name (`Options.Encoding`), so a ref to one can be resolved from the scope it is
@@ -12,10 +12,10 @@ import SwiftSyntax
12
12
  ///
13
13
  /// Each declaration and member reports the platforms that build it (see `PlatformResolver`), and one
14
14
  /// that no platform builds is not reported. `#if` is read only where the runtime sees what it holds:
15
- /// around types, `@Event` members, and enum cases. `@ExpoModule`, `@SharedObject`, `@Record`, and
16
- /// `@Union` read only the members written directly in the type body, so `@JS` members, record
17
- /// properties, and union cases in a `#if` clause are never bound and are not reported. An `@Event`
18
- /// expands on its own declaration, and the compiler reads enum cases itself.
15
+ /// around types, `@Event` members, and enum cases. `@ExpoModule`, `@SharedObject`, `@Record`,
16
+ /// `@Union`, and `@ViewProps` read only the members written directly in the type body, so `@JS`
17
+ /// members, record properties, union cases, and view props in a `#if` clause are never bound and are
18
+ /// not reported. An `@Event` expands on its own declaration, and the compiler reads enum cases itself.
19
19
  final class SurfaceVisitor: SyntaxVisitor {
20
20
  private let file: String
21
21
  private let platformResolver: PlatformResolver
@@ -24,6 +24,10 @@ final class SurfaceVisitor: SyntaxVisitor {
24
24
  private(set) var records: [ExportedRecord] = []
25
25
  private(set) var enums: [ExportedEnum] = []
26
26
  private(set) var unions: [ExportedUnion] = []
27
+ private(set) var views: [ExportedView] = []
28
+ /// The `@ViewProps` structs, which `ExportedSurface.joiningViewProps(_:)` copies into the views once
29
+ /// every file has been walked.
30
+ private(set) var viewProps: [ViewPropsDeclaration] = []
27
31
 
28
32
  init(file: String, tree: SourceFileSyntax) {
29
33
  self.file = file
@@ -120,8 +124,8 @@ final class SurfaceVisitor: SyntaxVisitor {
120
124
  }
121
125
 
122
126
  /// Routes a class or struct to the right collector based on which Expo macro it carries. A type
123
- /// carrying none of them is ignored. `@Record` and `@ExpoModule`/`@SharedObject` are mutually
124
- /// exclusive in practice, so the first match wins.
127
+ /// carrying none of them is ignored. The macros are mutually exclusive in practice, so the first
128
+ /// match wins.
125
129
  ///
126
130
  /// `name` is the declared name and `scope` the names of the types enclosing it. The reported `name`
127
131
  /// is qualified with the scope, while a default `jsName` is the declared name alone, as the macros
@@ -201,6 +205,38 @@ final class SurfaceVisitor: SyntaxVisitor {
201
205
  file: file
202
206
  )
203
207
  )
208
+ return
209
+ }
210
+
211
+ // `@ExpoView` applies only to a class, and needs the props type as its generic argument.
212
+ if node.is(ClassDeclSyntax.self),
213
+ let attribute = attributes.firstAttribute(named: DetectedMacro.expoView.rawValue),
214
+ let propsType = genericArgumentType(of: attribute)
215
+ {
216
+ // Only a file-scope view is reported, as `scan-modules` reports only those.
217
+ guard scope.isEmpty else {
218
+ return
219
+ }
220
+ // `props` and `events` are filled in by `ExportedSurface.joiningViewProps(_:)`, since the
221
+ // `@ViewProps` struct can be declared in another file.
222
+ views.append(
223
+ ExportedView(
224
+ name: qualified,
225
+ jsName: name,
226
+ propsType: String(propsType.trimmedDescription.filter { $0 != "`" }),
227
+ propsTypeLookupName: spellingWithoutGenericArguments(of: propsType),
228
+ accessLevel: accessLevel,
229
+ platforms: platforms,
230
+ file: file
231
+ )
232
+ )
233
+ return
234
+ }
235
+
236
+ // `@ViewProps` applies only to a struct.
237
+ if node.is(StructDeclSyntax.self), attributes.firstAttribute(named: DetectedMacro.viewProps.rawValue) != nil {
238
+ let (props, events) = collectViewProps(members)
239
+ viewProps.append(ViewPropsDeclaration(name: qualified, props: props, events: events, platforms: platforms))
204
240
  }
205
241
  }
206
242
 
@@ -425,7 +461,7 @@ final class SurfaceVisitor: SyntaxVisitor {
425
461
  // `@Record` reads only the properties written directly in the body, so `#if` is not looked into.
426
462
  for member in members {
427
463
  guard let varDecl = member.decl.as(VariableDeclSyntax.self),
428
- !isExcludedRecordModifier(varDecl.modifiers)
464
+ !isExcludedByModifier(varDecl.modifiers)
429
465
  else {
430
466
  continue
431
467
  }
@@ -459,6 +495,76 @@ final class SurfaceVisitor: SyntaxVisitor {
459
495
  return properties
460
496
  }
461
497
 
498
+ /// The value props and events of a `@ViewProps` struct, mirroring `validatedViewProps` in the macro:
499
+ /// every binding of a `var` or `let` that no modifier excludes, except a computed one. A property
500
+ /// with `willSet`/`didSet` or a `set` counts, as in the macro. A function-typed property is an event,
501
+ /// and every other one a value prop.
502
+ ///
503
+ /// The scan stays lenient where the macro reports an error, and leaves out only what can't be
504
+ /// described: a property whose type can't be determined, an optional function type, and an event
505
+ /// that doesn't return `Void`. A value prop the macro rejects for another reason, such as a `let` or
506
+ /// a missing default, is still reported.
507
+ private func collectViewProps(
508
+ _ members: MemberBlockItemListSyntax
509
+ ) -> (props: [ExportedViewProp], events: [ExportedViewEvent]) {
510
+ var props: [ExportedViewProp] = []
511
+ var events: [ExportedViewEvent] = []
512
+
513
+ // `@ViewProps` reads only the properties written directly in the body, so `#if` is not looked into.
514
+ for member in members {
515
+ guard let varDecl = member.decl.as(VariableDeclSyntax.self),
516
+ !isExcludedByModifier(varDecl.modifiers)
517
+ else {
518
+ continue
519
+ }
520
+ let platforms = platformResolver.platforms(of: varDecl)
521
+ guard !platforms.isEmpty else {
522
+ continue
523
+ }
524
+ for binding in varDecl.bindings {
525
+ if binding.accessorBlock != nil && !isSettable(binding: binding, isLet: false) {
526
+ continue
527
+ }
528
+ guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
529
+ continue
530
+ }
531
+ let name = ident.identifier.identifierName
532
+ let annotation = binding.typeAnnotation?.type
533
+
534
+ if let annotation, let functionType = viewEventFunctionType(of: annotation) {
535
+ // An optional function type and a non-`Void` result are errors in the macro.
536
+ guard !isOptionalEventType(annotation), isVoidEventReturn(functionType.returnClause.type) else {
537
+ continue
538
+ }
539
+ let parameters = functionType.parameters
540
+ events.append(
541
+ ExportedViewEvent(
542
+ name: name,
543
+ payload: parameters.count == 1 ? parameters.first.map { typeNode(from: $0.type) } : nil,
544
+ accessLevel: spelledAccessLevel(of: varDecl.modifiers),
545
+ platforms: platforms
546
+ )
547
+ )
548
+ continue
549
+ }
550
+
551
+ guard let type = valueTypeNode(annotation: annotation, initializer: binding.initializer?.value) else {
552
+ continue
553
+ }
554
+ props.append(
555
+ ExportedViewProp(
556
+ name: name,
557
+ type: type,
558
+ defaultValue: binding.initializer?.value.trimmedDescription,
559
+ accessLevel: spelledAccessLevel(of: varDecl.modifiers),
560
+ platforms: platforms
561
+ )
562
+ )
563
+ }
564
+ }
565
+ return (props, events)
566
+ }
567
+
462
568
  /// The declared cases of an enum, in source order. One `case` declaration can introduce several
463
569
  /// cases (`case a, b`), so each element is read separately. A case carrying associated values is
464
570
  /// skipped: it has no raw value, so it can't cross the boundary as one.
@@ -533,8 +639,10 @@ final class SurfaceVisitor: SyntaxVisitor {
533
639
 
534
640
  /// Projects a parameter clause into `ExportedParameter`s: label = first name, name = second (else
535
641
  /// first), `optional` when it has a default value or an optional type, and the default as written.
642
+ /// A parameter that takes the JavaScript runtime (its type is written as `JavaScriptRuntime`) is left
643
+ /// out: the binding passes the runtime itself, and JavaScript doesn't see the parameter.
536
644
  private func parameters(of clause: FunctionParameterClauseSyntax) -> [ExportedParameter] {
537
- clause.parameters.map { parameter in
645
+ clause.parameters.filter { !takesRuntime($0) }.map { parameter in
538
646
  let label = parameter.firstName.identifierName
539
647
  let name = parameter.secondName?.identifierName ?? label
540
648
  return ExportedParameter(
@@ -694,6 +802,35 @@ private func underlyingFunctionType(of type: TypeSyntax?) -> FunctionTypeSyntax?
694
802
  return type.as(FunctionTypeSyntax.self)
695
803
  }
696
804
 
805
+ /// True when a parameter takes the JavaScript runtime: its type, without specifiers such as `borrowing`,
806
+ /// is written as `JavaScriptRuntime` or `….JavaScriptRuntime`, or as an optional of it. This matches
807
+ /// `isRuntimeParameter` in the macros: a type that also carries attributes (`@Sendable`) isn't one.
808
+ private func takesRuntime(_ parameter: FunctionParameterSyntax) -> Bool {
809
+ var type = parameter.type
810
+ if let attributed = type.as(AttributedTypeSyntax.self) {
811
+ guard attributed.attributes.isEmpty else {
812
+ return false
813
+ }
814
+ type = attributed.baseType
815
+ }
816
+ if let optional = type.as(OptionalTypeSyntax.self) {
817
+ type = optional.wrappedType
818
+ } else if let implicitlyUnwrapped = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
819
+ type = implicitlyUnwrapped.wrappedType
820
+ } else if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional",
821
+ let argument = identifier.genericArgumentClause?.arguments.first, case .type(let wrapped) = argument.argument
822
+ {
823
+ type = wrapped
824
+ }
825
+ if let identifier = type.as(IdentifierTypeSyntax.self) {
826
+ return identifier.name.text == "JavaScriptRuntime"
827
+ }
828
+ if let member = type.as(MemberTypeSyntax.self) {
829
+ return member.name.text == "JavaScriptRuntime"
830
+ }
831
+ return false
832
+ }
833
+
697
834
  /// True when an event's function type returns `Void`. The shared `isVoidType` is not reused: it
698
835
  /// accepts neither `Swift.Void` nor a parenthesized `(Void)`, so it would drop valid events.
699
836
  private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
@@ -706,6 +843,44 @@ private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
706
843
  return text == "Void" || text == "()" || text == "Swift.Void"
707
844
  }
708
845
 
846
+ /// The function type a `@ViewProps` property declares, or `nil` when the type isn't a function.
847
+ /// Unwraps parentheses, attributes (`@Sendable`), and the optional spellings `T?`, `T!`, and
848
+ /// `Optional<T>`, like `underlyingFunctionType` in `ViewPropsMacro`.
849
+ private func viewEventFunctionType(of type: TypeSyntax) -> FunctionTypeSyntax? {
850
+ if let functionType = type.as(FunctionTypeSyntax.self) {
851
+ return functionType
852
+ }
853
+ if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1, let only = tuple.elements.first {
854
+ return viewEventFunctionType(of: only.type)
855
+ }
856
+ if let attributed = type.as(AttributedTypeSyntax.self) {
857
+ return viewEventFunctionType(of: attributed.baseType)
858
+ }
859
+ if let optional = type.as(OptionalTypeSyntax.self) {
860
+ return viewEventFunctionType(of: optional.wrappedType)
861
+ }
862
+ if let implicitlyUnwrapped = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
863
+ return viewEventFunctionType(of: implicitlyUnwrapped.wrappedType)
864
+ }
865
+ if let identifier = type.as(IdentifierTypeSyntax.self),
866
+ identifier.name.text == "Optional",
867
+ let argument = identifier.genericArgumentClause?.arguments.first,
868
+ case .type(let wrapped) = argument.argument
869
+ {
870
+ return viewEventFunctionType(of: wrapped)
871
+ }
872
+ return nil
873
+ }
874
+
875
+ /// True when a `@ViewProps` event is written as an optional (`(() -> Void)?`, also after attributes),
876
+ /// which the macro rejects. Like the macros' `isOptionalType`, it reads only the outermost type.
877
+ private func isOptionalEventType(_ type: TypeSyntax) -> Bool {
878
+ if let attributed = type.as(AttributedTypeSyntax.self) {
879
+ return isOptionalEventType(attributed.baseType)
880
+ }
881
+ return isOptionalType(type)
882
+ }
883
+
709
884
  /// True if an `@Event` attribute lists the given option member (`@Event(.inline)` or
710
885
  /// `@Event("name", EventOptions.inline)`). Like `hasOption` in the macros, but without the array-literal
711
886
  /// branch, which `EventOptions...` doesn't accept.
@@ -836,9 +1011,10 @@ private func isTypeLevel(_ modifiers: DeclModifierListSyntax) -> Bool {
836
1011
  }
837
1012
  }
838
1013
 
839
- /// True when a modifier excludes a property from being a `@Record` field (`static`, `class`,
840
- /// `private`, `fileprivate`, `lazy`), mirroring `RecordMacro.isExcludedByModifier`.
841
- private func isExcludedRecordModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
1014
+ /// True when a modifier excludes a property from being a `@Record` field or a `@ViewProps` prop
1015
+ /// (`static`, `class`, `private`, `fileprivate`, `lazy`), mirroring `isExcludedByModifier` in the
1016
+ /// macros.
1017
+ private func isExcludedByModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
842
1018
  modifiers.contains { modifier in
843
1019
  switch modifier.name.tokenKind {
844
1020
  case .keyword(.static), .keyword(.class), .keyword(.private), .keyword(.fileprivate), .keyword(.lazy):
@@ -951,9 +1127,17 @@ func rawValueType(of clause: InheritanceClauseSyntax?) -> TypeNode? {
951
1127
  return typeNode(from: first)
952
1128
  }
953
1129
 
954
- /// True when a type is written as an optional: `T?`, `T!`, or `Optional<T>`, mirroring the macros'
955
- /// `isOptionalType`.
1130
+ /// True when a type is written as an optional: `T?`, `T!`, or `Optional<T>`, also in parentheses
1131
+ /// (`(T?)`) or behind attributes, mirroring the macros' `isOptionalType`.
956
1132
  private func isOptionalType(_ type: TypeSyntax) -> Bool {
1133
+ if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1, let only = tuple.elements.first,
1134
+ only.firstName == nil
1135
+ {
1136
+ return isOptionalType(only.type)
1137
+ }
1138
+ if let attributed = type.as(AttributedTypeSyntax.self) {
1139
+ return isOptionalType(attributed.baseType)
1140
+ }
957
1141
  if type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
958
1142
  return true
959
1143
  }
@@ -963,13 +1147,14 @@ private func isOptionalType(_ type: TypeSyntax) -> Bool {
963
1147
  return false
964
1148
  }
965
1149
 
966
- /// The first attribute whose spelled name matches `name`. A local copy of the macros' helper (the
967
- /// macro target can't be imported here).
1150
+ /// The first attribute whose name matches `name` as `attributeName(of:)` reads it, so
1151
+ /// `@ExpoView<CardProps>` and `@ExpoModulesCore.ExpoView<CardProps>` match `ExpoView`. A local copy of
1152
+ /// the macros' helper (the macro target can't be imported here).
968
1153
  extension AttributeListSyntax {
969
1154
  fileprivate func firstAttribute(named name: String) -> AttributeSyntax? {
970
1155
  for element in self {
971
1156
  if let attribute = element.as(AttributeSyntax.self),
972
- attribute.attributeName.trimmedDescription == name
1157
+ attributeName(of: attribute) == name
973
1158
  {
974
1159
  return attribute
975
1160
  }
@@ -34,8 +34,8 @@ public enum Scanner {
34
34
  }
35
35
  }
36
36
 
37
- /// One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, or a `@SharedObject`
38
- /// class in `sharedObjects`. Trimmed to what `expo-modules-autolinking` needs to register the class:
37
+ /// One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, a `@SharedObject`
38
+ /// class in `sharedObjects`, or an `@ExpoView` class in `views`. Trimmed to what `expo-modules-autolinking` needs to register the class:
39
39
  /// the Swift class name, the JS name it registers under, the platforms that include it, and the file
40
40
  /// it's in. The richer fields the visitor captures (declaration kind, raw macro arguments,
41
41
  /// line/column) are dropped here, because this command doesn't need them (the macro is always on a
@@ -45,7 +45,7 @@ struct ScannedClass: Codable, Equatable {
45
45
  let name: String
46
46
 
47
47
  /// The fully-resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override when
48
- /// present, otherwise the class name. Resolved here (rather than left `nil`) so it matches how the
48
+ /// present, otherwise the class name. `@ExpoView` takes no name, so a view's JS name is its class name. Resolved here (rather than left `nil`) so it matches how the
49
49
  /// macro derives the name and the consumer never has to apply the fallback itself.
50
50
  let jsName: String
51
51
 
@@ -70,7 +70,7 @@ struct ScannedClass: Codable, Equatable {
70
70
  /// `ScannedClass`, so `expo-modules-autolinking` can verify it understands the output before
71
71
  /// trusting it (and fall back to config-declared modules when it doesn't). Version 2 added the
72
72
  /// per-module `platforms` list and made the module list platform-agnostic. A new field that older
73
- /// consumers can ignore, such as `sharedObjects`, doesn't change the version.
73
+ /// consumers can ignore, such as `sharedObjects` or `views`, doesn't change the version.
74
74
  let scanModulesSchemaVersion = 2
75
75
 
76
76
  /// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
@@ -84,6 +84,10 @@ struct ScanModulesResult: Codable, Equatable {
84
84
  /// `modules`.
85
85
  let sharedObjects: [ScannedClass]
86
86
 
87
+ /// The top-level `@ExpoView` classes, in the same order and with the same platform rules as
88
+ /// `modules`.
89
+ let views: [ScannedClass]
90
+
87
91
  /// Warnings for `#if` conditions the scan couldn't answer statically (see `ScanWarning`). Carried
88
92
  /// in the report rather than on stderr so the consumer can attach them to its own output.
89
93
  let warnings: [ScanWarning]
@@ -102,7 +106,7 @@ private let platformUniverse: [(condition: String, reported: String)] = [
102
106
  (condition: "visionOS", reported: "visionOS"),
103
107
  ]
104
108
 
105
- /// Scans the given paths for top-level `@ExpoModule` and `@SharedObject` types and returns every one
109
+ /// Scans the given paths for top-level `@ExpoModule`, `@SharedObject` and `@ExpoView` types and returns every one
106
110
  /// found in any
107
111
  /// `#if` branch (in file then source order), each with the platforms whose builds include it, plus
108
112
  /// the stats for the run — the `scan-modules` command. Kept separate from the public entry (and
@@ -115,10 +119,11 @@ private let platformUniverse: [(condition: String, reported: String)] = [
115
119
  func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResult {
116
120
  var modules: [ScannedClass] = []
117
121
  var sharedObjects: [ScannedClass] = []
122
+ var views: [ScannedClass] = []
118
123
  var warnings: [ScanWarning] = []
119
124
  var seenWarnings = Set<ScanWarning>()
120
125
 
121
- let macros: Set<DetectedMacro> = [.expoModule, .sharedObject]
126
+ let macros: Set<DetectedMacro> = [.expoModule, .sharedObject, .expoView]
122
127
  let stats = scanFiles(paths: paths, macros: macros) { source, file in
123
128
  let tree = Parser.parse(source: source)
124
129
 
@@ -162,6 +167,8 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
162
167
  switch detection.macro {
163
168
  case .sharedObject:
164
169
  sharedObjects.append(scanned)
170
+ case .expoView:
171
+ views.append(scanned)
165
172
  default:
166
173
  modules.append(scanned)
167
174
  }
@@ -172,6 +179,7 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
172
179
  schemaVersion: scanModulesSchemaVersion,
173
180
  modules: modules,
174
181
  sharedObjects: sharedObjects,
182
+ views: views,
175
183
  warnings: warnings,
176
184
  stats: stats
177
185
  )
@@ -7,6 +7,8 @@ enum DetectedMacro: String, Codable, CaseIterable {
7
7
  case sharedObject = "SharedObject"
8
8
  case record = "Record"
9
9
  case union = "Union"
10
+ case expoView = "ExpoView"
11
+ case viewProps = "ViewProps"
10
12
  }
11
13
 
12
14
  /// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil`
@@ -43,6 +45,10 @@ struct Detection: Codable, Equatable {
43
45
  /// `@SharedObject("Baz")`, otherwise `nil` (the name defaults to `name` at expansion time).
44
46
  let jsName: String?
45
47
 
48
+ /// The generic argument of the attribute as written, which is the props type of `@ExpoView<CardProps>`,
49
+ /// otherwise `nil`.
50
+ var genericArgument: String? = nil
51
+
46
52
  /// Every argument passed to the macro, in source order, e.g. `@ExpoModule("Foo", classes: [Bar.self])`
47
53
  /// yields a positional `"Foo"` and a `classes:` argument. Empty when the macro is written bare.
48
54
  let arguments: [MacroArgument]
@@ -3,7 +3,7 @@ import SwiftIfConfig
3
3
  import SwiftSyntax
4
4
 
5
5
  /// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`,
6
- /// `@SharedObject`, or `@Record`. Only file-scope declarations are considered: these macros apply to
6
+ /// `@SharedObject`, `@Record`, `@Union`, `@ExpoView`, or `@ViewProps`. Only file-scope declarations are considered: these macros apply to
7
7
  /// top-level types, so descending into type and function bodies would only surface false positives.
8
8
  /// Recognition mirrors the macros themselves — a purely syntactic match on the spelled attribute
9
9
  /// name — so it sees the same declarations the compiler would hand the plugin, without compiling
@@ -119,7 +119,7 @@ final class DetectionVisitor: SyntaxVisitor {
119
119
  ) {
120
120
  for element in attributes {
121
121
  guard let attribute = element.as(AttributeSyntax.self),
122
- let macro = DetectedMacro(rawValue: attribute.attributeName.trimmedDescription),
122
+ let macro = DetectedMacro(rawValue: attributeName(of: attribute)),
123
123
  detectedMacros.contains(macro)
124
124
  else {
125
125
  continue
@@ -132,6 +132,7 @@ final class DetectionVisitor: SyntaxVisitor {
132
132
  declarationKind: kind,
133
133
  accessLevel: accessLevel(of: modifiers),
134
134
  jsName: stringArgument(of: attribute),
135
+ genericArgument: genericArgument(of: attribute),
135
136
  arguments: arguments(of: attribute),
136
137
  file: file,
137
138
  line: location.line,
@@ -155,6 +156,44 @@ private func accessLevel(of modifiers: DeclModifierListSyntax) -> String {
155
156
  return "internal"
156
157
  }
157
158
 
159
+ /// The attribute's name without a generic argument clause, so `@ExpoView<CardProps>` is `ExpoView`. A
160
+ /// name qualified with the module that declares the macros is read without the module, so
161
+ /// `@ExpoModulesCore.ExpoView<CardProps>` is `ExpoView` too.
162
+ func attributeName(of attribute: AttributeSyntax) -> String {
163
+ if let identifier = attribute.attributeName.as(IdentifierTypeSyntax.self) {
164
+ return identifier.name.text
165
+ }
166
+ if let member = attribute.attributeName.as(MemberTypeSyntax.self),
167
+ member.baseType.as(IdentifierTypeSyntax.self)?.name.text == macrosModuleName
168
+ {
169
+ return member.name.text
170
+ }
171
+ return attribute.attributeName.trimmedDescription
172
+ }
173
+
174
+ /// The module that declares the macros, which an attribute may be qualified with:
175
+ /// `@ExpoModulesCore.ExpoView<CardProps>`.
176
+ let macrosModuleName = "ExpoModulesCore"
177
+
178
+ /// The first generic argument of the attribute as written, e.g. `@ExpoView<CardProps>` -> "CardProps".
179
+ /// Returns `nil` when the attribute has no generic argument clause.
180
+ private func genericArgument(of attribute: AttributeSyntax) -> String? {
181
+ return genericArgumentType(of: attribute)?.trimmedDescription
182
+ }
183
+
184
+ /// The first generic argument of the attribute, `CardProps` in `@ExpoView<CardProps>` and in
185
+ /// `@ExpoModulesCore.ExpoView<CardProps>`, or `nil` when the attribute has no generic argument clause.
186
+ /// Like `genericArgument(of:)` in `ExpoViewMacro`.
187
+ func genericArgumentType(of attribute: AttributeSyntax) -> TypeSyntax? {
188
+ let clause =
189
+ attribute.attributeName.as(IdentifierTypeSyntax.self)?.genericArgumentClause
190
+ ?? attribute.attributeName.as(MemberTypeSyntax.self)?.genericArgumentClause
191
+ guard let first = clause?.arguments.first, case .type(let type) = first.argument else {
192
+ return nil
193
+ }
194
+ return type
195
+ }
196
+
158
197
  /// Every argument passed to the attribute, in source order, each as a label (or `nil` when
159
198
  /// positional) plus the value expression's source text. Returns an empty array when the attribute
160
199
  /// is written bare (`@ExpoModule`) or with empty parens.
@@ -106,9 +106,10 @@ struct MacroPrefilter {
106
106
  }
107
107
 
108
108
  /// A pre-filter for attributes that `DetectedMacro` doesn't list, such as every macro that `expand`
109
- /// runs.
109
+ /// runs. Each name also matches qualified with the module that declares the macros
110
+ /// (`@ExpoModulesCore.ExpoView`), since the scanners read that spelling as the bare one.
110
111
  init(attributeNames: Set<String>, conformances: Set<String> = []) {
111
- attributes = attributeNames.map { Array($0.utf8) }
112
+ attributes = attributeNames.flatMap { [Array($0.utf8), Array("\(macrosModuleName).\($0)".utf8)] }
112
113
  bareNames = conformances.map { Array($0.utf8) }
113
114
  }
114
115