@expo/expo-modules-macros-plugin 0.10.0 → 0.12.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 (29) hide show
  1. package/.github/resources/expo-modules-macros.svg +23 -0
  2. package/.github/workflows/publish.yml +4 -0
  3. package/.github/workflows/swift.yml +6 -0
  4. package/README.md +119 -0
  5. package/apple/ExpoModulesMacros-tool +0 -0
  6. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +3 -1
  7. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +10 -0
  8. package/apple/Sources/ExpoModulesMacros/ExpoViewMacro.swift +259 -0
  9. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +2 -2
  10. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +24 -2
  11. package/apple/Sources/ExpoModulesMacros/Plugin.swift +2 -0
  12. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +0 -16
  13. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +487 -0
  14. package/apple/Sources/ExpoModulesScanner/CLI.swift +8 -4
  15. package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +6 -4
  16. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +28 -7
  17. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +3 -9
  18. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +24 -8
  19. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +133 -0
  20. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +199 -0
  21. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +25 -6
  22. package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +315 -6
  23. package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +35 -6
  24. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +89 -22
  25. package/build/index.d.ts +51 -0
  26. package/build/index.js +153 -0
  27. package/build/types.d.ts +264 -0
  28. package/build/types.js +15 -0
  29. package/package.json +12 -2
