expo-modules-macros 0.0.1 → 0.14.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 (46) hide show
  1. package/.github/resources/expo-modules-macros.svg +23 -0
  2. package/.github/workflows/publish.yml +122 -0
  3. package/.github/workflows/swift.yml +68 -0
  4. package/AGENTS.md +59 -0
  5. package/CLAUDE.md +1 -0
  6. package/LICENSE +21 -0
  7. package/README.md +119 -0
  8. package/apple/ExpoModulesMacros +0 -0
  9. package/apple/Package.resolved +15 -0
  10. package/apple/Package.swift +61 -0
  11. package/apple/PodTests/StubTest.swift +5 -0
  12. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +444 -0
  13. package/apple/Sources/ExpoModulesMacros/EventMacro.swift +330 -0
  14. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +263 -0
  15. package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +117 -0
  16. package/apple/Sources/ExpoModulesMacros/ExpoViewMacro.swift +259 -0
  17. package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +214 -0
  18. package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +55 -0
  19. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +294 -0
  20. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +433 -0
  21. package/apple/Sources/ExpoModulesMacros/OptimizedFunctionHelpers.swift +55 -0
  22. package/apple/Sources/ExpoModulesMacros/Plugin.swift +34 -0
  23. package/apple/Sources/ExpoModulesMacros/Receiver.swift +79 -0
  24. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +590 -0
  25. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +162 -0
  26. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +190 -0
  27. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +365 -0
  28. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +477 -0
  29. package/apple/Sources/ExpoModulesOptimized/ExpoModulesOptimized.swift +19 -0
  30. package/apple/Sources/ExpoModulesScanner/CLI.swift +114 -0
  31. package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +79 -0
  32. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +181 -0
  33. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +115 -0
  34. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +193 -0
  35. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +319 -0
  36. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +199 -0
  37. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +66 -0
  38. package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +616 -0
  39. package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +284 -0
  40. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +161 -0
  41. package/apple/build.js +138 -0
  42. package/build/index.d.ts +51 -0
  43. package/build/index.js +153 -0
  44. package/build/types.d.ts +264 -0
  45. package/build/types.js +15 -0
  46. package/package.json +22 -5
