expo-modules-macros 0.31.0 → 0.33.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/workflows/{swift.yml → macos.yml} +12 -3
- package/AGENTS.md +4 -2
- package/Package.swift +10 -1
- package/README.md +6 -6
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +124 -5
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/InheritedMembers.swift +2 -1
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ResolveRefs.swift +76 -17
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ScanExports.swift +15 -8
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +174 -20
- package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +14 -6
- package/apple/Sources/ExpoModulesCLI/Core/Detection.swift +6 -0
- package/apple/Sources/ExpoModulesCLI/Core/DetectionVisitor.swift +41 -2
- package/apple/Sources/ExpoModulesCLI/Core/SourceScan.swift +3 -2
- package/apple/Sources/ExpoModulesMacrosCore/Macros/EventMacro.swift +12 -2
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoModuleMacro.swift +12 -0
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoViewMacro.swift +64 -30
- package/apple/Sources/ExpoModulesMacrosCore/Macros/JSMacro.swift +124 -0
- package/apple/Sources/ExpoModulesMacrosCore/Macros/RecordMacro.swift +68 -0
- package/apple/Sources/ExpoModulesMacrosCore/Macros/SharedObjectMacro.swift +79 -1
- package/apple/Sources/ExpoModulesMacrosCore/Macros/UnionMacro.swift +101 -5
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +574 -23
- package/apple/Sources/ExpoModulesMacrosCore/ProvidedMacros.swift +1 -1
- package/apple/Sources/ExpoModulesMacrosCore/Support/JSNameCollisions.swift +111 -2
- package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +28 -2
- package/apple/Sources/ExpoModulesMacrosPlugin/ExpoModulesMacrosPlugin.swift +1 -0
- package/build/types.d.ts +58 -3
- package/package.json +5 -5
|
@@ -3,11 +3,11 @@ import SwiftSyntax
|
|
|
3
3
|
import SwiftSyntaxBuilder
|
|
4
4
|
import SwiftSyntaxMacros
|
|
5
5
|
|
|
6
|
-
/// Member macro applied to an `
|
|
6
|
+
/// Member and extension macro applied to an `ExpoNativeView` subclass, binding it to its props type:
|
|
7
7
|
///
|
|
8
8
|
/// @ExpoView<CardProps>
|
|
9
|
-
/// final class CardView:
|
|
10
|
-
///
|
|
9
|
+
/// final class CardView: ExpoNativeView {
|
|
10
|
+
/// func didUpdateProps(_ diff: CardProps.Diff) {
|
|
11
11
|
/// if diff.changed(.color) {
|
|
12
12
|
/// backgroundColor = props.color
|
|
13
13
|
/// }
|
|
@@ -21,23 +21,25 @@ import SwiftSyntaxMacros
|
|
|
21
21
|
/// compile error at the author's own line ("requires that 'X' conform to 'AnyViewProps'")
|
|
22
22
|
/// rather than a macro diagnostic. This macro therefore validates nothing about the props type.
|
|
23
23
|
///
|
|
24
|
-
/// The
|
|
24
|
+
/// The expansion is a typealias and a conformance:
|
|
25
25
|
///
|
|
26
26
|
/// public typealias Props = CardProps
|
|
27
|
+
/// extension CardView: AnyNativeView {}
|
|
27
28
|
///
|
|
28
|
-
///
|
|
29
|
-
/// `
|
|
30
|
-
///
|
|
31
|
-
///
|
|
32
|
-
///
|
|
33
|
-
/// static, neither needing a builder to be evaluated.
|
|
29
|
+
/// There is **no view definition**: no `ViewDefinition`, no `View(…) { }` builder, no `Props(_:)` or
|
|
30
|
+
/// `Events(_:)` element. Core reaches the view through the `AnyNativeView` conformance: it reads the
|
|
31
|
+
/// props type from the `Props` typealias, which satisfies the protocol's associated type, and the
|
|
32
|
+
/// event names from the props type's own `_eventNames` (synthesized by `@ViewProps`). Both are
|
|
33
|
+
/// static, so no builder has to be evaluated.
|
|
34
34
|
///
|
|
35
|
-
/// Views also get no `_decorateView` hook
|
|
36
|
-
///
|
|
37
|
-
///
|
|
35
|
+
/// Views also get no `_decorateView` hook: `_decorateModule` and `_decorateSharedObject` exist to bind
|
|
36
|
+
/// members into a JS object, and a view has no such object. Props reach a view through Fabric's
|
|
37
|
+
/// mounting layer, not a JS call, so a view's runtime entry point is the props update hook.
|
|
38
38
|
///
|
|
39
|
-
/// `didUpdateProps` is a core
|
|
40
|
-
|
|
39
|
+
/// `didUpdateProps` is an `AnyNativeView` requirement with a default in core, not synthesized here.
|
|
40
|
+
/// It isn't an `override`: `ExpoNativeView` has no such method, because its parameter type depends on
|
|
41
|
+
/// the props type.
|
|
42
|
+
public struct ExpoViewMacro: MemberMacro, ExtensionMacro {
|
|
41
43
|
public static func expansion(
|
|
42
44
|
of node: AttributeSyntax,
|
|
43
45
|
providingMembersOf declaration: some DeclGroupSyntax,
|
|
@@ -87,38 +89,66 @@ public struct ExpoViewMacro: MemberMacro {
|
|
|
87
89
|
"""
|
|
88
90
|
]
|
|
89
91
|
}
|
|
92
|
+
|
|
93
|
+
public static func expansion(
|
|
94
|
+
of node: AttributeSyntax,
|
|
95
|
+
attachedTo declaration: some DeclGroupSyntax,
|
|
96
|
+
providingExtensionsOf type: some TypeSyntaxProtocol,
|
|
97
|
+
conformingTo protocols: [TypeSyntax],
|
|
98
|
+
in context: some MacroExpansionContext
|
|
99
|
+
) throws -> [ExtensionDeclSyntax] {
|
|
100
|
+
// A class that the member macro rejects gets no conformance either: the member macro has already
|
|
101
|
+
// reported the error, and a conformance without the `Props` typealias would only add a second
|
|
102
|
+
// error about the associated type.
|
|
103
|
+
guard let classDecl = declaration.as(ClassDeclSyntax.self),
|
|
104
|
+
inheritsFromAny(classDecl, names: expoViewBaseClassNames),
|
|
105
|
+
classDecl.genericParameterClause == nil,
|
|
106
|
+
existingPropsMemberKind(in: classDecl) == nil,
|
|
107
|
+
genericArgument(of: node) != nil,
|
|
108
|
+
!inheritsProtocol(named: nativeViewProtocolName, in: declaration)
|
|
109
|
+
else {
|
|
110
|
+
return []
|
|
111
|
+
}
|
|
112
|
+
let ext: DeclSyntax = """
|
|
113
|
+
extension \(type.trimmed): \(raw: nativeViewProtocolName) {}
|
|
114
|
+
"""
|
|
115
|
+
guard let extDecl = ext.as(ExtensionDeclSyntax.self) else {
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
return [extDecl]
|
|
119
|
+
}
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
// MARK: - Diagnostics
|
|
93
123
|
|
|
94
|
-
/// The error for a class that doesn't name `
|
|
124
|
+
/// The error for a class that doesn't name `ExpoNativeView` in its inheritance clause, carrying a fix-it
|
|
95
125
|
/// that adds it.
|
|
96
126
|
///
|
|
97
127
|
/// The fix-it only applies where the edit is unambiguous. With no inheritance clause at all it
|
|
98
|
-
/// inserts `:
|
|
128
|
+
/// inserts `: ExpoNativeView`; with an existing clause it prepends `ExpoNativeView` as the first entry, since
|
|
99
129
|
/// Swift requires the superclass to come before any protocol. A class that already names some other
|
|
100
130
|
/// superclass gets the diagnostic without a fix-it: replacing that superclass is a decision the macro
|
|
101
131
|
/// can't make (see the indirect-inheritance note on `expoViewBaseClassNames`).
|
|
102
132
|
private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagnostic {
|
|
103
133
|
let message = ExpoViewDiagnosticMessage(
|
|
104
|
-
"@ExpoView class must inherit from
|
|
134
|
+
"@ExpoView class must inherit from ExpoNativeView. Add `: ExpoNativeView` to the class declaration.",
|
|
105
135
|
id: "expoview-missing-base-class"
|
|
106
136
|
)
|
|
107
137
|
var fixIts: [FixIt] = []
|
|
108
138
|
|
|
109
139
|
if classDecl.inheritanceClause == nil {
|
|
110
|
-
// `class CardView {` → `class CardView:
|
|
140
|
+
// `class CardView {` → `class CardView: ExpoNativeView {`. The name carries the space before `{` as
|
|
111
141
|
// trailing trivia, so move it onto the clause to keep the spacing.
|
|
112
142
|
let name = classDecl.name
|
|
113
143
|
let clause = InheritanceClauseSyntax(
|
|
114
144
|
colon: .colonToken(trailingTrivia: .space),
|
|
115
145
|
inheritedTypes: InheritedTypeListSyntax([
|
|
116
|
-
InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("
|
|
146
|
+
InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoNativeView"))))
|
|
117
147
|
])
|
|
118
148
|
)
|
|
119
149
|
fixIts.append(
|
|
120
150
|
FixIt(
|
|
121
|
-
message: ExpoViewFixItMessage("Inherit from '
|
|
151
|
+
message: ExpoViewFixItMessage("Inherit from 'ExpoNativeView'", id: "expoview-add-base-class"),
|
|
122
152
|
changes: [
|
|
123
153
|
.replace(
|
|
124
154
|
oldNode: Syntax(classDecl),
|
|
@@ -132,16 +162,16 @@ private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagn
|
|
|
132
162
|
)
|
|
133
163
|
)
|
|
134
164
|
} else if let clause = classDecl.inheritanceClause, inheritsOnlyProtocolsByConvention(clause) {
|
|
135
|
-
// `class CardView: Identifiable {` → `class CardView:
|
|
165
|
+
// `class CardView: Identifiable {` → `class CardView: ExpoNativeView, Identifiable {`.
|
|
136
166
|
var inherited = clause.inheritedTypes
|
|
137
167
|
if var first = inherited.first {
|
|
138
168
|
first.trailingComma = .commaToken(trailingTrivia: .space)
|
|
139
|
-
first.type = TypeSyntax(IdentifierTypeSyntax(name: .identifier("
|
|
169
|
+
first.type = TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoNativeView")))
|
|
140
170
|
inherited.insert(first, at: inherited.startIndex)
|
|
141
171
|
}
|
|
142
172
|
fixIts.append(
|
|
143
173
|
FixIt(
|
|
144
|
-
message: ExpoViewFixItMessage("Inherit from '
|
|
174
|
+
message: ExpoViewFixItMessage("Inherit from 'ExpoNativeView'", id: "expoview-add-base-class"),
|
|
145
175
|
changes: [
|
|
146
176
|
.replace(
|
|
147
177
|
oldNode: Syntax(clause),
|
|
@@ -157,7 +187,7 @@ private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagn
|
|
|
157
187
|
|
|
158
188
|
/// Whether every entry in the clause looks like a protocol rather than a superclass, by the
|
|
159
189
|
/// capitalization-free heuristic that a Swift superclass must come first: if the first entry is one
|
|
160
|
-
/// the macro would be replacing, prepending `
|
|
190
|
+
/// the macro would be replacing, prepending `ExpoNativeView` would produce two superclasses. A macro can't
|
|
161
191
|
/// resolve names, so this only reports true when the clause is empty of anything that could be a
|
|
162
192
|
/// base class the author chose deliberately. Conservative by design: a wrong guess here would emit a
|
|
163
193
|
/// fix-it that doesn't compile.
|
|
@@ -223,14 +253,14 @@ private func existingPropsMemberKind(in classDecl: ClassDeclSyntax) -> String? {
|
|
|
223
253
|
}
|
|
224
254
|
|
|
225
255
|
/// Base classes an `@ExpoView` may inherit from. The check is syntactic, matching the name in the
|
|
226
|
-
/// class's own inheritance clause: both `:
|
|
256
|
+
/// class's own inheritance clause: both `: ExpoNativeView` and a qualified `: ExpoModulesCore.ExpoNativeView`
|
|
227
257
|
/// pass, since `baseIdentifier` reads the trailing name.
|
|
228
258
|
///
|
|
229
|
-
/// Indirect inheritance does not: a view whose superclass is itself an `
|
|
230
|
-
/// (`class Base:
|
|
259
|
+
/// Indirect inheritance does not: a view whose superclass is itself an `ExpoNativeView` subclass
|
|
260
|
+
/// (`class Base: ExpoNativeView` then `class CardView: Base`) is rejected, because a macro can't resolve
|
|
231
261
|
/// the superclass to check. `@SharedObject` has the same limitation, through the same helper. The
|
|
232
|
-
/// workaround is to apply `@ExpoView` to the class that names `
|
|
233
|
-
private let expoViewBaseClassNames: Set<String> = ["
|
|
262
|
+
/// workaround is to apply `@ExpoView` to the class that names `ExpoNativeView` directly.
|
|
263
|
+
private let expoViewBaseClassNames: Set<String> = ["ExpoNativeView"]
|
|
234
264
|
|
|
235
265
|
/// The attribute's single generic argument, verbatim (`@ExpoView<CardProps>` → `"CardProps"`), or
|
|
236
266
|
/// `nil` when the attribute carries no generic argument clause.
|
|
@@ -257,3 +287,7 @@ private func genericArgument(of attribute: AttributeSyntax) -> String? {
|
|
|
257
287
|
}
|
|
258
288
|
return type.trimmedDescription
|
|
259
289
|
}
|
|
290
|
+
|
|
291
|
+
/// The core protocol that `@ExpoView` conforms the class to. Core registers the view and decodes its
|
|
292
|
+
/// props through it.
|
|
293
|
+
private let nativeViewProtocolName = "AnyNativeView"
|
|
@@ -31,6 +31,20 @@ public struct JSMacro: PeerMacro {
|
|
|
31
31
|
providingPeersOf declaration: some DeclSyntaxProtocol,
|
|
32
32
|
in context: some MacroExpansionContext
|
|
33
33
|
) throws -> [DeclSyntax] {
|
|
34
|
+
diagnosePlacement(of: node, on: declaration, in: context)
|
|
35
|
+
if let genericParameterClause = jsGenericParameterClause(of: declaration) {
|
|
36
|
+
context.diagnose(
|
|
37
|
+
Diagnostic(
|
|
38
|
+
node: genericParameterClause,
|
|
39
|
+
message: JSDiagnosticMessage(
|
|
40
|
+
"A @JS function or initializer can't be generic: the binding decodes each argument and encodes the result by its static type, which a generic parameter doesn't have. Use a concrete type, a @Union of the types, or 'JavaScriptValue'.",
|
|
41
|
+
id: "js-generic-member",
|
|
42
|
+
severity: .error
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
34
48
|
diagnoseParameterSpecifiers(in: declaration, in: context)
|
|
35
49
|
diagnoseFreeFormTypes(in: declaration, in: context)
|
|
36
50
|
diagnoseClosureTypes(in: declaration, in: context)
|
|
@@ -75,6 +89,116 @@ private func isInBindingClass(_ context: some MacroExpansionContext) -> Bool {
|
|
|
75
89
|
|| classDecl.attributes.firstAttribute(named: "SharedObject") != nil
|
|
76
90
|
}
|
|
77
91
|
|
|
92
|
+
/// Reports a `@JS` attribute that no macro binds, so the member would be missing in JavaScript without
|
|
93
|
+
/// an error:
|
|
94
|
+
/// - on a declaration other than a function, property or initializer, such as a subscript;
|
|
95
|
+
/// - on a member of an extension, since `@ExpoModule` and `@SharedObject` read only the class body;
|
|
96
|
+
/// - on a member of a type that isn't an `@ExpoModule` or `@SharedObject` class;
|
|
97
|
+
/// - on a `static` or `class` member of an `@ExpoModule` class, since JavaScript gets only the instance;
|
|
98
|
+
/// - on an initializer of an `@ExpoModule` class, since JavaScript doesn't construct a module.
|
|
99
|
+
private func diagnosePlacement(
|
|
100
|
+
of node: AttributeSyntax,
|
|
101
|
+
on declaration: some DeclSyntaxProtocol,
|
|
102
|
+
in context: some MacroExpansionContext
|
|
103
|
+
) {
|
|
104
|
+
guard
|
|
105
|
+
declaration.is(FunctionDeclSyntax.self) || declaration.is(VariableDeclSyntax.self)
|
|
106
|
+
|| declaration.is(InitializerDeclSyntax.self)
|
|
107
|
+
else {
|
|
108
|
+
let kind = declaration.is(SubscriptDeclSyntax.self) ? "a subscript" : "this declaration"
|
|
109
|
+
context.diagnose(
|
|
110
|
+
Diagnostic(
|
|
111
|
+
node: node,
|
|
112
|
+
message: JSDiagnosticMessage(
|
|
113
|
+
"@JS applies to a function, property or initializer, so \(kind) isn't bound to JavaScript. Expose a @JS function instead.",
|
|
114
|
+
id: "js-unsupported-declaration",
|
|
115
|
+
severity: .error
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if let extensionDecl = context.lexicalContext.first?.as(ExtensionDeclSyntax.self) {
|
|
122
|
+
let typeName = extensionDecl.extendedType.trimmedDescription
|
|
123
|
+
context.diagnose(
|
|
124
|
+
Diagnostic(
|
|
125
|
+
node: node,
|
|
126
|
+
message: JSDiagnosticMessage(
|
|
127
|
+
"A @JS member of an extension isn't bound to JavaScript: @ExpoModule and @SharedObject bind only the @JS members declared in the class body. Move the member into the declaration of '\(typeName)'.",
|
|
128
|
+
id: "js-in-extension",
|
|
129
|
+
severity: .error
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if let enclosingType = context.lexicalContext.first, enclosingType.isProtocol(DeclGroupSyntax.self),
|
|
136
|
+
!isInBindingClass(context)
|
|
137
|
+
{
|
|
138
|
+
let typeName = enclosingType.asProtocol(NamedDeclSyntax.self).map { "'\($0.name.text)'" } ?? "this type"
|
|
139
|
+
context.diagnose(
|
|
140
|
+
Diagnostic(
|
|
141
|
+
node: node,
|
|
142
|
+
message: JSDiagnosticMessage(
|
|
143
|
+
"A @JS member of \(typeName) isn't bound to JavaScript: only an @ExpoModule or @SharedObject class binds its @JS members. Add one of these macros to \(typeName), or move the member into such a class.",
|
|
144
|
+
id: "js-outside-binding-class",
|
|
145
|
+
severity: .error
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if let modifiers = declaration.asProtocol(WithModifiersSyntax.self)?.modifiers, isTypeLevel(modifiers),
|
|
152
|
+
let classDecl = context.lexicalContext.first?.as(ClassDeclSyntax.self),
|
|
153
|
+
classDecl.attributes.firstAttribute(named: "ExpoModule") != nil
|
|
154
|
+
{
|
|
155
|
+
context.diagnose(
|
|
156
|
+
Diagnostic(
|
|
157
|
+
node: node,
|
|
158
|
+
message: JSDiagnosticMessage(
|
|
159
|
+
"A @JS member of an @ExpoModule class can't be 'static' or 'class': JavaScript gets one module object, and @ExpoModule binds the members of the instance to it. Make the member an instance member.",
|
|
160
|
+
id: "js-module-static-member",
|
|
161
|
+
severity: .error
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if let initDecl = declaration.as(InitializerDeclSyntax.self),
|
|
168
|
+
let classDecl = context.lexicalContext.first?.as(ClassDeclSyntax.self),
|
|
169
|
+
classDecl.attributes.firstAttribute(named: "ExpoModule") != nil
|
|
170
|
+
{
|
|
171
|
+
context.diagnose(
|
|
172
|
+
Diagnostic(
|
|
173
|
+
node: node,
|
|
174
|
+
message: JSDiagnosticMessage(
|
|
175
|
+
"@JS on an initializer of an @ExpoModule class isn't bound to JavaScript: JavaScript doesn't construct a module, it gets the instance that the app context creates. Remove @JS, or construct a @SharedObject class from JavaScript instead.",
|
|
176
|
+
id: "js-module-initializer",
|
|
177
|
+
severity: .error
|
|
178
|
+
),
|
|
179
|
+
fixIts: removeAttributeFixIts(node, before: initDecl.initKeyword)
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// The fix-it that removes a `@JS` attribute written on its own line, with the line break and
|
|
186
|
+
/// indentation before it. The macro gets the attribute without the trivia after it, so when the
|
|
187
|
+
/// declaration starts on the line of the attribute (`@JS init()`), the fix-it can't tell how much
|
|
188
|
+
/// space to remove, and there is none. It edits only the text of the attribute, since the declaration
|
|
189
|
+
/// that a peer macro gets can carry attributes that other macros added.
|
|
190
|
+
private func removeAttributeFixIts(_ node: AttributeSyntax, before keyword: TokenSyntax) -> [FixIt] {
|
|
191
|
+
guard keyword.leadingTrivia.contains(where: \.isNewline) else {
|
|
192
|
+
return []
|
|
193
|
+
}
|
|
194
|
+
return [
|
|
195
|
+
FixIt(
|
|
196
|
+
message: JSFixItMessage("Remove '@JS'", id: "js-remove-attribute"),
|
|
197
|
+
changes: [.replaceText(range: node.position..<node.endPosition, with: "", in: Syntax(node))]
|
|
198
|
+
)
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
|
|
78
202
|
/// Emits the diagnostic for `@JS(.concurrent)` on a member that can't take it. The option maps to
|
|
79
203
|
/// Swift's `@concurrent`, which requires an `async` function: a synchronous member has nowhere to
|
|
80
204
|
/// suspend, and a property or initializer can't be async at all. Diagnosing here points at the
|
|
@@ -81,6 +81,12 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
|
|
|
81
81
|
// the author's version stand — the factories only need *an* `init(<properties>)` and `Record` only
|
|
82
82
|
// needs *an* `init()`, regardless of who wrote them.
|
|
83
83
|
let existingInitLabels = initializerParameterLabels(of: declaration)
|
|
84
|
+
if !existingInitLabels.contains(properties.map { $0.key }) {
|
|
85
|
+
let diagnostics = unsetPrivatePropertyDiagnostics(of: declaration)
|
|
86
|
+
guard diagnostics.isEmpty else {
|
|
87
|
+
throw DiagnosticsError(diagnostics: diagnostics)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
84
90
|
|
|
85
91
|
var members: [DeclSyntax] = []
|
|
86
92
|
|
|
@@ -227,6 +233,10 @@ private func recordProperties(
|
|
|
227
233
|
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
|
228
234
|
continue
|
|
229
235
|
}
|
|
236
|
+
// The memberwise init sets every property, and a `let` with a default value can't be set again.
|
|
237
|
+
if varDecl.bindingSpecifier.tokenKind == .keyword(.let), binding.initializer != nil {
|
|
238
|
+
throw DiagnosticsError(diagnostics: [letWithDefaultDiagnostic(for: varDecl, name: ident.identifier.text)])
|
|
239
|
+
}
|
|
230
240
|
// Prefer the explicit annotation. When it's omitted, recover the type from a literal default
|
|
231
241
|
// (Swift's own default-literal type) — covers the common `var name = "foo"` case. Anything
|
|
232
242
|
// whose type a syntactic macro can't determine (calls, collections, member access) still needs
|
|
@@ -637,6 +647,64 @@ private func unsupportedClassDiagnostics(of declaration: some DeclGroupSyntax) -
|
|
|
637
647
|
return diagnostics
|
|
638
648
|
}
|
|
639
649
|
|
|
650
|
+
/// The errors for the `private` and `fileprivate` stored properties that the synthesized memberwise
|
|
651
|
+
/// initializer would leave without a value. Such a property isn't a record property, so the initializer
|
|
652
|
+
/// doesn't set it, and it needs a default value. An optional `var` starts as `nil`, so it needs none.
|
|
653
|
+
private func unsetPrivatePropertyDiagnostics(of declaration: some DeclGroupSyntax) -> [Diagnostic] {
|
|
654
|
+
var diagnostics: [Diagnostic] = []
|
|
655
|
+
for member in declaration.memberBlock.members {
|
|
656
|
+
guard let varDecl = member.decl.as(VariableDeclSyntax.self),
|
|
657
|
+
let access = varDecl.modifiers.first(where: {
|
|
658
|
+
$0.name.tokenKind == .keyword(.private) || $0.name.tokenKind == .keyword(.fileprivate)
|
|
659
|
+
}),
|
|
660
|
+
!varDecl.modifiers.contains(where: {
|
|
661
|
+
$0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class)
|
|
662
|
+
|| $0.name.tokenKind == .keyword(.lazy)
|
|
663
|
+
})
|
|
664
|
+
else {
|
|
665
|
+
continue
|
|
666
|
+
}
|
|
667
|
+
let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
|
|
668
|
+
for binding in varDecl.bindings where binding.accessorBlock == nil && binding.initializer == nil {
|
|
669
|
+
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self),
|
|
670
|
+
!(isVar && binding.typeAnnotation.map { isOptionalType($0.type) } == true)
|
|
671
|
+
else {
|
|
672
|
+
continue
|
|
673
|
+
}
|
|
674
|
+
diagnostics.append(
|
|
675
|
+
Diagnostic(
|
|
676
|
+
node: ident,
|
|
677
|
+
message: RecordDiagnosticMessage(
|
|
678
|
+
"@Record leaves out the \(access.name.text) property '\(ident.identifier.text)', so the synthesized initializer doesn't set it. Give it a default value, or remove '\(access.name.text)' to make it a record property",
|
|
679
|
+
id: "record-unset-private-property"
|
|
680
|
+
)
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return diagnostics
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/// The error for a `let` property with a default value, attached to the `let` keyword and carrying a
|
|
689
|
+
/// fix-it that replaces it with `var`. The default still applies when JavaScript omits the property.
|
|
690
|
+
private func letWithDefaultDiagnostic(for varDecl: VariableDeclSyntax, name: String) -> Diagnostic {
|
|
691
|
+
let specifier = varDecl.bindingSpecifier
|
|
692
|
+
let fixIt = FixIt(
|
|
693
|
+
message: RecordFixItMessage("Replace 'let' with 'var'", id: "record-let-to-var"),
|
|
694
|
+
changes: [
|
|
695
|
+
.replace(oldNode: Syntax(specifier), newNode: Syntax(specifier.with(\.tokenKind, .keyword(.var))))
|
|
696
|
+
]
|
|
697
|
+
)
|
|
698
|
+
return Diagnostic(
|
|
699
|
+
node: specifier,
|
|
700
|
+
message: RecordDiagnosticMessage(
|
|
701
|
+
"@Record property '\(name)' is a 'let' with a default value, so the synthesized initializer can't set it from JavaScript. Make it a 'var', and the default applies when JavaScript omits the property, or make it 'static' or 'private' to leave it out of the record",
|
|
702
|
+
id: "record-let-with-default"
|
|
703
|
+
),
|
|
704
|
+
fixIts: [fixIt]
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
|
|
640
708
|
/// The fix-it for a record class that isn't `final`: add the modifier right before `class`.
|
|
641
709
|
private func markFinalFixIt(for classDecl: ClassDeclSyntax) -> FixIt {
|
|
642
710
|
// The new modifier takes over the leading trivia of `class` (the line break after the attribute
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import SwiftDiagnostics
|
|
1
2
|
import SwiftSyntax
|
|
2
3
|
import SwiftSyntaxBuilder
|
|
3
4
|
import SwiftSyntaxMacros
|
|
@@ -42,9 +43,13 @@ public struct SharedObjectMacro: MemberMacro {
|
|
|
42
43
|
in: context
|
|
43
44
|
)
|
|
44
45
|
|
|
45
|
-
//
|
|
46
|
+
// A class without an inheritance clause has no superclass, so it can't be a `SharedObject`. Any
|
|
47
|
+
// other superclass isn't checked: the macro sees only this declaration, so it can't tell an
|
|
46
48
|
// indirect subclass (`class Thumbnail: ImageRef`) from an unrelated class. The compiler reports a
|
|
47
49
|
// class that isn't a `SharedObject` subclass on the `_jsName` override, which is always emitted.
|
|
50
|
+
if classDecl.inheritanceClause == nil {
|
|
51
|
+
throw DiagnosticsError(diagnostics: [missingBaseClassDiagnostic(for: classDecl)])
|
|
52
|
+
}
|
|
48
53
|
let typeName = classDecl.name.text
|
|
49
54
|
let jsName = jsNameArgument(of: node) ?? withoutBackticks(typeName)
|
|
50
55
|
|
|
@@ -71,6 +76,10 @@ public struct SharedObjectMacro: MemberMacro {
|
|
|
71
76
|
for member in classDecl.memberBlock.members {
|
|
72
77
|
// The types of a `@JS` member are read with the class's tuple aliases resolved.
|
|
73
78
|
let decl = aliases.resolving(member.decl)
|
|
79
|
+
// A generic member is reported by `@JS` and left out, so the build has no errors in its binding.
|
|
80
|
+
if memberHasJSAttribute(decl) && jsGenericParameterClause(of: decl) != nil {
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
74
83
|
if memberHasJSAttribute(decl), let assertion = jsConformanceAssertionBody(for: decl) {
|
|
75
84
|
assertions.append(assertion)
|
|
76
85
|
}
|
|
@@ -114,6 +123,19 @@ public struct SharedObjectMacro: MemberMacro {
|
|
|
114
123
|
|
|
115
124
|
diagnoseJSNameCollisions(functions: instanceFunctions, properties: instanceProperties, in: context)
|
|
116
125
|
diagnoseJSNameCollisions(functions: staticFunctions, properties: staticProperties, in: context)
|
|
126
|
+
diagnoseReservedJSNames(
|
|
127
|
+
functions: instanceFunctions,
|
|
128
|
+
properties: instanceProperties,
|
|
129
|
+
on: .prototype,
|
|
130
|
+
in: context
|
|
131
|
+
)
|
|
132
|
+
diagnoseReservedJSNames(
|
|
133
|
+
functions: staticFunctions,
|
|
134
|
+
properties: staticProperties,
|
|
135
|
+
on: .constructor,
|
|
136
|
+
in: context
|
|
137
|
+
)
|
|
138
|
+
diagnoseEventNameCollisions(in: classDecl.memberBlock, in: context)
|
|
117
139
|
|
|
118
140
|
// The resolved JS class name, read by core when it builds the class. An `override` of the
|
|
119
141
|
// `SharedObject` class property, so core reads it through the concrete type's metatype.
|
|
@@ -199,3 +221,59 @@ extension SharedObjectMacro: ExtensionMacro {
|
|
|
199
221
|
return [sendable]
|
|
200
222
|
}
|
|
201
223
|
}
|
|
224
|
+
|
|
225
|
+
/// The error for a `@SharedObject` class without an inheritance clause, with a fix-it that makes it
|
|
226
|
+
/// inherit from `SharedObject`: `class Cache {` becomes `class Cache: SharedObject {`.
|
|
227
|
+
private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagnostic {
|
|
228
|
+
// The name carries the space before `{` as trailing trivia, so move it onto the clause to keep the
|
|
229
|
+
// spacing.
|
|
230
|
+
let name = classDecl.name
|
|
231
|
+
let clause = InheritanceClauseSyntax(
|
|
232
|
+
colon: .colonToken(trailingTrivia: .space),
|
|
233
|
+
inheritedTypes: InheritedTypeListSyntax([
|
|
234
|
+
InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("SharedObject"))))
|
|
235
|
+
])
|
|
236
|
+
)
|
|
237
|
+
let fixIt = FixIt(
|
|
238
|
+
message: SharedObjectFixItMessage("Inherit from 'SharedObject'", id: "sharedobject-add-base-class"),
|
|
239
|
+
changes: [
|
|
240
|
+
.replace(
|
|
241
|
+
oldNode: Syntax(classDecl),
|
|
242
|
+
newNode: Syntax(
|
|
243
|
+
classDecl
|
|
244
|
+
.with(\.name, name.with(\.trailingTrivia, []))
|
|
245
|
+
.with(\.inheritanceClause, clause.with(\.trailingTrivia, name.trailingTrivia))
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
]
|
|
249
|
+
)
|
|
250
|
+
return Diagnostic(
|
|
251
|
+
node: name,
|
|
252
|
+
message: SharedObjectDiagnosticMessage(
|
|
253
|
+
"@SharedObject class '\(name.text)' must inherit from 'SharedObject', directly or through another class such as 'SharedRef'. Add ': SharedObject' to the class declaration.",
|
|
254
|
+
id: "sharedobject-missing-base-class"
|
|
255
|
+
),
|
|
256
|
+
fixIts: [fixIt]
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private struct SharedObjectDiagnosticMessage: DiagnosticMessage {
|
|
261
|
+
let message: String
|
|
262
|
+
let diagnosticID: MessageID
|
|
263
|
+
let severity: DiagnosticSeverity = .error
|
|
264
|
+
|
|
265
|
+
init(_ message: String, id: String) {
|
|
266
|
+
self.message = message
|
|
267
|
+
self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private struct SharedObjectFixItMessage: FixItMessage {
|
|
272
|
+
let message: String
|
|
273
|
+
let fixItID: MessageID
|
|
274
|
+
|
|
275
|
+
init(_ message: String, id: String) {
|
|
276
|
+
self.message = message
|
|
277
|
+
self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -35,7 +35,9 @@ import SwiftSyntaxMacros
|
|
|
35
35
|
/// Discrimination is structural and order-dependent: the first case whose payload decodes wins. When
|
|
36
36
|
/// two payload shapes overlap (two records with compatible fields, `Int` and `Double`), the earlier
|
|
37
37
|
/// case matches; the author orders the more specific case first. A case may not repeat another case's
|
|
38
|
-
/// payload type, since it could never be chosen.
|
|
38
|
+
/// payload type, since it could never be chosen. A case after a `JavaScriptValue` case, or with a
|
|
39
|
+
/// number-only payload after a `Double`, `Float` or `CGFloat` case, can't be chosen either, so the macro
|
|
40
|
+
/// warns about it.
|
|
39
41
|
///
|
|
40
42
|
/// The named-union counterpart of core's `Either`: any number of named cases instead of two anonymous
|
|
41
43
|
/// slots, no `Any?` box, and an exhaustive `switch` as the primary way to read it (`as(_:)` covers the
|
|
@@ -48,6 +50,7 @@ public struct UnionMacro: MemberMacro, ExtensionMacro {
|
|
|
48
50
|
in context: some MacroExpansionContext
|
|
49
51
|
) throws -> [DeclSyntax] {
|
|
50
52
|
let union = try validatedUnion(of: declaration)
|
|
53
|
+
diagnoseUnreachableCases(of: declaration, in: context)
|
|
51
54
|
|
|
52
55
|
var members: [DeclSyntax] = []
|
|
53
56
|
|
|
@@ -223,8 +226,8 @@ private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> Unio
|
|
|
223
226
|
let payloadType = parameter.type.trimmedDescription
|
|
224
227
|
// Alternatives decode in declaration order and the first success wins, so a case whose payload
|
|
225
228
|
// type repeats an earlier case's is unreachable: an exact-spelling duplicate is an error. (An
|
|
226
|
-
// overlap between *different* types, like `Int` and `Double`, is the author's ordering call
|
|
227
|
-
//
|
|
229
|
+
// overlap between *different* types, like `Int` and `Double`, is the author's ordering call;
|
|
230
|
+
// `diagnoseUnreachableCases` warns about the overlaps that make a case unreachable.)
|
|
228
231
|
let normalizedType = payloadType.filter { !$0.isWhitespace }
|
|
229
232
|
if let earlierCase = seenPayloadTypes[normalizedType] {
|
|
230
233
|
diagnostics.append(
|
|
@@ -256,6 +259,88 @@ private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> Unio
|
|
|
256
259
|
return UnionType(name: enumDecl.name.text, cases: cases)
|
|
257
260
|
}
|
|
258
261
|
|
|
262
|
+
/// The payload types whose decode accepts every JavaScript number: they convert the number with
|
|
263
|
+
/// `asDouble()`, so they never fail on one.
|
|
264
|
+
private let anyNumberPayloadTypes: Set<String> = ["Double", "Float", "CGFloat"]
|
|
265
|
+
|
|
266
|
+
/// The payload types that decode only a JavaScript number, so a case before them with a type of
|
|
267
|
+
/// `anyNumberPayloadTypes` decodes every value they could. `Int`, `Int64`, `UInt` and `UInt64` aren't
|
|
268
|
+
/// listed, since they also decode a `bigint`.
|
|
269
|
+
private let numberOnlyPayloadTypes: Set<String> = [
|
|
270
|
+
"Double", "Float", "CGFloat", "Int8", "Int16", "Int32", "UInt8", "UInt16", "UInt32",
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
/// Warns about each case that an earlier case always decodes first, so it is never chosen. Cases are
|
|
274
|
+
/// tried in declaration order and the first that decodes wins. The macro knows that only for some
|
|
275
|
+
/// payload types: a `JavaScriptValue` payload decodes every value, and a floating-point payload every
|
|
276
|
+
/// number, so a later case that decodes only numbers can't be reached. Types with the same spelling
|
|
277
|
+
/// are reported as an error by `validatedUnion`, which runs first.
|
|
278
|
+
private func diagnoseUnreachableCases(of declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) {
|
|
279
|
+
var catchAllCase: (name: TokenSyntax, type: String)?
|
|
280
|
+
var anyNumberCase: (name: TokenSyntax, type: String)?
|
|
281
|
+
|
|
282
|
+
for member in declaration.memberBlock.members {
|
|
283
|
+
guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
for element in caseDecl.elements {
|
|
287
|
+
guard let parameter = element.parameterClause?.parameters.first else {
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
let type = unqualifiedPayloadTypeName(parameter.type)
|
|
291
|
+
let reason: String?
|
|
292
|
+
let earlier: (name: TokenSyntax, type: String)?
|
|
293
|
+
if let catchAllCase {
|
|
294
|
+
earlier = catchAllCase
|
|
295
|
+
reason = "'\(catchAllCase.type)' decodes every JavaScript value"
|
|
296
|
+
} else if let anyNumberCase, numberOnlyPayloadTypes.contains(type) {
|
|
297
|
+
earlier = anyNumberCase
|
|
298
|
+
reason = "'\(anyNumberCase.type)' decodes every JavaScript number, and '\(type)' decodes only numbers"
|
|
299
|
+
} else {
|
|
300
|
+
earlier = nil
|
|
301
|
+
reason = nil
|
|
302
|
+
}
|
|
303
|
+
if let earlier, let reason {
|
|
304
|
+
let name = element.name.text
|
|
305
|
+
context.diagnose(
|
|
306
|
+
Diagnostic(
|
|
307
|
+
node: element,
|
|
308
|
+
message: UnionDiagnosticMessage(
|
|
309
|
+
"@Union case '\(name)' is never decoded: case '\(earlier.name.text)' is tried first, and \(reason). Move case '\(name)' before case '\(earlier.name.text)'.",
|
|
310
|
+
id: "union-unreachable-case",
|
|
311
|
+
severity: .warning
|
|
312
|
+
),
|
|
313
|
+
notes: [
|
|
314
|
+
Note(
|
|
315
|
+
node: Syntax(earlier.name),
|
|
316
|
+
message: UnionNoteMessage(
|
|
317
|
+
"case '\(earlier.name.text)' is declared here",
|
|
318
|
+
id: "union-unreachable-case-earlier"
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
]
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
if type == "JavaScriptValue" {
|
|
327
|
+
catchAllCase = (element.name, type)
|
|
328
|
+
} else if anyNumberCase == nil, anyNumberPayloadTypes.contains(type) {
|
|
329
|
+
anyNumberCase = (element.name, type)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/// The name of a payload type without the module that it may be qualified with, such as `Swift.Double`.
|
|
336
|
+
private func unqualifiedPayloadTypeName(_ type: TypeSyntax) -> String {
|
|
337
|
+
let modules = ["Swift", "CoreGraphics", "CoreFoundation", "Foundation", "ExpoModulesCore", "ExpoModulesJSI"]
|
|
338
|
+
if let memberType = type.as(MemberTypeSyntax.self), modules.contains(memberType.baseType.trimmedDescription) {
|
|
339
|
+
return memberType.name.text
|
|
340
|
+
}
|
|
341
|
+
return type.trimmedDescription
|
|
342
|
+
}
|
|
343
|
+
|
|
259
344
|
// MARK: - Synthesized members
|
|
260
345
|
|
|
261
346
|
/// `decode(_:in:)`: tries each case's payload converter in declaration order and returns the first
|
|
@@ -429,11 +514,22 @@ private func defaultValueDiagnostic(
|
|
|
429
514
|
private struct UnionDiagnosticMessage: DiagnosticMessage {
|
|
430
515
|
let message: String
|
|
431
516
|
let diagnosticID: MessageID
|
|
432
|
-
let severity: DiagnosticSeverity
|
|
517
|
+
let severity: DiagnosticSeverity
|
|
433
518
|
|
|
434
|
-
init(_ message: String, id: String) {
|
|
519
|
+
init(_ message: String, id: String, severity: DiagnosticSeverity = .error) {
|
|
435
520
|
self.message = message
|
|
436
521
|
self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
522
|
+
self.severity = severity
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private struct UnionNoteMessage: NoteMessage {
|
|
527
|
+
let message: String
|
|
528
|
+
let noteID: MessageID
|
|
529
|
+
|
|
530
|
+
init(_ message: String, id: String) {
|
|
531
|
+
self.message = message
|
|
532
|
+
self.noteID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
437
533
|
}
|
|
438
534
|
}
|
|
439
535
|
|