@@ -10,6 +10,8 @@ final class SurfaceVisitor: SyntaxVisitor {
10
10
  private(set) var modules: [ExportedModule] = []
11
11
  private(set) var sharedObjects: [ExportedSharedObject] = []
12
12
  private(set) var records: [ExportedRecord] = []
13
+ private(set) var enums: [ExportedEnum] = []
14
+ private(set) var unions: [ExportedUnion] = []
13
15
 
14
16
  init(file: String) {
15
17
  self.file = file
@@ -32,25 +34,57 @@ final class SurfaceVisitor: SyntaxVisitor {
32
34
  return .skipChildren
33
35
  }
34
36
 
37
+ /// The two kinds of enum the surface reports: a `@Union` by its attribute, an `Enumerable` enum by
38
+ /// its conformance (core converts it with no macro involved, so there is no attribute to key on).
39
+ ///
40
+ /// `@Union` wins when a type carries both. Its cases hold payloads rather than raw values, so there
41
+ /// would be nothing to report as an enum, and listing it in both arrays would describe two
42
+ /// contradictory JS types for one declaration.
43
+ override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
44
+ guard isTopLevel(node) else {
45
+ return .skipChildren
46
+ }
47
+
48
+ if node.attributes.firstAttribute(named: DetectedMacro.union.rawValue) != nil {
49
+ unions.append(
50
+ ExportedUnion(
51
+ name: node.name.text,
52
+ members: collectUnionMembers(node.memberBlock.members),
53
+ file: file
54
+ ))
55
+ } else if inherits(from: enumerableConformanceName, in: node.inheritanceClause) {
56
+ let rawType = rawValueType(of: node.inheritanceClause)
57
+ enums.append(
58
+ ExportedEnum(
59
+ name: node.name.text,
60
+ rawType: rawType,
61
+ cases: collectEnumCases(node.memberBlock.members, rawType: rawType),
62
+ file: file
63
+ ))
64
+ }
65
+ return .skipChildren
66
+ }
67
+
35
68
  /// Routes a top-level type to the right collector based on which Expo macro it carries. A type
36
69
  /// carrying none of them is ignored. `@Record` and `@ExpoModule`/`@SharedObject` are mutually
37
70
  /// exclusive in practice, so the first match wins.
38
71
  private func classify(name: String, attributes: AttributeListSyntax, members: MemberBlockItemListSyntax) {
39
72
  if let attribute = attributes.firstAttribute(named: DetectedMacro.expoModule.rawValue) {
40
- let (functions, properties, _) = collectJSMembers(members)
73
+ let (functions, properties, events, _) = collectJSMembers(members)
41
74
  modules.append(
42
75
  ExportedModule(
43
76
  name: name,
44
77
  jsName: stringArgument(of: attribute) ?? name,
45
78
  functions: functions,
46
79
  properties: properties,
80
+ events: events,
47
81
  file: file
48
82
  ))
49
83
  return
50
84
  }
51
85
 
52
86
  if let attribute = attributes.firstAttribute(named: DetectedMacro.sharedObject.rawValue) {
53
- let (functions, properties, constructor) = collectJSMembers(members)
87
+ let (functions, properties, events, constructor) = collectJSMembers(members)
54
88
  sharedObjects.append(
55
89
  ExportedSharedObject(
56
90
  name: name,
@@ -58,6 +92,7 @@ final class SurfaceVisitor: SyntaxVisitor {
58
92
  constructorParameters: constructor,
59
93
  functions: functions,
60
94
  properties: properties,
95
+ events: events,
61
96
  file: file
62
97
  ))
63
98
  return
@@ -68,13 +103,17 @@ final class SurfaceVisitor: SyntaxVisitor {
68
103
  }
69
104
  }
70
105
 
71
- /// The `@JS` members of a module / shared-object body: functions, properties, and the single
72
- /// `@JS init` constructor parameters (`nil` when absent). Only declarations carrying `@JS` count.
106
+ /// The exported members of a module / shared-object body: `@JS` functions and properties, `@Event`
107
+ /// events, and the single `@JS init` constructor parameters (`nil` when absent).
73
108
  private func collectJSMembers(
74
109
  _ members: MemberBlockItemListSyntax
75
- ) -> (functions: [ExportedFunction], properties: [ExportedProperty], constructor: [ExportedParameter]?) {
110
+ ) -> (
111
+ functions: [ExportedFunction], properties: [ExportedProperty], events: [ExportedEvent],
112
+ constructor: [ExportedParameter]?
113
+ ) {
76
114
  var functions: [ExportedFunction] = []
77
115
  var properties: [ExportedProperty] = []
116
+ var events: [ExportedEvent] = []
78
117
  var constructor: [ExportedParameter]?
79
118
 
80
119
  for member in members {
@@ -98,10 +137,52 @@ final class SurfaceVisitor: SyntaxVisitor {
98
137
  if let varDecl = decl.as(VariableDeclSyntax.self),
99
138
  let attribute = varDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) {
100
139
  properties.append(contentsOf: makeProperties(varDecl: varDecl, attribute: attribute))
140
+ continue
141
+ }
142
+
143
+ if let varDecl = decl.as(VariableDeclSyntax.self),
144
+ let attribute = varDecl.attributes.firstAttribute(named: DetectedMacro.event.rawValue) {
145
+ events.append(contentsOf: makeEvents(varDecl: varDecl, attribute: attribute))
101
146
  }
102
147
  }
103
148
 
104
- return (functions, properties, constructor)
149
+ return (functions, properties, events, constructor)
150
+ }
151
+
152
+ /// Builds the `ExportedEvent` entries for an `@Event var`, applying the same checks
153
+ /// `EventMacro.validatedEvent(of:on:)` does. A rejected declaration expands to no event, so
154
+ /// reporting one would describe a surface that does not exist. `@JS` on the same property is
155
+ /// caught by the caller, which reaches the `@JS` branch first.
156
+ private func makeEvents(varDecl: VariableDeclSyntax, attribute: AttributeSyntax) -> [ExportedEvent] {
157
+ guard varDecl.bindingSpecifier.tokenKind != .keyword(.let),
158
+ !isTypeLevel(varDecl.modifiers) else {
159
+ return []
160
+ }
161
+ let override = stringArgument(of: attribute)
162
+ let isSync = boolArgument(of: attribute, label: "sync") == true
163
+ var result: [ExportedEvent] = []
164
+
165
+ for binding in varDecl.bindings {
166
+ // Binding-level checks, in the macro's order: a named binding with a function type that takes
167
+ // at most one payload and returns Void, with no initializer or hand-written accessors.
168
+ guard let ident = binding.pattern.as(IdentifierPatternSyntax.self),
169
+ binding.initializer == nil,
170
+ binding.accessorBlock == nil,
171
+ let functionType = underlyingFunctionType(of: binding.typeAnnotation?.type),
172
+ isVoidEventReturn(functionType.returnClause.type),
173
+ functionType.parameters.count <= 1 else {
174
+ continue
175
+ }
176
+ let name = ident.identifier.text
177
+ result.append(
178
+ ExportedEvent(
179
+ name: name,
180
+ jsName: override ?? defaultEventName(for: name),
181
+ payload: functionType.parameters.first.map { typeNode(from: $0.type) },
182
+ isSync: isSync
183
+ ))
184
+ }
185
+ return result
105
186
  }
106
187
 
107
188
  /// Builds an `ExportedFunction` from a `@JS func`: JS-name fallback, parameters, a `Void` return as
@@ -208,6 +289,61 @@ final class SurfaceVisitor: SyntaxVisitor {
208
289
  return properties
209
290
  }
210
291
 
292
+ /// The declared cases of an enum, in source order. One `case` declaration can introduce several
293
+ /// cases (`case a, b`), so each element is read separately. A case carrying associated values is
294
+ /// skipped: it has no raw value, so it can't cross the boundary as one.
295
+ ///
296
+ /// A `String`-backed case with no written value takes the case's own name, so those are filled in
297
+ /// here and a `String`-backed enum reports a raw value on every case. `Int` is deliberately left
298
+ /// alone: see `derivedStringRawValue(for:rawType:)`.
299
+ private func collectEnumCases(
300
+ _ members: MemberBlockItemListSyntax,
301
+ rawType: TypeNode?
302
+ ) -> [ExportedEnumCase] {
303
+ var cases: [ExportedEnumCase] = []
304
+
305
+ for member in members {
306
+ guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
307
+ continue
308
+ }
309
+ for element in caseDecl.elements where element.parameterClause == nil {
310
+ let name = element.name.text
311
+ cases.append(
312
+ ExportedEnumCase(
313
+ name: name,
314
+ rawValue: writtenRawValue(of: element) ?? derivedStringRawValue(for: name, rawType: rawType)
315
+ ))
316
+ }
317
+ }
318
+ return cases
319
+ }
320
+
321
+ /// The alternatives of a `@Union`, in declaration order (the decode depends on it). Skips the cases
322
+ /// `UnionMacro.validatedUnion(of:)` rejects (no associated value, more than one, or a default), since
323
+ /// those don't exist at runtime. The scanner never diagnoses, it just declines to report.
324
+ ///
325
+ /// A generic `@Union` is rejected wholesale by the macro but still reported, since the declaration
326
+ /// names a type a consumer may meet.
327
+ private func collectUnionMembers(_ members: MemberBlockItemListSyntax) -> [ExportedUnionMember] {
328
+ var result: [ExportedUnionMember] = []
329
+
330
+ for member in members {
331
+ guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
332
+ continue
333
+ }
334
+ for element in caseDecl.elements {
335
+ guard let parameters = element.parameterClause?.parameters,
336
+ parameters.count == 1, let parameter = parameters.first,
337
+ parameter.defaultValue == nil else {
338
+ continue
339
+ }
340
+ result.append(
341
+ ExportedUnionMember(name: element.name.text, type: typeNode(from: parameter.type)))
342
+ }
343
+ }
344
+ return result
345
+ }
346
+
211
347
  /// Projects a parameter clause into `ExportedParameter`s: label = first name, name = second (else
212
348
  /// first), and `optional` when it has a default value or an optional type.
213
349
  private func parameters(of clause: FunctionParameterClauseSyntax) -> [ExportedParameter] {
@@ -236,6 +372,79 @@ final class SurfaceVisitor: SyntaxVisitor {
236
372
  // MARK: - Syntactic helpers (shared spelling with the macros)
237
373
 
238
374
  /// True when the modifiers make a member type-level (`static` or `class`).
375
+ // MARK: - `@Event` helpers
376
+
377
+ // The macro target can't be imported, so these are deliberate copies of `EventMacro`'s logic. They
378
+ // must stay in step with it: a drift changes the reported surface without failing any build.
379
+
380
+ /// The Swift name with a conventional `on` prefix stripped and the remainder decapitalized
381
+ /// (`onStatusChange` -> `statusChange`); names without the prefix pass through verbatim. A drift
382
+ /// from the macro's copy would produce listener names the module never emits.
383
+ func defaultEventName(for swiftName: String) -> String {
384
+ guard swiftName.hasPrefix("on") else {
385
+ return swiftName
386
+ }
387
+ let rest = swiftName.dropFirst(2)
388
+ guard let first = rest.first, first.isUppercase else {
389
+ return swiftName
390
+ }
391
+ return decapitalized(String(rest))
392
+ }
393
+
394
+ /// Lowercases the leading uppercase run the way Swift's API importer does: a single leading capital
395
+ /// is lowercased, and a longer acronym run keeps its last capital when a lowercase letter follows it
396
+ /// (`StatusChange` -> `statusChange`, `URLChange` -> `urlChange`, `URL` -> `url`).
397
+ private func decapitalized(_ name: String) -> String {
398
+ let runEnd = name.firstIndex { !$0.isUppercase } ?? name.endIndex
399
+ if name[..<runEnd].count > 1 && runEnd != name.endIndex {
400
+ let lastCapital = name.index(before: runEnd)
401
+ return name[..<lastCapital].lowercased() + name[lastCapital...]
402
+ }
403
+ return name[..<runEnd].lowercased() + name[runEnd...]
404
+ }
405
+
406
+ /// The function type underlying a property's type annotation, unwrapping attributes
407
+ /// (`@Sendable (P) -> Void`) and single-element parentheses (`((P) -> Void)`). `nil` when the
408
+ /// annotation is missing or isn't a function type.
409
+ private func underlyingFunctionType(of type: TypeSyntax?) -> FunctionTypeSyntax? {
410
+ guard let type else {
411
+ return nil
412
+ }
413
+ if let attributed = type.as(AttributedTypeSyntax.self) {
414
+ return underlyingFunctionType(of: attributed.baseType)
415
+ }
416
+ if let tuple = type.as(TupleTypeSyntax.self),
417
+ tuple.elements.count == 1, let element = tuple.elements.first, element.firstName == nil {
418
+ return underlyingFunctionType(of: element.type)
419
+ }
420
+ return type.as(FunctionTypeSyntax.self)
421
+ }
422
+
423
+ /// True when an event's function type returns `Void`. The shared `isVoidType` is not reused: it
424
+ /// accepts neither `Swift.Void` nor a parenthesized `(Void)`, so it would drop valid events.
425
+ private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
426
+ if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1,
427
+ let element = tuple.elements.first, element.firstName == nil {
428
+ return isVoidEventReturn(element.type)
429
+ }
430
+ let text = type.trimmedDescription
431
+ return text == "Void" || text == "()" || text == "Swift.Void"
432
+ }
433
+
434
+ /// The value of a labeled boolean macro argument (`@Event(sync: true)`), or `nil` when absent.
435
+ private func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
436
+ guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
437
+ return nil
438
+ }
439
+ for argument in arguments where argument.label?.text == label {
440
+ guard let literal = argument.expression.as(BooleanLiteralExprSyntax.self) else {
441
+ return nil
442
+ }
443
+ return literal.literal.tokenKind == .keyword(.true)
444
+ }
445
+ return nil
446
+ }
447
+
239
448
  private func isTypeLevel(_ modifiers: DeclModifierListSyntax) -> Bool {
240
449
  modifiers.contains {
241
450
  $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class)
@@ -255,6 +464,106 @@ private func isExcludedRecordModifier(_ modifiers: DeclModifierListSyntax) -> Bo
255
464
  }
