@expo/expo-modules-macros-plugin 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/resources/expo-modules-macros.svg +23 -0
- package/.github/workflows/publish.yml +4 -0
- package/.github/workflows/swift.yml +6 -0
- package/README.md +119 -0
- package/apple/ExpoModulesMacros-tool +0 -0
- package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +3 -1
- package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +17 -0
- package/apple/Sources/ExpoModulesMacros/ExpoViewMacro.swift +259 -0
- package/apple/Sources/ExpoModulesMacros/JSMacro.swift +78 -2
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +89 -2
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +3 -0
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +0 -47
- package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +8 -2
- package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +6 -0
- package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +365 -0
- package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +487 -0
- package/apple/Sources/ExpoModulesScanner/CLI.swift +8 -4
- package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +4 -4
- package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +28 -7
- package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +3 -9
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +17 -7
- package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +7 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +1 -0
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +89 -22
- package/build/index.d.ts +51 -0
- package/build/index.js +153 -0
- package/build/types.d.ts +186 -0
- package/build/types.js +15 -0
- package/package.json +12 -2
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SwiftDiagnostics
|
|
3
|
+
import SwiftSyntax
|
|
4
|
+
import SwiftSyntaxBuilder
|
|
5
|
+
import SwiftSyntaxMacros
|
|
6
|
+
|
|
7
|
+
/// Member + extension macro applied to a view-props type. Like `@Record`, **every stored property**
|
|
8
|
+
/// that is not `static`, `private`, `fileprivate`, `lazy` or computed is part of the surface — no
|
|
9
|
+
/// `@Field` wrapper — but unlike a record, the properties split into two kinds by their type:
|
|
10
|
+
///
|
|
11
|
+
/// - a **function-typed** property is an **event** (`var onTap: (TapEvent) -> Void`), dispatched by
|
|
12
|
+
/// name through the view's event emitter. No JS function object is ever decoded into it.
|
|
13
|
+
/// - every other property is a **value prop**, decoded from the raw props Fabric delivers.
|
|
14
|
+
///
|
|
15
|
+
/// The macro synthesizes the identity surface the batched reaction model reads:
|
|
16
|
+
///
|
|
17
|
+
/// - `PropName`: a `String`-backed `CaseIterable` enum, one case per value prop, whose raw value is
|
|
18
|
+
/// the wire key. It doubles as the string → prop translation table, so the runtime maps a raw
|
|
19
|
+
/// changed key with `PropName(rawValue:)` and names a prop in a log with `prop.rawValue`.
|
|
20
|
+
/// - `PropSet`: an `OptionSet` over `UInt64`, one bit per value prop in declaration order. This is the
|
|
21
|
+
/// membership currency: the changed set on a diff is a bitmask, so asking what changed costs no
|
|
22
|
+
/// allocation and no hashing.
|
|
23
|
+
/// - `_eventNames`: the event props' names, verbatim, for core to register at view creation.
|
|
24
|
+
/// - `typealias Diff = PropsDiff<Self>`, the nested spelling of core's one generic diff.
|
|
25
|
+
///
|
|
26
|
+
/// Author-facing shape — no conformance to spell out:
|
|
27
|
+
///
|
|
28
|
+
/// @ViewProps
|
|
29
|
+
/// struct CardProps {
|
|
30
|
+
/// var color: UIColor = .red // value prop, bit 0
|
|
31
|
+
/// var radius: CGFloat = 0 // value prop, bit 1
|
|
32
|
+
/// var onTap: (TapEvent) -> Void // event, no bit
|
|
33
|
+
/// }
|
|
34
|
+
///
|
|
35
|
+
/// The diff type itself is deliberately **not** generated. Core's generic
|
|
36
|
+
/// `PropsDiff<Props: AnyViewProps>` owns both storage (`old`/`new`/`changedProps`) and behavior
|
|
37
|
+
/// (`changed(_:)`, `oldValue(_:)`, `isInitial`, `changedNames`), reaching the per-props types through
|
|
38
|
+
/// the conformance's associated types — so the query API can grow without a macro release.
|
|
39
|
+
///
|
|
40
|
+
/// Event names are emitted **verbatim**, with no `on`-prefix stripping. This differs from `@Event` on
|
|
41
|
+
/// modules and shared objects, which strips it (`onStatusChange` → `"statusChange"`): a view event prop
|
|
42
|
+
/// is a React prop name that Fabric carries as-is, so the native and JS spellings must match exactly.
|
|
43
|
+
public struct ViewPropsMacro: MemberMacro, ExtensionMacro {
|
|
44
|
+
public static func expansion(
|
|
45
|
+
of node: AttributeSyntax,
|
|
46
|
+
providingMembersOf declaration: some DeclGroupSyntax,
|
|
47
|
+
conformingTo protocols: [TypeSyntax],
|
|
48
|
+
in context: some MacroExpansionContext
|
|
49
|
+
) throws -> [DeclSyntax] {
|
|
50
|
+
let props = try validatedViewProps(of: declaration)
|
|
51
|
+
|
|
52
|
+
var members: [DeclSyntax] = []
|
|
53
|
+
members.append(propNameEnum(valueProps: props.valueProps))
|
|
54
|
+
members.append(propSetStruct(valueProps: props.valueProps))
|
|
55
|
+
members.append(eventNamesConstant(eventProps: props.eventProps))
|
|
56
|
+
members.append(diffTypealias())
|
|
57
|
+
return members
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Auto-conforms the type to `AnyViewProps`, supplying the two requirements that can only be written
|
|
61
|
+
/// per-props: `allProps` (every bit set, the changed set on the first application) and
|
|
62
|
+
/// `propSet(for:)` (one name's bit, for folding raw changed keys into the mask).
|
|
63
|
+
///
|
|
64
|
+
/// A conformance the author already spelled out in the inheritance clause is not repeated.
|
|
65
|
+
public static func expansion(
|
|
66
|
+
of node: AttributeSyntax,
|
|
67
|
+
attachedTo declaration: some DeclGroupSyntax,
|
|
68
|
+
providingExtensionsOf type: some TypeSyntaxProtocol,
|
|
69
|
+
conformingTo protocols: [TypeSyntax],
|
|
70
|
+
in context: some MacroExpansionContext
|
|
71
|
+
) throws -> [ExtensionDeclSyntax] {
|
|
72
|
+
guard declaration.is(StructDeclSyntax.self) else {
|
|
73
|
+
return []
|
|
74
|
+
}
|
|
75
|
+
// Both roles read the same model, but a validation failure is the member macro's to report:
|
|
76
|
+
// expansion runs each role independently, so throwing here too would surface every diagnostic
|
|
77
|
+
// twice. When the model doesn't validate, the member macro has already emitted the error and
|
|
78
|
+
// there is nothing to extend.
|
|
79
|
+
guard let props = try? validatedViewProps(of: declaration) else {
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
let alreadyConforms = inheritsProtocol(named: viewPropsProtocolName, in: declaration)
|
|
83
|
+
|
|
84
|
+
// `allProps` is read on every first application of props, once per view instance, so it's a
|
|
85
|
+
// stored constant with the mask already folded rather than a computed property rebuilding an
|
|
86
|
+
// array literal per call. The macro knows the bit count, so the literal is exact: n props
|
|
87
|
+
// occupy bits 0..<n, which is the low n bits set.
|
|
88
|
+
let allPropsMask = props.valueProps.isEmpty
|
|
89
|
+
? "0"
|
|
90
|
+
: "0b" + String(repeating: "1", count: props.valueProps.count)
|
|
91
|
+
|
|
92
|
+
// With no value prop there's nothing to switch over, and an empty `switch` over an uninhabited
|
|
93
|
+
// enum doesn't compile — return the empty set instead.
|
|
94
|
+
let propSetBody: String
|
|
95
|
+
if props.valueProps.isEmpty {
|
|
96
|
+
propSetBody = " return []"
|
|
97
|
+
} else {
|
|
98
|
+
let cases = props.valueProps
|
|
99
|
+
.map { " case .\($0.name):\n return .\($0.name)" }
|
|
100
|
+
.joined(separator: "\n")
|
|
101
|
+
propSetBody = " switch name {\n\(cases)\n }"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let conformanceClause = alreadyConforms ? "" : ": \(viewPropsProtocolName)"
|
|
105
|
+
let ext: DeclSyntax = """
|
|
106
|
+
extension \(type.trimmed)\(raw: conformanceClause) {
|
|
107
|
+
public static let allProps = PropSet(rawValue: \(raw: allPropsMask))
|
|
108
|
+
|
|
109
|
+
/// `@inlinable` so core's raw-key fold can inline the lookup across the module boundary:
|
|
110
|
+
/// it runs once per changed key per props batch.
|
|
111
|
+
@inlinable
|
|
112
|
+
public static func propSet(for name: PropName) -> PropSet {
|
|
113
|
+
\(raw: propSetBody)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
"""
|
|
117
|
+
guard let extDecl = ext.as(ExtensionDeclSyntax.self) else {
|
|
118
|
+
return []
|
|
119
|
+
}
|
|
120
|
+
return [extDecl]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// The core protocol carrying the `PropName`/`PropSet` associated types plus `allProps` and
|
|
125
|
+
/// `propSet(for:)`, through which the generic `PropsDiff` reaches a specific props type.
|
|
126
|
+
private let viewPropsProtocolName = "AnyViewProps"
|
|
127
|
+
|
|
128
|
+
/// The mask is a single `UInt64`, so a props type can carry at most this many value props. Event
|
|
129
|
+
/// props take no bit and don't count.
|
|
130
|
+
private let maxValueProps = 64
|
|
131
|
+
|
|
132
|
+
// MARK: - Diagnostics
|
|
133
|
+
|
|
134
|
+
/// The error for a leftover `@Field`, attached to the attribute itself and carrying a fix-it that
|
|
135
|
+
/// deletes it. `@ViewProps` treats every stored property as a prop, so the attribute has no meaning
|
|
136
|
+
/// here; left in place it would wrap the value in `Field<T>` and the props type would decode against
|
|
137
|
+
/// the wrong type.
|
|
138
|
+
private func fieldAttributeDiagnostic(
|
|
139
|
+
for attribute: AttributeSyntax,
|
|
140
|
+
on varDecl: VariableDeclSyntax
|
|
141
|
+
) -> Diagnostic {
|
|
142
|
+
// Rebuild the attribute list without this entry, so the fix-it removes the attribute and the
|
|
143
|
+
// trivia it carried rather than leaving a blank line behind.
|
|
144
|
+
var attributes = varDecl.attributes
|
|
145
|
+
if let index = attributes.firstIndex(where: { element in
|
|
146
|
+
guard case .attribute(let candidate) = element else {
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
return candidate == attribute
|
|
150
|
+
}) {
|
|
151
|
+
attributes.remove(at: index)
|
|
152
|
+
}
|
|
153
|
+
let fixIt = FixIt(
|
|
154
|
+
message: ViewPropsFixItMessage("Remove the '@Field' attribute", id: "viewprops-remove-field"),
|
|
155
|
+
changes: [
|
|
156
|
+
.replace(
|
|
157
|
+
oldNode: Syntax(varDecl),
|
|
158
|
+
newNode: Syntax(varDecl.with(\.attributes, attributes))
|
|
159
|
+
)
|
|
160
|
+
]
|
|
161
|
+
)
|
|
162
|
+
let message = ViewPropsDiagnosticMessage(
|
|
163
|
+
"@Field is no longer used — @ViewProps treats every stored property as a prop. Remove the @Field attribute",
|
|
164
|
+
id: "viewprops-field-attribute"
|
|
165
|
+
)
|
|
166
|
+
return Diagnostic(node: attribute, message: message, fixIts: [fixIt])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// The error for an optional event prop, attached to the declared type and carrying a fix-it that
|
|
170
|
+
/// unwraps it. Events are registered once at view creation from a static name list, so there is no
|
|
171
|
+
/// shape that could express "sometimes emitted".
|
|
172
|
+
private func optionalEventDiagnostic(for type: TypeSyntax, name: String) -> Diagnostic {
|
|
173
|
+
var fixIts: [FixIt] = []
|
|
174
|
+
if let unwrapped = unwrappedOptionalType(type) {
|
|
175
|
+
fixIts.append(
|
|
176
|
+
FixIt(
|
|
177
|
+
message: ViewPropsFixItMessage("Make '\(name)' non-optional", id: "viewprops-unwrap-event"),
|
|
178
|
+
changes: [
|
|
179
|
+
.replace(
|
|
180
|
+
oldNode: Syntax(type),
|
|
181
|
+
newNode: Syntax(unwrapped.with(\.trailingTrivia, type.trailingTrivia))
|
|
182
|
+
)
|
|
183
|
+
]
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
let message = ViewPropsDiagnosticMessage(
|
|
188
|
+
"Event props cannot be optional — '\(name)' is registered once at view creation, so its presence can't vary. Drop the '?'",
|
|
189
|
+
id: "viewprops-optional-event"
|
|
190
|
+
)
|
|
191
|
+
return Diagnostic(node: type, message: message, fixIts: fixIts)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// The type an optional wraps, with the enclosing parentheses of a parenthesized function type kept
|
|
195
|
+
/// (`(() -> Void)?` unwraps to `() -> Void`, not to a stray `(() -> Void)`). Returns `nil` for a
|
|
196
|
+
/// spelling the fix-it can't rewrite mechanically.
|
|
197
|
+
private func unwrappedOptionalType(_ type: TypeSyntax) -> TypeSyntax? {
|
|
198
|
+
if let optional = type.as(OptionalTypeSyntax.self) {
|
|
199
|
+
return innerFunctionType(of: optional.wrappedType) ?? optional.wrappedType
|
|
200
|
+
}
|
|
201
|
+
if let implicitlyUnwrapped = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
|
|
202
|
+
return innerFunctionType(of: implicitlyUnwrapped.wrappedType) ?? implicitlyUnwrapped.wrappedType
|
|
203
|
+
}
|
|
204
|
+
// `Optional<() -> Void>` unwraps to its single generic argument.
|
|
205
|
+
if let identifier = type.as(IdentifierTypeSyntax.self),
|
|
206
|
+
identifier.name.text == "Optional",
|
|
207
|
+
let argument = identifier.genericArgumentClause?.arguments.first,
|
|
208
|
+
case .type(let wrapped) = argument.argument {
|
|
209
|
+
return wrapped
|
|
210
|
+
}
|
|
211
|
+
return nil
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// The function type inside a single-element parenthesized type, so unwrapping `(() -> Void)?`
|
|
215
|
+
/// yields `() -> Void` rather than keeping the now-redundant parentheses.
|
|
216
|
+
private func innerFunctionType(of type: TypeSyntax) -> TypeSyntax? {
|
|
217
|
+
guard let tuple = type.as(TupleTypeSyntax.self),
|
|
218
|
+
tuple.elements.count == 1,
|
|
219
|
+
let only = tuple.elements.first,
|
|
220
|
+
only.type.is(FunctionTypeSyntax.self) else {
|
|
221
|
+
return nil
|
|
222
|
+
}
|
|
223
|
+
return only.type
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private struct ViewPropsDiagnosticMessage: DiagnosticMessage {
|
|
227
|
+
let message: String
|
|
228
|
+
let diagnosticID: MessageID
|
|
229
|
+
let severity: DiagnosticSeverity = .error
|
|
230
|
+
|
|
231
|
+
init(_ message: String, id: String) {
|
|
232
|
+
self.message = message
|
|
233
|
+
self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private struct ViewPropsFixItMessage: FixItMessage {
|
|
238
|
+
let message: String
|
|
239
|
+
let fixItID: MessageID
|
|
240
|
+
|
|
241
|
+
init(_ message: String, id: String) {
|
|
242
|
+
self.message = message
|
|
243
|
+
self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// MARK: - Property model
|
|
248
|
+
|
|
249
|
+
/// One stored property of the props type, classified as a value prop or an event.
|
|
250
|
+
private struct ViewProp {
|
|
251
|
+
/// The property name as written, backticks included for an escaped name. Every identifier position
|
|
252
|
+
/// in the generated code uses this, since `case default` and `static let default` don't parse.
|
|
253
|
+
let name: String
|
|
254
|
+
/// The property's declared type, verbatim. Retained for the decode surface, which lands with the
|
|
255
|
+
/// core props contract.
|
|
256
|
+
let type: String
|
|
257
|
+
|
|
258
|
+
/// The name with any escaping backticks removed: the JS-visible key, so the enum's raw value and
|
|
259
|
+
/// `_eventNames` both read `"default"`, never `` "`default`" ``.
|
|
260
|
+
var wireName: String {
|
|
261
|
+
return name.trimmingCharacters(in: CharacterSet(charactersIn: "`"))
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private struct ViewPropsModel {
|
|
266
|
+
/// Value props, in declaration order. The order is the bit order, so it is part of the ABI
|
|
267
|
+
/// between a compiled view and the runtime that fills the mask.
|
|
268
|
+
let valueProps: [ViewProp]
|
|
269
|
+
/// Event props, in declaration order.
|
|
270
|
+
let eventProps: [ViewProp]
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// Reads and validates the props type's stored properties, splitting them into value props and events.
|
|
274
|
+
///
|
|
275
|
+
/// Rejects what can't be expressed: a non-`struct` declaration (the class form is the SwiftUI path and
|
|
276
|
+
/// needs the observable protocol, which is deferred), a leftover `@Field` attribute, an optional
|
|
277
|
+
/// function type, a property with no determinable type, and more than 64 value props.
|
|
278
|
+
private func validatedViewProps(of declaration: some DeclGroupSyntax) throws -> ViewPropsModel {
|
|
279
|
+
guard let structDecl = declaration.as(StructDeclSyntax.self) else {
|
|
280
|
+
throw MacroExpansionErrorMessage(
|
|
281
|
+
"@ViewProps can only be applied to a struct — the class form (for SwiftUI views) is not supported yet"
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
// A generic props type can't work: the synthesized extension would have to repeat the generic
|
|
285
|
+
// parameter list and its constraints, and core reaches the props type through a view's static
|
|
286
|
+
// `Props` typealias, which names one concrete type.
|
|
287
|
+
if structDecl.genericParameterClause != nil {
|
|
288
|
+
throw MacroExpansionErrorMessage(
|
|
289
|
+
"@ViewProps does not support generic types — a view's props type must be concrete"
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
var valueProps: [ViewProp] = []
|
|
294
|
+
var eventProps: [ViewProp] = []
|
|
295
|
+
|
|
296
|
+
for member in declaration.memberBlock.members {
|
|
297
|
+
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else {
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
if isExcludedByModifier(varDecl.modifiers) {
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
// `@Field` is the v1 property wrapper and has no meaning here — every stored property is already
|
|
304
|
+
// part of the surface. Left in place it would wrap the value in `Field<T>`, so the props type
|
|
305
|
+
// would decode against the wrong type. Flag it rather than emit code built on it.
|
|
306
|
+
if let fieldAttribute = varDecl.attributes.firstAttribute(named: "Field") {
|
|
307
|
+
throw DiagnosticsError(diagnostics: [fieldAttributeDiagnostic(for: fieldAttribute, on: varDecl)])
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for binding in varDecl.bindings {
|
|
311
|
+
// A computed property is never a prop, but an accessor block alone doesn't mean computed:
|
|
312
|
+
// `willSet`/`didSet` observers imply stored storage. `bindingIsSettable` draws exactly that
|
|
313
|
+
// line, so a stored property with observers stays part of the surface.
|
|
314
|
+
if binding.accessorBlock != nil && !bindingIsSettable(binding) {
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
// A tuple-destructuring binding (`var (a, b) = (1, 2)`) has no single name to key a prop on,
|
|
318
|
+
// and silently dropping it would leave the props type missing fields the author declared.
|
|
319
|
+
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
|
320
|
+
if binding.pattern.is(TuplePatternSyntax.self) {
|
|
321
|
+
throw MacroExpansionErrorMessage(
|
|
322
|
+
"@ViewProps does not support tuple-destructuring properties — declare each prop separately"
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
// An escaped name (`` var `default`: Int ``) needs both spellings: `identifier.text` keeps the
|
|
328
|
+
// backticks, which every identifier position requires (`case \`default\``), while the wire key
|
|
329
|
+
// and `_eventNames` need the bare name.
|
|
330
|
+
let name = ident.identifier.text
|
|
331
|
+
|
|
332
|
+
// Prefer the explicit annotation. When it's omitted, recover the type from a literal default
|
|
333
|
+
// (`var title = ""`). Anything a syntactic macro can't resolve still needs an annotation,
|
|
334
|
+
// since the decode surface names the type.
|
|
335
|
+
let declaredType = binding.typeAnnotation?.type
|
|
336
|
+
let resolvedType = declaredType?.trimmedDescription
|
|
337
|
+
?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
|
|
338
|
+
guard let resolvedType else {
|
|
339
|
+
throw MacroExpansionErrorMessage(
|
|
340
|
+
"@ViewProps props must declare an explicit type — '\(name)' has none"
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// A function type means an event. An *optional* function type would make the event's presence
|
|
345
|
+
// dynamic, but events are registered once at view creation from a static name list, so there
|
|
346
|
+
// is no shape that could express "sometimes emitted".
|
|
347
|
+
if let declaredType, isOptionalType(declaredType), underlyingFunctionType(of: declaredType) != nil {
|
|
348
|
+
throw DiagnosticsError(diagnostics: [
|
|
349
|
+
optionalEventDiagnostic(for: declaredType, name: name)
|
|
350
|
+
])
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let isEvent = declaredType.map { underlyingFunctionType(of: $0) != nil } ?? false
|
|
354
|
+
// `PropSet` stores its mask in `rawValue`, so a value prop of that name would emit a static
|
|
355
|
+
// member shadowing it and the option set would not compile ("circular reference"). The error
|
|
356
|
+
// would point at generated code, so catch it here and name the property.
|
|
357
|
+
if !isEvent && name.trimmingCharacters(in: CharacterSet(charactersIn: "`")) == "rawValue" {
|
|
358
|
+
throw MacroExpansionErrorMessage(
|
|
359
|
+
"'rawValue' can't be used as a prop name — it collides with the synthesized PropSet's storage. Rename the property"
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
let prop = ViewProp(name: name, type: resolvedType)
|
|
363
|
+
if isEvent {
|
|
364
|
+
eventProps.append(prop)
|
|
365
|
+
} else {
|
|
366
|
+
valueProps.append(prop)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
guard valueProps.count <= maxValueProps else {
|
|
372
|
+
throw MacroExpansionErrorMessage(
|
|
373
|
+
"@ViewProps supports at most \(maxValueProps) value props (the changed-props mask is a UInt64), but this type declares \(valueProps.count); '\(valueProps[maxValueProps].name)' is the first over the limit. Event props don't count toward it"
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
return ViewPropsModel(valueProps: valueProps, eventProps: eventProps)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/// The function type a props property declares, unwrapping any number of enclosing parentheses and an
|
|
380
|
+
/// optional wrapper, or `nil` when the type isn't a function. `@Sendable`/`@escaping` and other
|
|
381
|
+
/// attributed forms unwrap to the function type underneath.
|
|
382
|
+
private func underlyingFunctionType(of type: TypeSyntax) -> FunctionTypeSyntax? {
|
|
383
|
+
if let functionType = type.as(FunctionTypeSyntax.self) {
|
|
384
|
+
return functionType
|
|
385
|
+
}
|
|
386
|
+
if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1, let only = tuple.elements.first {
|
|
387
|
+
return underlyingFunctionType(of: only.type)
|
|
388
|
+
}
|
|
389
|
+
if let attributed = type.as(AttributedTypeSyntax.self) {
|
|
390
|
+
return underlyingFunctionType(of: attributed.baseType)
|
|
391
|
+
}
|
|
392
|
+
if let optional = type.as(OptionalTypeSyntax.self) {
|
|
393
|
+
return underlyingFunctionType(of: optional.wrappedType)
|
|
394
|
+
}
|
|
395
|
+
if let implicitlyUnwrapped = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
|
|
396
|
+
return underlyingFunctionType(of: implicitlyUnwrapped.wrappedType)
|
|
397
|
+
}
|
|
398
|
+
// The long spelling of an optional: `Optional<() -> Void>` has to reach the same diagnostic as
|
|
399
|
+
// `(() -> Void)?` and `(() -> Void)!`, or the same declaration would be an event in one spelling
|
|
400
|
+
// and a value prop in another.
|
|
401
|
+
if let identifier = type.as(IdentifierTypeSyntax.self),
|
|
402
|
+
identifier.name.text == "Optional",
|
|
403
|
+
let argument = identifier.genericArgumentClause?.arguments.first,
|
|
404
|
+
case .type(let wrapped) = argument.argument {
|
|
405
|
+
return underlyingFunctionType(of: wrapped)
|
|
406
|
+
}
|
|
407
|
+
return nil
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// MARK: - Synthesized members
|
|
411
|
+
|
|
412
|
+
/// The `PropName` enum: one case per value prop, `String`-backed so the raw value is the wire key.
|
|
413
|
+
/// Emitted even when empty (an uninhabited enum is legal and keeps the conformance's associated type
|
|
414
|
+
/// satisfied), so a props type with only events still conforms.
|
|
415
|
+
private func propNameEnum(valueProps: [ViewProp]) -> DeclSyntax {
|
|
416
|
+
// An escaped case takes its raw value from the bare identifier already, but spelling it out keeps
|
|
417
|
+
// the wire key visible in the generated source and independent of that implicit rule.
|
|
418
|
+
let cases = valueProps
|
|
419
|
+
.map { prop in
|
|
420
|
+
prop.name == prop.wireName
|
|
421
|
+
? " case \(prop.name)"
|
|
422
|
+
: " case \(prop.name) = \"\(prop.wireName)\""
|
|
423
|
+
}
|
|
424
|
+
.joined(separator: "\n")
|
|
425
|
+
if valueProps.isEmpty {
|
|
426
|
+
return """
|
|
427
|
+
public enum PropName: String, CaseIterable {
|
|
428
|
+
}
|
|
429
|
+
"""
|
|
430
|
+
}
|
|
431
|
+
return """
|
|
432
|
+
public enum PropName: String, CaseIterable {
|
|
433
|
+
\(raw: cases)
|
|
434
|
+
}
|
|
435
|
+
"""
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/// The `PropSet` option set: one static member per value prop, at its declaration-order bit. This is
|
|
439
|
+
/// what a diff's `changedProps` holds, and what `changed(_:)` tests against, so the whole changed set
|
|
440
|
+
/// is one machine word.
|
|
441
|
+
private func propSetStruct(valueProps: [ViewProp]) -> DeclSyntax {
|
|
442
|
+
let members = valueProps.enumerated()
|
|
443
|
+
.map { index, prop in
|
|
444
|
+
" public static let \(prop.name) = PropSet(rawValue: 1 << \(index))"
|
|
445
|
+
}
|
|
446
|
+
.joined(separator: "\n")
|
|
447
|
+
|
|
448
|
+
if valueProps.isEmpty {
|
|
449
|
+
return """
|
|
450
|
+
public struct PropSet: OptionSet, Sendable {
|
|
451
|
+
public let rawValue: UInt64
|
|
452
|
+
|
|
453
|
+
public init(rawValue: UInt64) {
|
|
454
|
+
self.rawValue = rawValue
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
"""
|
|
458
|
+
}
|
|
459
|
+
return """
|
|
460
|
+
public struct PropSet: OptionSet, Sendable {
|
|
461
|
+
public let rawValue: UInt64
|
|
462
|
+
|
|
463
|
+
public init(rawValue: UInt64) {
|
|
464
|
+
self.rawValue = rawValue
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
\(raw: members)
|
|
468
|
+
}
|
|
469
|
+
"""
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/// The event props' names, verbatim, for core to register at view creation. Verbatim because a view
|
|
473
|
+
/// event prop is a React prop name Fabric carries as-is — unlike `@Event` on a module, which strips
|
|
474
|
+
/// the `on` prefix.
|
|
475
|
+
private func eventNamesConstant(eventProps: [ViewProp]) -> DeclSyntax {
|
|
476
|
+
let names = eventProps.map { "\"\($0.wireName)\"" }.joined(separator: ", ")
|
|
477
|
+
return """
|
|
478
|
+
public static let _eventNames: [String] = [\(raw: names)]
|
|
479
|
+
"""
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/// The nested spelling of core's one generic diff, so a view writes `MyProps.Diff`.
|
|
483
|
+
private func diffTypealias() -> DeclSyntax {
|
|
484
|
+
return """
|
|
485
|
+
public typealias Diff = PropsDiff<Self>
|
|
486
|
+
"""
|
|
487
|
+
}
|
|
@@ -49,10 +49,15 @@ public enum ScannerCLI {
|
|
|
49
49
|
|
|
50
50
|
switch subcommand {
|
|
51
51
|
case "scan-modules":
|
|
52
|
+
// scan-modules is platform-agnostic: each reported module carries the platforms that include
|
|
53
|
+
// it, and the consumer filters. An option selecting one platform would silently drop data.
|
|
54
|
+
guard platform == nil else {
|
|
55
|
+
return usageError("scan-modules does not take --platform; each module reports its 'platforms' and the consumer filters")
|
|
56
|
+
}
|
|
52
57
|
guard !paths.isEmpty else {
|
|
53
58
|
return usageError("scan-modules requires at least one path")
|
|
54
59
|
}
|
|
55
|
-
return Scanner.runModules(paths: paths,
|
|
60
|
+
return Scanner.runModules(paths: paths, defines: defines)
|
|
56
61
|
|
|
57
62
|
case "scan-exports":
|
|
58
63
|
// The exports surface visitor doesn't evaluate `#if` blocks yet, so accepting the options
|
|
@@ -86,9 +91,8 @@ private var usageText: String {
|
|
|
86
91
|
scan-exports deep scan of the full JS-exported surface (type generation)
|
|
87
92
|
|
|
88
93
|
options (scan-modules only):
|
|
89
|
-
--
|
|
90
|
-
|
|
91
|
-
--define <flag> treat a conditional compilation flag (e.g. DEBUG) as set; repeatable
|
|
94
|
+
--define <flag> treat a conditional compilation flag (e.g. DEBUG) as set when resolving
|
|
95
|
+
each module's 'platforms' list; repeatable
|
|
92
96
|
|
|
93
97
|
options:
|
|
94
98
|
-h, --help print this help and exit
|
|
@@ -54,10 +54,10 @@ struct Detection: Codable, Equatable {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/// A non-fatal problem found while scanning, tied to the source location that caused it — today,
|
|
57
|
-
/// an `#if` condition
|
|
58
|
-
/// `
|
|
59
|
-
///
|
|
60
|
-
struct ScanWarning: Codable, Equatable {
|
|
57
|
+
/// an `#if` condition a static scan cannot answer (`canImport` of a non-SDK module, `arch`,
|
|
58
|
+
/// `targetEnvironment`, …). The affected region counts as inactive on every platform, so the
|
|
59
|
+
/// warning tells the consumer which declarations may be missing platforms and why.
|
|
60
|
+
struct ScanWarning: Codable, Equatable, Hashable {
|
|
61
61
|
let message: String
|
|
62
62
|
let file: String
|
|
63
63
|
let line: Int
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import SwiftDiagnostics
|
|
1
2
|
import SwiftIfConfig
|
|
2
3
|
import SwiftSyntax
|
|
3
4
|
|
|
@@ -8,28 +9,34 @@ import SwiftSyntax
|
|
|
8
9
|
/// name — so it sees the same declarations the compiler would hand the plugin, without compiling
|
|
9
10
|
/// anything.
|
|
10
11
|
///
|
|
11
|
-
/// `#if` blocks are handled
|
|
12
|
-
/// `
|
|
13
|
-
/// when the scan targets tvOS
|
|
14
|
-
/// inactive and
|
|
15
|
-
|
|
12
|
+
/// `#if` blocks are handled according to the scan's configuration. With a `ScanBuildConfiguration`,
|
|
13
|
+
/// only the active clause of each `#if` is visited, so a declaration inside `#if os(tvOS)` is
|
|
14
|
+
/// recorded exactly when the scan targets tvOS, and conditions the configuration cannot answer make
|
|
15
|
+
/// their region inactive and surface as warnings. With a `nil` configuration every clause of every
|
|
16
|
+
/// `#if` is visited, so a declaration in any branch is recorded and nothing is evaluated; the
|
|
17
|
+
/// per-platform aggregation in `scanModules` builds on both walks.
|
|
18
|
+
final class DetectionVisitor: SyntaxVisitor {
|
|
16
19
|
private let file: String
|
|
17
20
|
private let converter: SourceLocationConverter
|
|
18
21
|
/// Only these macros are recorded; the rest are ignored. Lets a `modules` scan report just
|
|
19
22
|
/// `@ExpoModule` while an `exports` scan covers them all.
|
|
20
23
|
private let detectedMacros: Set<DetectedMacro>
|
|
24
|
+
/// The configuration `#if` conditions are evaluated against, or `nil` to visit every branch.
|
|
25
|
+
private let configuration: ScanBuildConfiguration?
|
|
26
|
+
private var diagnostics: [Diagnostic] = []
|
|
21
27
|
private(set) var detections: [Detection] = []
|
|
22
28
|
|
|
23
29
|
init(
|
|
24
30
|
file: String,
|
|
25
31
|
tree: SourceFileSyntax,
|
|
26
32
|
detectedMacros: Set<DetectedMacro>,
|
|
27
|
-
configuration: ScanBuildConfiguration
|
|
33
|
+
configuration: ScanBuildConfiguration?
|
|
28
34
|
) {
|
|
29
35
|
self.file = file
|
|
30
36
|
self.converter = SourceLocationConverter(fileName: file, tree: tree)
|
|
31
37
|
self.detectedMacros = detectedMacros
|
|
32
|
-
|
|
38
|
+
self.configuration = configuration
|
|
39
|
+
super.init(viewMode: .sourceAccurate)
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
/// The accumulated `#if` warnings as `Detection`-style locations plus the message, ready for the
|
|
@@ -41,6 +48,20 @@ final class DetectionVisitor: ActiveSyntaxVisitor {
|
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
52
|
+
guard let configuration else {
|
|
53
|
+
// Every branch is visited, so a declaration in any clause is recorded.
|
|
54
|
+
return .visitChildren
|
|
55
|
+
}
|
|
56
|
+
let (clause, clauseDiagnostics) = node.activeClause(in: configuration)
|
|
57
|
+
diagnostics.append(contentsOf: clauseDiagnostics)
|
|
58
|
+
if let elements = clause?.elements {
|
|
59
|
+
// A nested `#if` inside the active clause comes back through this method.
|
|
60
|
+
walk(elements)
|
|
61
|
+
}
|
|
62
|
+
return .skipChildren
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
45
66
|
if isTopLevel(node) {
|
|
46
67
|
record(attributes: node.attributes, modifiers: node.modifiers, name: node.name.text, kind: "class", at: node)
|
|
@@ -8,9 +8,9 @@ import SwiftSyntax
|
|
|
8
8
|
/// its declarations and surfaces a warning instead of guessing.
|
|
9
9
|
struct ScanBuildConfiguration: BuildConfiguration {
|
|
10
10
|
/// The target OS name to answer `os(...)` with, as spelled in the condition (`iOS`, `macOS`,
|
|
11
|
-
/// `tvOS`, `watchOS`, `visionOS`; compared case-insensitively)
|
|
12
|
-
///
|
|
13
|
-
let platform: String
|
|
11
|
+
/// `tvOS`, `watchOS`, `visionOS`; compared case-insensitively). A scan without a target OS runs
|
|
12
|
+
/// without a configuration at all (the platform-agnostic union scan), so this is always known.
|
|
13
|
+
let platform: String
|
|
14
14
|
|
|
15
15
|
/// The conditional compilation flags treated as set, from repeated `--define` options.
|
|
16
16
|
let defines: Set<String>
|
|
@@ -20,9 +20,6 @@ struct ScanBuildConfiguration: BuildConfiguration {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
func isActiveTargetOS(name: String) throws -> Bool {
|
|
23
|
-
guard let platform else {
|
|
24
|
-
throw ScanConfigurationError("cannot evaluate 'os(\(name))': no --platform was given")
|
|
25
|
-
}
|
|
26
23
|
return name.lowercased() == platform.lowercased()
|
|
27
24
|
}
|
|
28
25
|
|
|
@@ -51,9 +48,6 @@ struct ScanBuildConfiguration: BuildConfiguration {
|
|
|
51
48
|
let frameworkPlatforms = sdkFrameworkPlatforms[module] else {
|
|
52
49
|
throw ScanConfigurationError("cannot evaluate 'canImport(\(module))' in a static scan")
|
|
53
50
|
}
|
|
54
|
-
guard let platform else {
|
|
55
|
-
throw ScanConfigurationError("cannot evaluate 'canImport(\(module))': no --platform was given")
|
|
56
|
-
}
|
|
57
51
|
return frameworkPlatforms.contains(platform.lowercased())
|
|
58
52
|
}
|
|
59
53
|
|