@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.
Files changed (29) hide show
  1. package/.github/resources/expo-modules-macros.svg +23 -0
  2. package/.github/workflows/publish.yml +4 -0
  3. package/.github/workflows/swift.yml +6 -0
  4. package/README.md +119 -0
  5. package/apple/ExpoModulesMacros-tool +0 -0
  6. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +3 -1
  7. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +17 -0
  8. package/apple/Sources/ExpoModulesMacros/ExpoViewMacro.swift +259 -0
  9. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +78 -2
  10. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +89 -2
  11. package/apple/Sources/ExpoModulesMacros/Plugin.swift +3 -0
  12. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +0 -47
  13. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +8 -2
  14. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +6 -0
  15. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +365 -0
  16. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +487 -0
  17. package/apple/Sources/ExpoModulesScanner/CLI.swift +8 -4
  18. package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +4 -4
  19. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +28 -7
  20. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +3 -9
  21. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +17 -7
  22. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +7 -0
  23. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +1 -0
  24. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +89 -22
  25. package/build/index.d.ts +51 -0
  26. package/build/index.js +153 -0
  27. package/build/types.d.ts +186 -0
  28. package/build/types.js +15 -0
  29. 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, platform: platform, defines: defines)
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
- --platform <os> evaluate '#if os(...)' against this platform (iOS, macOS, tvOS, ...);
90
- without it, os-conditional declarations are skipped with a warning
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 the scan's static configuration cannot answer (`canImport`, `arch`, an
58
- /// `os(...)` check with no `--platform` given, …). The affected region is treated as inactive, so
59
- /// the warning tells the consumer which declarations may have been skipped and why.
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 by the `ActiveSyntaxVisitor` base: only clauses active under the scan's
12
- /// `ScanBuildConfiguration` are visited, so a declaration inside `#if os(tvOS)` is recorded exactly
13
- /// when the scan targets tvOS. Conditions the configuration cannot answer make their region
14
- /// inactive and land in the inherited `diagnostics`, which the scan surfaces as warnings.
15
- final class DetectionVisitor: ActiveSyntaxVisitor {
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
- super.init(viewMode: .sourceAccurate, configuration: configuration)
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), or `nil` when no `--platform`
12
- /// was given, in which case `os(...)` conditions are unanswerable.
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