256
465
  }
257
466
 
467
+ /// The protocol whose conformance marks an enum as convertible at the JS boundary. Core converts such
468
+ /// an enum through `Coding/…+Enumerable`, keyed on this conformance, so the scanner keys on it too.
469
+ let enumerableConformanceName = "Enumerable"
470
+
471
+ /// True when an inheritance clause names `name`. Matched on the trailing component of the written
472
+ /// spelling, so a qualified `ExpoModulesCore.Enumerable` counts. Purely syntactic: a conformance added
473
+ /// in a separate `extension`, or inherited through another protocol, is invisible to a scan and so is
474
+ /// not reported.
475
+ func inherits(from name: String, in clause: InheritanceClauseSyntax?) -> Bool {
476
+ guard let clause else {
477
+ return false
478
+ }
479
+ return clause.inheritedTypes.contains { inherited in
480
+ inherited.type.trimmedDescription.split(separator: ".").last.map(String.init) == name
481
+ }
482
+ }
483
+
484
+ /// The raw value a case writes, or `nil` when it writes none.
485
+ ///
486
+ /// A string literal is reported **decoded**: `case a = "act"` yields `act`, with no quotes, because a
487
+ /// `String` raw value is always fully known (see `derivedStringRawValue(for:rawType:)`) and a consumer
488
+ /// should not have to unquote it. Every other expression is reported as **source text**, since an
489
+ /// integer raw value may be any literal expression the scanner can't evaluate. Which of the two a
490
+ /// `rawValue` holds follows from the enum's `rawType`, and `ExportedEnumCase` documents that contract.
491
+ ///
492
+ /// A literal this can't decode is treated as writing no raw value rather than reported half-read: an
493
+ /// interpolated string (`case a = "x\(y)"`, not a legal raw value anyway), or one whose segment carries
494
+ /// a backslash escape, which would need real unescaping to turn into its value. A `String` case then
495
+ /// falls back to the derived name, keeping that invariant intact.
496
+ private func writtenRawValue(of element: EnumCaseElementSyntax) -> String? {
497
+ guard let value = element.rawValue?.value else {
498
+ return nil
499
+ }
500
+ guard let literal = value.as(StringLiteralExprSyntax.self) else {
501
+ // Not a string: an integer literal, a negative value, or an expression. Source text verbatim.
502
+ return value.trimmedDescription
503
+ }
504
+ guard literal.segments.count == 1,
505
+ let segment = literal.segments.first?.as(StringSegmentSyntax.self) else {
506
+ return nil
507
+ }
508
+ // The segment's text is the literal's content with its delimiters already stripped, so a plain
509
+ // `"act"` and a raw `#"act"#` both read as `act`. An escape is left to the fallback rather than
510
+ // emitted raw, since `\n` here is two characters, not a newline.
511
+ let content = segment.content.text
512
+ return content.contains("\\") ? nil : content
513
+ }
514
+
515
+ /// The raw value Swift gives a `String`-backed case that writes none: the case's own name.
516
+ ///
517
+ /// Only `String` is derived. Its defaulting is per-case and carry-free, so a case that can't be read
518
+ /// can't affect any other, and every legal spelling is a single-segment literal. That closes the case
519
+ /// and lets a `String`-backed enum report a raw value on *every* case. `Int` continues
520
+ /// from the preceding case's value (`case a = 1; case b` makes `b` 2), whether that value was written
521
+ /// or itself derived, so one unreadable expression would corrupt every case after it. Deriving it
522
+ /// partially would be worse than not deriving it, so integer-backed enums report only what's written
523
+ /// and the consumer applies the continuation rule.
524
+ private func derivedStringRawValue(for caseName: String, rawType: TypeNode?) -> String? {
525
+ // `String` is a `.primitive`, but a qualified `Swift.String` parses as a `.ref`, and both are legal
526
+ // raw types. Matching the trailing component covers each, the same way the conformance check does.
527
+ let name: String?
528
+ switch rawType {
529
+ case .primitive(let spelling, _), .ref(let spelling, _, _):
530
+ name = spelling.split(separator: ".").last.map(String.init)
531
+ default:
532
+ name = nil
533
+ }
534
+ guard name == "String" else {
535
+ return nil
536
+ }
537
+ // Decoded, matching how a written string literal is reported: the value, not its source spelling.
538
+ return caseName
539
+ }
540
+
541
+ /// Protocols an `Enumerable` enum commonly adopts, which a syntactic scan would otherwise mistake for
542
+ /// a raw value type when one is written ahead of the conformance (`enum E: Codable, Enumerable`, which
543
+ /// has no raw type). Only the first inherited entry is ever tested against this, so the list needs to
544
+ /// name just what can legally precede `Enumerable`, not every protocol in existence.
545
+ private let knownNonRawValueProtocols: Set<String> = [
546
+ "CaseIterable", "Codable", "Decodable", "Encodable", "Equatable", "Error", "Hashable",
547
+ "Identifiable", "Sendable", enumerableConformanceName,
548
+ ]
549
+
550
+ /// The raw value type of an enum, or `nil` when it declares none.
551
+ ///
552
+ /// Swift allows a raw type only in first position, so nothing after the first entry can be one. The
553
+ /// first entry is still not necessarily a raw type: `enum E: Codable, Enumerable` is a legal
554
+ /// raw-value-less enum, and a scan can't resolve a bare name to tell a protocol from a type. It's
555
+ /// matched against `knownNonRawValueProtocols` instead, which covers what an `Enumerable` enum
556
+ /// realistically adopts. An unlisted protocol written first would still be misreported as a raw type;
557
+ /// that is the residual limit of reading this syntactically.
558
+ func rawValueType(of clause: InheritanceClauseSyntax?) -> TypeNode? {
559
+ guard let first = clause?.inheritedTypes.first?.type,
560
+ let trailing = first.trimmedDescription.split(separator: ".").last.map(String.init),
561
+ !knownNonRawValueProtocols.contains(trailing) else {
562
+ return nil
563
+ }
564
+ return typeNode(from: first)
565
+ }
566
+
258
567
  /// True when a type is written as an optional: `T?`, `T!`, or `Optional<T>`, mirroring the macros'
