@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.
- package/apple/ExpoModulesMacros-tool +0 -0
- package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +51 -116
- package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +8 -16
- package/apple/Sources/ExpoModulesMacros/JSMacro.swift +43 -22
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +6 -24
- package/apple/Sources/ExpoModulesMacros/Receiver.swift +4 -5
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +50 -27
- package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +88 -34
- package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +3 -2
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +23 -7
- package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +186 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +47 -0
- package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +307 -0
- package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +255 -0
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +5 -5
- package/apple/Sources/ExpoModulesScannerCLI/main.swift +4 -3
- package/package.json +1 -1
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import SwiftSyntax
|
|
2
|
+
|
|
3
|
+
/// Extracts the full JS-exported surface of every top-level `@ExpoModule`, `@SharedObject`, and
|
|
4
|
+
/// `@Record` type: their `@JS` members and record properties. The deep counterpart to
|
|
5
|
+
/// `DetectionVisitor`. Recognition is purely syntactic and re-reads what the macros read (the macro
|
|
6
|
+
/// target can't be imported), so it stays in step with `JSFunction` / `JSProperty` / `JSConstructor` /
|
|
7
|
+
/// `RecordProperty`.
|
|
8
|
+
final class SurfaceVisitor: SyntaxVisitor {
|
|
9
|
+
private let file: String
|
|
10
|
+
private(set) var modules: [ExportedModule] = []
|
|
11
|
+
private(set) var sharedObjects: [ExportedSharedObject] = []
|
|
12
|
+
private(set) var records: [ExportedRecord] = []
|
|
13
|
+
|
|
14
|
+
init(file: String) {
|
|
15
|
+
self.file = file
|
|
16
|
+
super.init(viewMode: .sourceAccurate)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
20
|
+
if isTopLevel(node) {
|
|
21
|
+
classify(name: node.name.text, attributes: node.attributes, members: node.memberBlock.members)
|
|
22
|
+
}
|
|
23
|
+
// The member walk reads the body itself; nested types aren't part of this surface (matching
|
|
24
|
+
// `DetectionVisitor`'s top-level-only scope), so there's no reason to descend.
|
|
25
|
+
return .skipChildren
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
29
|
+
if isTopLevel(node) {
|
|
30
|
+
classify(name: node.name.text, attributes: node.attributes, members: node.memberBlock.members)
|
|
31
|
+
}
|
|
32
|
+
return .skipChildren
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Routes a top-level type to the right collector based on which Expo macro it carries. A type
|
|
36
|
+
/// carrying none of them is ignored. `@Record` and `@ExpoModule`/`@SharedObject` are mutually
|
|
37
|
+
/// exclusive in practice, so the first match wins.
|
|
38
|
+
private func classify(name: String, attributes: AttributeListSyntax, members: MemberBlockItemListSyntax) {
|
|
39
|
+
if let attribute = attributes.firstAttribute(named: DetectedMacro.expoModule.rawValue) {
|
|
40
|
+
let (functions, properties, _) = collectJSMembers(members)
|
|
41
|
+
modules.append(
|
|
42
|
+
ExportedModule(
|
|
43
|
+
name: name,
|
|
44
|
+
jsName: stringArgument(of: attribute) ?? name,
|
|
45
|
+
functions: functions,
|
|
46
|
+
properties: properties,
|
|
47
|
+
file: file
|
|
48
|
+
))
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if let attribute = attributes.firstAttribute(named: DetectedMacro.sharedObject.rawValue) {
|
|
53
|
+
let (functions, properties, constructor) = collectJSMembers(members)
|
|
54
|
+
sharedObjects.append(
|
|
55
|
+
ExportedSharedObject(
|
|
56
|
+
name: name,
|
|
57
|
+
jsName: stringArgument(of: attribute) ?? name,
|
|
58
|
+
constructorParameters: constructor,
|
|
59
|
+
functions: functions,
|
|
60
|
+
properties: properties,
|
|
61
|
+
file: file
|
|
62
|
+
))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if attributes.firstAttribute(named: DetectedMacro.record.rawValue) != nil {
|
|
67
|
+
records.append(ExportedRecord(name: name, properties: collectRecordProperties(members), file: file))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// The `@JS` members of a module / shared-object body: functions, properties, and the single
|
|
72
|
+
/// `@JS init` constructor parameters (`nil` when absent). Only declarations carrying `@JS` count.
|
|
73
|
+
private func collectJSMembers(
|
|
74
|
+
_ members: MemberBlockItemListSyntax
|
|
75
|
+
) -> (functions: [ExportedFunction], properties: [ExportedProperty], constructor: [ExportedParameter]?) {
|
|
76
|
+
var functions: [ExportedFunction] = []
|
|
77
|
+
var properties: [ExportedProperty] = []
|
|
78
|
+
var constructor: [ExportedParameter]?
|
|
79
|
+
|
|
80
|
+
for member in members {
|
|
81
|
+
let decl = member.decl
|
|
82
|
+
|
|
83
|
+
if let initDecl = decl.as(InitializerDeclSyntax.self),
|
|
84
|
+
initDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) != nil {
|
|
85
|
+
// At most one `@JS init`; keep the first if a malformed source has more (the macro errors).
|
|
86
|
+
if constructor == nil {
|
|
87
|
+
constructor = parameters(of: initDecl.signature.parameterClause)
|
|
88
|
+
}
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if let funcDecl = decl.as(FunctionDeclSyntax.self),
|
|
93
|
+
let attribute = funcDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) {
|
|
94
|
+
functions.append(makeFunction(funcDecl: funcDecl, attribute: attribute))
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if let varDecl = decl.as(VariableDeclSyntax.self),
|
|
99
|
+
let attribute = varDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) {
|
|
100
|
+
properties.append(contentsOf: makeProperties(varDecl: varDecl, attribute: attribute))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (functions, properties, constructor)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// Builds an `ExportedFunction` from a `@JS func`: JS-name fallback, parameters, a `Void` return as
|
|
108
|
+
/// `nil`, and the effect/static flags.
|
|
109
|
+
private func makeFunction(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) -> ExportedFunction {
|
|
110
|
+
let effects = funcDecl.signature.effectSpecifiers
|
|
111
|
+
let returnType = funcDecl.signature.returnClause?.type
|
|
112
|
+
return ExportedFunction(
|
|
113
|
+
name: funcDecl.name.text,
|
|
114
|
+
jsName: stringArgument(of: attribute) ?? funcDecl.name.text,
|
|
115
|
+
parameters: parameters(of: funcDecl.signature.parameterClause),
|
|
116
|
+
returns: isVoidType(returnType) ? nil : returnType.map { typeNode(from: $0) },
|
|
117
|
+
isAsync: effects?.asyncSpecifier != nil,
|
|
118
|
+
isThrowing: effects?.throwsClause?.throwsSpecifier != nil,
|
|
119
|
+
isStatic: isTypeLevel(funcDecl.modifiers)
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Builds the `ExportedProperty` entries for a `@JS var`/`let`. One declaration can introduce several
|
|
124
|
+
/// bindings (`var a, b: Int`), so this returns an array. The value type is the annotation, else the
|
|
125
|
+
/// literal default's inferred type, else `nil`.
|
|
126
|
+
private func makeProperties(varDecl: VariableDeclSyntax, attribute: AttributeSyntax) -> [ExportedProperty] {
|
|
127
|
+
let isLet = varDecl.bindingSpecifier.tokenKind == .keyword(.let)
|
|
128
|
+
let isStatic = isTypeLevel(varDecl.modifiers)
|
|
129
|
+
let override = stringArgument(of: attribute)
|
|
130
|
+
var result: [ExportedProperty] = []
|
|
131
|
+
|
|
132
|
+
for binding in varDecl.bindings {
|
|
133
|
+
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
let name = ident.identifier.text
|
|
137
|
+
let type = valueTypeNode(annotation: binding.typeAnnotation?.type, initializer: binding.initializer?.value)
|
|
138
|
+
result.append(
|
|
139
|
+
ExportedProperty(
|
|
140
|
+
name: name,
|
|
141
|
+
jsName: override ?? name,
|
|
142
|
+
type: type,
|
|
143
|
+
isSettable: isSettable(binding: binding, isLet: isLet),
|
|
144
|
+
isStatic: isStatic
|
|
145
|
+
))
|
|
146
|
+
}
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// True when a binding is assignable from JS, mirroring the macro's `bindingIsSettable`: a `let` is
|
|
151
|
+
/// never settable; a stored `var` is; a computed `var` is settable iff it declares `set`/`willSet`/
|
|
152
|
+
/// `didSet` (a getter-only `var` is read-only).
|
|
153
|
+
private func isSettable(binding: PatternBindingSyntax, isLet: Bool) -> Bool {
|
|
154
|
+
if isLet {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
guard let accessorBlock = binding.accessorBlock else {
|
|
158
|
+
// Stored `var`, settable.
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
switch accessorBlock.accessors {
|
|
162
|
+
case .accessors(let list):
|
|
163
|
+
return list.contains { accessor in
|
|
164
|
+
switch accessor.accessorSpecifier.tokenKind {
|
|
165
|
+
case .keyword(.set), .keyword(.willSet), .keyword(.didSet):
|
|
166
|
+
return true
|
|
167
|
+
default:
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
case .getter:
|
|
172
|
+
// `var x: Int { ... }` shorthand getter, read-only.
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// The `@Record` properties: every stored, non-excluded `var`/`let` binding, mirroring
|
|
178
|
+
/// `RecordMacro.recordProperties`. Computed and modifier-excluded bindings are skipped, as is one
|
|
179
|
+
/// whose type can't be determined (the macro would error, but the scan stays lenient).
|
|
180
|
+
private func collectRecordProperties(_ members: MemberBlockItemListSyntax) -> [ExportedRecordProperty] {
|
|
181
|
+
var properties: [ExportedRecordProperty] = []
|
|
182
|
+
|
|
183
|
+
for member in members {
|
|
184
|
+
guard let varDecl = member.decl.as(VariableDeclSyntax.self),
|
|
185
|
+
!isExcludedRecordModifier(varDecl.modifiers) else {
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
for binding in varDecl.bindings {
|
|
189
|
+
if binding.accessorBlock != nil {
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
let annotation = binding.typeAnnotation?.type
|
|
196
|
+
guard let type = valueTypeNode(annotation: annotation, initializer: binding.initializer?.value) else {
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
properties.append(
|
|
200
|
+
ExportedRecordProperty(
|
|
201
|
+
name: ident.identifier.text,
|
|
202
|
+
type: type,
|
|
203
|
+
isOptional: annotation.map { isOptionalType($0) } ?? false,
|
|
204
|
+
hasDefault: binding.initializer != nil
|
|
205
|
+
))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return properties
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Projects a parameter clause into `ExportedParameter`s: label = first name, name = second (else
|
|
212
|
+
/// first), and `optional` when it has a default value or an optional type.
|
|
213
|
+
private func parameters(of clause: FunctionParameterClauseSyntax) -> [ExportedParameter] {
|
|
214
|
+
clause.parameters.map { parameter in
|
|
215
|
+
let label = parameter.firstName.text
|
|
216
|
+
let name = parameter.secondName?.text ?? label
|
|
217
|
+
return ExportedParameter(
|
|
218
|
+
label: label,
|
|
219
|
+
name: name,
|
|
220
|
+
type: typeNode(from: parameter.type),
|
|
221
|
+
isOptional: parameter.defaultValue != nil || isOptionalType(parameter.type)
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// True when the declaration sits at file scope. Same rule as `DetectionVisitor.isTopLevel`: its
|
|
227
|
+
/// parent is a `CodeBlockItemSyntax` directly under the source file's top-level item list.
|
|
228
|
+
private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
|
|
229
|
+
guard let item = node.parent?.as(CodeBlockItemSyntax.self) else {
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
return item.parent?.parent?.is(SourceFileSyntax.self) == true
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// MARK: - Syntactic helpers (shared spelling with the macros)
|
|
237
|
+
|
|
238
|
+
/// True when the modifiers make a member type-level (`static` or `class`).
|
|
239
|
+
private func isTypeLevel(_ modifiers: DeclModifierListSyntax) -> Bool {
|
|
240
|
+
modifiers.contains {
|
|
241
|
+
$0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/// True when a modifier excludes a property from being a `@Record` field (`static`, `class`,
|
|
246
|
+
/// `private`, `fileprivate`, `lazy`), mirroring `RecordMacro.isExcludedByModifier`.
|
|
247
|
+
private func isExcludedRecordModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
|
|
248
|
+
modifiers.contains { modifier in
|
|
249
|
+
switch modifier.name.tokenKind {
|
|
250
|
+
case .keyword(.static), .keyword(.class), .keyword(.private), .keyword(.fileprivate), .keyword(.lazy):
|
|
251
|
+
return true
|
|
252
|
+
default:
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// True when a type is written as an optional: `T?`, `T!`, or `Optional<T>`, mirroring the macros'
|
|
259
|
+
/// `isOptionalType`.
|
|
260
|
+
private func isOptionalType(_ type: TypeSyntax) -> Bool {
|
|
261
|
+
if type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
|
|
262
|
+
return true
|
|
263
|
+
}
|
|
264
|
+
if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional" {
|
|
265
|
+
return true
|
|
266
|
+
}
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// The first attribute whose spelled name matches `name`. A local copy of the macros' helper (the
|
|
271
|
+
/// macro target can't be imported here).
|
|
272
|
+
extension AttributeListSyntax {
|
|
273
|
+
fileprivate func firstAttribute(named name: String) -> AttributeSyntax? {
|
|
274
|
+
for element in self {
|
|
275
|
+
if let attribute = element.as(AttributeSyntax.self),
|
|
276
|
+
attribute.attributeName.trimmedDescription == name {
|
|
277
|
+
return attribute
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return nil
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/// The node for a property/field value type: the annotation, else the literal default's inferred
|
|
285
|
+
/// primitive (`var n = 1` -> `Int`), else `nil`.
|
|
286
|
+
private func valueTypeNode(annotation: TypeSyntax?, initializer: ExprSyntax?) -> TypeNode? {
|
|
287
|
+
if let annotation {
|
|
288
|
+
return typeNode(from: annotation)
|
|
289
|
+
}
|
|
290
|
+
return initializer.flatMap(inferredLiteralType)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// The node for a simple literal default (`var n = 1` -> `Int`); `nil` for anything non-literal.
|
|
294
|
+
private func inferredLiteralType(of expression: ExprSyntax) -> TypeNode? {
|
|
295
|
+
switch expression.kind {
|
|
296
|
+
case .stringLiteralExpr:
|
|
297
|
+
return .primitive(name: "String", jsType: .string)
|
|
298
|
+
case .integerLiteralExpr:
|
|
299
|
+
return .primitive(name: "Int", jsType: .number)
|
|
300
|
+
case .floatLiteralExpr:
|
|
301
|
+
return .primitive(name: "Double", jsType: .number)
|
|
302
|
+
case .booleanLiteralExpr:
|
|
303
|
+
return .primitive(name: "Bool", jsType: .boolean)
|
|
304
|
+
default:
|
|
305
|
+
return nil
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import SwiftSyntax
|
|
2
|
+
|
|
3
|
+
/// A boundary type parsed into a structured, JS-oriented tree, so the consumer walks a tagged tree
|
|
4
|
+
/// instead of re-parsing Swift type syntax. The single Swift type parser lives here. Encoded as a
|
|
5
|
+
/// `kind` discriminator plus per-kind fields and a `typeof` on every node, e.g.
|
|
6
|
+
/// `{ "kind": "array", "typeof": "object", "element": { "kind": "primitive", "name": "Int",
|
|
7
|
+
/// "typeof": "number" } }`. An unmodeled spelling becomes `.unknown` (verbatim text, no `typeof`),
|
|
8
|
+
/// never silently dropped.
|
|
9
|
+
|
|
10
|
+
/// The runtime category mirroring JavaScript's `typeof`. A coarse companion to `TypeNode.kind`: every
|
|
11
|
+
/// structured object kind (array, dictionary, promise, ref) reports `object`. `bigint`/`symbol` are
|
|
12
|
+
/// included for completeness; the scanner doesn't produce them.
|
|
13
|
+
enum JSType: String, Encodable {
|
|
14
|
+
case undefined
|
|
15
|
+
case object
|
|
16
|
+
case boolean
|
|
17
|
+
case number
|
|
18
|
+
case bigint
|
|
19
|
+
case string
|
|
20
|
+
case symbol
|
|
21
|
+
case function
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
indirect enum TypeNode: Equatable {
|
|
25
|
+
/// `Bool`, `Int`, `Double`, `String`: the types core fast-decodes. `name` is the Swift spelling;
|
|
26
|
+
/// `jsType` is `boolean`/`number`/`string`.
|
|
27
|
+
case primitive(name: String, jsType: JSType)
|
|
28
|
+
|
|
29
|
+
/// `T?` / `T!` / `Optional<T>`.
|
|
30
|
+
case optional(wrapped: TypeNode)
|
|
31
|
+
|
|
32
|
+
/// `[T]` / `Array<T>`.
|
|
33
|
+
case array(element: TypeNode)
|
|
34
|
+
|
|
35
|
+
/// `[K: V]` / `Dictionary<K, V>`.
|
|
36
|
+
case dictionary(key: TypeNode, value: TypeNode)
|
|
37
|
+
|
|
38
|
+
/// `Promise<T>`.
|
|
39
|
+
case promise(value: TypeNode)
|
|
40
|
+
|
|
41
|
+
/// A closure `(A, B) async throws -> R`. `returns` is `nil` for `Void`; `isAsync`/`isThrowing` carry
|
|
42
|
+
/// the effects (encoded `async`/`throws`).
|
|
43
|
+
case function(parameters: [TypeNode], returns: TypeNode?, isAsync: Bool, isThrowing: Bool)
|
|
44
|
+
|
|
45
|
+
/// Any other named type (record, shared object, enum, …). `name` is the possibly-qualified spelling;
|
|
46
|
+
/// the generator resolves it against the scanned types or treats it as opaque.
|
|
47
|
+
case ref(name: String)
|
|
48
|
+
|
|
49
|
+
/// A spelling the parser doesn't model (generic parameter, tuple, metatype, …), kept verbatim so
|
|
50
|
+
/// nothing is lost. Has no `typeof`.
|
|
51
|
+
case unknown(text: String)
|
|
52
|
+
|
|
53
|
+
/// The `typeof` category, or `nil` for `.unknown`. An optional reports its *present* value's
|
|
54
|
+
/// category; the absent (`undefined`) case is carried by the `.optional` wrapper itself.
|
|
55
|
+
var jsType: JSType? {
|
|
56
|
+
switch self {
|
|
57
|
+
case .primitive(_, let jsType):
|
|
58
|
+
return jsType
|
|
59
|
+
case .array, .dictionary, .promise, .ref:
|
|
60
|
+
return .object
|
|
61
|
+
case .function:
|
|
62
|
+
return .function
|
|
63
|
+
case .optional(let wrapped):
|
|
64
|
+
return wrapped.jsType
|
|
65
|
+
case .unknown:
|
|
66
|
+
return nil
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
extension TypeNode: Encodable {
|
|
72
|
+
private enum CodingKeys: String, CodingKey {
|
|
73
|
+
case kind
|
|
74
|
+
case name
|
|
75
|
+
// The `typeof` category. Spelled `typeof` in JSON (what the consumer reads); the Swift property is
|
|
76
|
+
// `jsType` since it's the cleaner Swift name.
|
|
77
|
+
case jsType = "typeof"
|
|
78
|
+
case wrapped, element, key, value, parameters, returns, text
|
|
79
|
+
// A closure type's effects, matching `ExportedFunction`'s TS-keyword spellings.
|
|
80
|
+
case isAsync = "async"
|
|
81
|
+
case isThrowing = "throws"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func encode(to encoder: Encoder) throws {
|
|
85
|
+
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
86
|
+
// Every node carries its `typeof` category (except `.unknown`, whose `jsType` is nil). Encoded up
|
|
87
|
+
// front so it sits beside `kind` on each node.
|
|
88
|
+
try container.encodeIfPresent(jsType, forKey: .jsType)
|
|
89
|
+
switch self {
|
|
90
|
+
case .primitive(let name, _):
|
|
91
|
+
try container.encode("primitive", forKey: .kind)
|
|
92
|
+
try container.encode(name, forKey: .name)
|
|
93
|
+
case .optional(let wrapped):
|
|
94
|
+
try container.encode("optional", forKey: .kind)
|
|
95
|
+
try container.encode(wrapped, forKey: .wrapped)
|
|
96
|
+
case .array(let element):
|
|
97
|
+
try container.encode("array", forKey: .kind)
|
|
98
|
+
try container.encode(element, forKey: .element)
|
|
99
|
+
case .dictionary(let key, let value):
|
|
100
|
+
try container.encode("dictionary", forKey: .kind)
|
|
101
|
+
try container.encode(key, forKey: .key)
|
|
102
|
+
try container.encode(value, forKey: .value)
|
|
103
|
+
case .promise(let value):
|
|
104
|
+
try container.encode("promise", forKey: .kind)
|
|
105
|
+
try container.encode(value, forKey: .value)
|
|
106
|
+
case .function(let parameters, let returns, let isAsync, let isThrowing):
|
|
107
|
+
try container.encode("function", forKey: .kind)
|
|
108
|
+
try container.encode(parameters, forKey: .parameters)
|
|
109
|
+
try container.encodeIfPresent(returns, forKey: .returns)
|
|
110
|
+
try container.encode(isAsync, forKey: .isAsync)
|
|
111
|
+
try container.encode(isThrowing, forKey: .isThrowing)
|
|
112
|
+
case .ref(let name):
|
|
113
|
+
try container.encode("ref", forKey: .kind)
|
|
114
|
+
try container.encode(name, forKey: .name)
|
|
115
|
+
case .unknown(let text):
|
|
116
|
+
try container.encode("unknown", forKey: .kind)
|
|
117
|
+
try container.encode(text, forKey: .text)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// The primitive Swift types with a dedicated JS mapping (the set core fast-decodes), each paired with
|
|
123
|
+
/// its JS primitive. A bare name here is a `.primitive`; anything else is a `.ref`.
|
|
124
|
+
private let primitiveJSTypes: [String: JSType] = [
|
|
125
|
+
"Bool": .boolean,
|
|
126
|
+
"Int": .number,
|
|
127
|
+
"Double": .number,
|
|
128
|
+
"String": .string,
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
/// Parses a `TypeSyntax` into a `TypeNode`: the single place Swift type syntax is interpreted.
|
|
132
|
+
func typeNode(from type: TypeSyntax) -> TypeNode {
|
|
133
|
+
// `T?` sugar.
|
|
134
|
+
if let optional = type.as(OptionalTypeSyntax.self) {
|
|
135
|
+
return .optional(wrapped: typeNode(from: optional.wrappedType))
|
|
136
|
+
}
|
|
137
|
+
// `T!`, JS treats it the same as `T?`.
|
|
138
|
+
if let iuo = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
|
|
139
|
+
return .optional(wrapped: typeNode(from: iuo.wrappedType))
|
|
140
|
+
}
|
|
141
|
+
// `[T]` sugar.
|
|
142
|
+
if let arrayType = type.as(ArrayTypeSyntax.self) {
|
|
143
|
+
return .array(element: typeNode(from: arrayType.element))
|
|
144
|
+
}
|
|
145
|
+
// `[K: V]` sugar.
|
|
146
|
+
if let dictionaryType = type.as(DictionaryTypeSyntax.self) {
|
|
147
|
+
return .dictionary(key: typeNode(from: dictionaryType.key), value: typeNode(from: dictionaryType.value))
|
|
148
|
+
}
|
|
149
|
+
// `(A, B) -> R`.
|
|
150
|
+
if let functionType = type.as(FunctionTypeSyntax.self) {
|
|
151
|
+
return functionNode(from: functionType)
|
|
152
|
+
}
|
|
153
|
+
// `@escaping (…) -> …`, `@Sendable …`, etc.: strip the attributes and parse the underlying type.
|
|
154
|
+
if let attributed = type.as(AttributedTypeSyntax.self) {
|
|
155
|
+
return typeNode(from: attributed.baseType)
|
|
156
|
+
}
|
|
157
|
+
// A nominal type, possibly generic: `Int`, `Point`, `Optional<T>`, `Array<T>`, `Promise<T>`.
|
|
158
|
+
if let identifier = type.as(IdentifierTypeSyntax.self) {
|
|
159
|
+
return nominalNode(name: identifier.name.text, generics: identifier.genericArgumentClause)
|
|
160
|
+
}
|
|
161
|
+
// A qualified nominal type: `Foo.Bar`. Kept as a ref under its full spelling.
|
|
162
|
+
if let member = type.as(MemberTypeSyntax.self) {
|
|
163
|
+
return .ref(name: member.trimmedDescription)
|
|
164
|
+
}
|
|
165
|
+
// Tuples, metatypes, some/any types, etc.: not modeled, preserve the verbatim spelling.
|
|
166
|
+
return .unknown(text: type.trimmedDescription)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// A nominal type's node. The standard generic wrappers normalize to their sugared node
|
|
170
|
+
/// (`Optional<T>`/`Array<T>`/`Dictionary<K,V>`/`Promise<T>`); a bare name is a primitive when known,
|
|
171
|
+
/// else a ref; any other generic is a ref under its full spelling.
|
|
172
|
+
private func nominalNode(name: String, generics: GenericArgumentClauseSyntax?) -> TypeNode {
|
|
173
|
+
guard let generics else {
|
|
174
|
+
if let jsType = primitiveJSTypes[name] {
|
|
175
|
+
return .primitive(name: name, jsType: jsType)
|
|
176
|
+
}
|
|
177
|
+
return .ref(name: name)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let arguments = genericArguments(generics)
|
|
181
|
+
switch (name, arguments.count) {
|
|
182
|
+
case ("Optional", 1):
|
|
183
|
+
return .optional(wrapped: arguments[0])
|
|
184
|
+
case ("Array", 1):
|
|
185
|
+
return .array(element: arguments[0])
|
|
186
|
+
case ("Dictionary", 2):
|
|
187
|
+
return .dictionary(key: arguments[0], value: arguments[1])
|
|
188
|
+
case ("Promise", 1):
|
|
189
|
+
return .promise(value: arguments[0])
|
|
190
|
+
default:
|
|
191
|
+
// An unmodeled generic (`Set<Int>`, `Either<A, B>`, …). Keep the whole spelling as a ref so the
|
|
192
|
+
// name and its arguments survive for the generator to interpret or flag.
|
|
193
|
+
return .ref(name: "\(name)<\(arguments.map { $0.spelling }.joined(separator: ", "))>")
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// The argument types of a generic clause, each parsed into a node.
|
|
198
|
+
private func genericArguments(_ clause: GenericArgumentClauseSyntax) -> [TypeNode] {
|
|
199
|
+
clause.arguments.compactMap { argument in
|
|
200
|
+
guard case .type(let type) = argument.argument else {
|
|
201
|
+
return nil
|
|
202
|
+
}
|
|
203
|
+
return typeNode(from: type)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// A closure node: each parameter parsed, the result (`Void`/`()` collapsed to `nil`, matching how a
|
|
208
|
+
/// function's own return is reported), and the closure's `async`/`throws` effects.
|
|
209
|
+
private func functionNode(from type: FunctionTypeSyntax) -> TypeNode {
|
|
210
|
+
let parameters = type.parameters.map { typeNode(from: $0.type) }
|
|
211
|
+
let returnType = type.returnClause.type
|
|
212
|
+
let returns = isVoidType(returnType) ? nil : typeNode(from: returnType)
|
|
213
|
+
let effects = type.effectSpecifiers
|
|
214
|
+
return .function(
|
|
215
|
+
parameters: parameters,
|
|
216
|
+
returns: returns,
|
|
217
|
+
isAsync: effects?.asyncSpecifier != nil,
|
|
218
|
+
isThrowing: effects?.throwsClause?.throwsSpecifier != nil
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// True when a return clause is absent or written `Void` / `()`, so the surface reports `nil`. Shared
|
|
223
|
+
/// by the function-return read and the closure-type parser.
|
|
224
|
+
func isVoidType(_ type: TypeSyntax?) -> Bool {
|
|
225
|
+
guard let type else {
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
let text = type.trimmedDescription
|
|
229
|
+
return text == "Void" || text == "()"
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
extension TypeNode {
|
|
233
|
+
/// A best-effort source-like spelling, used only to re-compose an unmodeled generic ref's name.
|
|
234
|
+
/// Not a round-trip of arbitrary Swift; the structured node is the source of truth.
|
|
235
|
+
fileprivate var spelling: String {
|
|
236
|
+
switch self {
|
|
237
|
+
case .primitive(let name, _):
|
|
238
|
+
return name
|
|
239
|
+
case .ref(let name):
|
|
240
|
+
return name
|
|
241
|
+
case .optional(let wrapped):
|
|
242
|
+
return "\(wrapped.spelling)?"
|
|
243
|
+
case .array(let element):
|
|
244
|
+
return "[\(element.spelling)]"
|
|
245
|
+
case .dictionary(let key, let value):
|
|
246
|
+
return "[\(key.spelling): \(value.spelling)]"
|
|
247
|
+
case .promise(let value):
|
|
248
|
+
return "Promise<\(value.spelling)>"
|
|
249
|
+
case .function(let parameters, let returns, _, _):
|
|
250
|
+
return "(\(parameters.map { $0.spelling }.joined(separator: ", "))) -> \(returns?.spelling ?? "Void")"
|
|
251
|
+
case .unknown(let text):
|
|
252
|
+
return text
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -7,8 +7,8 @@ import Foundation
|
|
|
7
7
|
/// `@testable import`, and the CLI only needs these entries, so nothing else is exposed.
|
|
8
8
|
public enum Scanner {
|
|
9
9
|
/// Runs the `scan-modules` command over `paths`, prints the JSON report to stdout, and returns a
|
|
10
|
-
/// process exit code: `0` on success, `1` if encoding fails. (`scan-exports`
|
|
11
|
-
/// `
|
|
10
|
+
/// process exit code: `0` on success, `1` if encoding fails. (The deep `scan-exports` command has
|
|
11
|
+
/// its own `runExports` entry returning its own result type.)
|
|
12
12
|
public static func runModules(paths: [String]) -> Int32 {
|
|
13
13
|
let result = scanModules(paths: paths)
|
|
14
14
|
|
|
@@ -30,7 +30,7 @@ public enum Scanner {
|
|
|
30
30
|
/// register a module: the Swift class name, the JS name it registers under, and the file it's in.
|
|
31
31
|
/// The richer fields the visitor captures (declaration kind, raw macro arguments, line/column) are
|
|
32
32
|
/// dropped here — they're redundant for this command (the macro is always `@ExpoModule` on a class)
|
|
33
|
-
/// and
|
|
33
|
+
/// and the deep `scan-exports` surface carries the richer per-member detail instead.
|
|
34
34
|
struct ScannedModule: Codable, Equatable {
|
|
35
35
|
/// The Swift class name the module is declared as.
|
|
36
36
|
let name: String
|
|
@@ -45,8 +45,8 @@ struct ScannedModule: Codable, Equatable {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
|
|
48
|
-
/// command's JSON output. (`scan-exports`
|
|
49
|
-
///
|
|
48
|
+
/// command's JSON output. (`scan-exports` returns its own `ScanExportsResult` shape; the two commands
|
|
49
|
+
/// serve different consumers and don't share an envelope.)
|
|
50
50
|
struct ScanModulesResult: Codable, Equatable {
|
|
51
51
|
let modules: [ScannedModule]
|
|
52
52
|
let stats: ScanStats
|
|
@@ -61,9 +61,10 @@ case "scan-modules":
|
|
|
61
61
|
exit(Scanner.runModules(paths: paths))
|
|
62
62
|
|
|
63
63
|
case "scan-exports":
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
guard !paths.isEmpty else {
|
|
65
|
+
fail("scan-exports requires at least one path", usage: true)
|
|
66
|
+
}
|
|
67
|
+
exit(Scanner.runExports(paths: paths))
|
|
67
68
|
|
|
68
69
|
default:
|
|
69
70
|
fail("unknown subcommand '\(subcommand)'", usage: true)
|