expo-modules-macros 0.18.0 → 0.19.1

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.
@@ -158,7 +158,8 @@ struct ExportedEvent: Encodable, Equatable {
158
158
 
159
159
  /// A `@ExpoModule` type and its `@JS` surface.
160
160
  struct ExportedModule: Encodable, Equatable {
161
- /// The Swift class name.
161
+ /// The Swift class name. A module is always declared at file scope, since autolinking registers no
162
+ /// other.
162
163
  let name: String
163
164
 
164
165
  /// The JS module name: `@ExpoModule("Foo")` override, else the class name.
@@ -178,7 +179,9 @@ struct ExportedModule: Encodable, Equatable {
178
179
 
179
180
  /// One entry of `@ExpoModule(classes: [...])`.
180
181
  struct ExportedClass: Encodable, Equatable {
181
- /// The Swift name as spelled in the argument, e.g. `Database` or `Outer.Statement`.
182
+ /// The `name` of the matching `@SharedObject`, which is qualified when the class is nested
183
+ /// (`Outer.Statement`). The Swift name as spelled in the argument when the scan found no
184
+ /// `@SharedObject` with this name.
182
185
  let name: String
183
186
 
184
187
  /// The name of the module member JS reads the class from: the matching `@SharedObject`'s `jsName`.
@@ -189,10 +192,11 @@ struct ExportedClass: Encodable, Equatable {
189
192
 
190
193
  /// A `@SharedObject` type: a JS class with an optional `@JS init` constructor plus its `@JS` members.
191
194
  struct ExportedSharedObject: Encodable, Equatable {
192
- /// The Swift class name.
195
+ /// The Swift class name, qualified with the types it is nested in (`Outer.Player`).
193
196
  let name: String
194
197
 
195
- /// The JS class name: `@SharedObject("Foo")` override, else the class name.
198
+ /// The JS class name: `@SharedObject("Foo")` override, else the class name without the enclosing
199
+ /// types.
196
200
  let jsName: String
197
201
 
198
202
  /// The `@JS init` parameters, or `nil` when there's none. A shared object has at most one.
@@ -207,7 +211,8 @@ struct ExportedSharedObject: Encodable, Equatable {
207
211
 
208
212
  /// A `@Record` type and its properties (data only: no functions, accessors, or constructor).
209
213
  struct ExportedRecord: Encodable, Equatable {
210
- /// The Swift type name (struct or class).
214
+ /// The Swift type name (struct or class), qualified with the types it is nested in
215
+ /// (`Outer.Options`).
211
216
  let name: String
212
217
 
213
218
  let properties: [ExportedRecordProperty]
@@ -247,8 +252,9 @@ struct ExportedEnumCase: Encodable, Equatable {
247
252
  /// Detected by conformance, not by a macro attribute, because core converts it through its
248
253
  /// `RawRepresentable`/`Enumerable` conformance (`Coding/…+Enumerable`) with no macro involved.
249
254
  struct ExportedEnum: Encodable, Equatable {
250
- /// The Swift enum name. No `jsName`: an enum carries no macro to spell an override on, and it
251
- /// reaches JS as its raw values, not under a bound name.
255
+ /// The Swift enum name, qualified with the types it is nested in (`Options.Encoding`). No `jsName`:
256
+ /// an enum carries no macro to spell an override on, and it reaches JS as its raw values, not under
257
+ /// a bound name.
252
258
  let name: String
253
259
 
254
260
  /// The raw value type as written (`String`, `Int`, …), or `nil` for a bare `Enumerable` conformance
@@ -296,8 +302,9 @@ struct ExportedUnionMember: Encodable, Equatable {
296
302
  /// `Double`, two compatible records) the earlier case wins. Reordering them describes a different
297
303
  /// union than the one the module runs.
298
304
  struct ExportedUnion: Encodable, Equatable {
299
- /// The Swift enum name. No `jsName`: `@Union` takes no arguments, and a union reaches JS as its
300
- /// payload types rather than under a bound name.
305
+ /// The Swift enum name, qualified with the types it is nested in (`Options.Value`). No `jsName`:
306
+ /// `@Union` takes no arguments, and a union reaches JS as its payload types rather than under a bound
307
+ /// name.
301
308
  let name: String
302
309
 
303
310
  let members: [ExportedUnionMember]
@@ -320,8 +327,9 @@ struct ExportedSurface: Encodable, Equatable {
320
327
  /// Versioned independently of `scanModulesSchemaVersion`: the two commands serve different consumers
321
328
  /// and change for different reasons. Version 2 added `events` to modules and shared objects; version 3
322
329
  /// added `enums`; version 4 added `unions`; version 5 resolves refs, adding `refKind` and correcting
323
- /// an enum ref's `typeof`; version 6 added `classes` to modules.
324
- let scanExportsSchemaVersion = 6
330
+ /// an enum ref's `typeof`; version 6 added `classes` to modules; version 7 reports nested types under
331
+ /// qualified names and gives a resolved ref the name of its declaration.
332
+ let scanExportsSchemaVersion = 7
325
333
 
326
334
  /// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from
327
335
  /// `ScanModulesResult` (different consumer: TS generation vs. autolinking).
@@ -5,65 +5,88 @@
5
5
  /// enum, or nothing at all. Resolution is therefore a second pass over the collected surface, run once
6
6
  /// every file has been walked.
7
7
  ///
8
- /// Two things change on a resolved ref:
8
+ /// A name is looked up the way Swift looks it up: from the type the use site is written in, outward to
9
+ /// file scope. In `Options`, `Encoding` names `Options.Encoding` when the scan declares one, else a
10
+ /// file-scope `Encoding`.
11
+ ///
12
+ /// Three things change on a resolved ref:
13
+ /// - `name` becomes the declaration's qualified name, so a consumer can match it to the declaration
14
+ /// without repeating the scoped lookup.
9
15
  /// - `refKind` names which kind declares it, so a consumer doesn't repeat this lookup.
10
16
  /// - the `typeof` category is corrected for an enum, which crosses as its raw value rather than as an
11
17
  /// object. That correction is the reason this can't be left to the consumer: the scanner is the only
12
18
  /// side that knows the enum's `rawType`.
13
19
  ///
14
- /// A name the scan never declared keeps `refKind: nil` and its `object` category. Not an error: it is
15
- /// a platform convertible (`CGPoint`, `URL`) or a type from another module, and the consumer's own
16
- /// catalog decides.
20
+ /// A name the scan never declared keeps its spelling, `refKind: nil`, and its `object` category. Not
21
+ /// an error: it is a platform convertible (`CGPoint`, `URL`) or a type from another module, and the
22
+ /// consumer's own catalog decides.
17
23
 
18
24
  /// The declared names of a scanned surface, each mapped to how it should be reported at a use site.
19
25
  /// Built once per scan and used for every ref lookup.
20
26
  struct RefIndex {
21
- /// `name` -> the kind declaring it, plus the JS category a ref to it crosses as.
22
- private var entries: [String: (kind: RefKind, jsType: JSType)] = [:]
27
+ /// The declaration a ref resolves to.
28
+ struct Entry: Equatable {
29
+ /// The declaration's qualified name.
30
+ let name: String
31
+ let kind: RefKind
32
+ /// The JS category a ref to the declaration crosses as.
33
+ let jsType: JSType
34
+ /// A `@SharedObject`'s JS name, `nil` for every other kind.
35
+ var jsName: String? = nil
36
+ }
23
37
 
24
- /// A `@SharedObject`'s Swift name -> its JS name.
25
- private var sharedObjectJSNames: [String: String] = [:]
38
+ /// A declaration's qualified name -> its entry.
39
+ private var entries: [String: Entry] = [:]
26
40
 
27
41
  init(surface: ExportedSurface) {
28
42
  for record in surface.records {
29
- entries[record.name] = (.record, .object)
43
+ entries[record.name] = Entry(name: record.name, kind: .record, jsType: .object)
30
44
  }
31
45
  for sharedObject in surface.sharedObjects {
32
- entries[sharedObject.name] = (.sharedObject, .object)
33
- sharedObjectJSNames[sharedObject.name] = sharedObject.jsName
46
+ entries[sharedObject.name] = Entry(
47
+ name: sharedObject.name, kind: .sharedObject, jsType: .object, jsName: sharedObject.jsName)
34
48
  }
35
49
  for union in surface.unions {
36
50
  // A union crosses as whichever alternative matched, so its category is only meaningful per
37
51
  // member. `object` is the honest coarse answer; the consumer reads `members` for the real shape.
38
- entries[union.name] = (.union, .object)
52
+ entries[union.name] = Entry(name: union.name, kind: .union, jsType: .object)
39
53
  }
40
54
  for enumeration in surface.enums {
41
- entries[enumeration.name] = (.enum, jsType(ofRawType: enumeration.rawType))
55
+ entries[enumeration.name] = Entry(
56
+ name: enumeration.name, kind: .enum, jsType: jsType(ofRawType: enumeration.rawType))
42
57
  }
43
58
  }
44
59
 
45
- /// How a ref to `name` should be reported, or `nil` when the scan declares no such type.
46
- func lookup(_ name: String) -> (kind: RefKind, jsType: JSType)? {
47
- lookupQualified(name, in: entries)
48
- }
49
-
50
- /// The JS name of the `@SharedObject` declared as `name`, or `nil` when the scan declares none.
51
- func sharedObjectJSName(_ name: String) -> String? {
52
- lookupQualified(name, in: sharedObjectJSNames)
53
- }
54
- }
55
-
56
- /// The value for `name` in `table`, falling back to its trailing component. A qualified use site
57
- /// (`Media.Status`) names the same type a bare declaration did, which is how the conformance and
58
- /// raw-type checks already match names.
59
- private func lookupQualified<Value>(_ name: String, in table: [String: Value]) -> Value? {
60
- if let value = table[name] {
61
- return value
62
- }
63
- guard let trailing = name.split(separator: ".").last.map(String.init), trailing != name else {
60
+ /// The declaration a ref to `name` written inside the type `scope` resolves to, or `nil` when the
61
+ /// scan declares no such type. `scope` is the qualified name of the type the use site is written
62
+ /// in.
63
+ ///
64
+ /// The name is first looked up in `scope` and then in each enclosing scope out to file scope, so the
65
+ /// innermost declaration wins, as in Swift. A leading `Self.` names `scope` itself, so it is dropped
66
+ /// before the lookup. When that finds nothing, the leading components of a qualified spelling are
67
+ /// dropped one at a time (`Media.Status` -> `Status`): a use site may qualify a name with a module
68
+ /// the scan doesn't see, which is how the conformance and raw-type checks already match names.
69
+ func lookup(_ name: String, in scope: String) -> Entry? {
70
+ let name = name.hasPrefix("Self.") ? String(name.dropFirst("Self.".count)) : name
71
+ var components = scope.split(separator: ".").map(String.init)
72
+ while true {
73
+ if let entry = entries[(components + [name]).joined(separator: ".")] {
74
+ return entry
75
+ }
76
+ if components.isEmpty {
77
+ break
78
+ }
79
+ components.removeLast()
80
+ }
81
+ var suffix = name.split(separator: ".").map(String.init).dropFirst()
82
+ while !suffix.isEmpty {
83
+ if let entry = entries[suffix.joined(separator: ".")] {
84
+ return entry
85
+ }
86
+ suffix = suffix.dropFirst()
87
+ }
64
88
  return nil
65
89
  }
66
- return table[trailing]
67
90
  }
68
91
 
69
92
  /// The JS category a raw-value enum crosses as: its raw value's. A bare `Enumerable` conformance with
@@ -76,32 +99,35 @@ private func jsType(ofRawType rawType: TypeNode?) -> JSType {
76
99
  }
77
100
 
78
101
  extension TypeNode {
79
- /// This node with every `.ref` in it resolved against `index`, recursively. Returns an unchanged
80
- /// node when nothing in it resolves.
81
- func resolvingRefs(using index: RefIndex) -> TypeNode {
102
+ /// This node with every `.ref` in it resolved against `index` from the type `scope`, recursively.
103
+ /// Returns an unchanged node when nothing in it resolves.
104
+ func resolvingRefs(using index: RefIndex, in scope: String) -> TypeNode {
82
105
  switch self {
83
106
  case .primitive, .unknown:
84
107
  return self
85
108
  case .ref(let name, _, _):
86
- guard let entry = index.lookup(name) else {
109
+ guard let entry = index.lookup(name, in: scope) else {
87
110
  return self
88
111
  }
89
112
  // Only an enum crosses as something other than an object, so only it carries an override.
90
113
  return .ref(
91
- name: name, refKind: entry.kind, jsTypeOverride: entry.jsType == .object ? nil : entry.jsType)
114
+ name: entry.name,
115
+ refKind: entry.kind,
116
+ jsTypeOverride: entry.jsType == .object ? nil : entry.jsType)
92
117
  case .optional(let wrapped):
93
- return .optional(wrapped: wrapped.resolvingRefs(using: index))
118
+ return .optional(wrapped: wrapped.resolvingRefs(using: index, in: scope))
94
119
  case .array(let element):
95
- return .array(element: element.resolvingRefs(using: index))
120
+ return .array(element: element.resolvingRefs(using: index, in: scope))
96
121
  case .dictionary(let key, let value):
97
122
  return .dictionary(
98
- key: key.resolvingRefs(using: index), value: value.resolvingRefs(using: index))
123
+ key: key.resolvingRefs(using: index, in: scope),
124
+ value: value.resolvingRefs(using: index, in: scope))
99
125
  case .promise(let value):
100
- return .promise(value: value.resolvingRefs(using: index))
126
+ return .promise(value: value.resolvingRefs(using: index, in: scope))
101
127
  case .function(let parameters, let returns, let isAsync, let isThrowing):
102
128
  return .function(
103
- parameters: parameters.map { $0.resolvingRefs(using: index) },
104
- returns: returns?.resolvingRefs(using: index),
129
+ parameters: parameters.map { $0.resolvingRefs(using: index, in: scope) },
130
+ returns: returns?.resolvingRefs(using: index, in: scope),
105
131
  isAsync: isAsync,
106
132
  isThrowing: isThrowing
107
133
  )
@@ -113,9 +139,10 @@ extension ExportedSurface {
113
139
  /// This surface with every `.ref` in every reported type resolved against its own declarations. The
114
140
  /// second pass `scanExports` runs once the walk is done.
115
141
  ///
116
- /// Only the boundary types are rewritten; names, flags, and ordering are untouched. The declared
117
- /// types (a record's own name, a union's members) are not themselves refs, so they carry no
118
- /// `refKind`: it is the *use sites* that gain one.
142
+ /// Each type's refs are looked up from that type, so a ref resolves to a type nested in it before
143
+ /// one at file scope. Only the boundary types are rewritten; names, flags, and ordering are
144
+ /// untouched. The declared types (a record's own name, a union's members) are not themselves refs,
145
+ /// so they carry no `refKind`: it is the *use sites* that gain one.
119
146
  func resolvingRefs() -> ExportedSurface {
120
147
  let index = RefIndex(surface: self)
121
148
  return ExportedSurface(
@@ -123,11 +150,15 @@ extension ExportedSurface {
123
150
  ExportedModule(
124
151
  name: module.name,
125
152
  jsName: module.jsName,
126
- functions: module.functions.map { $0.resolvingRefs(using: index) },
127
- properties: module.properties.map { $0.resolvingRefs(using: index) },
128
- events: module.events.map { $0.resolvingRefs(using: index) },
129
- classes: module.classes.map {
130
- ExportedClass(name: $0.name, jsName: index.sharedObjectJSName($0.name))
153
+ functions: module.functions.map { $0.resolvingRefs(using: index, in: module.name) },
154
+ properties: module.properties.map { $0.resolvingRefs(using: index, in: module.name) },
155
+ events: module.events.map { $0.resolvingRefs(using: index, in: module.name) },
156
+ classes: module.classes.map { exportedClass in
157
+ guard let entry = index.lookup(exportedClass.name, in: module.name),
158
+ entry.kind == .sharedObject else {
159
+ return exportedClass
160
+ }
161
+ return ExportedClass(name: entry.name, jsName: entry.jsName)
131
162
  },
132
163
  file: module.file
133
164
  )
@@ -136,10 +167,12 @@ extension ExportedSurface {
136
167
  ExportedSharedObject(
137
168
  name: sharedObject.name,
138
169
  jsName: sharedObject.jsName,
139
- constructorParameters: sharedObject.constructorParameters?.map { $0.resolvingRefs(using: index) },
140
- functions: sharedObject.functions.map { $0.resolvingRefs(using: index) },
141
- properties: sharedObject.properties.map { $0.resolvingRefs(using: index) },
142
- events: sharedObject.events.map { $0.resolvingRefs(using: index) },
170
+ constructorParameters: sharedObject.constructorParameters?.map {
171
+ $0.resolvingRefs(using: index, in: sharedObject.name)
172
+ },
173
+ functions: sharedObject.functions.map { $0.resolvingRefs(using: index, in: sharedObject.name) },
174
+ properties: sharedObject.properties.map { $0.resolvingRefs(using: index, in: sharedObject.name) },
175
+ events: sharedObject.events.map { $0.resolvingRefs(using: index, in: sharedObject.name) },
143
176
  file: sharedObject.file
144
177
  )
145
178
  },
@@ -149,7 +182,7 @@ extension ExportedSurface {
149
182
  properties: record.properties.map { property in
150
183
  ExportedRecordProperty(
151
184
  name: property.name,
152
- type: property.type.resolvingRefs(using: index),
185
+ type: property.type.resolvingRefs(using: index, in: record.name),
153
186
  isOptional: property.isOptional,
154
187
  hasDefault: property.hasDefault
155
188
  )
@@ -164,7 +197,8 @@ extension ExportedSurface {
164
197
  ExportedUnion(
165
198
  name: union.name,
166
199
  members: union.members.map { member in
167
- ExportedUnionMember(name: member.name, type: member.type.resolvingRefs(using: index))
200
+ ExportedUnionMember(
201
+ name: member.name, type: member.type.resolvingRefs(using: index, in: union.name))
168
202
  },
169
203
  file: union.file
170
204
  )
@@ -174,19 +208,19 @@ extension ExportedSurface {
174
208
  }
175
209
 
176
210
  extension ExportedParameter {
177
- fileprivate func resolvingRefs(using index: RefIndex) -> ExportedParameter {
211
+ fileprivate func resolvingRefs(using index: RefIndex, in scope: String) -> ExportedParameter {
178
212
  return ExportedParameter(
179
- label: label, name: name, type: type.resolvingRefs(using: index), isOptional: isOptional)
213
+ label: label, name: name, type: type.resolvingRefs(using: index, in: scope), isOptional: isOptional)
180
214
  }
181
215
  }
182
216
 
183
217
  extension ExportedFunction {
184
- fileprivate func resolvingRefs(using index: RefIndex) -> ExportedFunction {
218
+ fileprivate func resolvingRefs(using index: RefIndex, in scope: String) -> ExportedFunction {
185
219
  return ExportedFunction(
186
220
  name: name,
187
221
  jsName: jsName,
188
- parameters: parameters.map { $0.resolvingRefs(using: index) },
189
- returns: returns?.resolvingRefs(using: index),
222
+ parameters: parameters.map { $0.resolvingRefs(using: index, in: scope) },
223
+ returns: returns?.resolvingRefs(using: index, in: scope),
190
224
  isAsync: isAsync,
191
225
  isThrowing: isThrowing,
192
226
  isStatic: isStatic
@@ -195,11 +229,11 @@ extension ExportedFunction {
195
229
  }
196
230
 
197
231
  extension ExportedProperty {
198
- fileprivate func resolvingRefs(using index: RefIndex) -> ExportedProperty {
232
+ fileprivate func resolvingRefs(using index: RefIndex, in scope: String) -> ExportedProperty {
199
233
  return ExportedProperty(
200
234
  name: name,
201
235
  jsName: jsName,
202
- type: type?.resolvingRefs(using: index),
236
+ type: type?.resolvingRefs(using: index, in: scope),
203
237
  isSettable: isSettable,
204
238
  isStatic: isStatic
205
239
  )
@@ -207,8 +241,8 @@ extension ExportedProperty {
207
241
  }
208
242
 
209
243
  extension ExportedEvent {
210
- fileprivate func resolvingRefs(using index: RefIndex) -> ExportedEvent {
244
+ fileprivate func resolvingRefs(using index: RefIndex, in scope: String) -> ExportedEvent {
211
245
  return ExportedEvent(
212
- name: name, jsName: jsName, payload: payload?.resolvingRefs(using: index), isSync: isSync)
246
+ name: name, jsName: jsName, payload: payload?.resolvingRefs(using: index, in: scope), isSync: isSync)
213
247
  }
214
248
  }
@@ -1,10 +1,14 @@
1
1
  import SwiftSyntax
2
2
 
3
- /// Extracts the full JS-exported surface of every top-level `@ExpoModule`, `@SharedObject`, and
4
- /// `@Record` type: their `@JS` members and record properties. The deep counterpart to
5
- /// `DetectionVisitor`. Recognition is purely syntactic and re-reads what the macros read (the macro
6
- /// target can't be imported), so it stays in step with `JSFunction` / `JSProperty` / `JSConstructor` /
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
7
  /// `RecordProperty`.
8
+ ///
9
+ /// Types nested in another type's body, or in a file-scope `extension`, are reported under their
10
+ /// qualified Swift name (`Options.Encoding`), so a ref to one can be resolved from the scope it is
11
+ /// written in. A type declared in a function body or a closure is local and is not reported.
8
12
  final class SurfaceVisitor: SyntaxVisitor {
9
13
  private let file: String
10
14
  private(set) var modules: [ExportedModule] = []
@@ -19,19 +23,48 @@ final class SurfaceVisitor: SyntaxVisitor {
19
23
  }
20
24
 
21
25
  override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
22
- if isTopLevel(node) {
23
- classify(name: node.name.identifierName, attributes: node.attributes, members: node.memberBlock.members)
26
+ guard let scope = enclosingTypes(of: node) else {
27
+ return .skipChildren
24
28
  }
25
- // The member walk reads the body itself; nested types aren't part of this surface (matching
26
- // `DetectionVisitor`'s top-level-only scope), so there's no reason to descend.
27
- return .skipChildren
29
+ classify(
30
+ name: node.name.identifierName, scope: scope, attributes: node.attributes, members: node.memberBlock.members)
31
+ // The member walk reads the body itself; the children are visited only to find nested types.
32
+ return .visitChildren
28
33
  }
29
34
 
30
35
  override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
31
- if isTopLevel(node) {
32
- classify(name: node.name.identifierName, attributes: node.attributes, members: node.memberBlock.members)
36
+ guard let scope = enclosingTypes(of: node) else {
37
+ return .skipChildren
33
38
  }
34
- return .skipChildren
39
+ classify(
40
+ name: node.name.identifierName, scope: scope, attributes: node.attributes, members: node.memberBlock.members)
41
+ return .visitChildren
42
+ }
43
+
44
+ override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind {
45
+ // No macro the surface reports applies to an actor, but its body can declare types that do.
46
+ enclosingTypes(of: node) == nil ? .skipChildren : .visitChildren
47
+ }
48
+
49
+ override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
50
+ // Only for the types declared in its body. Members an extension adds to a module, shared object,
51
+ // or record are not part of the surface, since the macros only read the type's own body.
52
+ enclosingTypes(of: node) == nil ? .skipChildren : .visitChildren
53
+ }
54
+
55
+ // A type declared in a function or accessor body or in a closure is local and never reported, so
56
+ // these are not walked.
57
+
58
+ override func visit(_ node: CodeBlockSyntax) -> SyntaxVisitorContinueKind {
59
+ .skipChildren
60
+ }
61
+
62
+ override func visit(_ node: AccessorBlockSyntax) -> SyntaxVisitorContinueKind {
63
+ .skipChildren
64
+ }
65
+
66
+ override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind {
67
+ .skipChildren
35
68
  }
36
69
 
37
70
  /// The two kinds of enum the surface reports: a `@Union` by its attribute, an `Enumerable` enum by
@@ -41,14 +74,15 @@ final class SurfaceVisitor: SyntaxVisitor {
41
74
  /// would be nothing to report as an enum, and listing it in both arrays would describe two
42
75
  /// contradictory JS types for one declaration.
43
76
  override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
44
- guard isTopLevel(node) else {
77
+ guard let scope = enclosingTypes(of: node) else {
45
78
  return .skipChildren
46
79
  }
80
+ let name = qualifiedName(node.name.identifierName, in: scope)
47
81
 
48
82
  if node.attributes.firstAttribute(named: DetectedMacro.union.rawValue) != nil {
49
83
  unions.append(
50
84
  ExportedUnion(
51
- name: node.name.identifierName,
85
+ name: name,
52
86
  members: collectUnionMembers(node.memberBlock.members),
53
87
  file: file
54
88
  ))
@@ -56,24 +90,38 @@ final class SurfaceVisitor: SyntaxVisitor {
56
90
  let rawType = rawValueType(of: node.inheritanceClause)
57
91
  enums.append(
58
92
  ExportedEnum(
59
- name: node.name.identifierName,
93
+ name: name,
60
94
  rawType: rawType,
61
95
  cases: collectEnumCases(node.memberBlock.members, rawType: rawType),
62
96
  file: file
63
97
  ))
64
98
  }
65
- return .skipChildren
99
+ // An enum is often a namespace for other types, so its body is searched too.
100
+ return .visitChildren
66
101
  }
67
102
 
68
- /// Routes a top-level type to the right collector based on which Expo macro it carries. A type
103
+ /// Routes a class or struct to the right collector based on which Expo macro it carries. A type
69
104
  /// carrying none of them is ignored. `@Record` and `@ExpoModule`/`@SharedObject` are mutually
70
105
  /// exclusive in practice, so the first match wins.
71
- private func classify(name: String, attributes: AttributeListSyntax, members: MemberBlockItemListSyntax) {
106
+ ///
107
+ /// `name` is the declared name and `scope` the names of the types enclosing it. The reported `name`
108
+ /// is qualified with the scope, while a default `jsName` is the declared name alone, as the macros
109
+ /// spell it.
110
+ private func classify(
111
+ name: String, scope: [String], attributes: AttributeListSyntax, members: MemberBlockItemListSyntax
112
+ ) {
113
+ let qualified = qualifiedName(name, in: scope)
114
+
72
115
  if let attribute = attributes.firstAttribute(named: DetectedMacro.expoModule.rawValue) {
116
+ // Only a file-scope module is reported. Autolinking registers no other (`DetectionVisitor`
117
+ // reads file scope only), so a nested one never loads.
118
+ guard scope.isEmpty else {
119
+ return
120
+ }
73
121
  let (functions, properties, events, _) = collectJSMembers(members)
74
122
  modules.append(
75
123
  ExportedModule(
76
- name: name,
124
+ name: qualified,
77
125
  jsName: stringArgument(of: attribute) ?? name,
78
126
  functions: functions,
79
127
  properties: properties,
@@ -92,7 +140,7 @@ final class SurfaceVisitor: SyntaxVisitor {
92
140
  let (functions, properties, events, constructor) = collectJSMembers(members)
93
141
  sharedObjects.append(
94
142
  ExportedSharedObject(
95
- name: name,
143
+ name: qualified,
96
144
  jsName: stringArgument(of: attribute) ?? name,
97
145
  constructorParameters: constructor,
98
146
  functions: functions,
@@ -104,7 +152,7 @@ final class SurfaceVisitor: SyntaxVisitor {
104
152
  }
105
153
 
106
154
  if attributes.firstAttribute(named: DetectedMacro.record.rawValue) != nil {
107
- records.append(ExportedRecord(name: name, properties: collectRecordProperties(members), file: file))
155
+ records.append(ExportedRecord(name: qualified, properties: collectRecordProperties(members), file: file))
108
156
  }
109
157
  }
110
158
 
@@ -364,16 +412,40 @@ final class SurfaceVisitor: SyntaxVisitor {
364
412
  }
365
413
  }
366
414
 
367
- /// True when the declaration sits at file scope. Same rule as `DetectionVisitor.isTopLevel`: its
368
- /// parent is a `CodeBlockItemSyntax` directly under the source file's top-level item list.
369
- private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
370
- guard let item = node.parent?.as(CodeBlockItemSyntax.self) else {
371
- return false
415
+ /// The names of the types enclosing a declaration, outermost first: empty at file scope, and `nil`
416
+ /// when the declaration is local to a function body or a closure, or sits in a `#if` block.
417
+ ///
418
+ /// File scope uses the same rule as `DetectionVisitor.isTopLevel`, except that `#if` is not looked
419
+ /// through: the parent is a `CodeBlockItemSyntax` directly under the source file's top-level item
420
+ /// list. A member is reached from its type through `MemberBlockItemSyntax`; an `extension`
421
+ /// contributes its extended type as written (without backticks), so its spelling may already be
422
+ /// qualified.
423
+ private func enclosingTypes(of node: some SyntaxProtocol) -> [String]? {
424
+ if let item = node.parent?.as(CodeBlockItemSyntax.self) {
425
+ return item.parent?.parent?.is(SourceFileSyntax.self) == true ? [] : nil
372
426
  }
373
- return item.parent?.parent?.is(SourceFileSyntax.self) == true
427
+ guard let item = node.parent?.as(MemberBlockItemSyntax.self),
428
+ let parent = item.parent?.parent?.parent,
429
+ let scope = enclosingTypes(of: parent) else {
430
+ return nil
431
+ }
432
+ if let decl = parent.as(ExtensionDeclSyntax.self) {
433
+ // Without backticks, like every other reported name.
434
+ return scope + [String(decl.extendedType.trimmedDescription.filter { $0 != "`" })]
435
+ }
436
+ // A class, struct, enum, or actor. A protocol body cannot declare types.
437
+ guard !parent.is(ProtocolDeclSyntax.self), let decl = parent.asProtocol(NamedDeclSyntax.self) else {
438
+ return nil
439
+ }
440
+ return scope + [decl.name.identifierName]
374
441
  }
375
442
  }
376
443
 
444
+ /// A declared name qualified with the names of its enclosing types: `Options.Encoding`.
445
+ private func qualifiedName(_ name: String, in scope: [String]) -> String {
446
+ (scope + [name]).joined(separator: ".")
447
+ }
448
+
377
449
  // MARK: - Syntactic helpers (shared spelling with the macros)
378
450
 
379
451
  extension TokenSyntax {
@@ -58,7 +58,9 @@ indirect enum TypeNode: Equatable {
58
58
  case function(parameters: [TypeNode], returns: TypeNode?, isAsync: Bool, isThrowing: Bool)
59
59
 
60
60
  /// Any other named type (record, shared object, enum, union, …). `name` is the possibly-qualified
61
- /// spelling.
61
+ /// spelling until resolution. A resolved ref takes the qualified name of its declaration instead,
62
+ /// so `Encoding` written in `Options` becomes `Options.Encoding`, and a consumer finds the
63
+ /// declaration by an exact match.
62
64
  ///
63
65
  /// `refKind` says which scanned kind declares that name, filled in after the scan by
64
66
  /// `ExportedSurface.resolvingRefs()`; it is `nil` until then, and stays `nil` for a name the scan