259
568
  /// `isOptionalType`.
260
569
  private func isOptionalType(_ type: TypeSyntax) -> Bool {
@@ -21,6 +21,21 @@ enum JSType: String, Encodable {
21
21
  case function
22
22
  }
23
23
 
24
+ /// Which of the scanned kinds declares the name a `.ref` points at.
25
+ ///
26
+ /// Resolution happens after the whole scan (`ExportedSurface.resolvingRefs()`), not while parsing a
27
+ /// type: a `TypeNode` is built from one `TypeSyntax` in isolation, long before the scanner knows what
28
+ /// else exists. A ref the scan can't place carries no `refKind` at all rather than a value meaning "none",
29
+ /// matching how the rest of the surface signals "nothing to say" (`returns` for `Void`, `rawType` for
30
+ /// a bare conformance). That is not an error: it covers a platform or built-in convertible (`CGPoint`,
31
+ /// `URL`) and a type from another module, which the consumer resolves against its own catalog.
32
+ enum RefKind: String, Encodable {
33
+ case record
34
+ case sharedObject
35
+ case `enum`
36
+ case union
37
+ }
38
+
24
39
  indirect enum TypeNode: Equatable {
25
40
  /// `Bool`, `Int`, `Double`, `String`: the types core fast-decodes. `name` is the Swift spelling;
26
41
  /// `jsType` is `boolean`/`number`/`string`.
@@ -42,9 +57,18 @@ indirect enum TypeNode: Equatable {
42
57
  /// the effects (encoded `async`/`throws`).
43
58
  case function(parameters: [TypeNode], returns: TypeNode?, isAsync: Bool, isThrowing: Bool)
44
59
 
45
- /// Any other named type (record, shared object, enum, …). `name` is the possibly-qualified spelling;
46
- /// the generator resolves it against the scanned types or treats it as opaque.
47
- case ref(name: String)
60
+ /// Any other named type (record, shared object, enum, union, …). `name` is the possibly-qualified
61
+ /// spelling.
62
+ ///
63
+ /// `refKind` says which scanned kind declares that name, filled in after the scan by
64
+ /// `ExportedSurface.resolvingRefs()`; it is `nil` until then, and stays `nil` for a name the scan
65
+ /// never declared (a platform type like `CGPoint`, or one from another module).
66
+ ///
67
+ /// `jsTypeOverride` carries the category a resolved ref actually crosses as, when that isn't
68
+ /// `object`: a raw-value enum reaches JS as its raw value, so `Status: String` is a `string`. Only
69
+ /// resolution can know this, since the category comes from the *declaration's* `rawType`, which a
70
+ /// node parsed at a use site has never seen.
71
+ case ref(name: String, refKind: RefKind? = nil, jsTypeOverride: JSType? = nil)
48
72
 
49
73
  /// A spelling the parser doesn't model (generic parameter, tuple, metatype, …), kept verbatim so
50
74
  /// nothing is lost. Has no `typeof`.
@@ -56,8 +80,11 @@ indirect enum TypeNode: Equatable {
56
80
  switch self {
57
81
  case .primitive(_, let jsType):
58
82
  return jsType
59
- case .array, .dictionary, .promise, .ref:
83
+ case .array, .dictionary, .promise:
60
84
  return .object
85
+ case .ref(_, _, let jsTypeOverride):
86
+ // An unresolved ref, and every resolved one but an enum, is an object.
87
+ return jsTypeOverride ?? .object
61
88
  case .function:
62
89
  return .function
63
90
  case .optional(let wrapped):
@@ -70,6 +97,7 @@ indirect enum TypeNode: Equatable {
70
97
 
71
98
  extension TypeNode: Encodable {
72
99
  private enum CodingKeys: String, CodingKey {
100
+ case refKind
73
101
  case kind
74
102
  case name
75
103
  // The `typeof` category. Spelled `typeof` in JSON (what the consumer reads); the Swift property is
@@ -109,9 +137,10 @@ extension TypeNode: Encodable {
109
137
  try container.encodeIfPresent(returns, forKey: .returns)
110
138
  try container.encode(isAsync, forKey: .isAsync)
111
139
  try container.encode(isThrowing, forKey: .isThrowing)
112
- case .ref(let name):
140
+ case .ref(let name, let refKind, _):
113
141
  try container.encode("ref", forKey: .kind)
114
142
  try container.encode(name, forKey: .name)
143
+ try container.encodeIfPresent(refKind, forKey: .refKind)
115
144
  case .unknown(let text):
116
145
  try container.encode("unknown", forKey: .kind)
117
146
  try container.encode(text, forKey: .text)
@@ -236,7 +265,7 @@ extension TypeNode {
236
265
  switch self {
237
266
  case .primitive(let name, _):
238
267
  return name
239
- case .ref(let name):
268
+ case .ref(let name, _, _):
240
269
  return name
241
270
  case .optional(let wrapped):
242
271
  return "\(wrapped.spelling)?"
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import SwiftParser
2
3
 
3
4
  /// The scanner's public entry point. Argument parsing, subcommand dispatch, and usage text live in
4
5
  /// the CLI target; this just runs a command and writes its JSON report to stdout.
@@ -10,11 +11,10 @@ public enum Scanner {
10
11
  /// process exit code: `0` on success, `1` if encoding fails. (The deep `scan-exports` command has
11
12
  /// its own `runExports` entry returning its own result type.)
12
13
  ///
13
- /// `platform` and `defines` (the `--platform` and `--define` options) form the configuration that
14
- /// `#if` conditions are evaluated against; see `ScanBuildConfiguration`.
15
- public static func runModules(paths: [String], platform: String? = nil, defines: [String] = []) -> Int32 {
16
- let configuration = ScanBuildConfiguration(platform: platform, defines: Set(defines))
17
- let result = scanModules(paths: paths, configuration: configuration)
14
+ /// `defines` (the `--define` options) asserts conditional compilation flags; see `scanModules`
15
+ /// for how they and platforms shape each module's `platforms` list.
16
+ public static func runModules(paths: [String], defines: [String] = []) -> Int32 {
17
+ let result = scanModules(paths: paths, defines: Set(defines))
18
18
 
19
19
  do {
20
20
  let encoder = JSONEncoder()
@@ -31,10 +31,11 @@ public enum Scanner {
31
31
  }
32
32
 
33
33
  /// One module in the `scan-modules` output. Trimmed to what `expo-modules-autolinking` needs to
34
- /// register a module: the Swift class name, the JS name it registers under, and the file it's in.
35
- /// The richer fields the visitor captures (declaration kind, raw macro arguments, line/column) are
36
- /// dropped here — they're redundant for this command (the macro is always `@ExpoModule` on a class)
37
- /// and the deep `scan-exports` surface carries the richer per-member detail instead.
34
+ /// register a module: the Swift class name, the JS name it registers under, the platforms that
35
+ /// include it, and the file it's in. The richer fields the visitor captures (declaration kind, raw
36
+ /// macro arguments, line/column) are dropped here — they're redundant for this command (the macro
37
+ /// is always `@ExpoModule` on a class) and the deep `scan-exports` surface carries the richer
38
+ /// per-member detail instead.
38
39
  struct ScannedModule: Codable, Equatable {
39
40
  /// The Swift class name the module is declared as.
40
41
  let name: String
@@ -50,14 +51,22 @@ struct ScannedModule: Codable, Equatable {
50
51
  /// classes with a diagnostic instead of emitting a provider that fails to compile.
51
52
  let accessLevel: String
52
53
 
54
+ /// The Apple OSes whose builds include this class, spelled as `os(...)` spells them and compared
55
+ /// case-sensitively. An unconditional module lists every OS. Empty means no build is known to
56
+ /// include it, because the enclosing conditions depend on flags not asserted with `--define` or
57
+ /// on conditions a static scan cannot answer (those are reported in `warnings`). The consumer
58
+ /// must not assume such a class exists.
59
+ let platforms: [String]
60
+
53
61
  /// Source file the module was found in, relative to the path the scanner was invoked with.
54
62
  let file: String
55
63
  }
56
64
 
57
65
  /// Version of the `scan-modules` output shape. Bumped on any breaking change to the envelope or to
58
66
  /// `ScannedModule`, so `expo-modules-autolinking` can verify it understands the output before
59
- /// trusting it (and fall back to config-declared modules when it doesn't).
60
- let scanModulesSchemaVersion = 1
67
+ /// trusting it (and fall back to config-declared modules when it doesn't). Version 2 added the
68
+ /// per-module `platforms` list and made the module list platform-agnostic.
69
+ let scanModulesSchemaVersion = 2
61
70
 
62
71
  /// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
63
72
  /// command's JSON output. (`scan-exports` returns its own `ScanExportsResult` shape; the two commands
@@ -73,22 +82,80 @@ struct ScanModulesResult: Codable, Equatable {
73
82
  let stats: ScanStats
74
83
  }
75
84
 
76
- /// Scans the given paths for top-level `@ExpoModule` types and returns the modules (in file then
77
- /// source order) plus the stats for the run the `scan-modules` command. Kept separate from the
78
- /// public entry (and `internal`) so tests can drive it without going through argv/stdout.
79
- func scanModules(paths: [String], configuration: ScanBuildConfiguration = .init(platform: nil, defines: [])) -> ScanModulesResult {
80
- let scan = collectDetections(paths: paths, macros: [.expoModule], configuration: configuration)
85
+ /// The Apple OSes a scan attributes modules to. `condition` is what `os(...)` matches; `reported`
86
+ /// is what the JSON carries. They are identical today, but stated separately so the output
87
+ /// contract cannot drift if the matcher's spelling changes.
88
+ private let platformUniverse: [(condition: String, reported: String)] = [
89
+ (condition: "iOS", reported: "iOS"),
90
+ (condition: "macOS", reported: "macOS"),
91
+ (condition: "tvOS", reported: "tvOS"),
92
+ (condition: "watchOS", reported: "watchOS"),
93
+ (condition: "visionOS", reported: "visionOS"),
94
+ ]
95
+
96
+ /// Scans the given paths for top-level `@ExpoModule` types and returns every module found in any
97
+ /// `#if` branch (in file then source order), each with the platforms whose builds include it, plus
98
+ /// the stats for the run — the `scan-modules` command. Kept separate from the public entry (and
99
+ /// `internal`) so tests can drive it without going through argv/stdout.
100
+ ///
101
+ /// Each file is parsed once and walked once per platform (plus once unconditionally to enumerate
102
+ /// every module): a module's `platforms` are the OSes whose evaluated walk reached it, given the
103
+ /// asserted `defines`. The scanner reports the facts; filtering to the platform being linked is the
104
+ /// consumer's call.
105
+ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResult {
106
+ var modules: [ScannedModule] = []
107
+ var warnings: [ScanWarning] = []
108
+ var seenWarnings = Set<ScanWarning>()
109
+
110
+ let stats = scanFiles(paths: paths, macros: [.expoModule]) { source, file in
111
+ let tree = Parser.parse(source: source)
112
+
113
+ // The unconditional walk enumerates every module in the file, in source order.
114
+ let allModules = DetectionVisitor(file: file, tree: tree, detectedMacros: [.expoModule], configuration: nil)
115
+ allModules.walk(tree)
81
116
 
82
- let modules = scan.detections.map {
83
- // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else the
84
- // class name.
85
- ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, accessLevel: $0.accessLevel, file: $0.file)
117
+ // One evaluated walk per OS attributes each module to the platforms that include it. The walks
118
+ // are cheap relative to the parse, which is shared.
119
+ var platformsByDetection: [String: [String]] = [:]
120
+ for platform in platformUniverse {
121
+ let configuration = ScanBuildConfiguration(platform: platform.condition, defines: defines)
122
+ let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: [.expoModule], configuration: configuration)
123
+ visitor.walk(tree)
124
+ for detection in visitor.detections {
125
+ platformsByDetection[detectionKey(detection), default: []].append(platform.reported)
126
+ }
127
+ // The same unanswerable condition diagnoses identically in every per-platform walk; report
128
+ // it once.
129
+ for warning in visitor.warnings where seenWarnings.insert(warning).inserted {
130
+ warnings.append(warning)
131
+ }
132
+ }
133
+
134
+ for detection in allModules.detections {
135
+ // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else
136
+ // the class name.
137
+ modules.append(
138
+ ScannedModule(
139
+ name: detection.name,
140
+ jsName: detection.jsName ?? detection.name,
141
+ accessLevel: detection.accessLevel,
142
+ platforms: platformsByDetection[detectionKey(detection)] ?? [],
143
+ file: detection.file
144
+ )
145
+ )
146
+ }
86
147
  }
87
148
 
88
149
  return ScanModulesResult(
89
150
  schemaVersion: scanModulesSchemaVersion,
90
151
  modules: modules,
91
- warnings: scan.warnings,
92
- stats: scan.stats
152
+ warnings: warnings,
153
+ stats: stats
93
154
  )
94
155
  }
156
+
157
+ /// Identifies one declaration across the per-platform walks of the same tree: the source position
158
+ /// is unique within a file, and the name guards against any position ambiguity.
159
+ private func detectionKey(_ detection: Detection) -> String {
160
+ return "\(detection.line):\(detection.column):\(detection.name)"
161
+ }