@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.
- package/apple/ExpoModulesMacros-tool +0 -0
- package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +2 -0
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +22 -6
- package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +128 -2
- package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +199 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +24 -6
- package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +315 -6
- package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +35 -6
- package/build/types.d.ts +81 -3
- package/build/types.js +1 -1
- package/package.json +1 -1
|
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`
|
|
6
|
-
/// it), reads the source and hands it to `process` along with the
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
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 `@
|
|
26
|
-
/// surface plus the run's stats. Separate from the public entry so
|
|
27
|
-
///
|
|
28
|
-
///
|
|
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(
|
|
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:
|
|
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
|
|
72
|
-
/// `@JS init` constructor parameters (`nil` when absent).
|
|
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
|
-
) -> (
|
|
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
|
|
46
|
-
///
|
|
47
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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 =
|
|
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 =
|
|
15
|
+
exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 5;
|