@expo/expo-modules-macros-plugin 0.9.0 → 0.10.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/ExpoModulesMacros/ExpoModuleMacro.swift +7 -0
- package/apple/Sources/ExpoModulesMacros/JSMacro.swift +76 -0
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +65 -0
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +1 -0
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +0 -31
- package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +8 -2
- package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +6 -0
- package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +365 -0
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +15 -5
- package/package.json +1 -1
|
Binary file
|
|
@@ -151,6 +151,13 @@ extension ExpoModuleMacro: MemberAttributeMacro {
|
|
|
151
151
|
attributes.append("@JavaScriptActor")
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// `@JS(.concurrent)` is the inverse: instead of the JS-thread stamp the member gets
|
|
155
|
+
// `@concurrent`, so its body runs on the concurrent pool. `shouldStampJavaScriptActor` already
|
|
156
|
+
// skipped the stamp above, leaving the two mutually exclusive.
|
|
157
|
+
if isConcurrentJSMember(member) {
|
|
158
|
+
attributes.append("@concurrent")
|
|
159
|
+
}
|
|
160
|
+
|
|
154
161
|
// Apply the result builder to `definition()` so the user doesn't have to. Skipped if
|
|
155
162
|
// they already wrote `@ModuleDefinitionBuilder` themselves, which would otherwise be
|
|
156
163
|
// a duplicate attribute.
|
|
@@ -32,6 +32,7 @@ public struct JSMacro: PeerMacro {
|
|
|
32
32
|
in context: some MacroExpansionContext
|
|
33
33
|
) throws -> [DeclSyntax] {
|
|
34
34
|
diagnoseFreeFormTypes(in: declaration, in: context)
|
|
35
|
+
diagnoseConcurrentOption(of: node, on: declaration, in: context)
|
|
35
36
|
|
|
36
37
|
guard let member = boundaryMember(of: declaration),
|
|
37
38
|
let assertion = directionalConformanceAssertion(
|
|
@@ -46,6 +47,71 @@ public struct JSMacro: PeerMacro {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/// Emits the diagnostic for `@JS(.concurrent)` on a member that can't take it. The option maps to
|
|
51
|
+
/// Swift's `@concurrent`, which requires an `async` function: a synchronous member has nowhere to
|
|
52
|
+
/// suspend, and a property or initializer can't be async at all. Diagnosing here points at the
|
|
53
|
+
/// user's own `@JS` attribute rather than at the `@concurrent` the module macro would attach.
|
|
54
|
+
private func diagnoseConcurrentOption(
|
|
55
|
+
of node: AttributeSyntax,
|
|
56
|
+
on declaration: some DeclSyntaxProtocol,
|
|
57
|
+
in context: some MacroExpansionContext
|
|
58
|
+
) {
|
|
59
|
+
guard hasJSOption(node, named: "concurrent") else {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
|
|
63
|
+
guard funcDecl.signature.effectSpecifiers?.asyncSpecifier == nil else {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
context.diagnose(
|
|
67
|
+
Diagnostic(
|
|
68
|
+
node: node,
|
|
69
|
+
message: JSDiagnosticMessage(
|
|
70
|
+
"'.concurrent' needs an 'async' function: a synchronous @JS member runs on the JavaScript thread by definition. Mark the function 'async' to run its body off that thread.",
|
|
71
|
+
id: "js-concurrent-requires-async",
|
|
72
|
+
severity: .error
|
|
73
|
+
),
|
|
74
|
+
fixIts: [insertAsyncFixIt(for: funcDecl)]))
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
context.diagnose(
|
|
78
|
+
Diagnostic(
|
|
79
|
+
node: node,
|
|
80
|
+
message: JSDiagnosticMessage(
|
|
81
|
+
"'.concurrent' applies only to an 'async' @JS function, not to a property or initializer.",
|
|
82
|
+
id: "js-concurrent-requires-function",
|
|
83
|
+
severity: .error
|
|
84
|
+
)))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// The fix-it offered alongside the synchronous-function diagnostic: insert `async` into the
|
|
88
|
+
/// signature so `@JS(.concurrent)` becomes valid. The macro can't add the keyword itself (no macro
|
|
89
|
+
/// role rewrites the declaration it's attached to), but Xcode can apply this in one click.
|
|
90
|
+
///
|
|
91
|
+
/// `async` goes at the front of the effect specifiers, ahead of any `throws`, which is the only
|
|
92
|
+
/// order Swift accepts. When the signature has no effect specifiers yet, the new clause inherits
|
|
93
|
+
/// what the parameter clause had trailing it and the parameter clause is left with a single space,
|
|
94
|
+
/// so `() -> Int` becomes `() async -> Int` rather than `() async-> Int`.
|
|
95
|
+
private func insertAsyncFixIt(for funcDecl: FunctionDeclSyntax) -> FixIt {
|
|
96
|
+
let signature = funcDecl.signature
|
|
97
|
+
var newSignature = signature
|
|
98
|
+
|
|
99
|
+
if var effectSpecifiers = signature.effectSpecifiers {
|
|
100
|
+
effectSpecifiers.asyncSpecifier = .keyword(.async, trailingTrivia: .space)
|
|
101
|
+
newSignature.effectSpecifiers = effectSpecifiers
|
|
102
|
+
} else {
|
|
103
|
+
newSignature.effectSpecifiers = FunctionEffectSpecifiersSyntax(
|
|
104
|
+
asyncSpecifier: .keyword(.async, trailingTrivia: signature.parameterClause.trailingTrivia)
|
|
105
|
+
)
|
|
106
|
+
newSignature.parameterClause.trailingTrivia = .space
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return FixIt(
|
|
110
|
+
message: JSFixItMessage("Mark the function 'async'", id: "js-concurrent-insert-async"),
|
|
111
|
+
changes: [.replace(oldNode: Syntax(signature), newNode: Syntax(newSignature))]
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
49
115
|
/// Emits the free-form (`Any` / `[Any]` / `[String: Any]`) diagnostics for a `@JS` declaration.
|
|
50
116
|
///
|
|
51
117
|
/// A free-form type is only supported crossing the boundary as an **argument**, decoded through
|
|
@@ -155,6 +221,16 @@ private struct JSDiagnosticMessage: DiagnosticMessage {
|
|
|
155
221
|
}
|
|
156
222
|
}
|
|
157
223
|
|
|
224
|
+
private struct JSFixItMessage: FixItMessage {
|
|
225
|
+
let message: String
|
|
226
|
+
let fixItID: MessageID
|
|
227
|
+
|
|
228
|
+
init(_ message: String, id: String) {
|
|
229
|
+
self.message = message
|
|
230
|
+
self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
158
234
|
/// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer unique
|
|
159
235
|
/// among siblings), the boundary types split by conversion direction, and whether the member is
|
|
160
236
|
/// type-level. Arguments (and a settable property's incoming value) are decoded; return values (and a
|
|
@@ -31,6 +31,44 @@ internal func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool
|
|
|
31
31
|
return nil
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/// True if the attribute lists the given `JSOptions` member, e.g. `@JS(.concurrent)` or
|
|
35
|
+
/// `@JS("name", [.concurrent])` -> true for "concurrent". Options are written as member-access
|
|
36
|
+
/// expressions (`.concurrent`), optionally inside an array literal when more than one is combined, so
|
|
37
|
+
/// both spellings are scanned. The leading string literal, when present, is the JS name and is skipped.
|
|
38
|
+
internal func hasJSOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
|
|
39
|
+
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
for arg in args {
|
|
43
|
+
if namesOption(arg.expression, option) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
if let array = arg.expression.as(ArrayExprSyntax.self),
|
|
47
|
+
array.elements.contains(where: { namesOption($0.expression, option) }) {
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// True when the expression is the member access `.<option>` (or `JSOptions.<option>`).
|
|
55
|
+
private func namesOption(_ expression: ExprSyntax, _ option: String) -> Bool {
|
|
56
|
+
guard let member = expression.as(MemberAccessExprSyntax.self) else {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
return member.declName.baseName.text == option
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// True if the `@JS`-marked declaration opted into running off the JavaScript thread with
|
|
63
|
+
/// `@JS(.concurrent)`. Such a member is left unstamped and gets `@concurrent` instead, so its body
|
|
64
|
+
/// runs on the concurrent pool rather than inheriting the JS thread.
|
|
65
|
+
internal func isConcurrentJSMember(_ decl: DeclSyntaxProtocol) -> Bool {
|
|
66
|
+
guard let attribute = memberAttributes(of: decl).firstAttribute(named: "JS") else {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
return hasJSOption(attribute, named: "concurrent")
|
|
70
|
+
}
|
|
71
|
+
|
|
34
72
|
/// True if the type is written as an optional: `T?`, `T!`, or the explicit `Optional<T>`. Used to
|
|
35
73
|
/// decide argument requiredness (an optional parameter may be omitted) and record-field nullability.
|
|
36
74
|
internal func isOptionalType(_ type: TypeSyntax) -> Bool {
|
|
@@ -156,6 +194,7 @@ internal func memberHasJSAttribute(_ decl: DeclSyntaxProtocol) -> Bool {
|
|
|
156
194
|
|
|
157
195
|
/// Decides whether the macro should stamp `@JavaScriptActor` on a `@JS`-marked member.
|
|
158
196
|
/// The macro defers to the user when they've already chosen an isolation:
|
|
197
|
+
/// - the member opted out with `@JS(.concurrent)`
|
|
159
198
|
/// - the `nonisolated` modifier is present on the member
|
|
160
199
|
/// - any attribute whose name matches a known global actor (`@MainActor`, `@JavaScriptActor`)
|
|
161
200
|
/// or follows the `*Actor` naming convention is present on the member or its enclosing type
|
|
@@ -165,6 +204,10 @@ internal func shouldStampJavaScriptActor(
|
|
|
165
204
|
on member: DeclSyntaxProtocol,
|
|
166
205
|
enclosedBy enclosing: some DeclGroupSyntax
|
|
167
206
|
) -> Bool {
|
|
207
|
+
if isConcurrentJSMember(member) {
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
|
|
168
211
|
let modifiers = memberModifiers(of: member)
|
|
169
212
|
if modifiers.contains(where: { $0.name.text == "nonisolated" }) {
|
|
170
213
|
return false
|
|
@@ -326,3 +369,25 @@ internal func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
|
|
|
326
369
|
return false
|
|
327
370
|
}
|
|
328
371
|
}
|
|
372
|
+
|
|
373
|
+
/// True if the type's inheritance clause already lists a protocol with the given name. Matches
|
|
374
|
+
/// either the bare identifier (`Record`) or a qualified member access ending in the name
|
|
375
|
+
/// (`ExpoModulesCore.Record`). Used by the extension macros to skip a conformance the author already
|
|
376
|
+
/// spelled out. Works for `struct`, `class`, and `enum` declarations; any other declaration kind has no
|
|
377
|
+
/// inheritance clause to read and reports `false`.
|
|
378
|
+
internal func inheritsProtocol(named name: String, in declaration: some DeclGroupSyntax) -> Bool {
|
|
379
|
+
let inheritanceClause: InheritanceClauseSyntax?
|
|
380
|
+
if let structDecl = declaration.as(StructDeclSyntax.self) {
|
|
381
|
+
inheritanceClause = structDecl.inheritanceClause
|
|
382
|
+
} else if let classDecl = declaration.as(ClassDeclSyntax.self) {
|
|
383
|
+
inheritanceClause = classDecl.inheritanceClause
|
|
384
|
+
} else if let enumDecl = declaration.as(EnumDeclSyntax.self) {
|
|
385
|
+
inheritanceClause = enumDecl.inheritanceClause
|
|
386
|
+
} else {
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
guard let inherited = inheritanceClause?.inheritedTypes else {
|
|
390
|
+
return false
|
|
391
|
+
}
|
|
392
|
+
return inherited.contains { baseIdentifier(of: $0.type) == name }
|
|
393
|
+
}
|
|
@@ -478,37 +478,6 @@ private func isExcludedByModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
|
|
|
478
478
|
return false
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
-
/**
|
|
482
|
-
True if the type's inheritance clause already lists a protocol with the given name.
|
|
483
|
-
Matches either the bare identifier (`Record`) or a qualified member access ending in
|
|
484
|
-
the name (`ExpoModulesCore.Record`).
|
|
485
|
-
*/
|
|
486
|
-
private func inheritsProtocol(named name: String, in declaration: some DeclGroupSyntax) -> Bool {
|
|
487
|
-
let inheritanceClause: InheritanceClauseSyntax?
|
|
488
|
-
if let structDecl = declaration.as(StructDeclSyntax.self) {
|
|
489
|
-
inheritanceClause = structDecl.inheritanceClause
|
|
490
|
-
} else if let classDecl = declaration.as(ClassDeclSyntax.self) {
|
|
491
|
-
inheritanceClause = classDecl.inheritanceClause
|
|
492
|
-
} else {
|
|
493
|
-
return false
|
|
494
|
-
}
|
|
495
|
-
guard let inherited = inheritanceClause?.inheritedTypes else {
|
|
496
|
-
return false
|
|
497
|
-
}
|
|
498
|
-
for entry in inherited {
|
|
499
|
-
let typeSyntax = entry.type
|
|
500
|
-
if let identifier = typeSyntax.as(IdentifierTypeSyntax.self),
|
|
501
|
-
identifier.name.text == name {
|
|
502
|
-
return true
|
|
503
|
-
}
|
|
504
|
-
if let member = typeSyntax.as(MemberTypeSyntax.self),
|
|
505
|
-
member.name.text == name {
|
|
506
|
-
return true
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
return false
|
|
510
|
-
}
|
|
511
|
-
|
|
512
481
|
/**
|
|
513
482
|
True if the class declaration has any inheritance clause. Used as a heuristic for
|
|
514
483
|
whether the superclass also conforms to `Record` and provides the synthesized methods;
|
|
@@ -141,8 +141,14 @@ extension SharedObjectMacro: MemberAttributeMacro {
|
|
|
141
141
|
// `@Event(sync: true)` members are stamped alongside `@JS` ones: a sync event dispatches
|
|
142
142
|
// inline, so the isolation forces its call site onto the JS thread. Async events (the
|
|
143
143
|
// default) stay unstamped — their `emit` schedules onto the JS thread itself.
|
|
144
|
-
guard memberHasJSAttribute(member) || isSyncEventMember(member)
|
|
145
|
-
|
|
144
|
+
guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
|
|
145
|
+
return []
|
|
146
|
+
}
|
|
147
|
+
// `@JS(.concurrent)` opts the member out of the JS-thread stamp and onto the concurrent pool.
|
|
148
|
+
if isConcurrentJSMember(member) {
|
|
149
|
+
return ["@concurrent"]
|
|
150
|
+
}
|
|
151
|
+
guard shouldStampJavaScriptActor(on: member, enclosedBy: declaration) else {
|
|
146
152
|
return []
|
|
147
153
|
}
|
|
148
154
|
return ["@JavaScriptActor"]
|
|
@@ -21,6 +21,12 @@ internal let javaScriptEncodableProtocolName = "JavaScriptEncodable"
|
|
|
21
21
|
/// support both directions, so the assertion requires the intersection.
|
|
22
22
|
internal let recordFieldProtocolName = "AnyArgument & JavaScriptDecodable & JavaScriptEncodable"
|
|
23
23
|
|
|
24
|
+
/// The constraint a `@Union` case payload type must satisfy: the union decodes by trying each payload's
|
|
25
|
+
/// `decode` and encodes through the matching payload's `encode`, so every alternative must convert in
|
|
26
|
+
/// both directions. Unlike a `@Record` field, a union never crosses the native-`Any` dictionary path, so
|
|
27
|
+
/// `AnyArgument` is not required.
|
|
28
|
+
internal let unionPayloadProtocolName = "JavaScriptDecodable & JavaScriptEncodable"
|
|
29
|
+
|
|
24
30
|
/// The protocol a type must conform to for `self.emit(event:…)` to resolve; core conforms
|
|
25
31
|
/// `BaseModule` and `SharedObject` to it. Asserted by `@Event` so attaching it to a type that can't
|
|
26
32
|
/// emit fails with a conformance diagnostic instead of an opaque "no member 'emit'" error.
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import SwiftDiagnostics
|
|
2
|
+
import SwiftSyntax
|
|
3
|
+
import SwiftSyntaxBuilder
|
|
4
|
+
import SwiftSyntaxMacros
|
|
5
|
+
|
|
6
|
+
/// Member + extension macro applied to an `enum` whose cases each carry one associated value: a typed
|
|
7
|
+
/// union of the payload types (`A | B | C` in TypeScript). The enum is a tagged union at the Swift
|
|
8
|
+
/// level, so the author switches over it exhaustively with each payload keeping its static type, and
|
|
9
|
+
/// the macro synthesizes the conversion surface that makes it a JS boundary type:
|
|
10
|
+
///
|
|
11
|
+
/// - `decode(_:in:)`: an ordered decode that tries each case's payload converter in declaration order
|
|
12
|
+
/// and returns the first case that decodes; when none does, it throws
|
|
13
|
+
/// `Exceptions.UnionCaseMismatch` naming the union, the JS kind received, and the alternatives.
|
|
14
|
+
/// - `encode(_:in:)`: a `switch` over the cases, encoding the payload through its own type.
|
|
15
|
+
/// - `as(_:)`: one throwing overload per case, keyed by the payload's metatype, returning that payload
|
|
16
|
+
/// (`try source.as(String.self)` is `String`; `try? source.as(String.self)` is `String?`). It unwraps
|
|
17
|
+
/// by type without naming the case and throws `Exceptions.UnionCaseMismatch` when the union holds a
|
|
18
|
+
/// different case. Since a payload type may appear only once, each overload is unambiguous, and asking
|
|
19
|
+
/// for a type the union doesn't carry is a compile error.
|
|
20
|
+
///
|
|
21
|
+
/// The type is auto-conformed to `JavaScriptDecodable` and `JavaScriptEncodable`, so it can be a `@JS`
|
|
22
|
+
/// argument or return value, an `@Event` payload, or nested inside an optional, array, or dictionary.
|
|
23
|
+
/// Author-facing shape:
|
|
24
|
+
///
|
|
25
|
+
/// @Union
|
|
26
|
+
/// enum Source {
|
|
27
|
+
/// case text(String)
|
|
28
|
+
/// case options(SourceOptions) // a @Record
|
|
29
|
+
/// }
|
|
30
|
+
///
|
|
31
|
+
/// Discrimination is structural and order-dependent: the first case whose payload decodes wins. When
|
|
32
|
+
/// two payload shapes overlap (two records with compatible fields, `Int` and `Double`), the earlier
|
|
33
|
+
/// case matches; the author orders the more specific case first. A case may not repeat another case's
|
|
34
|
+
/// payload type, since it could never be chosen.
|
|
35
|
+
///
|
|
36
|
+
/// The named-union counterpart of core's `Either`: any number of named cases instead of two anonymous
|
|
37
|
+
/// slots, no `Any?` box, and an exhaustive `switch` as the primary way to read it (`as(_:)` covers the
|
|
38
|
+
/// one-type lookup `Either.as(_:)` offered, with the same throwing shape).
|
|
39
|
+
public struct UnionMacro: MemberMacro, ExtensionMacro {
|
|
40
|
+
public static func expansion(
|
|
41
|
+
of node: AttributeSyntax,
|
|
42
|
+
providingMembersOf declaration: some DeclGroupSyntax,
|
|
43
|
+
conformingTo protocols: [TypeSyntax],
|
|
44
|
+
in context: some MacroExpansionContext
|
|
45
|
+
) throws -> [DeclSyntax] {
|
|
46
|
+
let union = try validatedUnion(of: declaration)
|
|
47
|
+
|
|
48
|
+
var members: [DeclSyntax] = []
|
|
49
|
+
|
|
50
|
+
// A single never-called member that makes the compiler verify each payload type converts both
|
|
51
|
+
// ways. Each case keeps its own named assertion inside, so the conformance diagnostic names the
|
|
52
|
+
// offending case (see `typeConformanceAssertions`). Emitted first so a non-conforming payload
|
|
53
|
+
// reports the clear "requires that '…' conform to …" error ahead of the noisier "no member
|
|
54
|
+
// 'decode'"/"'encode'" errors from the conversion code below.
|
|
55
|
+
let assertions = union.cases.map { ConformanceAssertion(name: $0.name, types: [$0.payloadType]) }
|
|
56
|
+
if let assertionMember = typeConformanceAssertions(for: assertions, constraint: unionPayloadProtocolName) {
|
|
57
|
+
members.append(assertionMember)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
members.append(decodeMethod(union: union))
|
|
61
|
+
members.append(encodeMethod(union: union))
|
|
62
|
+
members.append(contentsOf: accessorMethods(union: union))
|
|
63
|
+
members.append(payloadTypeNameProperty(union: union))
|
|
64
|
+
return members
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Auto-conforms the enum to `JavaScriptDecodable` and `JavaScriptEncodable`, the protocols whose
|
|
68
|
+
/// requirements are exactly the `decode`/`encode` members synthesized above. A conformance the author
|
|
69
|
+
/// already spelled out in the inheritance clause is not repeated.
|
|
70
|
+
public static func expansion(
|
|
71
|
+
of node: AttributeSyntax,
|
|
72
|
+
attachedTo declaration: some DeclGroupSyntax,
|
|
73
|
+
providingExtensionsOf type: some TypeSyntaxProtocol,
|
|
74
|
+
conformingTo protocols: [TypeSyntax],
|
|
75
|
+
in context: some MacroExpansionContext
|
|
76
|
+
) throws -> [ExtensionDeclSyntax] {
|
|
77
|
+
// Diagnostics are owned by the member expansion; an invalid declaration silently emits no extension
|
|
78
|
+
// here, so each error is reported once and no witness-less conformance piles "does not conform"
|
|
79
|
+
// errors on top of it.
|
|
80
|
+
guard (try? validatedUnion(of: declaration)) != nil else {
|
|
81
|
+
return []
|
|
82
|
+
}
|
|
83
|
+
// The compiler hands over only the conformances the type still lacks; the test harness passes the
|
|
84
|
+
// declared list verbatim, so filter against the inheritance clause here as well.
|
|
85
|
+
let missing = protocols.filter { protocolType in
|
|
86
|
+
guard let name = baseIdentifier(of: protocolType) else {
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
return !inheritsProtocol(named: name, in: declaration)
|
|
90
|
+
}
|
|
91
|
+
guard !missing.isEmpty else {
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let conformances = missing.map { $0.trimmedDescription }.joined(separator: ", ")
|
|
96
|
+
let ext: DeclSyntax = """
|
|
97
|
+
extension \(type.trimmed): \(raw: conformances) {}
|
|
98
|
+
"""
|
|
99
|
+
guard let extDecl = ext.as(ExtensionDeclSyntax.self) else {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
return [extDecl]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// MARK: - Union model
|
|
107
|
+
|
|
108
|
+
/// A validated `@Union` enum: its spelled name (for the mismatch error) and its cases in declaration
|
|
109
|
+
/// order (the decode order).
|
|
110
|
+
private struct UnionType {
|
|
111
|
+
let name: String
|
|
112
|
+
let cases: [UnionCase]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// One alternative of the union: the case name, its single payload type as written, and the payload's
|
|
116
|
+
/// argument label when the author gave it one (`case id(value: Int)`), needed to construct the case.
|
|
117
|
+
private struct UnionCase {
|
|
118
|
+
let name: String
|
|
119
|
+
let payloadType: String
|
|
120
|
+
let payloadLabel: String?
|
|
121
|
+
|
|
122
|
+
/// The expression constructing this case from a `payload` local: `.id(payload)`, or
|
|
123
|
+
/// `.id(value: payload)` for a labeled associated value.
|
|
124
|
+
var construction: String {
|
|
125
|
+
if let payloadLabel {
|
|
126
|
+
return ".\(name)(\(payloadLabel): payload)"
|
|
127
|
+
}
|
|
128
|
+
return ".\(name)(payload)"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// The pattern binding this case's payload to a `payload` local in a `switch`.
|
|
132
|
+
var pattern: String {
|
|
133
|
+
return ".\(name)(let payload)"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Reads and validates the union off the attached declaration. Every check is a compile error located
|
|
138
|
+
/// on the offending node: the macro must be on a non-generic `enum` with at least one case, and every
|
|
139
|
+
/// case must carry exactly one associated value, its payload type distinct from every earlier case's.
|
|
140
|
+
private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> UnionType {
|
|
141
|
+
guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
|
|
142
|
+
throw MacroExpansionErrorMessage("@Union can only be applied to an enum")
|
|
143
|
+
}
|
|
144
|
+
if let genericParameterClause = enumDecl.genericParameterClause {
|
|
145
|
+
throw DiagnosticsError(diagnostics: [
|
|
146
|
+
Diagnostic(
|
|
147
|
+
node: genericParameterClause,
|
|
148
|
+
message: UnionDiagnosticMessage(
|
|
149
|
+
"@Union cannot be applied to a generic enum: each case's payload type must be concrete so the macro can select its converter.",
|
|
150
|
+
id: "union-generic-enum"
|
|
151
|
+
))
|
|
152
|
+
])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
var cases: [UnionCase] = []
|
|
156
|
+
var seenPayloadTypes: [String: String] = [:]
|
|
157
|
+
var diagnostics: [Diagnostic] = []
|
|
158
|
+
|
|
159
|
+
for member in enumDecl.memberBlock.members {
|
|
160
|
+
guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
for element in caseDecl.elements {
|
|
164
|
+
let name = element.name.text
|
|
165
|
+
guard let parameters = element.parameterClause?.parameters, !parameters.isEmpty else {
|
|
166
|
+
diagnostics.append(
|
|
167
|
+
Diagnostic(
|
|
168
|
+
node: element,
|
|
169
|
+
message: UnionDiagnosticMessage(
|
|
170
|
+
"@Union case '\(name)' must carry exactly one associated value: the type this alternative decodes from. A payload-less enum is not a union; make it a raw-value enum conforming to 'Enumerable' instead.",
|
|
171
|
+
id: "union-case-without-payload"
|
|
172
|
+
)))
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
guard parameters.count == 1, let parameter = parameters.first else {
|
|
176
|
+
diagnostics.append(
|
|
177
|
+
Diagnostic(
|
|
178
|
+
node: element,
|
|
179
|
+
message: UnionDiagnosticMessage(
|
|
180
|
+
"@Union case '\(name)' must carry exactly one associated value, but has \(parameters.count). Group them in a @Record type and use it as the single payload.",
|
|
181
|
+
id: "union-case-with-multiple-payloads"
|
|
182
|
+
)))
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A default on the associated value can never apply: the macro constructs the case from the
|
|
187
|
+
// decoded JS value every time, and there is no "omitted" slot the way a `@Record` property has.
|
|
188
|
+
// Left in place it would read as a JS-side default that doesn't exist, so it's rejected with a
|
|
189
|
+
// fix-it that removes it.
|
|
190
|
+
if let defaultValue = parameter.defaultValue {
|
|
191
|
+
diagnostics.append(defaultValueDiagnostic(for: parameter, defaultValue: defaultValue, caseName: name))
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let payloadType = parameter.type.trimmedDescription
|
|
196
|
+
// Alternatives decode in declaration order and the first success wins, so a case whose payload
|
|
197
|
+
// type repeats an earlier case's is unreachable: an exact-spelling duplicate is an error. (An
|
|
198
|
+
// overlap between *different* types, like `Int` and `Double`, is the author's ordering call and
|
|
199
|
+
// isn't checked here.)
|
|
200
|
+
let normalizedType = payloadType.filter { !$0.isWhitespace }
|
|
201
|
+
if let earlierCase = seenPayloadTypes[normalizedType] {
|
|
202
|
+
diagnostics.append(
|
|
203
|
+
Diagnostic(
|
|
204
|
+
node: element,
|
|
205
|
+
message: UnionDiagnosticMessage(
|
|
206
|
+
"@Union case '\(name)' repeats the payload type '\(payloadType)' of case '\(earlierCase)' and can never be decoded: alternatives are tried in declaration order and the first match wins.",
|
|
207
|
+
id: "union-duplicate-payload-type"
|
|
208
|
+
)))
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
seenPayloadTypes[normalizedType] = name
|
|
212
|
+
|
|
213
|
+
// `firstName` is the associated value's label (`case id(value: Int)`); a `_` label is the same as
|
|
214
|
+
// none for construction purposes.
|
|
215
|
+
let label = parameter.firstName.flatMap { $0.text == "_" ? nil : $0.text }
|
|
216
|
+
cases.append(UnionCase(name: name, payloadType: payloadType, payloadLabel: label))
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if !diagnostics.isEmpty {
|
|
221
|
+
throw DiagnosticsError(diagnostics: diagnostics)
|
|
222
|
+
}
|
|
223
|
+
if cases.isEmpty {
|
|
224
|
+
throw MacroExpansionErrorMessage("@Union requires at least one case carrying an associated value")
|
|
225
|
+
}
|
|
226
|
+
return UnionType(name: enumDecl.name.text, cases: cases)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// MARK: - Synthesized members
|
|
230
|
+
|
|
231
|
+
/// `decode(_:in:)`: tries each case's payload converter in declaration order and returns the first
|
|
232
|
+
/// case that decodes. `try?` turns a candidate's failure into "try the next one" without erasing the
|
|
233
|
+
/// payload (each `payload` local keeps its concrete type); the candidate's own error is discarded, since
|
|
234
|
+
/// with several alternatives there is no single failure to surface. When no alternative accepts the
|
|
235
|
+
/// value the factory throws `Exceptions.UnionCaseMismatch`, naming the union, the JS kind of the value
|
|
236
|
+
/// received, and every payload type the union accepts.
|
|
237
|
+
private func decodeMethod(union: UnionType) -> DeclSyntax {
|
|
238
|
+
var lines: [String] = []
|
|
239
|
+
for unionCase in union.cases {
|
|
240
|
+
let payloadType = expressionType(unionCase.payloadType)
|
|
241
|
+
lines.append(" if let payload = try? \(payloadType).decode(value, in: runtime) {")
|
|
242
|
+
lines.append(" return \(unionCase.construction)")
|
|
243
|
+
lines.append(" }")
|
|
244
|
+
}
|
|
245
|
+
let expected = union.cases.map { "\"\($0.payloadType)\"" }.joined(separator: ", ")
|
|
246
|
+
let mismatch = "(unionName: \"\(union.name)\", received: value.kind.rawValue, expected: [\(expected)])"
|
|
247
|
+
lines.append(" throw Exceptions.UnionCaseMismatch(\(mismatch))")
|
|
248
|
+
let body = lines.joined(separator: "\n")
|
|
249
|
+
return """
|
|
250
|
+
@JavaScriptActor
|
|
251
|
+
public static func decode(_ value: borrowing JavaScriptValue, in runtime: borrowing JavaScriptRuntime) throws -> Self {
|
|
252
|
+
\(raw: body)
|
|
253
|
+
}
|
|
254
|
+
"""
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// `encode(_:in:)`: a `switch` over the cases, each encoding its payload through the payload type's
|
|
258
|
+
/// own `encode`. Exhaustive by construction, so a case added later without re-expansion can't slip
|
|
259
|
+
/// through silently.
|
|
260
|
+
private func encodeMethod(union: UnionType) -> DeclSyntax {
|
|
261
|
+
var lines: [String] = [" switch value {"]
|
|
262
|
+
for unionCase in union.cases {
|
|
263
|
+
lines.append(" case \(unionCase.pattern):")
|
|
264
|
+
lines.append(" return try \(expressionType(unionCase.payloadType)).encode(payload, in: runtime)")
|
|
265
|
+
}
|
|
266
|
+
lines.append(" }")
|
|
267
|
+
let body = lines.joined(separator: "\n")
|
|
268
|
+
return """
|
|
269
|
+
@JavaScriptActor
|
|
270
|
+
public static func encode(_ value: Self, in runtime: borrowing JavaScriptRuntime) throws -> JavaScriptValue {
|
|
271
|
+
\(raw: body)
|
|
272
|
+
}
|
|
273
|
+
"""
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// `as(_:)`: a typed accessor per case, selected by the payload's metatype rather than the case name,
|
|
277
|
+
/// so a caller that only knows the type it wants writes `try value.as(String.self)` and gets a `String`.
|
|
278
|
+
/// When
|
|
279
|
+
/// the union holds a different case it throws `Exceptions.UnionCaseMismatch`, the same error `decode`
|
|
280
|
+
/// throws, with the held case's payload type as `received` and the requested type as `expected`. The
|
|
281
|
+
/// overloads can't collide because a payload type appears in at most one case (enforced above), and a
|
|
282
|
+
/// metatype the union doesn't carry fails to resolve at compile time. `as` is a keyword, so the
|
|
283
|
+
/// declaration is backticked; a call site after a dot (`value.as(…)`) needs no backticks. The parameter
|
|
284
|
+
/// is spelled in expression form (`T!` rewritten to `T?`), since `T!.Type` isn't valid.
|
|
285
|
+
private func accessorMethods(union: UnionType) -> [DeclSyntax] {
|
|
286
|
+
return union.cases.map { unionCase in
|
|
287
|
+
let payloadType = expressionType(unionCase.payloadType)
|
|
288
|
+
let mismatch = "(unionName: \"\(union.name)\", received: _payloadTypeName, expected: [\"\(unionCase.payloadType)\"])"
|
|
289
|
+
return """
|
|
290
|
+
public func `as`(_ type: \(raw: payloadType).Type) throws -> \(raw: payloadType) {
|
|
291
|
+
if case \(raw: unionCase.pattern) = self {
|
|
292
|
+
return payload
|
|
293
|
+
}
|
|
294
|
+
throw Exceptions.UnionCaseMismatch(\(raw: mismatch))
|
|
295
|
+
}
|
|
296
|
+
"""
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// `_payloadTypeName`: the spelled payload type of the case the union currently holds, as written in the
|
|
301
|
+
/// declaration, so every `as(_:)` overload reports the actual type in its mismatch error through one
|
|
302
|
+
/// shared `switch` instead of each overload enumerating the other cases.
|
|
303
|
+
private func payloadTypeNameProperty(union: UnionType) -> DeclSyntax {
|
|
304
|
+
var lines: [String] = [" switch self {"]
|
|
305
|
+
for unionCase in union.cases {
|
|
306
|
+
lines.append(" case .\(unionCase.name):")
|
|
307
|
+
lines.append(" return \"\(unionCase.payloadType)\"")
|
|
308
|
+
}
|
|
309
|
+
lines.append(" }")
|
|
310
|
+
let body = lines.joined(separator: "\n")
|
|
311
|
+
return """
|
|
312
|
+
private var _payloadTypeName: String {
|
|
313
|
+
\(raw: body)
|
|
314
|
+
}
|
|
315
|
+
"""
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// MARK: - Diagnostics
|
|
319
|
+
|
|
320
|
+
/// The error for a default value on a case's associated value, attached to the `= …` clause and carrying
|
|
321
|
+
/// a fix-it that deletes it (trimming the space the type carried before the `=`).
|
|
322
|
+
private func defaultValueDiagnostic(
|
|
323
|
+
for parameter: EnumCaseParameterSyntax,
|
|
324
|
+
defaultValue: InitializerClauseSyntax,
|
|
325
|
+
caseName: String
|
|
326
|
+
) -> Diagnostic {
|
|
327
|
+
let fixIt = FixIt(
|
|
328
|
+
message: UnionFixItMessage("Remove the default value", id: "union-remove-default-value"),
|
|
329
|
+
changes: [
|
|
330
|
+
.replace(
|
|
331
|
+
oldNode: Syntax(parameter),
|
|
332
|
+
newNode: Syntax(
|
|
333
|
+
parameter
|
|
334
|
+
.with(\.type, parameter.type.with(\.trailingTrivia, []))
|
|
335
|
+
.with(\.defaultValue, nil))
|
|
336
|
+
)
|
|
337
|
+
]
|
|
338
|
+
)
|
|
339
|
+
let message = UnionDiagnosticMessage(
|
|
340
|
+
"@Union case '\(caseName)' cannot give its associated value a default: the payload is always decoded from the JavaScript value, so the default would never apply. Remove '\(defaultValue.trimmedDescription)'.",
|
|
341
|
+
id: "union-case-default-value"
|
|
342
|
+
)
|
|
343
|
+
return Diagnostic(node: defaultValue, message: message, fixIts: [fixIt])
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private struct UnionDiagnosticMessage: DiagnosticMessage {
|
|
347
|
+
let message: String
|
|
348
|
+
let diagnosticID: MessageID
|
|
349
|
+
let severity: DiagnosticSeverity = .error
|
|
350
|
+
|
|
351
|
+
init(_ message: String, id: String) {
|
|
352
|
+
self.message = message
|
|
353
|
+
self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private struct UnionFixItMessage: FixItMessage {
|
|
358
|
+
let message: String
|
|
359
|
+
let fixItID: MessageID
|
|
360
|
+
|
|
361
|
+
init(_ message: String, id: String) {
|
|
362
|
+
self.message = message
|
|
363
|
+
self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -100,11 +100,21 @@ func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Boo
|
|
|
100
100
|
|
|
101
101
|
// MARK: - File discovery
|
|
102
102
|
|
|
103
|
-
/// Directory names skipped during the recursive walk
|
|
104
|
-
///
|
|
105
|
-
///
|
|
106
|
-
///
|
|
107
|
-
|
|
103
|
+
/// Directory names skipped during the recursive walk, in two groups:
|
|
104
|
+
/// - Build products, dependencies, and git internals (`.build`, `Pods`, `.git`, `node_modules`) are
|
|
105
|
+
/// never source worth scanning, and pruning them keeps the walk from descending into the bulk of
|
|
106
|
+
/// a monorepo's files. `node_modules` also makes any npm package root safe to pass as a scan
|
|
107
|
+
/// path: nested dependencies are separate packages and get scanned on their own.
|
|
108
|
+
/// - Test and example directories, by the layout conventions of Expo module packages (`Tests`,
|
|
109
|
+
/// `UITests`, `__tests__`, `__mocks__`, `example(s)`, `e2e`). Their sources are not compiled into the
|
|
110
|
+
/// package's product (they belong to a `test_spec` or a standalone example app), so a declaration
|
|
111
|
+
/// found there would name a type the consumer can't reference. This is a name-based heuristic;
|
|
112
|
+
/// a package keeping product sources in such a directory can declare its modules in
|
|
113
|
+
/// `expo-module.config.json` instead.
|
|
114
|
+
private let prunedDirectoryNames: Set<String> = [
|
|
115
|
+
".build", "Pods", ".git", "node_modules",
|
|
116
|
+
"Tests", "UITests", "__tests__", "__mocks__", "example", "examples", "e2e",
|
|
117
|
+
]
|
|
108
118
|
|
|
109
119
|
/// Expands the given paths into the list of `.swift` files to parse: a file path passes through,
|
|
110
120
|
/// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic
|