@@ -0,0 +1,616 @@
1
+ import SwiftSyntax
2
+
3
+ /// Extracts the full JS-exported surface of every top-level `@ExpoModule`, `@SharedObject`, and
4
+ /// `@Record` type: their `@JS` members and record properties. The deep counterpart to
5
+ /// `DetectionVisitor`. Recognition is purely syntactic and re-reads what the macros read (the macro
6
+ /// target can't be imported), so it stays in step with `JSFunction` / `JSProperty` / `JSConstructor` /
7
+ /// `RecordProperty`.
8
+ final class SurfaceVisitor: SyntaxVisitor {
9
+ private let file: String
10
+ private(set) var modules: [ExportedModule] = []
11
+ private(set) var sharedObjects: [ExportedSharedObject] = []
12
+ private(set) var records: [ExportedRecord] = []
13
+ private(set) var enums: [ExportedEnum] = []
14
+ private(set) var unions: [ExportedUnion] = []
15
+
16
+ init(file: String) {
17
+ self.file = file
18
+ super.init(viewMode: .sourceAccurate)
19
+ }
20
+
21
+ override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
22
+ if isTopLevel(node) {
23
+ classify(name: node.name.text, attributes: node.attributes, members: node.memberBlock.members)
24
+ }
25
+ // The member walk reads the body itself; nested types aren't part of this surface (matching
26
+ // `DetectionVisitor`'s top-level-only scope), so there's no reason to descend.
27
+ return .skipChildren
28
+ }
29
+
30
+ override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
31
+ if isTopLevel(node) {
32
+ classify(name: node.name.text, attributes: node.attributes, members: node.memberBlock.members)
33
+ }
34
+ return .skipChildren
35
+ }
36
+
37
+ /// The two kinds of enum the surface reports: a `@Union` by its attribute, an `Enumerable` enum by
38
+ /// its conformance (core converts it with no macro involved, so there is no attribute to key on).
39
+ ///
40
+ /// `@Union` wins when a type carries both. Its cases hold payloads rather than raw values, so there
41
+ /// would be nothing to report as an enum, and listing it in both arrays would describe two
42
+ /// contradictory JS types for one declaration.
43
+ override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
44
+ guard isTopLevel(node) else {
45
+ return .skipChildren
46
+ }
47
+
48
+ if node.attributes.firstAttribute(named: DetectedMacro.union.rawValue) != nil {
49
+ unions.append(
50
+ ExportedUnion(
51
+ name: node.name.text,
52
+ members: collectUnionMembers(node.memberBlock.members),
53
+ file: file
54
+ ))
55
+ } else if inherits(from: enumerableConformanceName, in: node.inheritanceClause) {
56
+ let rawType = rawValueType(of: node.inheritanceClause)
57
+ enums.append(
58
+ ExportedEnum(
59
+ name: node.name.text,
60
+ rawType: rawType,
61
+ cases: collectEnumCases(node.memberBlock.members, rawType: rawType),
62
+ file: file
63
+ ))
64
+ }
65
+ return .skipChildren
66
+ }
67
+
68
+ /// Routes a top-level type to the right collector based on which Expo macro it carries. A type
69
+ /// carrying none of them is ignored. `@Record` and `@ExpoModule`/`@SharedObject` are mutually
70
+ /// exclusive in practice, so the first match wins.
71
+ private func classify(name: String, attributes: AttributeListSyntax, members: MemberBlockItemListSyntax) {
72
+ if let attribute = attributes.firstAttribute(named: DetectedMacro.expoModule.rawValue) {
73
+ let (functions, properties, events, _) = collectJSMembers(members)
74
+ modules.append(
75
+ ExportedModule(
76
+ name: name,
77
+ jsName: stringArgument(of: attribute) ?? name,
78
+ functions: functions,
79
+ properties: properties,
80
+ events: events,
81
+ file: file
82
+ ))
83
+ return
84
+ }
85
+
86
+ if let attribute = attributes.firstAttribute(named: DetectedMacro.sharedObject.rawValue) {
87
+ let (functions, properties, events, constructor) = collectJSMembers(members)
88
+ sharedObjects.append(
89
+ ExportedSharedObject(
90
+ name: name,
91
+ jsName: stringArgument(of: attribute) ?? name,
92
+ constructorParameters: constructor,
93
+ functions: functions,
94
+ properties: properties,
95
+ events: events,
96
+ file: file
97
+ ))
98
+ return
99
+ }
100
+
101
+ if attributes.firstAttribute(named: DetectedMacro.record.rawValue) != nil {
102
+ records.append(ExportedRecord(name: name, properties: collectRecordProperties(members), file: file))
103
+ }
104
+ }
105
+
106
+ /// The exported members of a module / shared-object body: `@JS` functions and properties, `@Event`
107
+ /// events, and the single `@JS init` constructor parameters (`nil` when absent).
108
+ private func collectJSMembers(
109
+ _ members: MemberBlockItemListSyntax
110
+ ) -> (
111
+ functions: [ExportedFunction], properties: [ExportedProperty], events: [ExportedEvent],
112
+ constructor: [ExportedParameter]?
113
+ ) {
114
+ var functions: [ExportedFunction] = []
115
+ var properties: [ExportedProperty] = []
116
+ var events: [ExportedEvent] = []
117
+ var constructor: [ExportedParameter]?
118
+
119
+ for member in members {
120
+ let decl = member.decl
121
+
122
+ if let initDecl = decl.as(InitializerDeclSyntax.self),
123
+ initDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) != nil {
124
+ // At most one `@JS init`; keep the first if a malformed source has more (the macro errors).
125
+ if constructor == nil {
126
+ constructor = parameters(of: initDecl.signature.parameterClause)
127
+ }
128
+ continue
129
+ }
130
+
131
+ if let funcDecl = decl.as(FunctionDeclSyntax.self),
132
+ let attribute = funcDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) {
133
+ functions.append(makeFunction(funcDecl: funcDecl, attribute: attribute))
134
+ continue
135
+ }
136
+
137
+ if let varDecl = decl.as(VariableDeclSyntax.self),
138
+ let attribute = varDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) {
139
+ properties.append(contentsOf: makeProperties(varDecl: varDecl, attribute: attribute))
140
+ continue
141
+ }
142
+
143
+ if let varDecl = decl.as(VariableDeclSyntax.self),
144
+ let attribute = varDecl.attributes.firstAttribute(named: DetectedMacro.event.rawValue) {
145
+ events.append(contentsOf: makeEvents(varDecl: varDecl, attribute: attribute))
146
+ }
147
+ }
148
+
149
+ return (functions, properties, events, constructor)
150
+ }
151
+
152
+ /// Builds the `ExportedEvent` entries for an `@Event var`, applying the same checks
153
+ /// `EventMacro.validatedEvent(of:on:)` does. A rejected declaration expands to no event, so
154
+ /// reporting one would describe a surface that does not exist. `@JS` on the same property is
155
+ /// caught by the caller, which reaches the `@JS` branch first.
156
+ private func makeEvents(varDecl: VariableDeclSyntax, attribute: AttributeSyntax) -> [ExportedEvent] {
157
+ guard varDecl.bindingSpecifier.tokenKind != .keyword(.let),
158
+ !isTypeLevel(varDecl.modifiers) else {
159
+ return []
160
+ }
161
+ let override = stringArgument(of: attribute)
162
+ let isSync = boolArgument(of: attribute, label: "sync") == true
163
+ var result: [ExportedEvent] = []
164
+
165
+ for binding in varDecl.bindings {
166
+ // Binding-level checks, in the macro's order: a named binding with a function type that takes
167
+ // at most one payload and returns Void, with no initializer or hand-written accessors.
168
+ guard let ident = binding.pattern.as(IdentifierPatternSyntax.self),
169
+ binding.initializer == nil,
170
+ binding.accessorBlock == nil,
171
+ let functionType = underlyingFunctionType(of: binding.typeAnnotation?.type),
172
+ isVoidEventReturn(functionType.returnClause.type),
173
+ functionType.parameters.count <= 1 else {
174
+ continue
175
+ }
176
+ let name = ident.identifier.text
177
+ result.append(
178
+ ExportedEvent(
179
+ name: name,
180
+ jsName: override ?? defaultEventName(for: name),
181
+ payload: functionType.parameters.first.map { typeNode(from: $0.type) },
182
+ isSync: isSync
183
+ ))
184
+ }
185
+ return result
186
+ }
187
+
188
+ /// Builds an `ExportedFunction` from a `@JS func`: JS-name fallback, parameters, a `Void` return as
189
+ /// `nil`, and the effect/static flags.
190
+ private func makeFunction(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) -> ExportedFunction {
191
+ let effects = funcDecl.signature.effectSpecifiers
192
+ let returnType = funcDecl.signature.returnClause?.type
193
+ return ExportedFunction(
194
+ name: funcDecl.name.text,
195
+ jsName: stringArgument(of: attribute) ?? funcDecl.name.text,
196
+ parameters: parameters(of: funcDecl.signature.parameterClause),
197
+ returns: isVoidType(returnType) ? nil : returnType.map { typeNode(from: $0) },
198
+ isAsync: effects?.asyncSpecifier != nil,
199
+ isThrowing: effects?.throwsClause?.throwsSpecifier != nil,
200
+ isStatic: isTypeLevel(funcDecl.modifiers)
201
+ )
202
+ }
203
+
204
+ /// Builds the `ExportedProperty` entries for a `@JS var`/`let`. One declaration can introduce several
205
+ /// bindings (`var a, b: Int`), so this returns an array. The value type is the annotation, else the
206
+ /// literal default's inferred type, else `nil`.
207
+ private func makeProperties(varDecl: VariableDeclSyntax, attribute: AttributeSyntax) -> [ExportedProperty] {
208
+ let isLet = varDecl.bindingSpecifier.tokenKind == .keyword(.let)
209
+ let isStatic = isTypeLevel(varDecl.modifiers)
210
+ let override = stringArgument(of: attribute)
211
+ var result: [ExportedProperty] = []
212
+
213
+ for binding in varDecl.bindings {
214
+ guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
215
+ continue
216
+ }
217
+ let name = ident.identifier.text
218
+ let type = valueTypeNode(annotation: binding.typeAnnotation?.type, initializer: binding.initializer?.value)
219
+ result.append(
220
+ ExportedProperty(
221
+ name: name,
222
+ jsName: override ?? name,
223
+ type: type,
224
+ isSettable: isSettable(binding: binding, isLet: isLet),
225
+ isStatic: isStatic
226
+ ))
227
+ }
228
+ return result
229
+ }
230
+
231
+ /// True when a binding is assignable from JS, mirroring the macro's `bindingIsSettable`: a `let` is
232
+ /// never settable; a stored `var` is; a computed `var` is settable iff it declares `set`/`willSet`/
233
+ /// `didSet` (a getter-only `var` is read-only).
234
+ private func isSettable(binding: PatternBindingSyntax, isLet: Bool) -> Bool {
235
+ if isLet {
236
+ return false
237
+ }
238
+ guard let accessorBlock = binding.accessorBlock else {
239
+ // Stored `var`, settable.
240
+ return true
241
+ }
242
+ switch accessorBlock.accessors {
243
+ case .accessors(let list):
244
+ return list.contains { accessor in
245
+ switch accessor.accessorSpecifier.tokenKind {
246
+ case .keyword(.set), .keyword(.willSet), .keyword(.didSet):
247
+ return true
248
+ default:
249
+ return false
250
+ }
251
+ }
252
+ case .getter:
253
+ // `var x: Int { ... }` shorthand getter, read-only.
254
+ return false
255
+ }
256
+ }
257
+
258
+ /// The `@Record` properties: every stored, non-excluded `var`/`let` binding, mirroring
259
+ /// `RecordMacro.recordProperties`. Computed and modifier-excluded bindings are skipped, as is one
260
+ /// whose type can't be determined (the macro would error, but the scan stays lenient).
261
+ private func collectRecordProperties(_ members: MemberBlockItemListSyntax) -> [ExportedRecordProperty] {
262
+ var properties: [ExportedRecordProperty] = []
263
+
264
+ for member in members {
265
+ guard let varDecl = member.decl.as(VariableDeclSyntax.self),
266
+ !isExcludedRecordModifier(varDecl.modifiers) else {
267
+ continue
268
+ }
269
+ for binding in varDecl.bindings {
270
+ if binding.accessorBlock != nil {
271
+ continue
272
+ }
273
+ guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
274
+ continue
275
+ }
276
+ let annotation = binding.typeAnnotation?.type
277
+ guard let type = valueTypeNode(annotation: annotation, initializer: binding.initializer?.value) else {
278
+ continue
279
+ }
280
+ properties.append(
281
+ ExportedRecordProperty(
282
+ name: ident.identifier.text,
283
+ type: type,
284
+ isOptional: annotation.map { isOptionalType($0) } ?? false,
285
+ hasDefault: binding.initializer != nil
286
+ ))
287
+ }
288
+ }
289
+ return properties
290
+ }
291
+
292
+ /// The declared cases of an enum, in source order. One `case` declaration can introduce several
293
+ /// cases (`case a, b`), so each element is read separately. A case carrying associated values is
294
+ /// skipped: it has no raw value, so it can't cross the boundary as one.
295
+ ///
296
+ /// A `String`-backed case with no written value takes the case's own name, so those are filled in
297
+ /// here and a `String`-backed enum reports a raw value on every case. `Int` is deliberately left
298
+ /// alone: see `derivedStringRawValue(for:rawType:)`.
299
+ private func collectEnumCases(
300
+ _ members: MemberBlockItemListSyntax,
301
+ rawType: TypeNode?
302
+ ) -> [ExportedEnumCase] {
303
+ var cases: [ExportedEnumCase] = []
304
+
305
+ for member in members {
306
+ guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
307
+ continue
308
+ }
309
+ for element in caseDecl.elements where element.parameterClause == nil {
310
+ let name = element.name.text
311
+ cases.append(
312
+ ExportedEnumCase(
313
+ name: name,
314
+ rawValue: writtenRawValue(of: element) ?? derivedStringRawValue(for: name, rawType: rawType)
315
+ ))
316
+ }
317
+ }
318
+ return cases
319
+ }
320
+
321
+ /// The alternatives of a `@Union`, in declaration order (the decode depends on it). Skips the cases
322
+ /// `UnionMacro.validatedUnion(of:)` rejects (no associated value, more than one, or a default), since
323
+ /// those don't exist at runtime. The scanner never diagnoses, it just declines to report.
324
+ ///
325
+ /// A generic `@Union` is rejected wholesale by the macro but still reported, since the declaration
326
+ /// names a type a consumer may meet.
327
+ private func collectUnionMembers(_ members: MemberBlockItemListSyntax) -> [ExportedUnionMember] {
328
+ var result: [ExportedUnionMember] = []
329
+
330
+ for member in members {
331
+ guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
332
+ continue
333
+ }
334
+ for element in caseDecl.elements {
335
+ guard let parameters = element.parameterClause?.parameters,
336
+ parameters.count == 1, let parameter = parameters.first,
337
+ parameter.defaultValue == nil else {
338
+ continue
339
+ }
340
+ result.append(
341
+ ExportedUnionMember(name: element.name.text, type: typeNode(from: parameter.type)))
342
+ }
343
+ }
344
+ return result
345
+ }
346
+
347
+ /// Projects a parameter clause into `ExportedParameter`s: label = first name, name = second (else
348
+ /// first), and `optional` when it has a default value or an optional type.
349
+ private func parameters(of clause: FunctionParameterClauseSyntax) -> [ExportedParameter] {
350
+ clause.parameters.map { parameter in
351
+ let label = parameter.firstName.text
352
+ let name = parameter.secondName?.text ?? label
353
+ return ExportedParameter(
354
+ label: label,
355
+ name: name,
356
+ type: typeNode(from: parameter.type),
357
+ isOptional: parameter.defaultValue != nil || isOptionalType(parameter.type)
358
+ )
359
+ }
360
+ }
361
+
362
+ /// True when the declaration sits at file scope. Same rule as `DetectionVisitor.isTopLevel`: its
363
+ /// parent is a `CodeBlockItemSyntax` directly under the source file's top-level item list.
364
+ private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
365
+ guard let item = node.parent?.as(CodeBlockItemSyntax.self) else {
366
+ return false
367
+ }
368
+ return item.parent?.parent?.is(SourceFileSyntax.self) == true
369
+ }
370
+ }
371
+
372
+ // MARK: - Syntactic helpers (shared spelling with the macros)
373
+
374
+ /// True when the modifiers make a member type-level (`static` or `class`).
375
+ // MARK: - `@Event` helpers
376
+
377
+ // The macro target can't be imported, so these are deliberate copies of `EventMacro`'s logic. They
378
+ // must stay in step with it: a drift changes the reported surface without failing any build.
379
+
380
+ /// The Swift name with a conventional `on` prefix stripped and the remainder decapitalized
381
+ /// (`onStatusChange` -> `statusChange`); names without the prefix pass through verbatim. A drift
382
+ /// from the macro's copy would produce listener names the module never emits.
383
+ func defaultEventName(for swiftName: String) -> String {
384
+ guard swiftName.hasPrefix("on") else {
385
+ return swiftName
386
+ }
387
+ let rest = swiftName.dropFirst(2)
388
+ guard let first = rest.first, first.isUppercase else {
389
+ return swiftName
390
+ }
391
+ return decapitalized(String(rest))
392
+ }
393
+
394
+ /// Lowercases the leading uppercase run the way Swift's API importer does: a single leading capital
395
+ /// is lowercased, and a longer acronym run keeps its last capital when a lowercase letter follows it
396
+ /// (`StatusChange` -> `statusChange`, `URLChange` -> `urlChange`, `URL` -> `url`).
397
+ private func decapitalized(_ name: String) -> String {
398
+ let runEnd = name.firstIndex { !$0.isUppercase } ?? name.endIndex
399
+ if name[..<runEnd].count > 1 && runEnd != name.endIndex {
400
+ let lastCapital = name.index(before: runEnd)
401
+ return name[..<lastCapital].lowercased() + name[lastCapital...]
402
+ }
403
+ return name[..<runEnd].lowercased() + name[runEnd...]
404
+ }
405
+
406
+ /// The function type underlying a property's type annotation, unwrapping attributes
407
+ /// (`@Sendable (P) -> Void`) and single-element parentheses (`((P) -> Void)`). `nil` when the
408
+ /// annotation is missing or isn't a function type.
409
+ private func underlyingFunctionType(of type: TypeSyntax?) -> FunctionTypeSyntax? {
410
+ guard let type else {
411
+ return nil
412
+ }
413
+ if let attributed = type.as(AttributedTypeSyntax.self) {
414
+ return underlyingFunctionType(of: attributed.baseType)
415
+ }
416
+ if let tuple = type.as(TupleTypeSyntax.self),
417
+ tuple.elements.count == 1, let element = tuple.elements.first, element.firstName == nil {
418
+ return underlyingFunctionType(of: element.type)
419
+ }
420
+ return type.as(FunctionTypeSyntax.self)
421
+ }
422
+
423
+ /// True when an event's function type returns `Void`. The shared `isVoidType` is not reused: it
424
+ /// accepts neither `Swift.Void` nor a parenthesized `(Void)`, so it would drop valid events.
425
+ private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
426
+ if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1,
427
+ let element = tuple.elements.first, element.firstName == nil {
428
+ return isVoidEventReturn(element.type)
429
+ }
430
+ let text = type.trimmedDescription
431
+ return text == "Void" || text == "()" || text == "Swift.Void"
432
+ }
433
+
434
+ /// The value of a labeled boolean macro argument (`@Event(sync: true)`), or `nil` when absent.
435
+ private func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
436
+ guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
437
+ return nil
438
+ }
439
+ for argument in arguments where argument.label?.text == label {
440
+ guard let literal = argument.expression.as(BooleanLiteralExprSyntax.self) else {
441
+ return nil
442
+ }
443
+ return literal.literal.tokenKind == .keyword(.true)
444
+ }
445
+ return nil
446
+ }
447
+
448
+ private func isTypeLevel(_ modifiers: DeclModifierListSyntax) -> Bool {
449
+ modifiers.contains {
450
+ $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class)
451
+ }
452
+ }
453
+
454
+ /// True when a modifier excludes a property from being a `@Record` field (`static`, `class`,
455
+ /// `private`, `fileprivate`, `lazy`), mirroring `RecordMacro.isExcludedByModifier`.
456
+ private func isExcludedRecordModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
457
+ modifiers.contains { modifier in
458
+ switch modifier.name.tokenKind {
459
+ case .keyword(.static), .keyword(.class), .keyword(.private), .keyword(.fileprivate), .keyword(.lazy):
460
+ return true
461
+ default:
462
+ return false
463
+ }
464
+ }
465
+ }
466
+
467
+ /// The protocol whose conformance marks an enum as convertible at the JS boundary. Core converts such
468
+ /// an enum through `Coding/…+Enumerable`, keyed on this conformance, so the scanner keys on it too.
469
+ let enumerableConformanceName = "Enumerable"
470
+
471
+ /// True when an inheritance clause names `name`. Matched on the trailing component of the written
472
+ /// spelling, so a qualified `ExpoModulesCore.Enumerable` counts. Purely syntactic: a conformance added
473
+ /// in a separate `extension`, or inherited through another protocol, is invisible to a scan and so is
474
+ /// not reported.
475
+ func inherits(from name: String, in clause: InheritanceClauseSyntax?) -> Bool {
476
+ guard let clause else {
477
+ return false
478
+ }
479
+ return clause.inheritedTypes.contains { inherited in
480
+ inherited.type.trimmedDescription.split(separator: ".").last.map(String.init) == name
481
+ }
482
+ }
483
+
484
+ /// The raw value a case writes, or `nil` when it writes none.
485
+ ///
486
+ /// A string literal is reported **decoded**: `case a = "act"` yields `act`, with no quotes, because a
487
+ /// `String` raw value is always fully known (see `derivedStringRawValue(for:rawType:)`) and a consumer
488
+ /// should not have to unquote it. Every other expression is reported as **source text**, since an
489
+ /// integer raw value may be any literal expression the scanner can't evaluate. Which of the two a
490
+ /// `rawValue` holds follows from the enum's `rawType`, and `ExportedEnumCase` documents that contract.
491
+ ///
492
+ /// A literal this can't decode is treated as writing no raw value rather than reported half-read: an
493
+ /// interpolated string (`case a = "x\(y)"`, not a legal raw value anyway), or one whose segment carries
494
+ /// a backslash escape, which would need real unescaping to turn into its value. A `String` case then
495
+ /// falls back to the derived name, keeping that invariant intact.
496
+ private func writtenRawValue(of element: EnumCaseElementSyntax) -> String? {
497
+ guard let value = element.rawValue?.value else {
498
+ return nil
499
+ }
500
+ guard let literal = value.as(StringLiteralExprSyntax.self) else {
501
+ // Not a string: an integer literal, a negative value, or an expression. Source text verbatim.
502
+ return value.trimmedDescription
503
+ }
504
+ guard literal.segments.count == 1,
505
+ let segment = literal.segments.first?.as(StringSegmentSyntax.self) else {
506
+ return nil
507
+ }
508
+ // The segment's text is the literal's content with its delimiters already stripped, so a plain
509
+ // `"act"` and a raw `#"act"#` both read as `act`. An escape is left to the fallback rather than
510
+ // emitted raw, since `\n` here is two characters, not a newline.
511
+ let content = segment.content.text
512
+ return content.contains("\\") ? nil : content
513
+ }
514
+
515
+ /// The raw value Swift gives a `String`-backed case that writes none: the case's own name.
516
+ ///
517
+ /// Only `String` is derived. Its defaulting is per-case and carry-free, so a case that can't be read
518
+ /// can't affect any other, and every legal spelling is a single-segment literal. That closes the case
519
+ /// and lets a `String`-backed enum report a raw value on *every* case. `Int` continues
520
+ /// from the preceding case's value (`case a = 1; case b` makes `b` 2), whether that value was written
521
+ /// or itself derived, so one unreadable expression would corrupt every case after it. Deriving it
522
+ /// partially would be worse than not deriving it, so integer-backed enums report only what's written
523
+ /// and the consumer applies the continuation rule.
524
+ private func derivedStringRawValue(for caseName: String, rawType: TypeNode?) -> String? {
525
+ // `String` is a `.primitive`, but a qualified `Swift.String` parses as a `.ref`, and both are legal
526
+ // raw types. Matching the trailing component covers each, the same way the conformance check does.
527
+ let name: String?
528
+ switch rawType {
529
+ case .primitive(let spelling, _), .ref(let spelling, _, _):
530
+ name = spelling.split(separator: ".").last.map(String.init)
531
+ default:
532
+ name = nil
533
+ }
534
+ guard name == "String" else {
535
+ return nil
536
+ }
537
+ // Decoded, matching how a written string literal is reported: the value, not its source spelling.
538
+ return caseName
539
+ }
540
+
541
+ /// Protocols an `Enumerable` enum commonly adopts, which a syntactic scan would otherwise mistake for
542
+ /// a raw value type when one is written ahead of the conformance (`enum E: Codable, Enumerable`, which
543
+ /// has no raw type). Only the first inherited entry is ever tested against this, so the list needs to
544
+ /// name just what can legally precede `Enumerable`, not every protocol in existence.
545
+ private let knownNonRawValueProtocols: Set<String> = [
546
+ "CaseIterable", "Codable", "Decodable", "Encodable", "Equatable", "Error", "Hashable",
547
+ "Identifiable", "Sendable", enumerableConformanceName,
548
+ ]
549
+
550
+ /// The raw value type of an enum, or `nil` when it declares none.
551
+ ///
552
+ /// Swift allows a raw type only in first position, so nothing after the first entry can be one. The
553
+ /// first entry is still not necessarily a raw type: `enum E: Codable, Enumerable` is a legal
554
+ /// raw-value-less enum, and a scan can't resolve a bare name to tell a protocol from a type. It's
555
+ /// matched against `knownNonRawValueProtocols` instead, which covers what an `Enumerable` enum
556
+ /// realistically adopts. An unlisted protocol written first would still be misreported as a raw type;
557
+ /// that is the residual limit of reading this syntactically.
558
+ func rawValueType(of clause: InheritanceClauseSyntax?) -> TypeNode? {
559
+ guard let first = clause?.inheritedTypes.first?.type,
560
+ let trailing = first.trimmedDescription.split(separator: ".").last.map(String.init),
561
+ !knownNonRawValueProtocols.contains(trailing) else {
562
+ return nil
563
+ }
564
+ return typeNode(from: first)
565
+ }
566
+
567
+ /// True when a type is written as an optional: `T?`, `T!`, or `Optional<T>`, mirroring the macros'
568
+ /// `isOptionalType`.
569
+ private func isOptionalType(_ type: TypeSyntax) -> Bool {
570
+ if type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
571
+ return true
572
+ }
573
+ if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional" {
574
+ return true
575
+ }
576
+ return false
577
+ }
578
+
579
+ /// The first attribute whose spelled name matches `name`. A local copy of the macros' helper (the
580
+ /// macro target can't be imported here).
581
+ extension AttributeListSyntax {
582
+ fileprivate func firstAttribute(named name: String) -> AttributeSyntax? {
583
+ for element in self {
584
+ if let attribute = element.as(AttributeSyntax.self),
585
+ attribute.attributeName.trimmedDescription == name {
586
+ return attribute
587
+ }
588
+ }
589
+ return nil
590
+ }
591
+ }
592
+
593
+ /// The node for a property/field value type: the annotation, else the literal default's inferred
594
+ /// primitive (`var n = 1` -> `Int`), else `nil`.
595
+ private func valueTypeNode(annotation: TypeSyntax?, initializer: ExprSyntax?) -> TypeNode? {
596
+ if let annotation {
597
+ return typeNode(from: annotation)
598
+ }
599
+ return initializer.flatMap(inferredLiteralType)
600
+ }
601
+
602
+ /// The node for a simple literal default (`var n = 1` -> `Int`); `nil` for anything non-literal.
603
+ private func inferredLiteralType(of expression: ExprSyntax) -> TypeNode? {
604
+ switch expression.kind {
605
+ case .stringLiteralExpr:
606
+ return .primitive(name: "String", jsType: .string)
607
+ case .integerLiteralExpr:
608
+ return .primitive(name: "Int", jsType: .number)
609
+ case .floatLiteralExpr:
610
+ return .primitive(name: "Double", jsType: .number)
611
+ case .booleanLiteralExpr:
612
+ return .primitive(name: "Bool", jsType: .boolean)
613
+ default:
614
+ return nil
615
+ }
616
+ }