@expo/expo-modules-macros-plugin 0.5.1 → 0.6.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.
@@ -29,10 +29,13 @@ import SwiftSyntaxMacros
29
29
  an optional type makes it nullable and optional, and a non-optional property without a
30
30
  default is required (the factories throw when the source omits it).
31
31
 
32
- Conversions go through the **public** dynamic-type API `T.getDynamicType()` plus
33
- `cast(jsValue:appContext:)` / `cast(_:appContext:)` / `convertToJS(_:appContext:)`
34
- so the synthesized code compiles inside user modules without any internal core symbols.
35
- Every property type must therefore conform to `AnyArgument`.
32
+ The JS-value paths convert through `JavaScriptDecodable.decode` / `JavaScriptEncodable.encode`
33
+ (`from(object:)` / `toObject(appContext:)`); the native-`Any` dictionary paths still go through the
34
+ public dynamic-type API `T.getDynamicType()` plus `cast(_:appContext:)` / `convertToJS(_:appContext:)`
35
+ (`from(dictionary:)` / `toDictionary(appContext:)`), since `JavaScriptCodable` only converts JS
36
+ values, not native `Any`. Both spell types as `public` symbols so the synthesized code compiles inside
37
+ user modules without any internal core symbols. Every property type must therefore conform to both
38
+ `AnyArgument` and `JavaScriptDecodable & JavaScriptEncodable`.
36
39
 
37
40
  For classes that inherit from another `@Record`-annotated class, the synthesized
38
41
  methods chain to `super` so inherited properties are handled first.
@@ -60,14 +63,15 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
60
63
 
61
64
  var members: [DeclSyntax] = []
62
65
 
63
- // A single never-called member that makes the compiler verify each property type is
64
- // JS-convertible (the conversions below go through its dynamic-type API). Each property keeps its
65
- // own named assertion inside, so the compiler's conformance diagnostic names the offending
66
- // property (see `typeConformanceAssertions`). Emitted first so that, for a non-conforming type,
67
- // this clear "requires that '…' conform to '…'" error is reported ahead of the noisier
68
- // "no member 'getDynamicType'" errors from the conversion code below.
66
+ // A single never-called member that makes the compiler verify each property type is convertible
67
+ // both ways (the JS-value paths call `decode`/`encode`; the native-`Any` dictionary paths call
68
+ // the dynamic-type API). Each property keeps its own named assertion inside, so the compiler's
69
+ // conformance diagnostic names the offending property (see `typeConformanceAssertions`). Emitted
70
+ // first so that, for a non-conforming type, this clear "requires that '…' conform to '…'" error
71
+ // is reported ahead of the noisier "no member 'decode'"/"getDynamicType" errors from the
72
+ // conversion code below.
69
73
  let assertions = properties.map { ConformanceAssertion(name: $0.name, types: [$0.type]) }
