expo-modules-macros 0.30.0 → 0.32.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 +2 -1
- package/README.md +9 -7
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +3 -2
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/MacroExpander.swift +87 -38
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +27 -9
- package/apple/Sources/ExpoModulesCLI/Commands/Fix/RunFix.swift +88 -26
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +14 -7
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +6 -4
- package/apple/Sources/ExpoModulesMacrosCore/Macros/EventMacro.swift +104 -17
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoModuleMacro.swift +13 -4
- 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 +80 -5
- package/apple/Sources/ExpoModulesMacrosCore/Macros/UnionMacro.swift +101 -5
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +609 -33
- package/apple/Sources/ExpoModulesMacrosCore/Support/JSNameCollisions.swift +111 -2
- package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +15 -16
- package/build/types.d.ts +3 -3
- package/build/types.js +1 -1
- package/package.json +5 -5
|
@@ -285,7 +285,9 @@ final class SurfaceVisitor: SyntaxVisitor {
|
|
|
285
285
|
return []
|
|
286
286
|
}
|
|
287
287
|
let override = stringArgument(of: attribute)
|
|
288
|
-
let
|
|
288
|
+
let dispatch: ExportedEventDispatch =
|
|
289
|
+
hasEventOption(attribute, named: "inline")
|
|
290
|
+
? .inline : hasEventOption(attribute, named: "blocking") ? .blocking : .scheduled
|
|
289
291
|
var result: [ExportedEvent] = []
|
|
290
292
|
|
|
291
293
|
for binding in varDecl.bindings {
|
|
@@ -306,7 +308,7 @@ final class SurfaceVisitor: SyntaxVisitor {
|
|
|
306
308
|
name: name,
|
|
307
309
|
jsName: override ?? defaultEventName(for: name),
|
|
308
310
|
payload: functionType.parameters.first.map { typeNode(from: $0.type) },
|
|
309
|
-
|
|
311
|
+
dispatch: dispatch,
|
|
310
312
|
accessLevel: spelledAccessLevel(of: varDecl.modifiers),
|
|
311
313
|
platforms: platforms
|
|
312
314
|
)
|
|
@@ -704,8 +706,8 @@ private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
|
|
|
704
706
|
return text == "Void" || text == "()" || text == "Swift.Void"
|
|
705
707
|
}
|
|
706
708
|
|
|
707
|
-
/// True if an `@Event` attribute lists the given option member (`@Event(.
|
|
708
|
-
/// `@Event("name", EventOptions.
|
|
709
|
+
/// True if an `@Event` attribute lists the given option member (`@Event(.inline)` or
|
|
710
|
+
/// `@Event("name", EventOptions.inline)`). Like `hasOption` in the macros, but without the array-literal
|
|
709
711
|
/// branch, which `EventOptions...` doesn't accept.
|
|
710
712
|
private func hasEventOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
|
|
711
713
|
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
@@ -29,15 +29,15 @@ import SwiftSyntaxMacros
|
|
|
29
29
|
/// The synthesized property is deliberately **not** isolated to `@JavaScriptActor`, unlike `@JS`
|
|
30
30
|
/// members: `emit` is itself non-isolated and schedules the dispatch onto the JS thread internally,
|
|
31
31
|
/// so the event is callable from any thread or isolation with no actor hop at the call site. It is
|
|
32
|
-
/// also self-contained: `@ExpoModule`/`@SharedObject`
|
|
33
|
-
///
|
|
32
|
+
/// also self-contained: `@ExpoModule`/`@SharedObject` don't register `@Event` members anywhere. They
|
|
33
|
+
/// read the event names only to warn when two events share one (see `diagnoseEventNameCollisions`).
|
|
34
34
|
///
|
|
35
|
-
///
|
|
36
|
-
///
|
|
37
|
-
///
|
|
38
|
-
///
|
|
39
|
-
///
|
|
40
|
-
///
|
|
35
|
+
/// Two options change how the event is dispatched, and each one calls its own `EventEmitter` method,
|
|
36
|
+
/// which is callable from any thread as well:
|
|
37
|
+
/// - `@Event(.inline)` calls `emitInline`: on the JS thread the listeners run before the call
|
|
38
|
+
/// returns; on any other thread the event is scheduled, the same as `emit`. It never blocks.
|
|
39
|
+
/// - `@Event(.blocking)` calls `emitBlocking`: the listeners run before the call returns on any
|
|
40
|
+
/// thread, so a call from another thread blocks until the JS thread runs them.
|
|
41
41
|
///
|
|
42
42
|
/// As a **peer**, the macro emits a never-called conformance assertion (see
|
|
43
43
|
/// `TypeConformanceAssertion.swift`) checking that the payload type is JS-convertible and that the
|
|
@@ -51,11 +51,10 @@ public struct EventMacro: AccessorMacro {
|
|
|
51
51
|
) throws -> [AccessorDeclSyntax] {
|
|
52
52
|
let event = try validatedEvent(of: node, on: declaration)
|
|
53
53
|
// The closure's parameter and return types are inferred from the property's declared type
|
|
54
|
-
// through the getter, so the body never has to spell the payload type.
|
|
55
|
-
// `
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
let emitMethod = event.isSync ? "emitSync" : "emit"
|
|
54
|
+
// through the getter, so the body never has to spell the payload type. The dispatch option
|
|
55
|
+
// picks the `EventEmitter` method. A payload that holds a tuple can't conform to the payload
|
|
56
|
+
// protocols, so it's passed with a closure that encodes it.
|
|
57
|
+
let emitMethod = event.dispatch.emitMethod
|
|
59
58
|
let closure: String
|
|
60
59
|
if let tuple = event.payloadType.flatMap({ tupleConversion(of: $0) }) {
|
|
61
60
|
let encode = encoderClosure(encoding: tuple.encodeCall(of: "value", into: "slot"))
|
|
@@ -96,9 +95,13 @@ extension EventMacro: PeerMacro {
|
|
|
96
95
|
//
|
|
97
96
|
// A payload that holds a tuple is encoded element by element (see `TupleConversion`), so the types
|
|
98
97
|
// in it are asserted `JavaScriptEncodable`, which the encode closure needs, in its place.
|
|
98
|
+
// `emitInline` and `emitBlocking` take only `JavaScriptEncodable` payloads, so with those options
|
|
99
|
+
// the payload is asserted `JavaScriptEncodable` too.
|
|
99
100
|
let name = event.swiftName
|
|
100
101
|
let tupleElementTypes = event.payloadType.flatMap { tupleConversion(of: $0)?.conformingTypes }
|
|
101
|
-
let payloadConstraint =
|
|
102
|
+
let payloadConstraint =
|
|
103
|
+
tupleElementTypes == nil && event.dispatch == .scheduled
|
|
104
|
+
? jsConvertibleProtocolName : javaScriptEncodableProtocolName
|
|
102
105
|
let payloads =
|
|
103
106
|
tupleElementTypes.map { distinctAssertableTypes($0, constraint: payloadConstraint) }
|
|
104
107
|
?? event.payloadType.flatMap { assertableBoundaryType($0, constraint: payloadConstraint) }.map { [$0] } ?? []
|
|
@@ -127,8 +130,8 @@ private struct EventMember {
|
|
|
127
130
|
let jsName: String
|
|
128
131
|
/// The payload type as written, or `nil` for a no-payload `() -> Void` event.
|
|
129
132
|
let payloadType: String?
|
|
130
|
-
///
|
|
131
|
-
let
|
|
133
|
+
/// How the event is dispatched, from the `.inline` or `.blocking` option.
|
|
134
|
+
let dispatch: EventDispatch
|
|
132
135
|
|
|
133
136
|
var hasPayload: Bool {
|
|
134
137
|
return payloadType != nil
|
|
@@ -208,14 +211,98 @@ private func validatedEvent(
|
|
|
208
211
|
}
|
|
209
212
|
|
|
210
213
|
let swiftName = identifier.identifier.text
|
|
214
|
+
let dispatch = try eventDispatch(of: node)
|
|
211
215
|
return EventMember(
|
|
212
216
|
swiftName: swiftName,
|
|
213
217
|
jsName: jsNameArgument(of: node) ?? defaultEventName(for: withoutBackticks(swiftName)),
|
|
214
218
|
payloadType: functionType.parameters.first?.type.trimmedDescription,
|
|
215
|
-
|
|
219
|
+
dispatch: dispatch
|
|
216
220
|
)
|
|
217
221
|
}
|
|
218
222
|
|
|
223
|
+
/// How an `@Event` is dispatched to JavaScript, picked by its option.
|
|
224
|
+
private enum EventDispatch {
|
|
225
|
+
case scheduled
|
|
226
|
+
case inline
|
|
227
|
+
case blocking
|
|
228
|
+
|
|
229
|
+
var emitMethod: String {
|
|
230
|
+
switch self {
|
|
231
|
+
case .scheduled:
|
|
232
|
+
return "emit"
|
|
233
|
+
case .inline:
|
|
234
|
+
return "emitInline"
|
|
235
|
+
case .blocking:
|
|
236
|
+
return "emitBlocking"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/// Reads the dispatch option of an `@Event` attribute. `.inline` and `.blocking` exclude each other.
|
|
242
|
+
private func eventDispatch(of node: AttributeSyntax) throws -> EventDispatch {
|
|
243
|
+
let isInline = hasOption(node, named: "inline")
|
|
244
|
+
let isBlocking = hasOption(node, named: "blocking")
|
|
245
|
+
if isInline && isBlocking {
|
|
246
|
+
throw conflictingOptionsDiagnostic(in: node)
|
|
247
|
+
}
|
|
248
|
+
if isInline {
|
|
249
|
+
return .inline
|
|
250
|
+
}
|
|
251
|
+
if isBlocking {
|
|
252
|
+
return .blocking
|
|
253
|
+
}
|
|
254
|
+
return .scheduled
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// The error for `@Event(.inline, .blocking)`, with a single Fix-It that removes the option written
|
|
258
|
+
/// last. One Fix-It (rather than one for each option) lets the `fix` command apply it, since it skips
|
|
259
|
+
/// a diagnostic with alternatives.
|
|
260
|
+
private func conflictingOptionsDiagnostic(in node: AttributeSyntax) -> DiagnosticsError {
|
|
261
|
+
let message = EventDiagnosticMessage(
|
|
262
|
+
"@Event can't be both '.inline' and '.blocking'; '.blocking' also runs the listeners inline on the JS thread, so remove one of the options",
|
|
263
|
+
id: "event-conflicting-options"
|
|
264
|
+
)
|
|
265
|
+
guard let arguments = node.arguments?.as(LabeledExprListSyntax.self),
|
|
266
|
+
let index = arguments.lastIndex(where: { isDispatchOption($0.expression) })
|
|
267
|
+
else {
|
|
268
|
+
return DiagnosticsError(diagnostics: [Diagnostic(node: node, message: message)])
|
|
269
|
+
}
|
|
270
|
+
let removed = arguments[index]
|
|
271
|
+
var remaining = Array(arguments)
|
|
272
|
+
remaining.removeAll { $0.id == removed.id }
|
|
273
|
+
// The argument before the removed one keeps its comma only when another argument follows it.
|
|
274
|
+
if let last = remaining.indices.last {
|
|
275
|
+
remaining[last] = remaining[last].with(\.trailingComma, nil)
|
|
276
|
+
}
|
|
277
|
+
let optionName = removed.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text ?? "option"
|
|
278
|
+
let fixIt = FixIt(
|
|
279
|
+
message: EventFixItMessage("Remove '.\(optionName)'", id: "event-remove-option"),
|
|
280
|
+
changes: [
|
|
281
|
+
.replace(oldNode: Syntax(arguments), newNode: Syntax(LabeledExprListSyntax(remaining)))
|
|
282
|
+
]
|
|
283
|
+
)
|
|
284
|
+
return DiagnosticsError(diagnostics: [Diagnostic(node: node, message: message, fixIts: [fixIt])])
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// True when the expression is the `.inline` or `.blocking` option (or `EventOptions.<option>`).
|
|
288
|
+
private func isDispatchOption(_ expression: ExprSyntax) -> Bool {
|
|
289
|
+
guard let member = expression.as(MemberAccessExprSyntax.self) else {
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
let name = member.declName.baseName.text
|
|
293
|
+
return name == "inline" || name == "blocking"
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// The JS name of an `@Event` property: the `@Event("…")` override, or the default name derived from
|
|
297
|
+
/// the property name. `nil` when the declaration isn't a single named property, which the macro reports.
|
|
298
|
+
internal func eventJSName(of varDecl: VariableDeclSyntax, attribute: AttributeSyntax) -> String? {
|
|
299
|
+
guard varDecl.bindings.count == 1, let identifier = varDecl.bindings.first?.pattern.as(IdentifierPatternSyntax.self)
|
|
300
|
+
else {
|
|
301
|
+
return nil
|
|
302
|
+
}
|
|
303
|
+
return jsNameArgument(of: attribute) ?? defaultEventName(for: withoutBackticks(identifier.identifier.text))
|
|
304
|
+
}
|
|
305
|
+
|
|
219
306
|
// MARK: - Default event name
|
|
220
307
|
|
|
221
308
|
/// The default JS event name for a property: the Swift name with the conventional `on` prefix
|
|
@@ -63,6 +63,16 @@ public struct ExpoModuleMacro: MemberMacro {
|
|
|
63
63
|
for member in classDecl.memberBlock.members {
|
|
64
64
|
// The types of a `@JS` member are read with the class's tuple aliases resolved.
|
|
65
65
|
let decl = aliases.resolving(member.decl)
|
|
66
|
+
// A generic member is reported by `@JS` and left out, so the build has no errors in its binding.
|
|
67
|
+
if memberHasJSAttribute(decl) && jsGenericParameterClause(of: decl) != nil {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
// So is a `static` or `class` member: the module object binds only instance members.
|
|
71
|
+
if memberHasJSAttribute(decl),
|
|
72
|
+
let modifiers = decl.asProtocol(WithModifiersSyntax.self)?.modifiers, isTypeLevel(modifiers)
|
|
73
|
+
{
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
66
76
|
if memberHasJSAttribute(decl), let assertion = jsConformanceAssertionBody(for: decl) {
|
|
67
77
|
assertions.append(assertion)
|
|
68
78
|
}
|
|
@@ -86,6 +96,8 @@ public struct ExpoModuleMacro: MemberMacro {
|
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
diagnoseJSNameCollisions(functions: functions, properties: properties, in: context)
|
|
99
|
+
diagnoseReservedJSNames(functions: functions, properties: properties, on: .module, in: context)
|
|
100
|
+
diagnoseEventNameCollisions(in: classDecl.memberBlock, in: context)
|
|
89
101
|
|
|
90
102
|
var emitted: [DeclSyntax] = []
|
|
91
103
|
|
|
@@ -167,10 +179,7 @@ extension ExpoModuleMacro: MemberAttributeMacro {
|
|
|
167
179
|
// checked at compile time. Skipped when the member already chose an isolation
|
|
168
180
|
// (`nonisolated` or another global actor), or when `.concurrent` or `.withAsync("name")` runs
|
|
169
181
|
// it on the concurrent pool. See `shouldStampJavaScriptActor`.
|
|
170
|
-
|
|
171
|
-
// isolation forces its call site onto the JS thread. Async events (the default) are
|
|
172
|
-
// deliberately left unstamped — their `emit` schedules onto the JS thread itself.
|
|
173
|
-
if memberHasJSAttribute(member) || isSyncEventMember(member),
|
|
182
|
+
if memberHasJSAttribute(member),
|
|
174
183
|
shouldStampJavaScriptActor(on: member, enclosedBy: declaration)
|
|
175
184
|
{
|
|
176
185
|
attributes.append("@JavaScriptActor")
|
|
@@ -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.
|
|
@@ -171,10 +193,7 @@ extension SharedObjectMacro: MemberAttributeMacro {
|
|
|
171
193
|
providingAttributesFor member: some DeclSyntaxProtocol,
|
|
172
194
|
in context: some MacroExpansionContext
|
|
173
195
|
) throws -> [AttributeSyntax] {
|
|
174
|
-
|
|
175
|
-
// inline, so the isolation forces its call site onto the JS thread. Async events (the
|
|
176
|
-
// default) stay unstamped — their `emit` schedules onto the JS thread itself.
|
|
177
|
-
guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
|
|
196
|
+
guard memberHasJSAttribute(member) else {
|
|
178
197
|
return []
|
|
179
198
|
}
|
|
180
199
|
// `@JS(.concurrent)` opts the member out of the JS-thread stamp and onto the concurrent pool.
|
|
@@ -202,3 +221,59 @@ extension SharedObjectMacro: ExtensionMacro {
|
|
|
202
221
|
return [sendable]
|
|
203
222
|
}
|
|
204
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
|
+
}
|