expo-modules-macros 0.29.0 → 0.31.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/AGENTS.md +2 -2
- package/README.md +37 -4
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Sources/ExpoModulesCLI/CLI.swift +46 -9
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +38 -1
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/MacroExpander.swift +171 -31
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +43 -15
- package/apple/Sources/ExpoModulesCLI/Commands/Fix/RunFix.swift +290 -0
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +14 -7
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +11 -11
- package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +48 -34
- package/apple/Sources/ExpoModulesMacrosCore/Macros/EventMacro.swift +92 -15
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoModuleMacro.swift +8 -4
- package/apple/Sources/ExpoModulesMacrosCore/Macros/JSMacro.swift +54 -1
- package/apple/Sources/ExpoModulesMacrosCore/Macros/SharedObjectMacro.swift +9 -5
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +60 -16
- package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +9 -37
- package/apple/Sources/ExpoModulesMacrosCore/Support/PublicAccess.swift +105 -0
- package/build/types.d.ts +17 -8
- package/build/types.js +1 -1
- package/package.json +5 -5
|
@@ -32,12 +32,12 @@ import SwiftSyntaxMacros
|
|
|
32
32
|
/// also self-contained: `@ExpoModule`/`@SharedObject` neither collect `@Event` members nor register
|
|
33
33
|
/// their names anywhere.
|
|
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,88 @@ 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
|
+
|
|
219
296
|
// MARK: - Default event name
|
|
220
297
|
|
|
221
298
|
/// The default JS event name for a property: the Swift name with the conventional `on` prefix
|
|
@@ -38,6 +38,13 @@ public struct ExpoModuleMacro: MemberMacro {
|
|
|
38
38
|
guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
|
|
39
39
|
throw MacroExpansionErrorMessage("@ExpoModule can only be applied to a class")
|
|
40
40
|
}
|
|
41
|
+
diagnoseNonPublicClass(
|
|
42
|
+
classDecl,
|
|
43
|
+
macroName: "@ExpoModule",
|
|
44
|
+
reason: "register it",
|
|
45
|
+
idPrefix: "expomodule",
|
|
46
|
+
in: context
|
|
47
|
+
)
|
|
41
48
|
|
|
42
49
|
// The module name is fully resolved here (the explicit `@ExpoModule("…")` argument, else the
|
|
43
50
|
// class name) and emitted as a non-optional `_jsName` static below — so the
|
|
@@ -160,10 +167,7 @@ extension ExpoModuleMacro: MemberAttributeMacro {
|
|
|
160
167
|
// checked at compile time. Skipped when the member already chose an isolation
|
|
161
168
|
// (`nonisolated` or another global actor), or when `.concurrent` or `.withAsync("name")` runs
|
|
162
169
|
// it on the concurrent pool. See `shouldStampJavaScriptActor`.
|
|
163
|
-
|
|
164
|
-
// isolation forces its call site onto the JS thread. Async events (the default) are
|
|
165
|
-
// deliberately left unstamped — their `emit` schedules onto the JS thread itself.
|
|
166
|
-
if memberHasJSAttribute(member) || isSyncEventMember(member),
|
|
170
|
+
if memberHasJSAttribute(member),
|
|
167
171
|
shouldStampJavaScriptActor(on: member, enclosedBy: declaration)
|
|
168
172
|
{
|
|
169
173
|
attributes.append("@JavaScriptActor")
|
|
@@ -36,6 +36,7 @@ public struct JSMacro: PeerMacro {
|
|
|
36
36
|
diagnoseClosureTypes(in: declaration, in: context)
|
|
37
37
|
diagnoseConcurrentOption(of: node, on: declaration, in: context)
|
|
38
38
|
diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
|
|
39
|
+
diagnoseJavaScriptValuesOffThread(of: node, on: declaration, in: context)
|
|
39
40
|
|
|
40
41
|
// In an `@ExpoModule` or `@SharedObject` class, the class macro emits the assertion instead: it sees
|
|
41
42
|
// the type aliases declared in the class body, which this macro can't (see `TupleTypeAliases`).
|
|
@@ -83,7 +84,7 @@ private func diagnoseConcurrentOption(
|
|
|
83
84
|
on declaration: some DeclSyntaxProtocol,
|
|
84
85
|
in context: some MacroExpansionContext
|
|
85
86
|
) {
|
|
86
|
-
guard
|
|
87
|
+
guard hasOption(node, named: "concurrent") else {
|
|
87
88
|
return
|
|
88
89
|
}
|
|
89
90
|
if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
|
|
@@ -248,6 +249,58 @@ private func insertAsyncFixIt(for funcDecl: FunctionDeclSyntax) -> FixIt {
|
|
|
248
249
|
/// `consuming`, `sending`) are fine: the binding passes the decoded value under whichever convention
|
|
249
250
|
/// the parameter asks for. The specifier is left off the decode and the assertion (see
|
|
250
251
|
/// `SpecifiedType`); a parameter reported here is also left out of the assertion.
|
|
252
|
+
/// The JSI types that own an engine value and release it wherever they are dropped. Such a value is
|
|
253
|
+
/// valid only on the JavaScript thread. `JavaScriptPromise` releases its value on the JavaScript
|
|
254
|
+
/// thread, and `JavaScriptRef` and `JavaScriptCallback` exist to carry a value across threads, so they
|
|
255
|
+
/// aren't listed.
|
|
256
|
+
private let javaScriptThreadBoundTypes: Set<String> = [
|
|
257
|
+
"JavaScriptValue", "JavaScriptObject", "JavaScriptFunction", "JavaScriptArray", "JavaScriptArrayBuffer",
|
|
258
|
+
"JavaScriptTypedArray", "JavaScriptBigInt", "JavaScriptWeakObject",
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
/// Emits the diagnostic for a JSI value parameter of a `@JS(.concurrent)` function or of the variant
|
|
262
|
+
/// that `@JS(.withAsync("name"))` adds. Both run the body on the concurrent thread pool, never on the
|
|
263
|
+
/// JavaScript thread, so the body can't use the value, and the value is released off the JavaScript
|
|
264
|
+
/// thread too.
|
|
265
|
+
private func diagnoseJavaScriptValuesOffThread(
|
|
266
|
+
of node: AttributeSyntax,
|
|
267
|
+
on declaration: some DeclSyntaxProtocol,
|
|
268
|
+
in context: some MacroExpansionContext
|
|
269
|
+
) {
|
|
270
|
+
guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
let option: String
|
|
274
|
+
let reason: String
|
|
275
|
+
if hasOption(node, named: "concurrent") {
|
|
276
|
+
option = ".concurrent"
|
|
277
|
+
reason = "'.concurrent' runs the body on the concurrent thread pool"
|
|
278
|
+
} else if asyncVariantOption(of: node) != nil {
|
|
279
|
+
option = ".withAsync"
|
|
280
|
+
reason = "the variant that '.withAsync' adds runs the function on the concurrent thread pool"
|
|
281
|
+
} else {
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
for parameter in funcDecl.signature.parameterClause.parameters {
|
|
285
|
+
let type = SpecifiedType(parameter.type).type
|
|
286
|
+
guard let name = baseIdentifier(of: optionalWrappedType(type) ?? type), javaScriptThreadBoundTypes.contains(name)
|
|
287
|
+
else {
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
let parameterName = (parameter.secondName ?? parameter.firstName).text
|
|
291
|
+
context.diagnose(
|
|
292
|
+
Diagnostic(
|
|
293
|
+
node: parameter.type,
|
|
294
|
+
message: JSDiagnosticMessage(
|
|
295
|
+
"'\(parameterName)' can't be a '\(name)' in a '\(option)' function: \(reason), and a JavaScript value is valid only on the JavaScript thread. Take a native type that the value decodes to, such as a @Record or [String: Any], or remove '\(option)'.",
|
|
296
|
+
id: "js-javascript-value-off-thread",
|
|
297
|
+
severity: .error
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
251
304
|
private func diagnoseParameterSpecifiers(
|
|
252
305
|
in declaration: some DeclSyntaxProtocol,
|
|
253
306
|
in context: some MacroExpansionContext
|
|
@@ -8,7 +8,7 @@ import SwiftSyntaxMacros
|
|
|
8
8
|
/// is emitted as a `_jsName` override.
|
|
9
9
|
///
|
|
10
10
|
/// @SharedObject
|
|
11
|
-
/// final class Cache: SharedObject {
|
|
11
|
+
/// public final class Cache: SharedObject {
|
|
12
12
|
/// @JS
|
|
13
13
|
/// init(name: String) { self.name = name }
|
|
14
14
|
///
|
|
@@ -34,6 +34,13 @@ public struct SharedObjectMacro: MemberMacro {
|
|
|
34
34
|
guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
|
|
35
35
|
throw MacroExpansionErrorMessage("@SharedObject can only be applied to a class")
|
|
36
36
|
}
|
|
37
|
+
diagnoseNonPublicClass(
|
|
38
|
+
classDecl,
|
|
39
|
+
macroName: "@SharedObject",
|
|
40
|
+
reason: "export it",
|
|
41
|
+
idPrefix: "sharedobject",
|
|
42
|
+
in: context
|
|
43
|
+
)
|
|
37
44
|
|
|
38
45
|
// The superclass isn't checked here: the macro sees only this declaration, so it can't tell an
|
|
39
46
|
// indirect subclass (`class Thumbnail: ImageRef`) from an unrelated class. The compiler reports a
|
|
@@ -164,10 +171,7 @@ extension SharedObjectMacro: MemberAttributeMacro {
|
|
|
164
171
|
providingAttributesFor member: some DeclSyntaxProtocol,
|
|
165
172
|
in context: some MacroExpansionContext
|
|
166
173
|
) throws -> [AttributeSyntax] {
|
|
167
|
-
|
|
168
|
-
// inline, so the isolation forces its call site onto the JS thread. Async events (the
|
|
169
|
-
// default) stay unstamped — their `emit` schedules onto the JS thread itself.
|
|
170
|
-
guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
|
|
174
|
+
guard memberHasJSAttribute(member) else {
|
|
171
175
|
return []
|
|
172
176
|
}
|
|
173
177
|
// `@JS(.concurrent)` opts the member out of the JS-thread stamp and onto the concurrent pool.
|
|
@@ -134,36 +134,71 @@ private let maxValueProps = 64
|
|
|
134
134
|
/// The error for a leftover `@Field`, attached to the attribute itself and carrying a fix-it that
|
|
135
135
|
/// deletes it. `@ViewProps` treats every stored property as a prop, so the attribute has no meaning
|
|
136
136
|
/// here; left in place it would wrap the value in `Field<T>` and the props type would decode against
|
|
137
|
-
/// the wrong type.
|
|
137
|
+
/// the wrong type. A `@Field` with arguments, such as `@Field(.keyed("borderColor"))`, gets no
|
|
138
|
+
/// fix-it: they can set the prop name, which removing the attribute would change silently, so the
|
|
139
|
+
/// message tells the author to rename the property.
|
|
138
140
|
private func fieldAttributeDiagnostic(
|
|
139
141
|
for attribute: AttributeSyntax,
|
|
140
142
|
on varDecl: VariableDeclSyntax
|
|
141
143
|
) -> Diagnostic {
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
var
|
|
145
|
-
|
|
144
|
+
// The token after the attribute takes over the trivia of the attribute: the line break,
|
|
145
|
+
// indentation and comments before it, and the comments after it. Its own leading whitespace is
|
|
146
|
+
// left out unless a comment comes before it, so `@Field var size` and `@Field` on its own line both
|
|
147
|
+
// become `var size` on the line of the attribute, and `@Field // note` keeps the line break after
|
|
148
|
+
// the comment.
|
|
149
|
+
var newDecl = varDecl
|
|
150
|
+
if let next = attribute.lastToken(viewMode: .sourceAccurate)?.nextToken(viewMode: .sourceAccurate) {
|
|
151
|
+
let comments = Trivia(pieces: attribute.trailingTrivia.pieces.drop(while: \.isWhitespace))
|
|
152
|
+
let nextTrivia =
|
|
153
|
+
comments.isEmpty ? Trivia(pieces: next.leadingTrivia.pieces.drop(while: \.isWhitespace)) : next.leadingTrivia
|
|
154
|
+
newDecl =
|
|
155
|
+
LeadingTriviaRewriter(token: next.id, trivia: attribute.leadingTrivia + comments + nextTrivia)
|
|
156
|
+
.rewrite(varDecl).cast(VariableDeclSyntax.self)
|
|
157
|
+
}
|
|
158
|
+
// Then remove the attribute with its trivia, so it doesn't leave a blank line behind. The rewritten
|
|
159
|
+
// declaration is a new tree, so the attribute is found by its place in the list.
|
|
160
|
+
if let index = varDecl.attributes.firstIndex(where: { element in
|
|
146
161
|
guard case .attribute(let candidate) = element else {
|
|
147
162
|
return false
|
|
148
163
|
}
|
|
149
164
|
return candidate == attribute
|
|
150
165
|
}) {
|
|
151
|
-
attributes.
|
|
166
|
+
let offset = varDecl.attributes.distance(from: varDecl.attributes.startIndex, to: index)
|
|
167
|
+
newDecl.attributes.remove(at: newDecl.attributes.index(newDecl.attributes.startIndex, offsetBy: offset))
|
|
152
168
|
}
|
|
153
169
|
let fixIt = FixIt(
|
|
154
170
|
message: ViewPropsFixItMessage("Remove the '@Field' attribute", id: "viewprops-remove-field"),
|
|
155
171
|
changes: [
|
|
156
172
|
.replace(
|
|
157
173
|
oldNode: Syntax(varDecl),
|
|
158
|
-
newNode: Syntax(
|
|
174
|
+
newNode: Syntax(newDecl)
|
|
159
175
|
)
|
|
160
176
|
]
|
|
161
177
|
)
|
|
178
|
+
let hasArguments = attribute.arguments?.as(LabeledExprListSyntax.self)?.isEmpty == false
|
|
162
179
|
let message = ViewPropsDiagnosticMessage(
|
|
163
|
-
|
|
180
|
+
hasArguments
|
|
181
|
+
? "@Field is no longer used: @ViewProps treats every stored property as a prop, named after the property. Remove the @Field attribute, and rename the property if its arguments set another prop name"
|
|
182
|
+
: "@Field is no longer used: @ViewProps treats every stored property as a prop. Remove the @Field attribute",
|
|
164
183
|
id: "viewprops-field-attribute"
|
|
165
184
|
)
|
|
166
|
-
return Diagnostic(node: attribute, message: message, fixIts: [fixIt])
|
|
185
|
+
return Diagnostic(node: attribute, message: message, fixIts: hasArguments ? [] : [fixIt])
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Replaces the leading trivia of one token.
|
|
189
|
+
private final class LeadingTriviaRewriter: SyntaxRewriter {
|
|
190
|
+
private let token: SyntaxIdentifier
|
|
191
|
+
private let trivia: Trivia
|
|
192
|
+
|
|
193
|
+
init(token: SyntaxIdentifier, trivia: Trivia) {
|
|
194
|
+
self.token = token
|
|
195
|
+
self.trivia = trivia
|
|
196
|
+
super.init(viewMode: .sourceAccurate)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
override func visit(_ token: TokenSyntax) -> TokenSyntax {
|
|
200
|
+
token.id == self.token ? token.with(\.leadingTrivia, trivia) : token
|
|
201
|
+
}
|
|
167
202
|
}
|
|
168
203
|
|
|
169
204
|
/// The error for an optional event prop, attached to the declared type and carrying a fix-it that
|
|
@@ -281,6 +316,22 @@ private func validatedViewProps(of declaration: some DeclGroupSyntax) throws ->
|
|
|
281
316
|
)
|
|
282
317
|
}
|
|
283
318
|
|
|
319
|
+
// `@Field` is the v1 property wrapper and has no meaning here — every stored property is already
|
|
320
|
+
// part of the surface. Left in place it would wrap the value in `Field<T>`, so the props type
|
|
321
|
+
// would decode against the wrong type. Flag every one rather than emit code built on them, all at
|
|
322
|
+
// once, so a `@Field` without a fix-it doesn't hide the fix-its of the others.
|
|
323
|
+
let fieldDiagnostics = declaration.memberBlock.members.compactMap { member -> Diagnostic? in
|
|
324
|
+
guard let varDecl = member.decl.as(VariableDeclSyntax.self), !isExcludedByModifier(varDecl.modifiers),
|
|
325
|
+
let fieldAttribute = varDecl.attributes.firstAttribute(named: "Field")
|
|
326
|
+
else {
|
|
327
|
+
return nil
|
|
328
|
+
}
|
|
329
|
+
return fieldAttributeDiagnostic(for: fieldAttribute, on: varDecl)
|
|
330
|
+
}
|
|
331
|
+
if !fieldDiagnostics.isEmpty {
|
|
332
|
+
throw DiagnosticsError(diagnostics: fieldDiagnostics)
|
|
333
|
+
}
|
|
334
|
+
|
|
284
335
|
var valueProps: [ViewProp] = []
|
|
285
336
|
var eventProps: [ViewProp] = []
|
|
286
337
|
|
|
@@ -291,13 +342,6 @@ private func validatedViewProps(of declaration: some DeclGroupSyntax) throws ->
|
|
|
291
342
|
if isExcludedByModifier(varDecl.modifiers) {
|
|
292
343
|
continue
|
|
293
344
|
}
|
|
294
|
-
// `@Field` is the v1 property wrapper and has no meaning here — every stored property is already
|
|
295
|
-
// part of the surface. Left in place it would wrap the value in `Field<T>`, so the props type
|
|
296
|
-
// would decode against the wrong type. Flag it rather than emit code built on it.
|
|
297
|
-
if let fieldAttribute = varDecl.attributes.firstAttribute(named: "Field") {
|
|
298
|
-
throw DiagnosticsError(diagnostics: [fieldAttributeDiagnostic(for: fieldAttribute, on: varDecl)])
|
|
299
|
-
}
|
|
300
|
-
|
|
301
345
|
for binding in varDecl.bindings {
|
|
302
346
|
// A computed property is never a prop, but an accessor block alone doesn't mean computed:
|
|
303
347
|
// `willSet`/`didSet` observers imply stored storage. `bindingIsSettable` draws exactly that
|
|
@@ -13,26 +13,12 @@ internal func jsNameArgument(of attribute: AttributeSyntax) -> String? {
|
|
|
13
13
|
return segment.content.text
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
///
|
|
17
|
-
///
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
for arg in args where arg.label?.text == label {
|
|
23
|
-
guard let literal = arg.expression.as(BooleanLiteralExprSyntax.self) else {
|
|
24
|
-
return nil
|
|
25
|
-
}
|
|
26
|
-
return literal.literal.tokenKind == .keyword(.true)
|
|
27
|
-
}
|
|
28
|
-
return nil
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/// True if the attribute lists the given `JSOptions` member, e.g. `@JS(.concurrent)` or
|
|
32
|
-
/// `@JS("name", [.concurrent])` -> true for "concurrent". Options are written as member-access
|
|
33
|
-
/// expressions (`.concurrent`), optionally inside an array literal when more than one is combined, so
|
|
34
|
-
/// both spellings are scanned. The leading string literal, when present, is the JS name and is skipped.
|
|
35
|
-
internal func hasJSOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
|
|
16
|
+
/// True if the attribute lists the given option member, e.g. `@JS(.concurrent)`, `@Event(.inline)` or
|
|
17
|
+
/// `@Event("name", EventOptions.inline)` -> true for the matching name. Options are written as
|
|
18
|
+
/// member-access expressions, one per variadic argument. Array literals are scanned too, though the
|
|
19
|
+
/// variadic declarations in `expo-modules-core` don't accept them. The leading string literal, when
|
|
20
|
+
/// present, is the JS name and is skipped.
|
|
21
|
+
internal func hasOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
|
|
36
22
|
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
37
23
|
return false
|
|
38
24
|
}
|
|
@@ -49,7 +35,7 @@ internal func hasJSOption(_ attribute: AttributeSyntax, named option: String) ->
|
|
|
49
35
|
return false
|
|
50
36
|
}
|
|
51
37
|
|
|
52
|
-
/// True when the expression is the member access `.<option>` (or `JSOptions.<option>`).
|
|
38
|
+
/// True when the expression is the member access `.<option>` (or `JSOptions.<option>`, `EventOptions.<option>`).
|
|
53
39
|
private func namesOption(_ expression: ExprSyntax, _ option: String) -> Bool {
|
|
54
40
|
guard let member = expression.as(MemberAccessExprSyntax.self) else {
|
|
55
41
|
return false
|
|
@@ -64,7 +50,7 @@ internal func isConcurrentJSMember(_ decl: DeclSyntaxProtocol) -> Bool {
|
|
|
64
50
|
guard let attribute = memberAttributes(of: decl).firstAttribute(named: "JS") else {
|
|
65
51
|
return false
|
|
66
52
|
}
|
|
67
|
-
return
|
|
53
|
+
return hasOption(attribute, named: "concurrent")
|
|
68
54
|
}
|
|
69
55
|
|
|
70
56
|
/// The `.withAsync("name")` option of a `@JS` attribute, e.g. `@JS(.withAsync("loadAsync"))` or
|
|
@@ -74,7 +60,7 @@ internal func asyncVariantOption(of attribute: AttributeSyntax) -> FunctionCallE
|
|
|
74
60
|
return asyncVariantOptions(of: attribute).first
|
|
75
61
|
}
|
|
76
62
|
|
|
77
|
-
/// Every `.withAsync(...)` option of a `@JS` attribute, in source order. Like `
|
|
63
|
+
/// Every `.withAsync(...)` option of a `@JS` attribute, in source order. Like `hasOption`, it scans
|
|
78
64
|
/// the arguments and the elements of an array literal.
|
|
79
65
|
internal func asyncVariantOptions(of attribute: AttributeSyntax) -> [FunctionCallExprSyntax] {
|
|
80
66
|
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
@@ -290,20 +276,6 @@ internal func hasDefaultValue(_ parameter: FunctionParameterSyntax) -> Bool {
|
|
|
290
276
|
return parameter.defaultValue != nil
|
|
291
277
|
}
|
|
292
278
|
|
|
293
|
-
/// True if the declaration is a `@Event(sync: true)` property. A sync event dispatches inline on the
|
|
294
|
-
/// JS thread instead of scheduling, so `@ExpoModule`/`@SharedObject` stamp it with `@JavaScriptActor`,
|
|
295
|
-
/// making "must be called on the JS thread" a compile-time guarantee at the call site. Async events
|
|
296
|
-
/// (the default) are deliberately not stamped: their `emit` schedules onto the JS thread itself, so
|
|
297
|
-
/// they stay callable from any thread.
|
|
298
|
-
internal func isSyncEventMember(_ decl: DeclSyntaxProtocol) -> Bool {
|
|
299
|
-
guard let varDecl = decl.as(VariableDeclSyntax.self),
|
|
300
|
-
let attribute = varDecl.attributes.firstAttribute(named: "Event")
|
|
301
|
-
else {
|
|
302
|
-
return false
|
|
303
|
-
}
|
|
304
|
-
return boolArgument(of: attribute, label: "sync") == true
|
|
305
|
-
}
|
|
306
|
-
|
|
307
279
|
/// Reads a labeled array-literal argument of an attribute, e.g. `@ExpoModule(classes: [Foo.self, Bar.self])`,
|
|
308
280
|
/// and returns the type names referenced (e.g. `["Foo", "Bar"]`). Each element must be a
|
|
309
281
|
/// `<TypeName>.self` member-access expression; non-conforming elements are skipped silently.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import SwiftDiagnostics
|
|
2
|
+
import SwiftSyntax
|
|
3
|
+
import SwiftSyntaxMacros
|
|
4
|
+
|
|
5
|
+
/// Warns when a class that autolinking exports isn't `public` or `open`. Autolinking generates the modules
|
|
6
|
+
/// provider in the app target, so it can reference only public classes of other modules. The warning has a
|
|
7
|
+
/// fix-it that makes the class `public`. A local class is skipped, because it can't have an access modifier.
|
|
8
|
+
internal func diagnoseNonPublicClass(
|
|
9
|
+
_ classDecl: ClassDeclSyntax,
|
|
10
|
+
macroName: String,
|
|
11
|
+
reason: String,
|
|
12
|
+
idPrefix: String,
|
|
13
|
+
in context: some MacroExpansionContext
|
|
14
|
+
) {
|
|
15
|
+
guard !isLocal(context: context) else {
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
let accessModifier = classDecl.modifiers.first { accessLevelKeywords.contains($0.name.tokenKind) }
|
|
19
|
+
if let accessModifier,
|
|
20
|
+
[.keyword(.public), .keyword(.open)].contains(accessModifier.name.tokenKind)
|
|
21
|
+
{
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
let name = classDecl.name.text
|
|
25
|
+
context.diagnose(
|
|
26
|
+
Diagnostic(
|
|
27
|
+
node: accessModifier.map(Syntax.init) ?? Syntax(classDecl.classKeyword),
|
|
28
|
+
message: PublicAccessDiagnosticMessage(
|
|
29
|
+
"\(macroName) class '\(name)' should be public or open, so that the modules provider that autolinking generates in the app target can \(reason)",
|
|
30
|
+
id: "\(idPrefix)-class-not-public"
|
|
31
|
+
),
|
|
32
|
+
fixIts: [makePublicFixIt(for: classDecl, replacing: accessModifier, idPrefix: idPrefix)]
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private let accessLevelKeywords: [TokenKind] = [
|
|
38
|
+
.keyword(.open), .keyword(.public), .keyword(.package), .keyword(.internal), .keyword(.fileprivate),
|
|
39
|
+
.keyword(.private),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/// True when the declaration is inside a function, closure or another scope that isn't a type or an extension.
|
|
43
|
+
private func isLocal(context: some MacroExpansionContext) -> Bool {
|
|
44
|
+
return context.lexicalContext.contains { node in
|
|
45
|
+
return !node.is(ClassDeclSyntax.self) && !node.is(StructDeclSyntax.self) && !node.is(EnumDeclSyntax.self)
|
|
46
|
+
&& !node.is(ActorDeclSyntax.self) && !node.is(ExtensionDeclSyntax.self)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// The fix-it for a class that isn't public: replace its access modifier with `public`, or add `public` before
|
|
51
|
+
/// its other modifiers.
|
|
52
|
+
private func makePublicFixIt(
|
|
53
|
+
for classDecl: ClassDeclSyntax,
|
|
54
|
+
replacing accessModifier: DeclModifierSyntax?,
|
|
55
|
+
idPrefix: String
|
|
56
|
+
) -> FixIt {
|
|
57
|
+
let message = PublicAccessFixItMessage("Make the class 'public'", id: "\(idPrefix)-class-insert-public")
|
|
58
|
+
if let accessModifier {
|
|
59
|
+
let newModifier = accessModifier.with(\.name, accessModifier.name.with(\.tokenKind, .keyword(.public)))
|
|
60
|
+
return FixIt(message: message, changes: [.replace(oldNode: Syntax(accessModifier), newNode: Syntax(newModifier))])
|
|
61
|
+
}
|
|
62
|
+
// The new modifier takes over the leading trivia of the first modifier or of `class` (the line break after the
|
|
63
|
+
// attribute), so it lands where the declaration started.
|
|
64
|
+
let newDecl: ClassDeclSyntax
|
|
65
|
+
if let firstModifier = classDecl.modifiers.first {
|
|
66
|
+
let publicModifier = DeclModifierSyntax(
|
|
67
|
+
name: .keyword(.public, leadingTrivia: firstModifier.leadingTrivia, trailingTrivia: .space)
|
|
68
|
+
)
|
|
69
|
+
var modifiers = classDecl.modifiers
|
|
70
|
+
modifiers[modifiers.startIndex] = firstModifier.with(\.leadingTrivia, [])
|
|
71
|
+
modifiers.insert(publicModifier, at: modifiers.startIndex)
|
|
72
|
+
newDecl = classDecl.with(\.modifiers, modifiers)
|
|
73
|
+
} else {
|
|
74
|
+
let classKeyword = classDecl.classKeyword
|
|
75
|
+
let publicModifier = DeclModifierSyntax(
|
|
76
|
+
name: .keyword(.public, leadingTrivia: classKeyword.leadingTrivia, trailingTrivia: .space)
|
|
77
|
+
)
|
|
78
|
+
newDecl =
|
|
79
|
+
classDecl
|
|
80
|
+
.with(\.modifiers, [publicModifier])
|
|
81
|
+
.with(\.classKeyword, classKeyword.with(\.leadingTrivia, []))
|
|
82
|
+
}
|
|
83
|
+
return FixIt(message: message, changes: [.replace(oldNode: Syntax(classDecl), newNode: Syntax(newDecl))])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private struct PublicAccessDiagnosticMessage: DiagnosticMessage {
|
|
87
|
+
let message: String
|
|
88
|
+
let diagnosticID: MessageID
|
|
89
|
+
let severity: DiagnosticSeverity = .warning
|
|
90
|
+
|
|
91
|
+
init(_ message: String, id: String) {
|
|
92
|
+
self.message = message
|
|
93
|
+
self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private struct PublicAccessFixItMessage: FixItMessage {
|
|
98
|
+
let message: String
|
|
99
|
+
let fixItID: MessageID
|
|
100
|
+
|
|
101
|
+
init(_ message: String, id: String) {
|
|
102
|
+
self.message = message
|
|
103
|
+
self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
104
|
+
}
|
|
105
|
+
}
|
package/build/types.d.ts
CHANGED
|
@@ -260,8 +260,8 @@ export interface ExportedEvent extends ExportedAccess, ExportedAvailability {
|
|
|
260
260
|
jsName: string;
|
|
261
261
|
/** The payload type, absent for a no-payload `() -> Void` event. */
|
|
262
262
|
payload?: TypeNode;
|
|
263
|
-
/**
|
|
264
|
-
|
|
263
|
+
/** How the event is dispatched: the default `scheduled`, or the `.inline` or `.blocking` option. */
|
|
264
|
+
dispatch: 'scheduled' | 'inline' | 'blocking';
|
|
265
265
|
}
|
|
266
266
|
/** The `@JS init` of a shared object: the constructor JS calls with `new`. */
|
|
267
267
|
export interface ExportedInitializer extends ExportedAccess, ExportedAvailability {
|
|
@@ -391,10 +391,16 @@ export interface ScanStats {
|
|
|
391
391
|
/** Wall-clock duration of the scan, in milliseconds. */
|
|
392
392
|
durationMs: number;
|
|
393
393
|
}
|
|
394
|
-
/**
|
|
395
|
-
|
|
394
|
+
/**
|
|
395
|
+
* One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, or a `@SharedObject`
|
|
396
|
+
* class in `sharedObjects`.
|
|
397
|
+
*/
|
|
398
|
+
export interface ScannedClass {
|
|
396
399
|
name: string;
|
|
397
|
-
/**
|
|
400
|
+
/**
|
|
401
|
+
* The resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override, else the class
|
|
402
|
+
* name.
|
|
403
|
+
*/
|
|
398
404
|
jsName: string;
|
|
399
405
|
/**
|
|
400
406
|
* The spelled access level, or `internal` when none is written. The generated provider references
|
|
@@ -403,7 +409,7 @@ export interface ScannedModule {
|
|
|
403
409
|
accessLevel: string;
|
|
404
410
|
/**
|
|
405
411
|
* The OSes whose builds include this class, resolved from the enclosing `#if` conditions. An
|
|
406
|
-
* unconditional
|
|
412
|
+
* unconditional class lists every OS. Empty means no build is known to include it, so don't
|
|
407
413
|
* register it.
|
|
408
414
|
*/
|
|
409
415
|
platforms: ScannedPlatform[];
|
|
@@ -412,7 +418,10 @@ export interface ScannedModule {
|
|
|
412
418
|
/** The `scan-modules` result. */
|
|
413
419
|
export interface ScanModulesResult {
|
|
414
420
|
schemaVersion: number;
|
|
415
|
-
|
|
421
|
+
/** The top-level `@ExpoModule` classes. */
|
|
422
|
+
modules: ScannedClass[];
|
|
423
|
+
/** The top-level `@SharedObject` classes, with the same platform rules as `modules`. */
|
|
424
|
+
sharedObjects: ScannedClass[];
|
|
416
425
|
warnings: ScanWarning[];
|
|
417
426
|
stats: ScanStats;
|
|
418
427
|
}
|
|
@@ -471,5 +480,5 @@ export interface ExpandResult {
|
|
|
471
480
|
* as a clear error instead of silently misread fields.
|
|
472
481
|
*/
|
|
473
482
|
export declare const SUPPORTED_SCAN_MODULES_SCHEMA_VERSION = 2;
|
|
474
|
-
export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION =
|
|
483
|
+
export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 11;
|
|
475
484
|
export declare const SUPPORTED_EXPAND_SCHEMA_VERSION = 1;
|