70
- if let assertionMember = typeConformanceAssertions(for: assertions) {
74
+ if let assertionMember = typeConformanceAssertions(for: assertions, constraint: recordFieldProtocolName) {
71
75
  members.append(assertionMember)
72
76
  }
73
77
 
@@ -131,8 +135,9 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
131
135
  */
132
136
  private struct RecordProperty {
133
137
  let name: String
134
- /// The property's declared type, verbatim (e.g. `String`, `Int`, `String?`). Used to build
135
- /// the memberwise-init parameter and to look up the dynamic type via `T.getDynamicType()`.
138
+ /// The property's declared type, verbatim (e.g. `String`, `Int`, `String?`). Used to build the
139
+ /// memberwise-init parameter and as the receiver of the per-property `decode`/`encode` (and, on the
140
+ /// dictionary paths, `getDynamicType()`) conversions.
136
141
  let type: String
137
142
  /// The default-value expression verbatim (`0`, `""`, `[]`), or `nil` when the property has none.
138
143
  /// Inlined into the memberwise init and the factories' omitted-property branch so the synthesized
@@ -155,7 +160,7 @@ private struct RecordProperty {
155
160
  /**
156
161
  Discovers the record's properties: every stored `var`/`let` binding that is not `static`,
157
162
  `private`, `fileprivate`, `lazy`, or computed. Each property must declare an explicit type
158
- annotation, since the synthesized conversions reference `Type.getDynamicType()`.
163
+ annotation, since the synthesized conversions name the type (e.g. `Type.decode()`).
159
164
  */
160
165
  private func recordProperties(
161
166
  of declaration: some DeclGroupSyntax
@@ -271,11 +276,20 @@ private func memberwiseInit(properties: [RecordProperty]) -> DeclSyntax {
271
276
  /**
272
277
  `from(object:appContext:)` — reads each property off the `JavaScriptObject` into a local,
273
278
  then constructs the record through the memberwise init. Required properties throw when
274
- undefined; defaulted properties fall back to the property's declared default (read from a
275
- throwaway `Self()`) when undefined; optional properties become `nil` when undefined/null.
279
+ undefined; defaulted properties fall back to the property's declared default (inlined) when
280
+ undefined; optional properties become `nil` when undefined/null. Each property is decoded with
281
+ `JavaScriptDecodable.decode`, so the factory binds the `runtime` from the app context once and
282
+ threads it to every read.
276
283
  */
277
284
  private func fromJSObjectFactory(properties: [RecordProperty]) -> DeclSyntax {
278
- let body = factoryBody(properties: properties, readLines: jsObjectReadLines(properties: properties))
285
+ var lines: [String] = []
286
+ // Only bind the runtime when there's a property to decode — an empty record's factory would
287
+ // otherwise leave it unused.
288
+ if !properties.isEmpty {
289
+ lines.append(" let runtime = try appContext.runtime")
290
+ }
291
+ lines.append(factoryBody(properties: properties, readLines: jsObjectReadLines(properties: properties)))
292
+ let body = lines.joined(separator: "\n")
279
293
  return """
280
294
  @JavaScriptActor
281
295
  public static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self {
@@ -311,23 +325,25 @@ private func factoryBody(properties: [RecordProperty], readLines: [String]) -> S
311
325
  return lines.joined(separator: "\n")
312
326
  }
313
327
 
314
- /// Per-property read statements for the JS-object factory, each producing a `let <name>`.
328
+ /// Per-property read statements for the JS-object factory, each producing a `let <name>` by decoding
329
+ /// the JS value with `JavaScriptDecodable.decode` (recovering the app context from `runtime` itself).
315
330
  private func jsObjectReadLines(properties: [RecordProperty]) -> [String] {
316
331
  var lines: [String] = []
317
332
  for property in properties {
318
333
  let valueVar = "\(property.name)JSValue"
319
334
  let exprType = expressionType(property.type)
320
- let cast = "try \(exprType).getDynamicType().cast(jsValue: \(valueVar), appContext: appContext) as! \(exprType)"
335
+ let decode = "try \(exprType).decode(\(valueVar), in: runtime)"
321
336
  lines.append(" let \(valueVar) = object.getProperty(\"\(property.name)\")")
322
337
  if property.isRequired {
323
338
  lines.append(" guard !\(valueVar).isUndefined() else {")
324
339
  lines.append(" throw RecordPropertyRequiredException(\"\(property.name)\")")
325
340
  lines.append(" }")
326
- lines.append(" let \(property.name) = \(cast)")
341
+ lines.append(" let \(property.name) = \(decode)")
327
342
  } else if property.isOptional {
328
- lines.append(" let \(property.name): \(property.type) = (\(valueVar).isUndefined() || \(valueVar).isNull()) ? nil : \(cast)")
343
+ // `Optional.decode` already maps `undefined`/`null` to `nil`, so the read is a plain decode.
344
+ lines.append(" let \(property.name) = \(decode)")
329
345
  } else {
330
- lines.append(" let \(property.name) = \(valueVar).isUndefined() ? \(property.defaultValue!) : \(cast)")
346
+ lines.append(" let \(property.name) = \(valueVar).isUndefined() ? \(property.defaultValue!) : \(decode)")
331
347
  }
332
348
  }
333
349
  return lines
@@ -388,20 +404,27 @@ private func toDictionaryMethod(properties: [RecordProperty], inheritsRecord: Bo
388
404
  }
389
405
 
390
406
  /**
391
- `toObject(appContext:)` — builds a `JavaScriptObject` directly, converting each property via
392
- `convertToJS`. The fast write path mirroring `from(object:)`. Subclasses chain to `super` so
393
- inherited properties are written first.
407
+ `toObject(appContext:)` — builds a `JavaScriptObject` directly, encoding each property with
408
+ `JavaScriptEncodable.encode`. The fast write path mirroring `from(object:)`. The `runtime` is bound
409
+ from the app context once and threaded to every write. Subclasses chain to `super` so inherited
410
+ properties are written first.
394
411
  */
395
412
  private func toObjectMethod(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
396
413
  let overrideKeyword = inheritsRecord ? "override " : ""
397
414
  var lines: [String] = []
415
+ // The base case needs the runtime to create the object; the inheriting case only needs it when
416
+ // there's a property to encode (it chains to `super` for the object itself). Binding it when unused
417
+ // would warn.
418
+ if !inheritsRecord || !properties.isEmpty {
419
+ lines.append(" let runtime = try appContext.runtime")
420
+ }
398
421
  if inheritsRecord {
399
422
  lines.append(" let object = try super.toObject(appContext: appContext)")
400
423
  } else {
401
- lines.append(" let object = try appContext.runtime.createObject()")
424
+ lines.append(" let object = runtime.createObject()")
402
425
  }
403
426
  for property in properties {
404
- lines.append(" object.setProperty(\"\(property.name)\", value: try \(expressionType(property.type)).getDynamicType().convertToJS(self.\(property.name), appContext: appContext))")
427
+ lines.append(" object.setProperty(\"\(property.name)\", value: try \(expressionType(property.type)).encode(self.\(property.name), in: runtime))")
405
428
  }
406
429
  lines.append(" return object")
407
430
  let body = lines.joined(separator: "\n")
@@ -1,19 +1,33 @@
1
1
  import SwiftSyntax
2
2
 
3
3
  /// The protocol that every type crossing the JS boundary must conform to. Centralized here so the
4
- /// eventual rename (this is a placeholder name) is a single edit, and shared by every macro that
5
- /// asserts the conformance (`@JS`, `@Record`, …).
4
+ /// eventual rename (this is a placeholder name) is a single edit, and shared by the macros whose
5
+ /// generated conversions go through the dynamic-type API (`@Record`, `@Event`).
6
6
  internal let jsConvertibleProtocolName = "AnyArgument"
7
7
 
8
+ /// The constraint a `@JS` boundary type must satisfy, by direction: an argument (and a settable
9
+ /// property's incoming value) is decoded, so it must be `JavaScriptDecodable`; a return value (and a
10
+ /// property's outgoing value) is encoded, so it must be `JavaScriptEncodable`. Asserting each
11
+ /// direction on its own keeps a decode-only or encode-only type from being over-constrained, and
12
+ /// surfaces a missing conformance as a clear "requires that '…' conform to …" diagnostic on the
13
+ /// member instead of an opaque error inside the generated closure.
14
+ internal let javaScriptDecodableProtocolName = "JavaScriptDecodable"
15
+ internal let javaScriptEncodableProtocolName = "JavaScriptEncodable"
16
+
17
+ /// The constraint a `@Record` property type must satisfy: it must be both `AnyArgument` (the
18
+ /// `from(dictionary:)` / `toDictionary(appContext:)` paths still convert native `Any` through the
19
+ /// dynamic-type API) and `JavaScriptDecodable & JavaScriptEncodable` (the `from(object:)` /
20
+ /// `toObject(appContext:)` paths convert JS values through `decode`/`encode`). A record field has to
21
+ /// support both directions, so the assertion requires the intersection.
22
+ internal let recordFieldProtocolName = "AnyArgument & JavaScriptDecodable & JavaScriptEncodable"
23
+
8
24
  /// The protocol a type must conform to for `self.emit(event:…)` to resolve; core conforms
9
25
  /// `BaseModule` and `SharedObject` to it. Asserted by `@Event` so attaching it to a type that can't
10
26
  /// emit fails with a conformance diagnostic instead of an opaque "no member 'emit'" error.
11
27
  internal let eventEmitterProtocolName = "EventEmitter"
12
28
 
13
- /// Types we never assert because they're statically known to conform and never reach the dynamic
14
- /// converter: the JS primitives. Asserting them would only add noise to the expansion. Kept here
15
- /// (rather than reusing the decode-path's `fastDecodeAccessor`) because "known-to-conform" is a
16
- /// concept that belongs with the assertion logic, not with how a value is decoded.
29
+ /// Types we never assert because they're statically known to conform: the JS primitives. Asserting
30
+ /// them would only add noise to the expansion.
17
31
  private let knownConformingPrimitives: Set<String> = ["Bool", "Int", "Double", "String"]
18
32
 
19
33
  /// One member's worth of conformance assertion: a name (the member it stands for) and the declared
@@ -27,27 +41,55 @@ internal struct ConformanceAssertion {
27
41
  let types: [String]
28
42
  }
29
43
 
30
- /// A single conformance-assertion peer for a `@JS` member: a never-called `private func` whose body
31
- /// statically asserts the member's boundary types conform. The assertion is compile-time only — the
32
- /// function is never invoked, but Swift still type-checks its body, so a non-conforming type becomes
33
- /// a compile error. Emitted as a **peer** of the user's declaration, so that error lands on the
34
- /// user's own member rather than on the enclosing macro.
35
- ///
36
- /// `isStatic` makes the peer `static`, mirroring a `static`/`class` member so it's emitted in the
37
- /// right metatype context (a peer of a type-level member can't be an instance method). `class func`
38
- /// members collapse to `static` here too: the peer is private and never called or overridden, so
39
- /// `static` is always sufficient.
44
+ /// A directional conformance-assertion peer for a `@JS` member: a never-called `private func` whose
45
+ /// body asserts each argument type is `JavaScriptDecodable` and the return/getter type is
46
+ /// `JavaScriptEncodable`, matching how the generated binding converts each (decode on the way in,
47
+ /// encode on the way out). The check is a single member-named nested helper with one `A0…` generic
48
+ /// parameter per non-primitive argument and a single `Return` parameter for the value, called once with
49
+ /// every type's metatype; naming the helper after the member puts the member in the compiler's
50
+ /// diagnostic, and the per-slot constraint reports the offending type against the protocol for *its*
51
+ /// direction.
40
52
  ///
41
- /// Returns `nil` when nothing is left to assert (every type was a known-conforming primitive, or the
42
- /// list was empty), so the caller emits nothing in that case.
43
- internal func typeConformanceAssertion(for assertion: ConformanceAssertion, isStatic: Bool) -> DeclSyntax? {
44
- guard let body = conformanceAssertionBody(assertion) else {
53
+ /// `decodableTypes` are the argument types (and a settable property's value type); `encodableType` is
54
+ /// the single return type (or a property's value type on read), or `nil` when there's none. Primitives
55
+ /// are dropped from each; when nothing is left in either, returns `nil` so the caller emits no peer.
56
+ /// `isStatic` mirrors a `static`/`class` member so the peer sits in the right metatype context.
57
+ internal func directionalConformanceAssertion(
58
+ name: String,
59
+ decodableTypes: [String],
60
+ encodableType: String?,
61
+ isStatic: Bool
62
+ ) -> DeclSyntax? {
63
+ let decodables = distinctAssertableTypes(decodableTypes)
64
+ let encodable = encodableType.flatMap(assertableBoundaryType)
65
+ guard !decodables.isEmpty || encodable != nil else {
45
66
  return nil
46
67
  }
68
+
69
+ // Build the generic parameter list and the matching call arguments in lockstep: one `A0…` slot
70
+ // constrained `JavaScriptDecodable` per argument, and a single `Return` slot constrained
71
+ // `JavaScriptEncodable` for the return/getter value (there's only ever one, so it isn't indexed).
72
+ var parameters: [String] = []
73
+ var typeParameters: [String] = []
74
+ var arguments: [String] = []
75
+ for (index, type) in decodables.enumerated() {
76
+ parameters.append("A\(index): \(javaScriptDecodableProtocolName)")
77
+ typeParameters.append("_: A\(index).Type")
78
+ arguments.append("\(type).self")
79
+ }
80
+ if let encodable {
81
+ parameters.append("Return: \(javaScriptEncodableProtocolName)")
82
+ typeParameters.append("_: Return.Type")
83
+ arguments.append("\(encodable).self")
84
+ }
85
+
86
+ let helper = "func \(name)<\(parameters.joined(separator: ", "))>(\(typeParameters.joined(separator: ", "))) {}"
87
+ let call = "\(name)(\(arguments.joined(separator: ", ")))"
47
88
  let staticKeyword = isStatic ? "static " : ""
48
89
  return """
49
- private \(raw: staticKeyword)func _assertTypesConformance_\(raw: assertion.name)() {
50
- \(raw: body)
90
+ private \(raw: staticKeyword)func _assertTypesConformance_\(raw: name)() {
91
+ \(raw: helper)
92
+ \(raw: call)
51
93
  }
52
94
  """
53
95
  }
@@ -59,8 +101,11 @@ internal func typeConformanceAssertion(for assertion: ConformanceAssertion, isSt
59
101
  ///
60
102
  /// Returns `nil` when no assertion has anything left to verify (all primitives / empty), so the
61
103
  /// caller emits nothing.
62
- internal func typeConformanceAssertions(for assertions: [ConformanceAssertion]) -> DeclSyntax? {
63
- let bodies = assertions.compactMap(conformanceAssertionBody)
104
+ internal func typeConformanceAssertions(
105
+ for assertions: [ConformanceAssertion],
106
+ constraint: String = jsConvertibleProtocolName
107
+ ) -> DeclSyntax? {
108
+ let bodies = assertions.compactMap { conformanceAssertionBody($0, constraint: constraint) }
64
109
  guard !bodies.isEmpty else {
65
110
  return nil
66
111
  }
@@ -85,24 +130,33 @@ internal func assertableBoundaryType(_ type: String) -> String? {
85
130
  /// symbol, nothing to collide, nothing left in the type's namespace — and naming it after the member
86
131
  /// puts the member's name in the compiler's conformance diagnostic. Returns `nil` when every type was
87
132
  /// a known-conforming primitive or the list was empty.
88
- private func conformanceAssertionBody(_ assertion: ConformanceAssertion) -> String? {
89
- // Unwrap top-level optionals to the core type, then dedup so each type is asserted once even when
90
- // it appears more than once; skip known primitives.
91
- var seen: Set<String> = []
92
- var distinct: [String] = []
93
- for type in assertion.types.map(unwrappedOptional)
94
- where !knownConformingPrimitives.contains(type) && seen.insert(type).inserted {
95
- distinct.append(type)
96
- }
133
+ private func conformanceAssertionBody(
134
+ _ assertion: ConformanceAssertion,
135
+ constraint: String = jsConvertibleProtocolName
136
+ ) -> String? {
137
+ let distinct = distinctAssertableTypes(assertion.types)
97
138
  guard !distinct.isEmpty else {
98
139
  return nil
99
140
  }
100
141
 
101
- var lines = ["func \(assertion.name)<T: \(jsConvertibleProtocolName)>(_: T.Type) {}"]
142
+ var lines = ["func \(assertion.name)<T: \(constraint)>(_: T.Type) {}"]
102
143
  lines.append(contentsOf: distinct.map { "\(assertion.name)(\($0).self)" })
103
144
  return lines.joined(separator: "\n")
104
145
  }
105
146
 
147
+ /// Normalizes a list of boundary types for assertion: unwraps each to its core type (trailing
148
+ /// optionals stripped), drops the known-conforming primitives that never need asserting, and dedups
149
+ /// while preserving first-seen order so a type is asserted once even when it appears more than once.
150
+ private func distinctAssertableTypes(_ types: [String]) -> [String] {
151
+ var seen: Set<String> = []
152
+ var distinct: [String] = []
153
+ for type in types.map(unwrappedOptional)
154
+ where !knownConformingPrimitives.contains(type) && seen.insert(type).inserted {
155
+ distinct.append(type)
156
+ }
157
+ return distinct
158
+ }
159
+
106
160
  /// Strips every trailing optional marker (`?`/`!`) so the assertion targets the core wrapped type.
107
161
  /// `Optional<W>: AnyArgument` holds exactly when `W: AnyArgument` (and `T!` is just `T?`), so each
108
162
  /// layer is conformance-equivalent to its wrapped type. Asserting the core gives a cleaner diagnostic
@@ -95,8 +95,9 @@ private func arguments(of attribute: AttributeSyntax) -> [MacroArgument] {
95
95
 
96
96
  /// The first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork". Returns
97
97
  /// `nil` when there's no argument or it isn't a plain string literal. (Same shape the
98
- /// `jsNameArgument` helper reads inside the macros.)
99
- private func stringArgument(of attribute: AttributeSyntax) -> String? {
98
+ /// `jsNameArgument` helper reads inside the macros.) Shared with the `scan-exports` surface visitor,
99
+ /// which reads the same JS-name override off `@JS`/`@ExpoModule`/`@SharedObject`.
100
+ func stringArgument(of attribute: AttributeSyntax) -> String? {
100
101
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
101
102
  let first = args.first,
102
103
  first.label == nil,
@@ -2,14 +2,19 @@ import Foundation
2
2
  import SwiftParser
3
3
  import SwiftSyntax
4
4
 
5
- /// Walks `paths`, parses each `.swift` file that might contain one of `macros` (the pre-filter), and
6
- /// returns every detection (in file then source order) with the run's stats. The shared core every
7
- /// scan command builds on; each command projects these detections into its own output shape.
8
- func collectDetections(paths: [String], macros: Set<DetectedMacro>) -> (detections: [Detection], stats: ScanStats) {
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.
7
+ /// The shared core every scan command builds on: the walk, read, pre-filter, and stats are identical;
8
+ /// only what each command does per parsed file differs (`scan-modules` collects `Detection`s,
9
+ /// `scan-exports` walks a `SurfaceVisitor`), and that lives in `process`.
10
+ func scanFiles(
11
+ paths: [String],
12
+ macros: Set<DetectedMacro>,
13
+ process: (_ source: String, _ file: String) -> Void
14
+ ) -> ScanStats {
9
15
  let clock = ContinuousClock()
10
16
  let start = clock.now
11
17
 
12
- var detections: [Detection] = []
13
18
  var filesScanned = 0
14
19
  var filesParsed = 0
15
20
 
@@ -29,13 +34,24 @@ func collectDetections(paths: [String], macros: Set<DetectedMacro>) -> (detectio
29
34
  continue
30
35
  }
31
36
  filesParsed += 1
32
- detections.append(contentsOf: detect(source: source, file: file, macros: macros))
37
+ process(source, file)
33
38
  }
34
39
 
35
40
  let elapsed = (clock.now - start).components
36
41
  let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15
37
42
 
38
- return (detections, ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs))
43
+ return ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs)
44
+ }
45
+
46
+ /// Walks `paths`, parses each `.swift` file that might contain one of `macros`, and returns every
47
+ /// detection (in file then source order) with the run's stats — the shape `scan-modules` projects.
48
+ /// A thin layer over `scanFiles` that accumulates the per-file detections.
49
+ func collectDetections(paths: [String], macros: Set<DetectedMacro>) -> (detections: [Detection], stats: ScanStats) {
50
+ var detections: [Detection] = []
51
+ let stats = scanFiles(paths: paths, macros: macros) { source, file in
52
+ detections.append(contentsOf: detect(source: source, file: file, macros: macros))
53
+ }
54
+ return (detections, stats)
39
55
  }
40
56
 
41
57
  /// Parses one source string and returns its detections for the given macro set. The unit of work the
@@ -0,0 +1,186 @@
1
+ import Foundation
2
+
3
+ /// The deep-scan surface: the full JS-exported shape of every `@ExpoModule`, `@SharedObject`, and
4
+ /// `@Record` type, with the per-member detail a TypeScript type generator needs. Read syntactically
5
+ /// (like the macros): each boundary type becomes a structured `TypeNode` tree so the consumer walks a
6
+ /// tagged tree rather than re-parsing Swift type syntax.
7
+
8
+ /// One parameter of a `@JS` function or `@JS init`.
9
+ struct ExportedParameter: Encodable, Equatable {
10
+ /// The argument label (the `first` name): `to` in `func move(to point: Point)`, or `_` if unlabeled.
11
+ let label: String
12
+
13
+ /// The internal parameter name (the `second` name, else the same as `label`): `point` above.
14
+ let name: String
15
+
16
+ let type: TypeNode
17
+
18
+ /// True when the caller may omit it: a default value or an optional type. Distinct from the type
19
+ /// being `.optional` (a defaulted non-optional is also omittable). Encoded as `optional`.
20
+ let isOptional: Bool
21
+
22
+ private enum CodingKeys: String, CodingKey {
23
+ case label, name, type
24
+ case isOptional = "optional"
25
+ }
26
+ }
27
+
28
+ /// One `@JS func` on a module or shared object.
29
+ struct ExportedFunction: Encodable, Equatable {
30
+ /// The Swift declaration name.
31
+ let name: String
32
+
33
+ /// The JS name it binds under: the `@JS("x")` override, else `name`.
34
+ let jsName: String
35
+
36
+ let parameters: [ExportedParameter]
37
+
38
+ /// The return type, or `nil` for `Void`. Named `returns` to pair with `parameters`.
39
+ let returns: TypeNode?
40
+
41
+ /// `async` (an async function is promise-returning in JS).
42
+ let isAsync: Bool
43
+
44
+ /// `throws`.
45
+ let isThrowing: Bool
46
+
47
+ /// `static`/`class` member.
48
+ let isStatic: Bool
49
+
50
+ /// The flags are encoded under their TS-keyword spellings.
51
+ private enum CodingKeys: String, CodingKey {
52
+ case name, jsName, parameters, returns
53
+ case isAsync = "async"
54
+ case isThrowing = "throws"
55
+ case isStatic = "static"
56
+ }
57
+ }
58
+
59
+ /// One `@JS var` on a module or shared object.
60
+ struct ExportedProperty: Encodable, Equatable {
61
+ /// The Swift declaration name.
62
+ let name: String
63
+
64
+ /// The JS name it binds under: the `@JS("x")` override, else `name`.
65
+ let jsName: String
66
+
67
+ /// The value type, or `nil` when undeterminable syntactically (no annotation and no literal
68
+ /// default), the case the macro binds getter-only.
69
+ let type: TypeNode?
70
+
71
+ /// True when JS can assign to it: a stored `var` or a computed `var` with a `set`. Encoded as its
72
+ /// inverse, `readonly`.
73
+ let isSettable: Bool
74
+
75
+ /// `static`/`class` member. Encoded as `static`.
76
+ let isStatic: Bool
77
+
78
+ private enum CodingKeys: String, CodingKey {
79
+ case name, jsName, type
80
+ case isReadonly = "readonly"
81
+ case isStatic = "static"
82
+ }
83
+
84
+ func encode(to encoder: Encoder) throws {
85
+ var container = encoder.container(keyedBy: CodingKeys.self)
86
+ try container.encode(name, forKey: .name)
87
+ try container.encode(jsName, forKey: .jsName)
88
+ try container.encodeIfPresent(type, forKey: .type)
89
+ try container.encode(!isSettable, forKey: .isReadonly)
90
+ try container.encode(isStatic, forKey: .isStatic)
91
+ }
92
+ }
93
+
94
+ /// One `@Record` property: a plain data slot exposing `optional`/`required` (vs. `ExportedProperty`,
95
+ /// a JS accessor exposing `readonly`/`static`). A record crosses the boundary by value, so the whole
96
+ /// type is read-only in JS; that's a record-level fact and isn't stamped per property.
97
+ struct ExportedRecordProperty: Encodable, Equatable {
98
+ let name: String
99
+
100
+ /// `@Record` requires a determinable type on every property, so this is never `nil`.
101
+ let type: TypeNode
102
+
103
+ /// Optional-typed. Encoded as `optional`.
104
+ let isOptional: Bool
105
+
106
+ /// Has a default value. Not encoded (derivable as `!isOptional && !isRequired`, and a Swift default
107
+ /// never reaches JS); kept only to derive `isRequired`.
108
+ let hasDefault: Bool
109
+
110
+ /// Whether JS must supply this property, matching the macro's `RecordProperty.isRequired`. Encoded
111
+ /// as `required`.
112
+ var isRequired: Bool {
113
+ return !hasDefault && !isOptional
114
+ }
115
+
116
+ private enum CodingKeys: String, CodingKey {
117
+ case name, type
118
+ case isOptional = "optional"
119
+ case isRequired = "required"
120
+ }
121
+
122
+ func encode(to encoder: Encoder) throws {
123
+ var container = encoder.container(keyedBy: CodingKeys.self)
124
+ try container.encode(name, forKey: .name)
125
+ try container.encode(type, forKey: .type)
126
+ try container.encode(isOptional, forKey: .isOptional)
127
+ try container.encode(isRequired, forKey: .isRequired)
128
+ }
129
+ }
130
+
131
+ /// A `@ExpoModule` type and its `@JS` surface.
132
+ struct ExportedModule: Encodable, Equatable {
133
+ /// The Swift class name.
134
+ let name: String
135
+
136
+ /// The JS module name: `@ExpoModule("Foo")` override, else the class name.
137
+ let jsName: String
138
+
139
+ let functions: [ExportedFunction]
140
+ let properties: [ExportedProperty]
141
+
142
+ /// Absolute source path, matching `scan-modules`.
143
+ let file: String
144
+ }
145
+
146
+ /// A `@SharedObject` type: a JS class with an optional `@JS init` constructor plus its `@JS` members.
147
+ struct ExportedSharedObject: Encodable, Equatable {
148
+ /// The Swift class name.
149
+ let name: String
150
+
151
+ /// The JS class name: `@SharedObject("Foo")` override, else the class name.
152
+ let jsName: String
153
+
154
+ /// The `@JS init` parameters, or `nil` when there's none. A shared object has at most one.
155
+ let constructorParameters: [ExportedParameter]?
156
+
157
+ let functions: [ExportedFunction]
158
+ let properties: [ExportedProperty]
159
+
160
+ let file: String
161
+ }
162
+
163
+ /// A `@Record` type and its properties (data only: no functions, accessors, or constructor).
164
+ struct ExportedRecord: Encodable, Equatable {
165
+ /// The Swift type name (struct or class).
166
+ let name: String
167
+
168
+ let properties: [ExportedRecordProperty]
169
+
170
+ let file: String
171
+ }
172
+
173
+ /// The exported types grouped by kind, nested under `exports` in the result so the surface is one
174
+ /// self-contained object separate from `stats`.
175
+ struct ExportedSurface: Encodable, Equatable {
176
+ let modules: [ExportedModule]
177
+ let sharedObjects: [ExportedSharedObject]
178
+ let records: [ExportedRecord]
179
+ }
180
+
181
+ /// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from
182
+ /// `ScanModulesResult` (different consumer: TS generation vs. autolinking).
183
+ struct ScanExportsResult: Encodable, Equatable {
184
+ let exports: ExportedSurface
185
+ let stats: ScanStats
186
+ }
@@ -0,0 +1,47 @@
1
+ import Foundation
2
+ import SwiftParser
3
+ import SwiftSyntax
4
+
5
+ extension Scanner {
6
+ /// Runs `scan-exports` over `paths`, prints the JSON report to stdout, and returns the exit code
7
+ /// (`0` on success, `1` if encoding fails). The deep counterpart to `runModules`.
8
+ public static func runExports(paths: [String]) -> Int32 {
9
+ let result = scanExports(paths: paths)
10
+
11
+ do {
12
+ let encoder = JSONEncoder()
13
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
14
+ let data = try encoder.encode(result)
15
+ FileHandle.standardOutput.write(data)
16
+ FileHandle.standardOutput.write(Data("\n".utf8))
17
+ return 0
18
+ } catch {
19
+ FileHandle.standardError.write(Data("error: failed to encode results: \(error)\n".utf8))
20
+ return 1
21
+ }
22
+ }
23
+ }
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.
29
+ func scanExports(paths: [String]) -> ScanExportsResult {
30
+ var modules: [ExportedModule] = []
31
+ var sharedObjects: [ExportedSharedObject] = []
32
+ var records: [ExportedRecord] = []
33
+
34
+ let stats = scanFiles(paths: paths, macros: [.expoModule, .sharedObject, .record]) { source, file in
35
+ let tree = Parser.parse(source: source)
36
+ let visitor = SurfaceVisitor(file: file)
37
+ visitor.walk(tree)
38
+ modules.append(contentsOf: visitor.modules)
39
+ sharedObjects.append(contentsOf: visitor.sharedObjects)
40
+ records.append(contentsOf: visitor.records)
41
+ }
42
+
43
+ return ScanExportsResult(
44
+ exports: ExportedSurface(modules: modules, sharedObjects: sharedObjects, records: records),
45
+ stats: stats
46
+ )
47
+ }