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,590 @@
1
+ import SwiftSyntax
2
+ import SwiftSyntaxBuilder
3
+ import SwiftSyntaxMacros
4
+
5
+ /**
6
+ Member macro applied to a record type. Treats **every stored property** that is not
7
+ `static`, `private`, `fileprivate`, `lazy` or computed as a property — no `@Field` wrapper
8
+ needed — and synthesizes the conversion surface from the property's static type:
9
+
10
+ - an explicit memberwise `init` (so the static factories have a single construction point),
11
+ - `from(object:appContext:)` — the fast path, reading each property straight off a
12
+ `JavaScriptObject`,
13
+ - `from(dictionary:appContext:)` — reading each property from a `[String: Any]` dictionary,
14
+ - `toDictionary(appContext:)` and `toObject(appContext:)` for the write side.
15
+
16
+ The type is auto-conformed to `Record` (a core protocol whose requirements are exactly this
17
+ surface); the synthesized methods override `Record`'s reflection-based defaults. Author-facing
18
+ shape — no conformance to spell out:
19
+
20
+ @Record
21
+ struct Options {
22
+ var name: String // required (non-optional, no default)
23
+ var count: Int = 0 // optional (has default)
24
+ var note: String? // nullable + optional
25
+ }
26
+
27
+ The JS key is always the property name. Requiredness is inferred from the declaration:
28
+ a default value makes a property optional (the default applies when the source omits it),
29
+ an optional type makes it nullable and optional, and a non-optional property without a
30
+ default is required (the factories throw when the source omits it).
31
+
32
+ The JS-value paths convert through `JavaScriptDecodable.decode` / `JavaScriptEncodable.encode`
33
+ (`from(object:)` / `toObject(appContext:)`); the native-`Any` dictionary paths still go through the
34
+ public dynamic-type API — `T.getDynamicType()` plus `cast(_:appContext:)` / `convertToJS(_:appContext:)`
35
+ (`from(dictionary:)` / `toDictionary(appContext:)`), since `JavaScriptCodable` only converts JS
36
+ values, not native `Any`. Both spell types as `public` symbols so the synthesized code compiles inside
37
+ user modules without any internal core symbols. Every property type must therefore conform to both
38
+ `AnyArgument` and `JavaScriptDecodable & JavaScriptEncodable`.
39
+
40
+ A property whose type mentions `Any` (`Any?`, `[String: Any]?`, `[String: [Any]]`, …) can't conform to
41
+ the JS codable protocols. On the JS-value paths it converts through the `JavaScriptValue` free-form
42
+ methods instead: `decodeAny`/`decodeAnyArray`/`decodeAnyDictionary` and the matching `encodeAny…`
43
+ for `Any`, `[Any]` and `[String: Any]` (optional or not), and the generic `decodeAny(_:as:in:)` /
44
+ `encodeAny(_:in:)` for any other type. Such a property only needs `AnyArgument` for the dictionary
45
+ paths, and a bare `Any` needs nothing, since the dictionary paths pass it through unchanged.
46
+
47
+ For classes that inherit from another `@Record`-annotated class, the synthesized
48
+ methods chain to `super` so inherited properties are handled first.
49
+ */
50
+ public struct RecordMacro: MemberMacro, ExtensionMacro {
51
+ public static func expansion(
52
+ of node: AttributeSyntax,
53
+ providingMembersOf declaration: some DeclGroupSyntax,
54
+ conformingTo protocols: [TypeSyntax],
55
+ in context: some MacroExpansionContext
56
+ ) throws -> [DeclSyntax] {
57
+ let isClass = declaration.is(ClassDeclSyntax.self)
58
+ guard declaration.is(StructDeclSyntax.self) || isClass else {
59
+ throw MacroExpansionErrorMessage("@Record can only be applied to a struct or class")
60
+ }
61
+
62
+ let properties = try recordProperties(of: declaration)
63
+ let inheritsRecord = isClass && classHasInheritance(declaration)
64
+
65
+ // The user may hand-write any of the initializers the macro would otherwise synthesize. Emitting
66
+ // a duplicate is a hard "invalid redeclaration" error, so skip anything already declared and let
67
+ // the author's version stand — the factories only need *an* `init(<properties>)` and `Record` only
68
+ // needs *an* `init()`, regardless of who wrote them.
69
+ let existingInitLabels = initializerParameterLabels(of: declaration)
70
+
71
+ var members: [DeclSyntax] = []
72
+
73
+ // A single never-called member that makes the compiler verify each property type is convertible
74
+ // both ways (the JS-value paths call `decode`/`encode`; the native-`Any` dictionary paths call
75
+ // the dynamic-type API). Each property keeps its own named assertion inside, so the compiler's
76
+ // conformance diagnostic names the offending property (see `typeConformanceAssertions`). Emitted
77
+ // first so that, for a non-conforming type, this clear "requires that '…' conform to '…'" error
78
+ // is reported ahead of the noisier "no member 'decode'"/"getDynamicType" errors from the
79
+ // conversion code below.
80
+ let assertions = properties.map { property in
81
+ // A bare `Any` can't conform to anything and needs no conformance, since every path converts it
82
+ // without one.
83
+ ConformanceAssertion(
84
+ name: property.name,
85
+ types: property.freeForm?.isBareAny == true ? [] : [property.type],
86
+ constraint: property.freeForm != nil ? jsConvertibleProtocolName : nil
87
+ )
88
+ }
89
+ if let assertionMember = typeConformanceAssertions(for: assertions, constraint: recordFieldProtocolName) {
90
+ members.append(assertionMember)
91
+ }
92
+
93
+ if !existingInitLabels.contains([]) {
94
+ if let defaultInit = defaultInit(properties: properties, isClass: isClass) {
95
+ members.append(defaultInit)
96
+ }
97
+ }
98
+ if !existingInitLabels.contains(properties.map { $0.name }) {
99
+ members.append(memberwiseInit(properties: properties))
100
+ }
101
+ members.append(fromJSObjectFactory(properties: properties))
102
+ members.append(fromDictionaryFactory(properties: properties))
103
+ members.append(toDictionaryMethod(properties: properties, inheritsRecord: inheritsRecord))
104
+ members.append(toObjectMethod(properties: properties, inheritsRecord: inheritsRecord))
105
+ return members
106
+ }
107
+
108
+ /**
109
+ Auto-conforms the type to `Record` — the protocol whose requirements are exactly the members
110
+ synthesized above (`init()`, the two `from(_:)` factories, and the `toDictionary`/`toObject`
111
+ write side), which the synthesized methods satisfy by overriding `Record`'s reflection-based
112
+ defaults.
113
+
114
+ The conformance is skipped when the type already declares it, and for class subclasses
115
+ (which inherit it from a `@Record`-annotated parent) no extension is emitted at all.
116
+ */
117
+ public static func expansion(
118
+ of node: AttributeSyntax,
119
+ attachedTo declaration: some DeclGroupSyntax,
120
+ providingExtensionsOf type: some TypeSyntaxProtocol,
121
+ conformingTo protocols: [TypeSyntax],
122
+ in context: some MacroExpansionContext
123
+ ) throws -> [ExtensionDeclSyntax] {
124
+ if declaration.is(ClassDeclSyntax.self) && classHasInheritance(declaration) {
125
+ return []
126
+ }
127
+ guard declaration.is(StructDeclSyntax.self) || declaration.is(ClassDeclSyntax.self) else {
128
+ return []
129
+ }
130
+ if inheritsProtocol(named: "Record", in: declaration) {
131
+ return []
132
+ }
133
+
134
+ let ext: DeclSyntax = """
135
+ extension \(type.trimmed): Record {}
136
+ """
137
+ guard let extDecl = ext.as(ExtensionDeclSyntax.self) else {
138
+ return []
139
+ }
140
+ return [extDecl]
141
+ }
142
+ }
143
+
144
+ // MARK: - Property model
145
+
146
+ /**
147
+ How a property whose type mentions `Any` converts on the JS-value paths. Such a type can't conform to
148
+ the JS codable protocols, so the property converts through the `JavaScriptValue` free-form methods.
149
+ */
150
+ private enum FreeFormConversion {
151
+ /// `Any`, `[Any]` or `[String: Any]`, wrapped in at most one optional: the dedicated methods for
152
+ /// that shape, named by `decodeMethod` and `encodeMethod`. The optional is handled around them.
153
+ case shape(decodeMethod: String, encodeMethod: String, isOptional: Bool, isBareAny: Bool)
154
+ /// Any other type that mentions `Any`: the generic `decodeAny(_:as:in:)` and `encodeAny(_:in:)`.
155
+ case generic
156
+
157
+ /// Classifies a type that mentions `Any`; the caller checks `isConvertibleFreeFormType` first.
158
+ init(type: TypeSyntax) {
159
+ let wrapped = optionalWrappedType(type)
160
+ switch freeFormShape(of: wrapped ?? type) {
161
+ case .any:
162
+ self = .shape(
163
+ decodeMethod: "decodeAny",
164
+ encodeMethod: "encodeAny",
165
+ isOptional: wrapped != nil,
166
+ isBareAny: true
167
+ )
168
+ case .array:
169
+ self = .shape(
170
+ decodeMethod: "decodeAnyArray",
171
+ encodeMethod: "encodeAnyArray",
172
+ isOptional: wrapped != nil,
173
+ isBareAny: false
174
+ )
175
+ case .dictionary:
176
+ self = .shape(
177
+ decodeMethod: "decodeAnyDictionary",
178
+ encodeMethod: "encodeAnyDictionary",
179
+ isOptional: wrapped != nil,
180
+ isBareAny: false
181
+ )
182
+ case nil:
183
+ self = .generic
184
+ }
185
+ }
186
+
187
+ /// True for `Any` itself, optional or not.
188
+ var isBareAny: Bool {
189
+ guard case .shape(_, _, _, let isBareAny) = self else {
190
+ return false
191
+ }
192
+ return isBareAny
193
+ }
194
+ }
195
+
196
+ /**
197
+ A single record property discovered on the type, paired with everything the synthesized
198
+ conversions need: its property name (also the JS key), its written type, and how the
199
+ source may omit it.
200
+ */
201
+ private struct RecordProperty {
202
+ let name: String
203
+ /// The property's declared type, verbatim (e.g. `String`, `Int`, `String?`). Used to build the
204
+ /// memberwise-init parameter and as the receiver of the per-property `decode`/`encode` (and, on the
205
+ /// dictionary paths, `getDynamicType()`) conversions.
206
+ let type: String
207
+ /// The default-value expression verbatim (`0`, `""`, `[]`), or `nil` when the property has none.
208
+ /// Inlined into the memberwise init and the factories' omitted-property branch so the synthesized
209
+ /// code never needs a throwaway `Self()` to recover defaults.
210
+ let defaultValue: String?
211
+ /// True when the property's type is optional (`T?` / `T!` / `Optional<T>`).
212
+ let isOptional: Bool
213
+ /// How the property converts when its type mentions `Any`, or `nil` for every other type.
214
+ let freeForm: FreeFormConversion?
215
+
216
+ /// True when the property declares a default value (`var x: T = …`).
217
+ var hasDefault: Bool {
218
+ return defaultValue != nil
219
+ }
220
+
221
+ /// Required properties must be present in the source; omission throws.
222
+ var isRequired: Bool {
223
+ return !hasDefault && !isOptional
224
+ }
225
+ }
226
+
227
+ /**
228
+ Discovers the record's properties: every stored `var`/`let` binding that is not `static`,
229
+ `private`, `fileprivate`, `lazy`, or computed. Each property must declare an explicit type
230
+ annotation, since the synthesized conversions name the type (e.g. `Type.decode(…)`).
231
+ */
232
+ private func recordProperties(
233
+ of declaration: some DeclGroupSyntax
234
+ ) throws -> [RecordProperty] {
235
+ var properties: [RecordProperty] = []
236
+
237
+ for member in declaration.memberBlock.members {
238
+ guard let varDecl = member.decl.as(VariableDeclSyntax.self) else {
239
+ continue
240
+ }
241
+ if isExcludedByModifier(varDecl.modifiers) {
242
+ continue
243
+ }
244
+ // `@Field` is the v1 property wrapper and has no meaning here — every stored property is a
245
+ // record property now. Left in place it would wrap the value in `Field<T>` (backing storage
246
+ // `_name`), so the synthesized conversions would be generated against the wrong type. Flag it
247
+ // explicitly rather than emit broken code.
248
+ if varDecl.attributes.firstAttribute(named: "Field") != nil {
249
+ throw MacroExpansionErrorMessage(
250
+ "@Field is no longer used — @Record treats every stored property as a record property. Remove the @Field attribute"
251
+ )
252
+ }
253
+ for binding in varDecl.bindings {
254
+ // Computed properties (and `{ get set }`) carry an accessor block — never properties.
255
+ if binding.accessorBlock != nil {
256
+ continue
257
+ }
258
+ guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
259
+ continue
260
+ }
261
+ // Prefer the explicit annotation. When it's omitted, recover the type from a literal default
262
+ // (Swift's own default-literal type) — covers the common `var name = "foo"` case. Anything
263
+ // whose type a syntactic macro can't determine (calls, collections, member access) still needs
264
+ // an annotation.
265
+ let inferredType = binding.typeAnnotation?.type.trimmedDescription
266
+ ?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
267
+ guard let type = inferredType else {
268
+ throw MacroExpansionErrorMessage(
269
+ "@Record properties must declare an explicit type — '\(ident.identifier.text)' has none"
270
+ )
271
+ }
272
+ // A literal-inferred type never mentions `Any`, so only an explicit annotation can be free-form.
273
+ let annotatedType = binding.typeAnnotation?.type
274
+ let freeForm = try annotatedType.flatMap { annotatedType -> FreeFormConversion? in
275
+ guard mentionsFreeFormAny(annotatedType) else {
276
+ return nil
277
+ }
278
+ guard isConvertibleFreeFormType(annotatedType) else {
279
+ throw MacroExpansionErrorMessage(
280
+ "@Record property '\(ident.identifier.text)' can't be typed '\(type)', because a type holding Any converts to and from JavaScript only when it's built from Any, arrays, String-keyed dictionaries and optionals. Use one of those shapes, such as [String: Any], or JavaScriptValue to keep the JavaScript value unconverted"
281
+ )
282
+ }
283
+ return FreeFormConversion(type: annotatedType)
284
+ }
285
+ properties.append(
286
+ RecordProperty(
287
+ name: ident.identifier.text,
288
+ type: type,
289
+ defaultValue: binding.initializer?.value.trimmedDescription,
290
+ isOptional: annotatedType.map(isOptionalType) ?? false,
291
+ freeForm: freeForm
292
+ )
293
+ )
294
+ }
295
+ }
296
+ return properties
297
+ }
298
+
299
+ // MARK: - Synthesized members
300
+
301
+ /**
302
+ The parameterless `init()` required by the `Record` protocol. Nothing in the synthesized code uses
303
+ it — the factories construct through the memberwise init with defaults inlined — so it's purely a
304
+ conformance witness. Swift only synthesizes an implicit `init()` for a struct that declares no other
305
+ initializer, but the macro always emits a memberwise `init(…)`, which suppresses it, so for structs
306
+ the macro must supply `init()` itself.
307
+
308
+ When no property is required, the body is empty: every stored property initializes from its own
309
+ declaration. When a property *is* required there's no value to give it, so the body traps — this `init()`
310
+ is never reached (the `from(…)` factories don't call it), it exists only to satisfy the requirement.
311
+
312
+ Not emitted when there are no properties at all — there the memberwise init already *is* `init()`. Classes
313
+ are left alone: they inherit `init()` from their `@Record` superclass, and a base `@Record` class is
314
+ unsupported today.
315
+ */
316
+ private func defaultInit(properties: [RecordProperty], isClass: Bool) -> DeclSyntax? {
317
+ if isClass || properties.isEmpty {
318
+ return nil
319
+ }
320
+ if properties.contains(where: { $0.isRequired }) {
321
+ return """
322
+ public init() {
323
+ fatalError("\\(Self.self) has required properties and cannot be created with init(); construct it through the @Record-synthesized from(dictionary:) or from(object:) factories")
324
+ }
325
+ """
326
+ }
327
+ return """
328
+ public init() {
329
+ }
330
+ """
331
+ }
332
+
333
+ /**
334
+ Explicit memberwise initializer over the discovered properties, in declaration order. The factories
335
+ call this as the single construction point. Optional properties default to `nil` for ergonomics; all
336
+ other parameters are required at this level (the factories supply a value for every property, inlining
337
+ each declared default where the source omits it).
338
+ */
339
+
340
+ private func memberwiseInit(properties: [RecordProperty]) -> DeclSyntax {
341
+ let params = properties.map { property -> String in
342
+ if property.isOptional {
343
+ return "\(property.name): \(property.type) = nil"
344
+ }
345
+ return "\(property.name): \(property.type)"
346
+ }
347
+ let assignments = properties.map { " self.\($0.name) = \($0.name)" }.joined(separator: "\n")
348
+ let signature = params.joined(separator: ", ")
349
+
350
+ return """
351
+ public init(\(raw: signature)) {
352
+ \(raw: assignments)
353
+ }
354
+ """
355
+ }
356
+
357
+ /**
358
+ `from(object:appContext:)` — reads each property off the `JavaScriptObject` into a local,
359
+ then constructs the record through the memberwise init. Required properties throw when
360
+ undefined; defaulted properties fall back to the property's declared default (inlined) when
361
+ undefined; optional properties become `nil` when undefined/null. Each property is decoded with
362
+ `JavaScriptDecodable.decode`, so the factory binds the `runtime` from the app context once and
363
+ threads it to every read. A free-form property decodes through the `JavaScriptValue` free-form
364
+ methods, which take the runtime too.
365
+ */
366
+ private func fromJSObjectFactory(properties: [RecordProperty]) -> DeclSyntax {
367
+ var lines: [String] = []
368
+ // Only bind the runtime when there's a property to decode — an empty record's factory would
369
+ // otherwise leave it unused.
370
+ if !properties.isEmpty {
371
+ lines.append(" let runtime = try appContext.runtime")
372
+ }
373
+ lines.append(factoryBody(properties: properties, readLines: jsObjectReadLines(properties: properties)))
374
+ let body = lines.joined(separator: "\n")
375
+ return """
376
+ @JavaScriptActor
377
+ public static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self {
378
+ \(raw: body)
379
+ }
380
+ """
381
+ }
382
+
383
+ /**
384
+ `from(dictionary:appContext:)` — same as the JS path but reads from `[String: Any]` and
385
+ uses the `Any?` cast overload; a missing key is `dictionary[key] == nil`.
386
+ */
387
+ private func fromDictionaryFactory(properties: [RecordProperty]) -> DeclSyntax {
388
+ let body = factoryBody(properties: properties, readLines: dictionaryReadLines(properties: properties))
389
+ return """
390
+ public static func from(dictionary: [String: Any], appContext: AppContext) throws -> Self {
391
+ \(raw: body)
392
+ }
393
+ """
394
+ }
395
+
396
+ /**
397
+ Builds a factory body: per-property reads into locals, then a single call to the synthesized
398
+ memberwise init. Each property's declared default expression is captured at parse time and inlined
399
+ directly into the omitted-property branch (see the read-line builders), so the factory needs neither
400
+ a throwaway `Self()` nor the record's `init()`.
401
+ */
402
+ private func factoryBody(properties: [RecordProperty], readLines: [String]) -> String {
403
+ var lines: [String] = []
404
+ lines.append(contentsOf: readLines)
405
+ let initArgs = properties.map { "\($0.name): \($0.name)" }.joined(separator: ", ")
406
+ lines.append(" return Self(\(initArgs))")
407
+ return lines.joined(separator: "\n")
408
+ }
409
+
410
+ /// Per-property read statements for the JS-object factory, each producing a `let <name>` by decoding
411
+ /// the JS value with `JavaScriptDecodable.decode` (recovering the app context from `runtime` itself).
412
+ /// A free-form property decodes through the `JavaScriptValue` free-form methods instead; for an optional
413
+ /// free-form shape, `undefined`/`null` map to `nil` around the call, just like `Optional.decode`.
414
+ private func jsObjectReadLines(properties: [RecordProperty]) -> [String] {
415
+ var lines: [String] = []
416
+ for property in properties {
417
+ let valueVar = "\(property.name)JSValue"
418
+ let exprType = expressionType(property.type)
419
+ let decode: String
420
+ switch property.freeForm {
421
+ case .shape(let decodeMethod, _, let isOptional, _):
422
+ let call = "try JavaScriptValue.\(decodeMethod)(\(valueVar), in: runtime)"
423
+ decode = isOptional ? "\(valueVar).isUndefined() || \(valueVar).isNull() ? nil : \(call)" : call
424
+ case .generic:
425
+ decode = "try JavaScriptValue.decodeAny(\(valueVar), as: \(exprType).self, in: runtime)"
426
+ case nil:
427
+ decode = "try \(exprType).decode(\(valueVar), in: runtime)"
428
+ }
429
+ lines.append(" let \(valueVar) = object.getProperty(\"\(property.name)\")")
430
+ if property.isRequired {
431
+ lines.append(" guard !\(valueVar).isUndefined() else {")
432
+ lines.append(" throw RecordPropertyRequiredException(\"\(property.name)\")")
433
+ lines.append(" }")
434
+ lines.append(" let \(property.name) = \(decode)")
435
+ } else if property.isOptional {
436
+ // `Optional.decode` (and the free-form reads above) already map `undefined`/`null` to `nil`, so
437
+ // the read is a plain decode.
438
+ lines.append(" let \(property.name) = \(decode)")
439
+ } else {
440
+ lines.append(" let \(property.name) = \(valueVar).isUndefined() ? \(property.defaultValue!) : \(decode)")
441
+ }
442
+ }
443
+ return lines
444
+ }
445
+
446
+ /// Per-property read statements for the dictionary factory, each producing a `let <name>`. A bare `Any`
447
+ /// property has no dynamic type to cast through, and the dictionary value already is an `Any`, so it's
448
+ /// read unchanged.
449
+ private func dictionaryReadLines(properties: [RecordProperty]) -> [String] {
450
+ var lines: [String] = []
451
+ for property in properties {
452
+ let valueVar = "\(property.name)Value"
453
+ let exprType = expressionType(property.type)
454
+ let isBareAny = property.freeForm?.isBareAny == true
455
+ let cast = isBareAny
456
+ ? valueVar
457
+ : "try \(exprType).getDynamicType().cast(\(valueVar), appContext: appContext) as! \(exprType)"
458
+ lines.append(" let \(valueVar) = dictionary[\"\(property.name)\"]")
459
+ if property.isRequired {
460
+ lines.append(" guard let \(valueVar) else {")
461
+ lines.append(" throw RecordPropertyRequiredException(\"\(property.name)\")")
462
+ lines.append(" }")
463
+ lines.append(" let \(property.name) = \(cast)")
464
+ } else if property.isOptional {
465
+ lines.append(" let \(property.name): \(property.type) = (\(valueVar) == nil || \(valueVar)! is NSNull) ? nil : \(cast)")
466
+ } else {
467
+ lines.append(" let \(property.name) = \(valueVar) == nil ? \(property.defaultValue!) : \(isBareAny ? valueVar + "!" : cast)")
468
+ }
469
+ }
470
+ return lines
471
+ }
472
+
473
+ /**
474
+ `toDictionary(appContext:)` — converts each property back to a JS-compatible value via the
475
+ dynamic type and assembles a `[String: Any]`. Inherited properties are merged in first.
476
+ */
477
+ private func toDictionaryMethod(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
478
+ let overrideKeyword = inheritsRecord ? "override " : ""
479
+ var lines: [String] = []
480
+ if inheritsRecord {
481
+ lines.append(" var dictionary = super.toDictionary(appContext: appContext)")
482
+ } else {
483
+ lines.append(" var dictionary: [String: Any] = [:]")
484
+ }
485
+ for property in properties {
486
+ lines.append(" dictionary[\"\(property.name)\"] = self.\(property.name)")
487
+ }
488
+ lines.append(" return dictionary")
489
+ let body = lines.joined(separator: "\n")
490
+
491
+ if inheritsRecord {
492
+ return """
493
+ public \(raw: overrideKeyword)func toDictionary(appContext: AppContext? = nil) -> [String: Any] {
494
+ \(raw: body)
495
+ }
496
+ """
497
+ }
498
+ return """
499
+ public func toDictionary(appContext: AppContext? = nil) -> [String: Any] {
500
+ \(raw: body)
501
+ }
502
+ """
503
+ }
504
+
505
+ /**
506
+ `toObject(appContext:)` — builds a `JavaScriptObject` directly, encoding each property with
507
+ `JavaScriptEncodable.encode` (or, for a free-form property, the matching `JavaScriptValue` free-form
508
+ method). The fast write path mirroring `from(object:)`. The `runtime` is bound
509
+ from the app context once and threaded to every write. Subclasses chain to `super` so inherited
510
+ properties are written first.
511
+ */
512
+ private func toObjectMethod(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
513
+ let overrideKeyword = inheritsRecord ? "override " : ""
514
+ var lines: [String] = []
515
+ // The base case needs the runtime to create the object; the inheriting case only needs it when
516
+ // there's a property to encode (it chains to `super` for the object itself). Binding it when unused
517
+ // would warn.
518
+ if !inheritsRecord || !properties.isEmpty {
519
+ lines.append(" let runtime = try appContext.runtime")
520
+ }
521
+ if inheritsRecord {
522
+ lines.append(" let object = try super.toObject(appContext: appContext)")
523
+ } else {
524
+ lines.append(" let object = runtime.createObject()")
525
+ }
526
+ for property in properties {
527
+ let exprType = expressionType(property.type)
528
+ let encode: String
529
+ switch property.freeForm {
530
+ case .shape(_, let encodeMethod, let isOptional, _):
531
+ encode = isOptional
532
+ ? "self.\(property.name) == nil ? .null : try JavaScriptValue.\(encodeMethod)(self.\(property.name)!, in: runtime)"
533
+ : "try JavaScriptValue.\(encodeMethod)(self.\(property.name), in: runtime)"
534
+ case .generic:
535
+ // The explicit `as Any` keeps an optional property from warning about its implicit coercion.
536
+ encode = "try JavaScriptValue.encodeAny(self.\(property.name) as Any, in: runtime)"
537
+ case nil:
538
+ encode = "try \(exprType).encode(self.\(property.name), in: runtime)"
539
+ }
540
+ lines.append(" object.setProperty(\"\(property.name)\", value: \(encode))")
541
+ }
542
+ lines.append(" return object")
543
+ let body = lines.joined(separator: "\n")
544
+
545
+ return """
546
+ @JavaScriptActor
547
+ public \(raw: overrideKeyword)func toObject(appContext: AppContext) throws -> JavaScriptObject {
548
+ \(raw: body)
549
+ }
550
+ """
551
+ }
552
+
553
+ // MARK: - Helpers
554
+
555
+ /**
556
+ The parameter-label list of every initializer the type already declares, used to avoid emitting a
557
+ duplicate of one the author hand-wrote. Each entry is the ordered external argument labels of one
558
+ `init` — e.g. `init(name: String, count: Int)` → `["name", "count"]`, and `init()` → `[]`. A
559
+ parameter written with `_` or with no external label contributes its internal name's absence as an
560
+ empty-string label, which simply won't match the macro's always-labeled signatures (a safe miss).
561
+ */
562
+ private func initializerParameterLabels(of declaration: some DeclGroupSyntax) -> [[String]] {
563
+ var signatures: [[String]] = []
564
+ for member in declaration.memberBlock.members {
565
+ guard let initDecl = member.decl.as(InitializerDeclSyntax.self) else {
566
+ continue
567
+ }
568
+ let labels = initDecl.signature.parameterClause.parameters.map { parameter in
569
+ // `firstName` is the external label (or `_`); fall back to the internal name when omitted.
570
+ let label = parameter.firstName.text
571
+ return label == "_" ? "" : label
572
+ }
573
+ signatures.append(labels)
574
+ }
575
+ return signatures
576
+ }
577
+
578
+ /**
579
+ True if the class declaration has any inheritance clause. Used as a heuristic for
580
+ whether the superclass also conforms to `Record` and provides the synthesized methods;
581
+ the macro emits `override` in this case.
582
+ */
583
+ private func classHasInheritance(_ declaration: some DeclGroupSyntax) -> Bool {
584
+ guard let classDecl = declaration.as(ClassDeclSyntax.self),
585
+ let inherited = classDecl.inheritanceClause?.inheritedTypes,
586
+ !inherited.isEmpty else {
587
+ return false
588
+ }
589
+ return true
590
+ }