@expo/expo-modules-macros-plugin 0.11.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.
Binary file
@@ -5,8 +5,10 @@ import Foundation
5
5
  enum DetectedMacro: String, Codable, CaseIterable {
6
6
  case expoModule = "ExpoModule"
7
7
  case js = "JS"
8
+ case event = "Event"
8
9
  case sharedObject = "SharedObject"
9
10
  case record = "Record"
11
+ case union = "Union"
10
12
  }
11
13
 
12
14
  /// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil`
@@ -2,14 +2,17 @@ import Foundation
2
2
  import SwiftParser
3
3
  import SwiftSyntax
4
4
 
5
- /// Walks `paths`, and for each `.swift` file that might contain one of `macros` (the pre-filter passes
6
- /// it), reads the source and hands it to `process` along with the file path. Returns the run's stats.
5
+ /// Walks `paths`, and for each `.swift` file that might contain one of `macros` or one of
6
+ /// `conformances` (the pre-filter passes it), reads the source and hands it to `process` along with the
7
+ /// file path. Returns the run's stats.
8
+ ///
7
9
  /// The shared core every scan command builds on: the walk, read, pre-filter, and stats are identical;
8
10
  /// only what each command does per parsed file differs (`scan-modules` collects `Detection`s,
9
11
  /// `scan-exports` walks a `SurfaceVisitor`), and that lives in `process`.
