@expo/expo-modules-macros-plugin 0.10.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/.github/resources/expo-modules-macros.svg +23 -0
- package/.github/workflows/publish.yml +4 -0
- package/.github/workflows/swift.yml +6 -0
- package/README.md +119 -0
- package/apple/ExpoModulesMacros-tool +0 -0
- package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +3 -1
- package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +10 -0
- package/apple/Sources/ExpoModulesMacros/ExpoViewMacro.swift +259 -0
- package/apple/Sources/ExpoModulesMacros/JSMacro.swift +2 -2
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +24 -2
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +2 -0
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +0 -16
- package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +487 -0
- package/apple/Sources/ExpoModulesScanner/CLI.swift +8 -4
- package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +6 -4
- package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +28 -7
- package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +3 -9
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +24 -8
- package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +133 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +199 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +25 -6
- package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +315 -6
- package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +35 -6
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +89 -22
- package/build/index.d.ts +51 -0
- package/build/index.js +153 -0
- package/build/types.d.ts +264 -0
- package/build/types.js +15 -0
- package/package.json +12 -2
|
@@ -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)?"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
import SwiftParser
|
|
2
3
|
|
|
3
4
|
/// The scanner's public entry point. Argument parsing, subcommand dispatch, and usage text live in
|
|
4
5
|
/// the CLI target; this just runs a command and writes its JSON report to stdout.
|
|
@@ -10,11 +11,10 @@ public enum Scanner {
|
|
|
10
11
|
/// process exit code: `0` on success, `1` if encoding fails. (The deep `scan-exports` command has
|
|
11
12
|
/// its own `runExports` entry returning its own result type.)
|
|
12
13
|
///
|
|
13
|
-
/// `
|
|
14
|
-
///
|
|
15
|
-
public static func runModules(paths: [String],
|
|
16
|
-
let
|
|
17
|
-
let result = scanModules(paths: paths, configuration: configuration)
|
|
14
|
+
/// `defines` (the `--define` options) asserts conditional compilation flags; see `scanModules`
|
|
15
|
+
/// for how they and platforms shape each module's `platforms` list.
|
|
16
|
+
public static func runModules(paths: [String], defines: [String] = []) -> Int32 {
|
|
17
|
+
let result = scanModules(paths: paths, defines: Set(defines))
|
|
18
18
|
|
|
19
19
|
do {
|
|
20
20
|
let encoder = JSONEncoder()
|
|
@@ -31,10 +31,11 @@ public enum Scanner {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/// One module in the `scan-modules` output. Trimmed to what `expo-modules-autolinking` needs to
|
|
34
|
-
/// register a module: the Swift class name, the JS name it registers under,
|
|
35
|
-
/// The richer fields the visitor captures (declaration kind, raw
|
|
36
|
-
/// dropped here — they're redundant for this command (the macro
|
|
37
|
-
/// and the deep `scan-exports` surface carries the richer
|
|
34
|
+
/// register a module: the Swift class name, the JS name it registers under, the platforms that
|
|
35
|
+
/// include it, and the file it's in. The richer fields the visitor captures (declaration kind, raw
|
|
36
|
+
/// macro arguments, line/column) are dropped here — they're redundant for this command (the macro
|
|
37
|
+
/// is always `@ExpoModule` on a class) and the deep `scan-exports` surface carries the richer
|
|
38
|
+
/// per-member detail instead.
|
|
38
39
|
struct ScannedModule: Codable, Equatable {
|
|
39
40
|
/// The Swift class name the module is declared as.
|
|
40
41
|
let name: String
|
|
@@ -50,14 +51,22 @@ struct ScannedModule: Codable, Equatable {
|
|
|
50
51
|
/// classes with a diagnostic instead of emitting a provider that fails to compile.
|
|
51
52
|
let accessLevel: String
|
|
52
53
|
|
|
54
|
+
/// The Apple OSes whose builds include this class, spelled as `os(...)` spells them and compared
|
|
55
|
+
/// case-sensitively. An unconditional module lists every OS. Empty means no build is known to
|
|
56
|
+
/// include it, because the enclosing conditions depend on flags not asserted with `--define` or
|
|
57
|
+
/// on conditions a static scan cannot answer (those are reported in `warnings`). The consumer
|
|
58
|
+
/// must not assume such a class exists.
|
|
59
|
+
let platforms: [String]
|
|
60
|
+
|
|
53
61
|
/// Source file the module was found in, relative to the path the scanner was invoked with.
|
|
54
62
|
let file: String
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
/// Version of the `scan-modules` output shape. Bumped on any breaking change to the envelope or to
|
|
58
66
|
/// `ScannedModule`, so `expo-modules-autolinking` can verify it understands the output before
|
|
59
|
-
/// trusting it (and fall back to config-declared modules when it doesn't).
|
|
60
|
-
|
|
67
|
+
/// trusting it (and fall back to config-declared modules when it doesn't). Version 2 added the
|
|
68
|
+
/// per-module `platforms` list and made the module list platform-agnostic.
|
|
69
|
+
let scanModulesSchemaVersion = 2
|
|
61
70
|
|
|
62
71
|
/// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
|
|
63
72
|
/// command's JSON output. (`scan-exports` returns its own `ScanExportsResult` shape; the two commands
|
|
@@ -73,22 +82,80 @@ struct ScanModulesResult: Codable, Equatable {
|
|
|
73
82
|
let stats: ScanStats
|
|
74
83
|
}
|
|
75
84
|
|
|
76
|
-
///
|
|
77
|
-
///
|
|
78
|
-
///
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
/// The Apple OSes a scan attributes modules to. `condition` is what `os(...)` matches; `reported`
|
|
86
|
+
/// is what the JSON carries. They are identical today, but stated separately so the output
|
|
87
|
+
/// contract cannot drift if the matcher's spelling changes.
|
|
88
|
+
private let platformUniverse: [(condition: String, reported: String)] = [
|
|
89
|
+
(condition: "iOS", reported: "iOS"),
|
|
90
|
+
(condition: "macOS", reported: "macOS"),
|
|
91
|
+
(condition: "tvOS", reported: "tvOS"),
|
|
92
|
+
(condition: "watchOS", reported: "watchOS"),
|
|
93
|
+
(condition: "visionOS", reported: "visionOS"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
/// Scans the given paths for top-level `@ExpoModule` types and returns every module found in any
|
|
97
|
+
/// `#if` branch (in file then source order), each with the platforms whose builds include it, plus
|
|
98
|
+
/// the stats for the run — the `scan-modules` command. Kept separate from the public entry (and
|
|
99
|
+
/// `internal`) so tests can drive it without going through argv/stdout.
|
|
100
|
+
///
|
|
101
|
+
/// Each file is parsed once and walked once per platform (plus once unconditionally to enumerate
|
|
102
|
+
/// every module): a module's `platforms` are the OSes whose evaluated walk reached it, given the
|
|
103
|
+
/// asserted `defines`. The scanner reports the facts; filtering to the platform being linked is the
|
|
104
|
+
/// consumer's call.
|
|
105
|
+
func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResult {
|
|
106
|
+
var modules: [ScannedModule] = []
|
|
107
|
+
var warnings: [ScanWarning] = []
|
|
108
|
+
var seenWarnings = Set<ScanWarning>()
|
|
109
|
+
|
|
110
|
+
let stats = scanFiles(paths: paths, macros: [.expoModule]) { source, file in
|
|
111
|
+
let tree = Parser.parse(source: source)
|
|
112
|
+
|
|
113
|
+
// The unconditional walk enumerates every module in the file, in source order.
|
|
114
|
+
let allModules = DetectionVisitor(file: file, tree: tree, detectedMacros: [.expoModule], configuration: nil)
|
|
115
|
+
allModules.walk(tree)
|
|
81
116
|
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
117
|
+
// One evaluated walk per OS attributes each module to the platforms that include it. The walks
|
|
118
|
+
// are cheap relative to the parse, which is shared.
|
|
119
|
+
var platformsByDetection: [String: [String]] = [:]
|
|
120
|
+
for platform in platformUniverse {
|
|
121
|
+
let configuration = ScanBuildConfiguration(platform: platform.condition, defines: defines)
|
|
122
|
+
let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: [.expoModule], configuration: configuration)
|
|
123
|
+
visitor.walk(tree)
|
|
124
|
+
for detection in visitor.detections {
|
|
125
|
+
platformsByDetection[detectionKey(detection), default: []].append(platform.reported)
|
|
126
|
+
}
|
|
127
|
+
// The same unanswerable condition diagnoses identically in every per-platform walk; report
|
|
128
|
+
// it once.
|
|
129
|
+
for warning in visitor.warnings where seenWarnings.insert(warning).inserted {
|
|
130
|
+
warnings.append(warning)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for detection in allModules.detections {
|
|
135
|
+
// Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else
|
|
136
|
+
// the class name.
|
|
137
|
+
modules.append(
|
|
138
|
+
ScannedModule(
|
|
139
|
+
name: detection.name,
|
|
140
|
+
jsName: detection.jsName ?? detection.name,
|
|
141
|
+
accessLevel: detection.accessLevel,
|
|
142
|
+
platforms: platformsByDetection[detectionKey(detection)] ?? [],
|
|
143
|
+
file: detection.file
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
}
|
|
86
147
|
}
|
|
87
148
|
|
|
88
149
|
return ScanModulesResult(
|
|
89
150
|
schemaVersion: scanModulesSchemaVersion,
|
|
90
151
|
modules: modules,
|
|
91
|
-
warnings:
|
|
92
|
-
stats:
|
|
152
|
+
warnings: warnings,
|
|
153
|
+
stats: stats
|
|
93
154
|
)
|
|
94
155
|
}
|
|
156
|
+
|
|
157
|
+
/// Identifies one declaration across the per-platform walks of the same tree: the source position
|
|
158
|
+
/// is unique within a file, and the name guards against any position ambiguity.
|
|
159
|
+
private func detectionKey(_ detection: Detection) -> String {
|
|
160
|
+
return "\(detection.line):\(detection.column):\(detection.name)"
|
|
161
|
+
}
|