10
12
  func scanFiles(
11
13
  paths: [String],
12
14
  macros: Set<DetectedMacro>,
15
+ conformances: Set<String> = [],
13
16
  process: (_ source: String, _ file: String) -> Void
14
17
  ) -> ScanStats {
15
18
  let clock = ContinuousClock()
@@ -19,7 +22,7 @@ func scanFiles(
19
22
  var filesParsed = 0
20
23
 
21
24
  // Compile the pre-filter regex once per run, not once per file.
22
- let prefilter = macroAttributeRegex(for: macros)
25
+ let prefilter = macroAttributeRegex(for: macros, conformances: conformances)
23
26
 
24
27
  for file in swiftFiles(in: paths) {
25
28
  guard let source = try? String(contentsOfFile: file, encoding: .utf8) else {
@@ -82,10 +85,23 @@ func detect(
82
85
  /// `@(ExpoModule|JS|Record|SharedObject)` for an `exports` scan. A precompiled `NSRegularExpression`
83
86
  /// benchmarked ~20x faster over a large source tree than calling `String.contains` once per macro
84
87
  /// name, because it scans each file in a single pass. Compiled once per run and reused per file.
85
- func macroAttributeRegex(for macros: Set<DetectedMacro>) -> NSRegularExpression {
88
+ ///
89
+ /// `conformances` adds bare (unprefixed) alternatives for types recognized by conformance rather than
90
+ /// by an attribute (`Enumerable` for `scan-exports`). They join the same alternation so the scan stays
91
+ /// one pass; a bare name matches more loosely than an `@`-prefixed one, which costs a wasted parse and
92
+ /// never a miss.
93
+ func macroAttributeRegex(
94
+ for macros: Set<DetectedMacro>,
95
+ conformances: Set<String> = []
96
+ ) -> NSRegularExpression {
86
97
  // Sort for a stable pattern regardless of the set's iteration order.
87
- let alternation = macros.map(\.rawValue).sorted().joined(separator: "|")
88
- return try! NSRegularExpression(pattern: "@(\(alternation))")
98
+ let attributes = macros.map(\.rawValue).sorted().joined(separator: "|")
99
+ var alternatives: [String] = []
100
+ if !attributes.isEmpty {
101
+ alternatives.append("@(\(attributes))")
102
+ }
103
+ alternatives.append(contentsOf: conformances.sorted())
104
+ return try! NSRegularExpression(pattern: alternatives.joined(separator: "|"))
89
105
  }
90
106
 
91
107
  /// True if the source text contains one of the pre-filter's spelled macro attributes, so it's worth
@@ -128,6 +128,36 @@ struct ExportedRecordProperty: Encodable, Equatable {
128
128
  }
129
129
  }
130
130
 
131
+ /// One `@Event var` on a module or shared object: a typed event JS listens for with
132
+ /// `addListener(jsName, …)` rather than calls.
133
+ struct ExportedEvent: Encodable, Equatable {
134
+ /// The Swift property name, e.g. `onStatusChange`.
135
+ let name: String
136
+
137
+ /// The name JS listens under: the `@Event("x")` override, else `name` with a conventional `on`
138
+ /// prefix stripped and decapitalized (`onStatusChange` -> `statusChange`).
139
+ let jsName: String
140
+
141
+ /// The single payload parameter's type, or `nil` for a no-payload `() -> Void` event.
142
+ let payload: TypeNode?
143
+
144
+ /// `@Event(sync: true)`, dispatching inline on the JS thread instead of scheduling.
145
+ let isSync: Bool
146
+
147
+ private enum CodingKeys: String, CodingKey {
148
+ case name, jsName, payload
149
+ case isSync = "sync"
150
+ }
151
+
152
+ func encode(to encoder: Encoder) throws {
153
+ var container = encoder.container(keyedBy: CodingKeys.self)
154
+ try container.encode(name, forKey: .name)
155
+ try container.encode(jsName, forKey: .jsName)
156
+ try container.encodeIfPresent(payload, forKey: .payload)
157
+ try container.encode(isSync, forKey: .isSync)
158
+ }
159
+ }
160
+
131
161
  /// A `@ExpoModule` type and its `@JS` surface.
132
162
  struct ExportedModule: Encodable, Equatable {
133
163
  /// The Swift class name.
@@ -138,6 +168,7 @@ struct ExportedModule: Encodable, Equatable {
138
168
 
139
169
  let functions: [ExportedFunction]
140
170
  let properties: [ExportedProperty]
171
+ let events: [ExportedEvent]
141
172
 
142
173
  /// Absolute source path, matching `scan-modules`.
143
174
  let file: String
@@ -156,6 +187,7 @@ struct ExportedSharedObject: Encodable, Equatable {
156
187
 
157
188
  let functions: [ExportedFunction]
158
189
  let properties: [ExportedProperty]
190
+ let events: [ExportedEvent]
159
191
 
160
192
  let file: String
161
193
  }
@@ -170,19 +202,113 @@ struct ExportedRecord: Encodable, Equatable {
170
202
  let file: String
171
203
  }
172
204
 
205
+ /// One case of a reported enum.
206
+ ///
207
+ /// What `rawValue` carries depends on the enum's raw type, and a consumer reads it that way:
208
+ /// - `String`: always present, and **decoded** (`case active = "act"` reports `act`, unquoted). A case
209
+ /// writing none takes its own name, so nothing is left to derive.
210
+ /// - an integer type: present only where written, as **source text** (`1`, `1 << 3`), since the value
211
+ /// may be an expression a syntactic scan can't evaluate. Swift also continues from the preceding
212
+ /// case's value (`case a = 1; case b` makes `b` 2), and that carry is the consumer's to apply.
213
+ /// - no raw type: always absent, since the enum has no raw values.
214
+ struct ExportedEnumCase: Encodable, Equatable {
215
+ /// The case name as declared.
216
+ let name: String
217
+
218
+ /// The raw value, decoded for a string and verbatim source text otherwise, or `nil` per the rule
219
+ /// above.
220
+ let rawValue: String?
221
+
222
+ private enum CodingKeys: String, CodingKey {
223
+ case name, rawValue
224
+ }
225
+
226
+ func encode(to encoder: Encoder) throws {
227
+ var container = encoder.container(keyedBy: CodingKeys.self)
228
+ try container.encode(name, forKey: .name)
229
+ try container.encodeIfPresent(rawValue, forKey: .rawValue)
230
+ }
231
+ }
232
+
233
+ /// An `Enumerable` enum: a type that crosses the boundary as its raw value rather than as an object.
234
+ /// Detected by conformance, not by a macro attribute, because core converts it through its
235
+ /// `RawRepresentable`/`Enumerable` conformance (`Coding/…+Enumerable`) with no macro involved.
236
+ struct ExportedEnum: Encodable, Equatable {
237
+ /// The Swift enum name. No `jsName`: an enum carries no macro to spell an override on, and it
238
+ /// reaches JS as its raw values, not under a bound name.
239
+ let name: String
240
+
241
+ /// The raw value type as written (`String`, `Int`, …), or `nil` for a bare `Enumerable` conformance
242
+ /// with no raw type. A `nil` raw type is reported as-is rather than dropped: the enum is still
243
+ /// declared convertible, and the consumer decides how to treat it.
244
+ let rawType: TypeNode?
245
+
246
+ let cases: [ExportedEnumCase]
247
+
248
+ let file: String
249
+
250
+ private enum CodingKeys: String, CodingKey {
251
+ case name, rawType, cases, file
252
+ }
253
+
254
+ func encode(to encoder: Encoder) throws {
255
+ var container = encoder.container(keyedBy: CodingKeys.self)
256
+ try container.encode(name, forKey: .name)
257
+ try container.encodeIfPresent(rawType, forKey: .rawType)
258
+ try container.encode(cases, forKey: .cases)
259
+ try container.encode(file, forKey: .file)
260
+ }
261
+ }
262
+
263
+ /// One alternative a `@Union` may hold: the Swift case name plus its payload type. Not a member in the
264
+ /// sense a module or shared object has members (functions, properties, events).
265
+ struct ExportedUnionMember: Encodable, Equatable {
266
+ /// The case name as declared. Swift-side only: discrimination is structural, so it never reaches JS,
267
+ /// but it ties an alternative back to the declaration.
268
+ let name: String
269
+
270
+ /// The associated value's type: what this alternative decodes from. Named `type` like every other
271
+ /// type-valued field here; `payload` already means an event's argument type on `ExportedEvent`.
272
+ let type: TypeNode
273
+
274
+ private enum CodingKeys: String, CodingKey {
275
+ case name, type
276
+ }
277
+ }
278
+
279
+ /// A `@Union` enum: a typed union of its cases' payload types (`A | B | C` in TypeScript).
280
+ ///
281
+ /// **`members` is ordered, and the order is part of the contract.** Decode tries each payload in
282
+ /// declaration order and takes the first that succeeds, so where two shapes overlap (`Int` and
283
+ /// `Double`, two compatible records) the earlier case wins. Reordering them describes a different
284
+ /// union than the one the module runs.
285
+ struct ExportedUnion: Encodable, Equatable {
286
+ /// The Swift enum name. No `jsName`: `@Union` takes no arguments, and a union reaches JS as its
287
+ /// payload types rather than under a bound name.
288
+ let name: String
289
+
290
+ let members: [ExportedUnionMember]
291
+
292
+ let file: String
293
+ }
294
+
173
295
  /// The exported types grouped by kind, nested under `exports` in the result so the surface is one
174
296
  /// self-contained object separate from `stats`.
175
297
  struct ExportedSurface: Encodable, Equatable {
176
298
  let modules: [ExportedModule]
177
299
  let sharedObjects: [ExportedSharedObject]
178
300
  let records: [ExportedRecord]
301
+ let enums: [ExportedEnum]
302
+ let unions: [ExportedUnion]
179
303
  }
180
304
 
181
305
  /// Version of the `scan-exports` output shape. Bumped on any breaking change to the envelope or to
182
306
  /// anything under `exports`, so a consumer can verify it understands the output before trusting it.
183
307
  /// Versioned independently of `scanModulesSchemaVersion`: the two commands serve different consumers
184
- /// and change for different reasons.
185
- let scanExportsSchemaVersion = 1
308
+ /// and change for different reasons. Version 2 added `events` to modules and shared objects; version 3
309
+ /// added `enums`; version 4 added `unions`; version 5 resolves refs, adding `refKind` and correcting
310
+ /// an enum ref's `typeof`.
311
+ let scanExportsSchemaVersion = 5
186
312
 
187
313
  /// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from
188
314
  /// `ScanModulesResult` (different consumer: TS generation vs. autolinking).
@@ -0,0 +1,199 @@
1
+ import Foundation
2
+
3
+ /// Fills in each `.ref`'s `refKind` once the whole scan is done.
4
+ ///
5
+ /// A `TypeNode` is parsed from one `TypeSyntax` in isolation, so at parse time a name like `Status` is
6
+ /// just a name: the parser has no idea whether the scan will turn up an `@Record`, an `Enumerable`
7
+ /// enum, or nothing at all. Resolution is therefore a second pass over the collected surface, run once
8
+ /// every file has been walked.
9
+ ///
10
+ /// Two things change on a resolved ref:
11
+ /// - `refKind` names which kind declares it, so a consumer doesn't repeat this lookup.
12
+ /// - the `typeof` category is corrected for an enum, which crosses as its raw value rather than as an
13
+ /// object. That correction is the reason this can't be left to the consumer: the scanner is the only
14
+ /// side that knows the enum's `rawType`.
15
+ ///
16
+ /// A name the scan never declared keeps `refKind: nil` and its `object` category. Not an error: it is
17
+ /// a platform convertible (`CGPoint`, `URL`) or a type from another module, and the consumer's own
18
+ /// catalog decides.
19
+
20
+ /// The declared names of a scanned surface, each mapped to how it should be reported at a use site.
21
+ /// Built once per scan and used for every ref lookup.
22
+ struct RefIndex {
23
+ /// `name` -> the kind declaring it, plus the JS category a ref to it crosses as.
24
+ private var entries: [String: (kind: RefKind, jsType: JSType)] = [:]
25
+
26
+ init(surface: ExportedSurface) {
27
+ for record in surface.records {
28
+ entries[record.name] = (.record, .object)
29
+ }
30
+ for sharedObject in surface.sharedObjects {
31
+ entries[sharedObject.name] = (.sharedObject, .object)
32
+ }
33
+ for union in surface.unions {
34
+ // A union crosses as whichever alternative matched, so its category is only meaningful per
35
+ // member. `object` is the honest coarse answer; the consumer reads `members` for the real shape.
36
+ entries[union.name] = (.union, .object)
37
+ }
38
+ for enumeration in surface.enums {
39
+ entries[enumeration.name] = (.enum, jsType(ofRawType: enumeration.rawType))
40
+ }
41
+ }
42
+
43
+ /// How a ref to `name` should be reported, or `nil` when the scan declares no such type.
44
+ func lookup(_ name: String) -> (kind: RefKind, jsType: JSType)? {
45
+ if let entry = entries[name] {
46
+ return entry
47
+ }
48
+ // A qualified use site (`Media.Status`) names the same type a bare declaration did. Fall back to
49
+ // the trailing component, which is how the conformance and raw-type checks already match names.
50
+ guard let trailing = name.split(separator: ".").last.map(String.init), trailing != name else {
51
+ return nil
52
+ }
53
+ return entries[trailing]
54
+ }
55
+ }
56
+
57
+ /// The JS category a raw-value enum crosses as: its raw value's. A bare `Enumerable` conformance with
58
+ /// no raw type has nothing to go on, so it stays an object.
59
+ private func jsType(ofRawType rawType: TypeNode?) -> JSType {
60
+ guard let rawType, let jsType = rawType.jsType else {
61
+ return .object
62
+ }
63
+ return jsType
64
+ }
65
+
66
+ extension TypeNode {
67
+ /// This node with every `.ref` in it resolved against `index`, recursively. Returns an unchanged
68
+ /// node when nothing in it resolves.
69
+ func resolvingRefs(using index: RefIndex) -> TypeNode {
70
+ switch self {
71
+ case .primitive, .unknown:
72
+ return self
73
+ case .ref(let name, _, _):
74
+ guard let entry = index.lookup(name) else {
75
+ return self
76
+ }
77
+ // Only an enum crosses as something other than an object, so only it carries an override.
78
+ return .ref(
79
+ name: name, refKind: entry.kind, jsTypeOverride: entry.jsType == .object ? nil : entry.jsType)
80
+ case .optional(let wrapped):
81
+ return .optional(wrapped: wrapped.resolvingRefs(using: index))
82
+ case .array(let element):
83
+ return .array(element: element.resolvingRefs(using: index))
84
+ case .dictionary(let key, let value):
85
+ return .dictionary(
86
+ key: key.resolvingRefs(using: index), value: value.resolvingRefs(using: index))
87
+ case .promise(let value):
88
+ return .promise(value: value.resolvingRefs(using: index))
89
+ case .function(let parameters, let returns, let isAsync, let isThrowing):
90
+ return .function(
91
+ parameters: parameters.map { $0.resolvingRefs(using: index) },
92
+ returns: returns?.resolvingRefs(using: index),
93
+ isAsync: isAsync,
94
+ isThrowing: isThrowing
95
+ )
96
+ }
97
+ }
98
+ }
99
+
100
+ extension ExportedSurface {
101
+ /// This surface with every `.ref` in every reported type resolved against its own declarations. The
102
+ /// second pass `scanExports` runs once the walk is done.
103
+ ///
104
+ /// Only the boundary types are rewritten; names, flags, and ordering are untouched. The declared
105
+ /// types (a record's own name, a union's members) are not themselves refs, so they carry no
106
+ /// `refKind`: it is the *use sites* that gain one.
107
+ func resolvingRefs() -> ExportedSurface {
108
+ let index = RefIndex(surface: self)
109
+ return ExportedSurface(
110
+ modules: modules.map { module in
111
+ ExportedModule(
112
+ name: module.name,
113
+ jsName: module.jsName,
114
+ functions: module.functions.map { $0.resolvingRefs(using: index) },
115
+ properties: module.properties.map { $0.resolvingRefs(using: index) },
116
+ events: module.events.map { $0.resolvingRefs(using: index) },
117
+ file: module.file
118
+ )
119
+ },
120
+ sharedObjects: sharedObjects.map { sharedObject in
121
+ ExportedSharedObject(
122
+ name: sharedObject.name,
123
+ jsName: sharedObject.jsName,
124
+ constructorParameters: sharedObject.constructorParameters?.map { $0.resolvingRefs(using: index) },
125
+ functions: sharedObject.functions.map { $0.resolvingRefs(using: index) },
126
+ properties: sharedObject.properties.map { $0.resolvingRefs(using: index) },
127
+ events: sharedObject.events.map { $0.resolvingRefs(using: index) },
128
+ file: sharedObject.file
129
+ )
130
+ },
131
+ records: records.map { record in
132
+ ExportedRecord(
133
+ name: record.name,
134
+ properties: record.properties.map { property in
135
+ ExportedRecordProperty(
136
+ name: property.name,
137
+ type: property.type.resolvingRefs(using: index),
138
+ isOptional: property.isOptional,
139
+ hasDefault: property.hasDefault
140
+ )
141
+ },
142
+ file: record.file
143
+ )
144
+ },
145
+ // An enum's raw type is a primitive or an unscanned spelling, never a ref to another scanned
146
+ // type, so there is nothing in one to resolve.
147
+ enums: enums,
148
+ unions: unions.map { union in
149
+ ExportedUnion(
150
+ name: union.name,
151
+ members: union.members.map { member in
152
+ ExportedUnionMember(name: member.name, type: member.type.resolvingRefs(using: index))
153
+ },
154
+ file: union.file
155
+ )
156
+ }
157
+ )
158
+ }
159
+ }
160
+
161
+ extension ExportedParameter {
162
+ fileprivate func resolvingRefs(using index: RefIndex) -> ExportedParameter {
163
+ return ExportedParameter(
164
+ label: label, name: name, type: type.resolvingRefs(using: index), isOptional: isOptional)
165
+ }
166
+ }
167
+
168
+ extension ExportedFunction {
169
+ fileprivate func resolvingRefs(using index: RefIndex) -> ExportedFunction {
170
+ return ExportedFunction(
171
+ name: name,
172
+ jsName: jsName,
173
+ parameters: parameters.map { $0.resolvingRefs(using: index) },
174
+ returns: returns?.resolvingRefs(using: index),
175
+ isAsync: isAsync,
176
+ isThrowing: isThrowing,
177
+ isStatic: isStatic
178
+ )
179
+ }
180
+ }
181
+
182
+ extension ExportedProperty {
183
+ fileprivate func resolvingRefs(using index: RefIndex) -> ExportedProperty {
184
+ return ExportedProperty(
185
+ name: name,
186
+ jsName: jsName,
187
+ type: type?.resolvingRefs(using: index),
188
+ isSettable: isSettable,
189
+ isStatic: isStatic
190
+ )
191
+ }
192
+ }
193
+
194
+ extension ExportedEvent {
195
+ fileprivate func resolvingRefs(using index: RefIndex) -> ExportedEvent {
196
+ return ExportedEvent(
197
+ name: name, jsName: jsName, payload: payload?.resolvingRefs(using: index), isSync: isSync)
198
+ }
199
+ }
@@ -22,27 +22,45 @@ extension Scanner {
22
22
  }
23
23
  }
24
24
 
25
- /// Scans `paths` for `@ExpoModule`, `@SharedObject`, and `@Record` types and returns their exported
26
- /// surface plus the run's stats. Separate from the public entry so tests can drive it without
27
- /// argv/stdout. The shared `scanFiles` walk + pre-filter selects files; a `SurfaceVisitor` extracts
28
- /// each one.
25
+ /// Scans `paths` for `@ExpoModule`, `@SharedObject`, `@Record`, and `@Union` types plus `Enumerable`
26
+ /// enums, and returns their exported surface plus the run's stats. Separate from the public entry so
27
+ /// tests can drive it without argv/stdout.
28
+ ///
29
+ /// The pre-filter omits `@Event`: an event only declares a member of one of these three types, so a
30
+ /// file containing one already matches on its enclosing type. It does include `Enumerable`, which is a
31
+ /// conformance rather than an attribute: an enum is commonly declared in a file of its own, which no
32
+ /// macro attribute would match.
29
33
  func scanExports(paths: [String]) -> ScanExportsResult {
30
34
  var modules: [ExportedModule] = []
31
35
  var sharedObjects: [ExportedSharedObject] = []
32
36
  var records: [ExportedRecord] = []
37
+ var enums: [ExportedEnum] = []
38
+ var unions: [ExportedUnion] = []
33
39
 
34
- let stats = scanFiles(paths: paths, macros: [.expoModule, .sharedObject, .record]) { source, file in
40
+ let stats = scanFiles(
41
+ paths: paths,
42
+ macros: [.expoModule, .sharedObject, .record, .union],
43
+ conformances: [enumerableConformanceName]
44
+ ) { source, file in
35
45
  let tree = Parser.parse(source: source)
36
46
  let visitor = SurfaceVisitor(file: file)
37
47
  visitor.walk(tree)
38
48
  modules.append(contentsOf: visitor.modules)
39
49
  sharedObjects.append(contentsOf: visitor.sharedObjects)
40
50
  records.append(contentsOf: visitor.records)
51
+ enums.append(contentsOf: visitor.enums)
52
+ unions.append(contentsOf: visitor.unions)
41
53
  }
42
54
 
55
+ // Refs resolve only once every file has been walked: a type parsed in one file may name a type
56
+ // declared in another, so the full set of declarations has to exist before any lookup is valid.
57
+ let surface = ExportedSurface(
58
+ modules: modules, sharedObjects: sharedObjects, records: records, enums: enums, unions: unions
59
+ ).resolvingRefs()
60
+
43
61
  return ScanExportsResult(
44
62
  schemaVersion: scanExportsSchemaVersion,
45
- exports: ExportedSurface(modules: modules, sharedObjects: sharedObjects, records: records),
63
+ exports: surface,
46
64
  stats: stats
47
65
  )
48
66
  }
@@ -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)?"
package/build/types.d.ts CHANGED
@@ -43,11 +43,21 @@ export type TypeNode = {
43
43
  async: boolean;
44
44
  throws: boolean;
45
45
  }
46
- /** Any other named type (record, shared object, enum, ...); `name` may be qualified. */
46
+ /**
47
+ * Any other named type; `name` may be qualified.
48
+ *
49
+ * `refKind` says which of this surface's arrays declares that name, so you don't have to look it
50
+ * up. It is absent when the scan declares no such type: a platform convertible (`CGPoint`, `URL`)
51
+ * or a type from another module, which your own catalog resolves.
52
+ *
53
+ * `typeof` follows the resolved kind, so a raw-value enum reports `string` or `number` rather than
54
+ * `object`. Only the scanner can determine that, since it comes from the declaration's `rawType`.
55
+ */
47
56
  | {
48
57
  kind: 'ref';
49
58
  typeof: JSType;
50
59
  name: string;
60
+ refKind?: 'record' | 'sharedObject' | 'enum' | 'union';
51
61
  }
52
62
  /** A type the scanner couldn't interpret; `text` is its source spelling. */
53
63
  | {
@@ -101,9 +111,24 @@ export interface ExportedModule {
101
111
  jsName: string;
102
112
  functions: ExportedFunction[];
103
113
  properties: ExportedProperty[];
114
+ events: ExportedEvent[];
104
115
  /** Absolute source path. */
105
116
  file: string;
106
117
  }
118
+ /** One `@Event var`: a typed event JS listens for by name, rather than calls. */
119
+ export interface ExportedEvent {
120
+ /** The Swift property name, e.g. `onStatusChange`. */
121
+ name: string;
122
+ /**
123
+ * The name JS listens under: the `@Event("x")` override, else `name` with a conventional `on`
124
+ * prefix stripped and decapitalized (`onStatusChange` -> `statusChange`).
125
+ */
126
+ jsName: string;
127
+ /** The payload type, absent for a no-payload `() -> Void` event. */
128
+ payload?: TypeNode;
129
+ /** `@Event(sync: true)`, dispatching inline on the JS thread instead of scheduling. */
130
+ sync: boolean;
131
+ }
107
132
  /** A `@SharedObject` type: a JS class with an optional constructor plus its `@JS` members. */
108
133
  export interface ExportedSharedObject {
109
134
  name: string;
@@ -112,6 +137,7 @@ export interface ExportedSharedObject {
112
137
  constructorParameters?: ExportedParameter[];
113
138
  functions: ExportedFunction[];
114
139
  properties: ExportedProperty[];
140
+ events: ExportedEvent[];
115
141
  file: string;
116
142
  }
117
143
  /** A `@Record` type and its properties. */
@@ -120,11 +146,63 @@ export interface ExportedRecord {
120
146
  properties: ExportedRecordProperty[];
121
147
  file: string;
122
148
  }
149
+ /**
150
+ * One case of a reported enum.
151
+ *
152
+ * What `rawValue` carries depends on the enum's raw type:
153
+ * - `String`: always present, and decoded (`case active = "act"` reports `act`, unquoted). A case
154
+ * writing none takes its own name, so nothing is left to derive.
155
+ * - an integer type: present only where written, as source text (`1`, `1 << 3`), since the value may
156
+ * be an expression a syntactic scan cannot evaluate. Swift also continues from the preceding case's
157
+ * value (`case a = 1; case b` makes `b` 2), and that carry is yours to apply.
158
+ * - no raw type: always absent, since the enum has no raw values.
159
+ */
160
+ export interface ExportedEnumCase {
161
+ /** The case name as declared. */
162
+ name: string;
163
+ /** The raw value, decoded for a string and verbatim source text otherwise, or absent per the rule above. */
164
+ rawValue?: string;
165
+ }
166
+ /**
167
+ * An `Enumerable` enum: a type crossing the boundary as its raw value rather than as an object.
168
+ * Detected by conformance, not by a macro attribute, so it carries no `jsName`.
169
+ */
170
+ export interface ExportedEnum {
171
+ name: string;
172
+ /** The raw value type as written, absent for a bare `Enumerable` conformance with no raw type. */
173
+ rawType?: TypeNode;
174
+ cases: ExportedEnumCase[];
175
+ file: string;
176
+ }
177
+ /**
178
+ * One alternative a `@Union` may hold. Not a member in the sense a module or shared object has members
179
+ * (functions, properties, events).
180
+ */
181
+ export interface ExportedUnionMember {
182
+ /** The Swift case name. Swift-side only: discrimination is structural, so it never reaches JS. */
183
+ name: string;
184
+ /** The associated value's type: what this alternative decodes from. */
185
+ type: TypeNode;
186
+ }
187
+ /**
188
+ * A `@Union` enum: a typed union of its members' payload types (`A | B | C`).
189
+ *
190
+ * `members` is ordered, and the order is part of the contract: decode takes the first payload that
191
+ * succeeds, so where two shapes overlap (`Int` and `Double`, two compatible records) the earlier one
192
+ * wins. Reordering them describes a different union than the one the module runs.
193
+ */
194
+ export interface ExportedUnion {
195
+ name: string;
196
+ members: ExportedUnionMember[];
197
+ file: string;
198
+ }
123
199
  /** The exported types grouped by kind. */
124
200
  export interface ExportedSurface {
125
201
  modules: ExportedModule[];
126
202
  sharedObjects: ExportedSharedObject[];
127
203
  records: ExportedRecord[];
204
+ enums: ExportedEnum[];
205
+ unions: ExportedUnion[];
128
206
  }
129
207
  /** A `#if` condition the scan couldn't answer statically. */
130
208
  export interface ScanWarning {
@@ -136,7 +214,7 @@ export interface ScanWarning {
136
214
  export interface ScanStats {
137
215
  /** `.swift` files the walk found and read. */
138
216
  filesScanned: number;
139
- /** Of those, how many contained a macro attribute and so were parsed. */
217
+ /** Of those, how many matched the pre-filter (a macro attribute or a scanned conformance). */
140
218
  filesParsed: number;
141
219
  /** Wall-clock duration of the scan, in milliseconds. */
142
220
  durationMs: number;
@@ -183,4 +261,4 @@ export interface ScanExportsResult {
183
261
  * as a clear error instead of silently misread fields.
184
262
  */
185
263
  export declare const SUPPORTED_SCAN_MODULES_SCHEMA_VERSION = 2;
186
- export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 1;
264
+ export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 5;
package/build/types.js CHANGED
@@ -12,4 +12,4 @@ exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = exports.SUPPORTED_SCAN_MODULES_S
12
12
  * as a clear error instead of silently misread fields.
13
13
  */
14
14
  exports.SUPPORTED_SCAN_MODULES_SCHEMA_VERSION = 2;
15
- exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 1;
15
+ exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 5;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/expo-modules-macros-plugin",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Swift macro plugin for Expo modules",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",