expo-modules-macros 0.20.0 → 0.22.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 (39) hide show
  1. package/.github/workflows/linux.yml +132 -0
  2. package/.github/workflows/publish.yml +160 -11
  3. package/.github/workflows/swift-format.yml +76 -0
  4. package/.github/workflows/windows.yml +3 -28
  5. package/AGENTS.md +19 -9
  6. package/README.md +27 -8
  7. package/apple/ExpoModulesMacros +0 -0
  8. package/apple/Package.resolved +3 -3
  9. package/apple/Package.swift +1 -2
  10. package/apple/Sources/ExpoModulesMacros/ClosureArguments.swift +39 -14
  11. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +117 -25
  12. package/apple/Sources/ExpoModulesMacros/EventMacro.swift +29 -15
  13. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +55 -45
  14. package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +2 -1
  15. package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +2 -61
  16. package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +6 -3
  17. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +180 -44
  18. package/apple/Sources/ExpoModulesMacros/JSNameCollisions.swift +84 -0
  19. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +165 -43
  20. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +358 -234
  21. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +48 -32
  22. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +57 -27
  23. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +23 -9
  24. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +10 -5
  25. package/apple/Sources/ExpoModulesScanner/CLI.swift +3 -1
  26. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +11 -7
  27. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +2 -1
  28. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +10 -7
  29. package/apple/Sources/ExpoModulesScanner/Exports/Availability.swift +6 -3
  30. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +3 -2
  31. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +15 -5
  32. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +8 -3
  33. package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +127 -45
  34. package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +2 -1
  35. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +8 -2
  36. package/build/index.d.ts +4 -3
  37. package/build/index.js +33 -18
  38. package/build/types.d.ts +7 -1
  39. package/package.json +7 -3
@@ -3,51 +3,59 @@ import SwiftSyntax
3
3
  import SwiftSyntaxBuilder
4
4
  import SwiftSyntaxMacros
5
5
 
6
- /**
7
- Member macro applied to a record type. Treats **every stored property** that is not
8
- `static`, `private`, `fileprivate`, `lazy` or computed as a property — no `@Field` wrapper
9
- needed — and synthesizes the conversion surface from the property's static type:
10
-
11
- - an explicit memberwise `init` (so the static factories have a single construction point),
12
- - `from(object:appContext:)` — the fast path, reading each property straight off a
13
- `JavaScriptObject`,
14
- - `from(dictionary:appContext:)` — reading each property from a `[String: Any]` dictionary,
15
- - `toDictionary(appContext:)` and `toObject(appContext:)` for the write side.
16
-
17
- The type is auto-conformed to `Record` (a core protocol whose requirements are exactly this
18
- surface); the synthesized methods override `Record`'s reflection-based defaults. Author-facing
19
- shape — no conformance to spell out:
20
-
21
- @Record
22
- struct Options {
23
- var name: String // required (non-optional, no default)
24
- var count: Int = 0 // optional (has default)
25
- var note: String? // nullable + optional
26
- }
27
-
28
- The JS key is always the property name. Requiredness is inferred from the declaration:
29
- a default value makes a property optional (the default applies when the source omits it),
30
- an optional type makes it nullable and optional, and a non-optional property without a
31
- default is required (the factories throw when the source omits it).
32
-
33
- The JS-value paths convert through `JavaScriptDecodable.decode` / `JavaScriptEncodable.encode`
34
- (`from(object:)` / `toObject(appContext:)`); the native-`Any` dictionary paths still go through the
35
- public dynamic-type API — `T.getDynamicType()` plus `cast(_:appContext:)` / `convertToJS(_:appContext:)`
36
- (`from(dictionary:)` / `toDictionary(appContext:)`), since `JavaScriptCodable` only converts JS
37
- values, not native `Any`. Both spell types as `public` symbols so the synthesized code compiles inside
38
- user modules without any internal core symbols. Every property type must therefore conform to both
39
- `AnyArgument` and `JavaScriptDecodable & JavaScriptEncodable`.
40
-
41
- A property whose type mentions `Any` (`Any?`, `[String: Any]?`, `[String: [Any]]`, …) can't conform to
42
- the JS codable protocols. On the JS-value paths it converts through the `JavaScriptValue` free-form
43
- methods instead: `decodeAny`/`decodeAnyArray`/`decodeAnyDictionary` and the matching `encodeAny…`
44
- for `Any`, `[Any]` and `[String: Any]` (optional or not), and the generic `decodeAny(_:as:in:)` /
45
- `encodeAny(_:in:)` for any other type. Such a property only needs `AnyArgument` for the dictionary
46
- paths, and a bare `Any` needs nothing, since the dictionary paths pass it through unchanged.
47
-
48
- For classes that inherit from another `@Record`-annotated class, the synthesized
49
- methods chain to `super` so inherited properties are handled first.
50
- */
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
+ /// - `decode(_:in:)` and `encode(_:in:)` — the fast paths, reading each property straight off a JS
12
+ /// object and writing it straight into one,
13
+ /// - `from(object:appContext:)` and `toObject(appContext:)`, which forward to `decode` and `encode`,
14
+ /// - `from(dictionary:appContext:)` and `toDictionary(appContext:)` — converting each property from and
15
+ /// to a `[String: Any]` dictionary.
16
+ ///
17
+ /// The type is auto-conformed to `Record` (a core protocol whose requirements are this surface); the
18
+ /// synthesized methods override `Record`'s reflection-based defaults, and the synthesized `decode` and
19
+ /// `encode` replace its default `JavaScriptCodable` witnesses. Author-facing shape — no conformance to
20
+ /// spell out:
21
+ ///
22
+ /// @Record
23
+ /// struct Options {
24
+ /// var name: String // required (non-optional, no default)
25
+ /// var count: Int = 0 // optional (has default)
26
+ /// var note: String? // nullable + optional
27
+ /// }
28
+ ///
29
+ /// The JS key is always the property name. Requiredness is inferred from the declaration:
30
+ /// a default value makes a property optional (the default applies when the source omits it),
31
+ /// an optional type makes it nullable and optional, and a non-optional property without a
32
+ /// default is required (the factories throw when the source omits it).
33
+ ///
34
+ /// `decode(_:in:)` and `encode(_:in:)` name each property with a `JavaScriptPropNameID` that each runtime
35
+ /// creates once. The names are held by the nested `_PropNames` class, which each runtime caches under one
36
+ /// key. A record that is generic or nested in a generic type uses string names, since a generic type can't
37
+ /// have static stored properties. The macro can't tell that an extension extends a generic type, so a
38
+ /// record declared in one doesn't compile.
39
+ ///
40
+ /// The JS-value paths convert through `JavaScriptDecodable.decode` / `JavaScriptEncodable.encode`
41
+ /// (`decode(_:in:)` / `encode(_:in:)`); the native-`Any` dictionary paths still go through the
42
+ /// public dynamic-type API — `T.getDynamicType()` plus `cast(_:appContext:)` / `convertToJS(_:appContext:)`
43
+ /// (`from(dictionary:)` / `toDictionary(appContext:)`), since `JavaScriptCodable` only converts JS
44
+ /// values, not native `Any`. Both spell types as `public` symbols so the synthesized code compiles inside
45
+ /// user modules without any internal core symbols. Every property type must therefore conform to both
46
+ /// `AnyArgument` and `JavaScriptDecodable & JavaScriptEncodable`.
47
+ ///
48
+ /// A property whose type mentions `Any` (`Any?`, `[String: Any]?`, `[String: [Any]]`, …) can't conform to
49
+ /// the JS codable protocols. On the JS-value paths it converts through the `JavaScriptValue` free-form
50
+ /// methods instead: `decodeAny`/`decodeAnyArray`/`decodeAnyDictionary` and the matching `encodeAny…`
51
+ /// for `Any`, `[Any]` and `[String: Any]` (optional or not), and the generic `decodeAny(_:as:in:)` /
52
+ /// `encodeAny(_:in:)` for any other type. Such a property only needs `AnyArgument` for the dictionary
53
+ /// paths, and a bare `Any` needs nothing, since the dictionary paths pass it through unchanged.
54
+ ///
55
+ /// A record class must be `final` and have no inheritance clause. The synthesized factories construct
56
+ /// `Self` through the memberwise init, which only a final class can do without a `required` init, and
57
+ /// the macro can't tell a superclass from a protocol in the clause, so protocol conformances go in an
58
+ /// extension.
51
59
  public struct RecordMacro: MemberMacro, ExtensionMacro {
52
60
  public static func expansion(
53
61
  of node: AttributeSyntax,
@@ -55,13 +63,21 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
55
63
  conformingTo protocols: [TypeSyntax],
56
64
  in context: some MacroExpansionContext
57
65
  ) throws -> [DeclSyntax] {
58
- let isClass = declaration.is(ClassDeclSyntax.self)
59
- guard declaration.is(StructDeclSyntax.self) || isClass else {
66
+ let typeName: String
67
+ if let structDecl = declaration.as(StructDeclSyntax.self) {
68
+ typeName = structDecl.name.text
69
+ } else if let classDecl = declaration.as(ClassDeclSyntax.self) {
70
+ typeName = classDecl.name.text
71
+ } else {
60
72
  throw MacroExpansionErrorMessage("@Record can only be applied to a struct or class")
61
73
  }
74
+ let classDiagnostics = unsupportedClassDiagnostics(of: declaration)
75
+ guard classDiagnostics.isEmpty else {
76
+ throw DiagnosticsError(diagnostics: classDiagnostics)
77
+ }
62
78
 
63
79
  let properties = try recordProperties(of: declaration)
64
- let inheritsRecord = isClass && classHasInheritance(declaration)
80
+ let cachesPropNames = !properties.isEmpty && !isInGenericContext(declaration, context: context)
65
81
 
66
82
  // The user may hand-write any of the initializers the macro would otherwise synthesize. Emitting
67
83
  // a duplicate is a hard "invalid redeclaration" error, so skip anything already declared and let
@@ -92,29 +108,37 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
92
108
  }
93
109
 
94
110
  if !existingInitLabels.contains([]) {
95
- if let defaultInit = defaultInit(properties: properties, isClass: isClass) {
111
+ if let defaultInit = defaultInit(properties: properties) {
96
112
  members.append(defaultInit)
97
113
  }
98
114
  }
99
115
  if !existingInitLabels.contains(properties.map { $0.key }) {
100
116
  members.append(memberwiseInit(properties: properties))
101
117
  }
102
- members.append(fromJSObjectFactory(properties: properties))
118
+ members.append(fromJSObjectFactory())
103
119
  members.append(fromDictionaryFactory(properties: properties))
104
- members.append(toDictionaryMethod(properties: properties, inheritsRecord: inheritsRecord))
105
- members.append(toObjectMethod(properties: properties, inheritsRecord: inheritsRecord))
120
+ members.append(toDictionaryMethod(properties: properties))
121
+ members.append(toObjectMethod())
122
+ if cachesPropNames {
123
+ members.append(propNamesClass(properties: properties))
124
+ }
125
+ members.append(
126
+ contentsOf: javaScriptCodableWitnesses(
127
+ properties: properties,
128
+ typeName: typeName,
129
+ cachesPropNames: cachesPropNames
130
+ )
131
+ )
106
132
  return members
107
133
  }
108
134
 
109
- /**
110
- Auto-conforms the type to `Record` — the protocol whose requirements are exactly the members
111
- synthesized above (`init()`, the two `from(_:)` factories, and the `toDictionary`/`toObject`
112
- write side), which the synthesized methods satisfy by overriding `Record`'s reflection-based
113
- defaults.
114
-
115
- The conformance is skipped when the type already declares it, and for class subclasses
116
- (which inherit it from a `@Record`-annotated parent) no extension is emitted at all.
117
- */
135
+ /// Auto-conforms the type to `Record` — the protocol whose requirements are exactly the members
136
+ /// synthesized above (`init()`, the two `from(_:)` factories, and the `toDictionary`/`toObject`
137
+ /// write side), which the synthesized methods satisfy by overriding `Record`'s reflection-based
138
+ /// defaults.
139
+ ///
140
+ /// The conformance is skipped when the type already declares it, and for a class the member role
141
+ /// rejects, which would otherwise get conformance errors for the members it didn't synthesize.
118
142
  public static func expansion(
119
143
  of node: AttributeSyntax,
120
144
  attachedTo declaration: some DeclGroupSyntax,
@@ -122,10 +146,10 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
122
146
  conformingTo protocols: [TypeSyntax],
123
147
  in context: some MacroExpansionContext
124
148
  ) throws -> [ExtensionDeclSyntax] {
125
- if declaration.is(ClassDeclSyntax.self) && classHasInheritance(declaration) {
149
+ guard declaration.is(StructDeclSyntax.self) || declaration.is(ClassDeclSyntax.self) else {
126
150
  return []
127
151
  }
128
- guard declaration.is(StructDeclSyntax.self) || declaration.is(ClassDeclSyntax.self) else {
152
+ guard unsupportedClassDiagnostics(of: declaration).isEmpty else {
129
153
  return []
130
154
  }
131
155
  if inheritsProtocol(named: "Record", in: declaration) {
@@ -144,10 +168,8 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
144
168
 
145
169
  // MARK: - Property model
146
170
 
147
- /**
148
- A single record property discovered on the type, paired with everything the synthesized
149
- conversions need: its property name, its written type, and how the source may omit it.
150
- */
171
+ /// A single record property discovered on the type, paired with everything the synthesized
172
+ /// conversions need: its property name, its written type, and how the source may omit it.
151
173
  private struct RecordProperty {
152
174
  /// The property name as written, backticks included for an escaped name (`` `default` ``), since
153
175
  /// every identifier position in the generated code needs them.
@@ -182,11 +204,9 @@ private struct RecordProperty {
182
204
  }
183
205
  }
184
206
 
185
- /**
186
- Discovers the record's properties: every stored `var`/`let` binding that is not `static`,
187
- `private`, `fileprivate`, `lazy`, or computed. Each property must declare an explicit type
188
- annotation, since the synthesized conversions name the type (e.g. `Type.decode(…)`).
189
- */
207
+ /// Discovers the record's properties: every stored `var`/`let` binding that is not `static`,
208
+ /// `private`, `fileprivate`, `lazy`, or computed. Each property must declare an explicit type
209
+ /// annotation, since the synthesized conversions name the type (e.g. `Type.decode(…)`).
190
210
  private func recordProperties(
191
211
  of declaration: some DeclGroupSyntax
192
212
  ) throws -> [RecordProperty] {
@@ -220,7 +240,8 @@ private func recordProperties(
220
240
  // (Swift's own default-literal type) — covers the common `var name = "foo"` case. Anything
221
241
  // whose type a syntactic macro can't determine (calls, collections, member access) still needs
222
242
  // an annotation.
223
- let inferredType = binding.typeAnnotation?.type.trimmedDescription
243
+ let inferredType =
244
+ binding.typeAnnotation?.type.trimmedDescription
224
245
  ?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
225
246
  guard let type = inferredType else {
226
247
  throw MacroExpansionErrorMessage(
@@ -239,7 +260,8 @@ private func recordProperties(
239
260
  message: RecordDiagnosticMessage(
240
261
  "@Record property '\(ident.identifier.text)' can't hold a closure: a record property is also encoded to JavaScript, and a native closure can't be encoded",
241
262
  id: "record-closure-property"
242
- ))
263
+ )
264
+ )
243
265
  ])
244
266
  }
245
267
  let freeForm = try annotatedType.flatMap { annotatedType -> FreeFormConversion? in
@@ -269,23 +291,19 @@ private func recordProperties(
269
291
 
270
292
  // MARK: - Synthesized members
271
293
 
272
- /**
273
- The parameterless `init()` required by the `Record` protocol. Nothing in the synthesized code uses
274
- it — the factories construct through the memberwise init with defaults inlined — so it's purely a
275
- conformance witness. Swift only synthesizes an implicit `init()` for a struct that declares no other
276
- initializer, but the macro always emits a memberwise `init(…)`, which suppresses it, so for structs
277
- the macro must supply `init()` itself.
278
-
279
- When no property is required, the body is empty: every stored property initializes from its own
280
- declaration. When a property *is* required there's no value to give it, so the body traps — this `init()`
281
- is never reached (the `from(…)` factories don't call it), it exists only to satisfy the requirement.
282
-
283
- Not emitted when there are no properties at all — there the memberwise init already *is* `init()`. Classes
284
- are left alone: they inherit `init()` from their `@Record` superclass, and a base `@Record` class is
285
- unsupported today.
286
- */
287
- private func defaultInit(properties: [RecordProperty], isClass: Bool) -> DeclSyntax? {
288
- if isClass || properties.isEmpty {
294
+ /// The parameterless `init()` required by the `Record` protocol. Nothing in the synthesized code uses
295
+ /// it — the factories construct through the memberwise init with defaults inlined — so it's purely a
296
+ /// conformance witness. Swift only synthesizes an implicit `init()` for a type that declares no other
297
+ /// initializer, but the macro always emits a memberwise `init(…)`, which suppresses it, so the macro
298
+ /// must supply `init()` itself.
299
+ ///
300
+ /// When no property is required, the body is empty: every stored property initializes from its own
301
+ /// declaration. When a property *is* required there's no value to give it, so the body traps — this `init()`
302
+ /// is never reached (the `from(…)` factories don't call it), it exists only to satisfy the requirement.
303
+ ///
304
+ /// Not emitted when there are no properties at all — there the memberwise init already *is* `init()`.
305
+ private func defaultInit(properties: [RecordProperty]) -> DeclSyntax? {
306
+ if properties.isEmpty {
289
307
  return nil
290
308
  }
291
309
  if properties.contains(where: { $0.isRequired }) {
@@ -325,36 +343,24 @@ private func memberwiseInit(properties: [RecordProperty]) -> DeclSyntax {
325
343
  """
326
344
  }
327
345
 
328
- /**
329
- `from(object:appContext:)` — reads each property off the `JavaScriptObject` into a local,
330
- then constructs the record through the memberwise init. Required properties throw when
331
- undefined; defaulted properties fall back to the property's declared default (inlined) when
332
- undefined; optional properties become `nil` when undefined/null. Each property is decoded with
333
- `JavaScriptDecodable.decode`, so the factory binds the `runtime` from the app context once and
334
- threads it to every read. A free-form property decodes through the `JavaScriptValue` free-form
335
- methods, which take the runtime too.
336
- */
337
- private func fromJSObjectFactory(properties: [RecordProperty]) -> DeclSyntax {
338
- var lines: [String] = []
339
- // Only bind the runtime when there's a property to decode — an empty record's factory would
340
- // otherwise leave it unused.
341
- if !properties.isEmpty {
342
- lines.append(" let runtime = try appContext.runtime")
343
- }
344
- lines.append(factoryBody(properties: properties, readLines: jsObjectReadLines(properties: properties)))
345
- let body = lines.joined(separator: "\n")
346
+ /// `from(object:appContext:)`, which forwards to the record's own unowned `decode(_:in:)` witness (see
347
+ /// `javaScriptCodableWitnesses`), where the property reads are.
348
+ private func fromJSObjectFactory() -> DeclSyntax {
349
+ // The record declares only the unowned `decode`, so the call lends the value unowned. With an owning
350
+ // value it would reach an owning `decode` default, and `Record`'s converts through this factory.
346
351
  return """
347
352
  @JavaScriptActor
348
353
  public static func from(object: borrowing JavaScriptObject, appContext: AppContext) throws -> Self {
349
- \(raw: body)
354
+ let runtime = try appContext.runtime
355
+ return try object.asValue().withUnownedValue(in: runtime) { unownedValue in
356
+ return try decode(unownedValue, in: runtime)
357
+ }
350
358
  }
351
359
  """
352
360
  }
353
361
 
354
- /**
355
- `from(dictionary:appContext:)` — same as the JS path but reads from `[String: Any]` and
356
- uses the `Any?` cast overload; a missing key is `dictionary[key] == nil`.
357
- */
362
+ /// `from(dictionary:appContext:)` — same as the JS path but reads from `[String: Any]` and
363
+ /// uses the `Any?` cast overload; a missing key is `dictionary[key] == nil`.
358
364
  private func fromDictionaryFactory(properties: [RecordProperty]) -> DeclSyntax {
359
365
  let body = factoryBody(properties: properties, readLines: dictionaryReadLines(properties: properties))
360
366
  return """
@@ -364,172 +370,239 @@ private func fromDictionaryFactory(properties: [RecordProperty]) -> DeclSyntax {
364
370
  """
365
371
  }
366
372
 
367
- /**
368
- Builds a factory body: per-property reads into locals, then a single call to the synthesized
369
- memberwise init. Each property's declared default expression is captured at parse time and inlined
370
- directly into the omitted-property branch (see the read-line builders), so the factory needs neither
371
- a throwaway `Self()` nor the record's `init()`.
372
- */
373
+ /// Builds a factory body: per-property reads into locals, then a single call to the synthesized
374
+ /// memberwise init. Each property's declared default expression is captured at parse time and inlined
375
+ /// directly into the omitted-property branch (see the read-line builders), so the factory needs neither
376
+ /// a throwaway `Self()` nor the record's `init()`.
373
377
  private func factoryBody(properties: [RecordProperty], readLines: [String]) -> String {
374
378
  var lines: [String] = []
375
379
  lines.append(contentsOf: readLines)
376
- let initArgs = properties.map { "\($0.name): \($0.name)" }.joined(separator: ", ")
380
+ let initArgs = properties.enumerated().map { "\($1.name): \(fieldLocal($0))" }.joined(separator: ", ")
377
381
  lines.append(" return Self(\(initArgs))")
378
382
  return lines.joined(separator: "\n")
379
383
  }
380
384
 
381
- /// Per-property read statements for the JS-object factory, each producing a `let <name>` by decoding
382
- /// the JS value with `JavaScriptDecodable.decode` (recovering the app context from `runtime` itself).
383
- /// A free-form property decodes through the `JavaScriptValue` free-form methods instead; for an optional
384
- /// free-form shape, `undefined`/`null` map to `nil` around the call, just like `Optional.decode`.
385
- private func jsObjectReadLines(properties: [RecordProperty]) -> [String] {
385
+ /// The local that holds the value of the property at `index` in a factory. It's numbered rather than
386
+ /// named after the property, so no property can collide with it or with the factory's other parameters
387
+ /// and locals (`object`, `runtime`, `dictionary`, `appContext`).
388
+ private func fieldLocal(_ index: Int) -> String {
389
+ return "field\(index)"
390
+ }
391
+
392
+ /// Per-property read statements, each producing a `let field<index>` from the property of the JS object
393
+ /// named `object`. The property is lent to a closure as a `JavaScriptUnownedValue` and decoded there the
394
+ /// same way as a `@JS` argument (see `decodeCall`), so the read doesn't allocate a `JavaScriptValue`.
395
+ /// Only the generic free-form decode copies the value into one.
396
+ private func jsObjectReadLines(properties: [RecordProperty], cachesPropNames: Bool) -> [String] {
386
397
  var lines: [String] = []
387
- for property in properties {
398
+ for (index, property) in properties.enumerated() {
388
399
  let valueVar = derivedIdentifier(from: property.name, suffix: "JSValue")
389
- let exprType = expressionType(property.type)
390
- let decode: String
391
- switch property.freeForm {
392
- case .shape(let decodeMethod, _, let isOptional, _):
393
- let call = "try JavaScriptValue.\(decodeMethod)(\(valueVar), in: runtime)"
394
- decode = isOptional ? "\(valueVar).isUndefined() || \(valueVar).isNull() ? nil : \(call)" : call
395
- case .generic:
396
- decode = "try JavaScriptValue.decodeAny(\(valueVar), as: \(exprType).self, in: runtime)"
397
- case nil:
398
- decode = "try \(exprType).decode(\(valueVar), in: runtime)"
399
- }
400
- lines.append(" let \(valueVar) = object.getProperty(\"\(property.key)\")")
400
+ let decode = "try \(decodeCall(property.type, freeForm: property.freeForm, from: valueVar))"
401
+ let propName = propNameArgument(for: property, at: index, cached: cachesPropNames)
402
+ lines.append(" let \(fieldLocal(index)) = try object.withUnownedProperty(\(propName)) { \(valueVar) in")
401
403
  if property.isRequired {
402
- lines.append(" guard !\(valueVar).isUndefined() else {")
403
- lines.append(" throw RecordPropertyRequiredException(\"\(property.key)\")")
404
- lines.append(" }")
405
- lines.append(" let \(property.name) = \(decode)")
404
+ lines.append(" guard !\(valueVar).isUndefined() else {")
405
+ lines.append(" throw RecordPropertyRequiredException(\"\(property.key)\")")
406
+ lines.append(" }")
407
+ lines.append(" return \(decode)")
406
408
  } else if property.isOptional {
407
- // `Optional.decode` (and the free-form reads above) already map `undefined`/`null` to `nil`, so
408
- // the read is a plain decode.
409
- lines.append(" let \(property.name) = \(decode)")
409
+ // `Optional.decode` (and the free-form decodes of optional shapes) already map `undefined` and
410
+ // `null` to `nil`, so the read is a plain decode.
411
+ lines.append(" return \(decode)")
410
412
  } else {
411
- lines.append(" let \(property.name) = \(valueVar).isUndefined() ? \(property.defaultValue!) : \(decode)")
413
+ lines.append(" return \(valueVar).isUndefined() ? \(property.defaultValue!) : \(decode)")
412
414
  }
415
+ lines.append(" }")
413
416
  }
414
417
  return lines
415
418
  }
416
419
 
417
- /// Per-property read statements for the dictionary factory, each producing a `let <name>`. A bare `Any`
418
- /// property has no dynamic type to cast through, and the dictionary value already is an `Any`, so it's
419
- /// read unchanged.
420
+ /// Per-property read statements for the dictionary factory, each producing a `let field<index>` from the
421
+ /// raw dictionary value in `entry<index>`. A bare `Any` property has no dynamic type to cast through, and
422
+ /// the dictionary value already is an `Any`, so it's read unchanged.
420
423
  private func dictionaryReadLines(properties: [RecordProperty]) -> [String] {
421
424
  var lines: [String] = []
422
- for property in properties {
423
- let valueVar = derivedIdentifier(from: property.name, suffix: "Value")
425
+ for (index, property) in properties.enumerated() {
426
+ let field = fieldLocal(index)
427
+ let entry = "entry\(index)"
424
428
  let exprType = expressionType(property.type)
425
429
  let isBareAny = property.freeForm?.isBareAny == true
426
- let cast = isBareAny
427
- ? valueVar
428
- : "try \(exprType).getDynamicType().cast(\(valueVar), appContext: appContext) as! \(exprType)"
429
- lines.append(" let \(valueVar) = dictionary[\"\(property.key)\"]")
430
+ let cast =
431
+ isBareAny
432
+ ? entry
433
+ : "try \(exprType).getDynamicType().cast(\(entry), appContext: appContext) as! \(exprType)"
434
+ lines.append(" let \(entry) = dictionary[\"\(property.key)\"]")
430
435
  if property.isRequired {
431
- lines.append(" guard let \(valueVar) else {")
436
+ lines.append(" guard let \(entry) else {")
432
437
  lines.append(" throw RecordPropertyRequiredException(\"\(property.key)\")")
433
438
  lines.append(" }")
434
- lines.append(" let \(property.name) = \(cast)")
439
+ lines.append(" let \(field) = \(cast)")
435
440
  } else if property.isOptional {
436
- lines.append(" let \(property.name): \(property.type) = (\(valueVar) == nil || \(valueVar)! is NSNull) ? nil : \(cast)")
441
+ lines.append(" let \(field): \(property.type) = (\(entry) == nil || \(entry)! is NSNull) ? nil : \(cast)")
437
442
  } else {
438
- lines.append(" let \(property.name) = \(valueVar) == nil ? \(property.defaultValue!) : \(isBareAny ? valueVar + "!" : cast)")
443
+ lines.append(" let \(field) = \(entry) == nil ? \(property.defaultValue!) : \(isBareAny ? entry + "!" : cast)")
439
444
  }
440
445
  }
441
446
  return lines
442
447
  }
443
448
 
444
- /**
445
- `toDictionary(appContext:)` — converts each property back to a JS-compatible value via the
446
- dynamic type and assembles a `[String: Any]`. Inherited properties are merged in first.
447
- */
448
- private func toDictionaryMethod(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
449
- let overrideKeyword = inheritsRecord ? "override " : ""
450
- var lines: [String] = []
451
- if inheritsRecord {
452
- lines.append(" var dictionary = super.toDictionary(appContext: appContext)")
453
- } else {
454
- lines.append(" var dictionary: [String: Any] = [:]")
455
- }
449
+ /// `toDictionary(appContext:)` — converts each property back to a JS-compatible value via the
450
+ /// dynamic type and assembles a `[String: Any]`.
451
+ private func toDictionaryMethod(properties: [RecordProperty]) -> DeclSyntax {
452
+ var lines = [" var dictionary: [String: Any] = [:]"]
456
453
  for property in properties {
457
454
  lines.append(" dictionary[\"\(property.key)\"] = self.\(property.name)")
458
455
  }
459
456
  lines.append(" return dictionary")
460
- let body = lines.joined(separator: "\n")
461
-
462
- if inheritsRecord {
463
- return """
464
- public \(raw: overrideKeyword)func toDictionary(appContext: AppContext? = nil) -> [String: Any] {
465
- \(raw: body)
466
- }
467
- """
468
- }
469
457
  return """
470
458
  public func toDictionary(appContext: AppContext? = nil) -> [String: Any] {
471
- \(raw: body)
459
+ \(raw: lines.joined(separator: "\n"))
472
460
  }
473
461
  """
474
462
  }
475
463
 
476
- /**
477
- `toObject(appContext:)` — builds a `JavaScriptObject` directly, encoding each property with
478
- `JavaScriptEncodable.encode` (or, for a free-form property, the matching `JavaScriptValue` free-form
479
- method). The fast write path mirroring `from(object:)`. The `runtime` is bound
480
- from the app context once and threaded to every write. Subclasses chain to `super` so inherited
481
- properties are written first.
482
- */
483
- private func toObjectMethod(properties: [RecordProperty], inheritsRecord: Bool) -> DeclSyntax {
484
- let overrideKeyword = inheritsRecord ? "override " : ""
485
- var lines: [String] = []
486
- // The base case needs the runtime to create the object; the inheriting case only needs it when
487
- // there's a property to encode (it chains to `super` for the object itself). Binding it when unused
488
- // would warn.
489
- if !inheritsRecord || !properties.isEmpty {
490
- lines.append(" let runtime = try appContext.runtime")
491
- }
492
- if inheritsRecord {
493
- lines.append(" let object = try super.toObject(appContext: appContext)")
494
- } else {
495
- lines.append(" let object = runtime.createObject()")
496
- }
497
- for property in properties {
498
- let exprType = expressionType(property.type)
464
+ /// `toObject(appContext:)`, which forwards to the record's own `encode(_:in:)` witness (see
465
+ /// `javaScriptCodableWitnesses`), where the property writes are.
466
+ private func toObjectMethod() -> DeclSyntax {
467
+ return """
468
+ @JavaScriptActor
469
+ public func toObject(appContext: AppContext) throws -> JavaScriptObject {
470
+ return try Self.encode(self, in: appContext.runtime).asObject()
471
+ }
472
+ """
473
+ }
474
+
475
+ /// The record's own `JavaScriptDecodable` and `JavaScriptEncodable` witnesses, which convert in the
476
+ /// runtime they're given. Without them the record would convert through `Record`'s default witnesses,
477
+ /// which recover the app context from the runtime and convert in the app context's runtime.
478
+ ///
479
+ /// `decode` reads each property off the JS object into a local, then constructs the record through the
480
+ /// memberwise init. Required properties throw when undefined; defaulted properties fall back to the
481
+ /// property's declared default (inlined) when undefined; optional properties become `nil` when
482
+ /// undefined/null. It's the unowned overload: an owning value decodes through `JavaScriptDecodable`'s
483
+ /// default, which forwards to it. `encode` builds the JS object, writing each property. Its parameter
484
+ /// names the type, because a class can't take `Self` as a parameter type.
485
+ private func javaScriptCodableWitnesses(
486
+ properties: [RecordProperty],
487
+ typeName: String,
488
+ cachesPropNames: Bool
489
+ ) -> [DeclSyntax] {
490
+ // An empty record still decodes only from an object, but has no property to read off it.
491
+ let objectLine =
492
+ properties.isEmpty
493
+ ? " _ = try value.asObject(in: runtime)"
494
+ : " let object = try value.asObject(in: runtime)"
495
+ let lookupLines = cachesPropNames ? propNamesLookupLines() : []
496
+ let readLines = lookupLines + jsObjectReadLines(properties: properties, cachesPropNames: cachesPropNames)
497
+ let decodeBody = objectLine + "\n" + factoryBody(properties: properties, readLines: readLines)
498
+ let encodeLines =
499
+ [" let object = runtime.createObject()"]
500
+ + lookupLines
501
+ + objectWriteLines(properties: properties, of: "value", cachesPropNames: cachesPropNames)
502
+ + [" return object.asValue()"]
503
+ return [
504
+ """
505
+ @JavaScriptActor
506
+ public static func decode(_ value: borrowing JavaScriptUnownedValue, in runtime: borrowing JavaScriptRuntime) throws -> Self {
507
+ \(raw: decodeBody)
508
+ }
509
+ """,
510
+ """
511
+ @JavaScriptActor
512
+ public static func encode(_ value: \(raw: typeName), in runtime: borrowing JavaScriptRuntime) throws -> JavaScriptValue {
513
+ \(raw: encodeLines.joined(separator: "\n"))
514
+ }
515
+ """,
516
+ ]
517
+ }
518
+
519
+ /// The statements that write each property of `record` (`self` or the `encode` parameter) into the
520
+ /// `object` local, encoding it with `JavaScriptEncodable.encode` or, for a free-form property, the
521
+ /// matching `JavaScriptValue` free-form method.
522
+ private func objectWriteLines(properties: [RecordProperty], of record: String, cachesPropNames: Bool) -> [String] {
523
+ return properties.enumerated().map { index, property in
524
+ let value = "\(record).\(property.name)"
499
525
  let encode: String
500
526
  switch property.freeForm {
501
527
  case .shape(_, let encodeMethod, let isOptional, _):
502
- encode = isOptional
503
- ? "self.\(property.name) == nil ? .null() : try JavaScriptValue.\(encodeMethod)(self.\(property.name)!, in: runtime)"
504
- : "try JavaScriptValue.\(encodeMethod)(self.\(property.name), in: runtime)"
528
+ encode =
529
+ isOptional
530
+ ? "\(value) == nil ? .null() : try JavaScriptValue.\(encodeMethod)(\(value)!, in: runtime)"
531
+ : "try JavaScriptValue.\(encodeMethod)(\(value), in: runtime)"
505
532
  case .generic:
506
533
  // The explicit `as Any` keeps an optional property from warning about its implicit coercion.
507
- encode = "try JavaScriptValue.encodeAny(self.\(property.name) as Any, in: runtime)"
534
+ encode = "try JavaScriptValue.encodeAny(\(value) as Any, in: runtime)"
508
535
  case nil:
509
- encode = "try \(exprType).encode(self.\(property.name), in: runtime)"
536
+ encode = "try \(expressionType(property.type)).encode(\(value), in: runtime)"
510
537
  }
511
- lines.append(" object.setProperty(\"\(property.key)\", value: \(encode))")
538
+ let propName = propNameArgument(for: property, at: index, cached: cachesPropNames)
539
+ return " object.setProperty(\(propName), value: \(encode))"
512
540
  }
513
- lines.append(" return object")
514
- let body = lines.joined(separator: "\n")
541
+ }
515
542
 
543
+ /// The name of the nested class that holds the property names (see `propNamesClass`).
544
+ private let propNamesTypeName = "_PropNames"
545
+
546
+ /// A nested class that holds the `JavaScriptPropNameID` of each property, `_name<index>` for the property
547
+ /// at `index`, and the runtime cache key for its instances. Each runtime creates one instance, so the
548
+ /// whole record takes one cache key.
549
+ private func propNamesClass(properties: [RecordProperty]) -> DeclSyntax {
550
+ let storedProperties = properties.indices.map { index in
551
+ " let \(propNameMember(index)): JavaScriptPropNameID"
552
+ }
553
+ let assignments = properties.enumerated().map { index, property in
554
+ " self.\(propNameMember(index)) = JavaScriptPropNameID(runtime, string: \"\(property.key)\")"
555
+ }
516
556
  return """
517
- @JavaScriptActor
518
- public \(raw: overrideKeyword)func toObject(appContext: AppContext) throws -> JavaScriptObject {
519
- \(raw: body)
557
+ private final class \(raw: propNamesTypeName) {
558
+ static let _key = JavaScriptRuntime.Cache.Key<\(raw: propNamesTypeName)>()
559
+
560
+ \(raw: storedProperties.joined(separator: "\n"))
561
+
562
+ init(_ runtime: borrowing JavaScriptRuntime) {
563
+ \(raw: assignments.joined(separator: "\n"))
564
+ }
520
565
  }
521
566
  """
522
567
  }
523
568
 
569
+ private func propNameMember(_ index: Int) -> String {
570
+ return "_name\(index)"
571
+ }
572
+
573
+ /// The statements that get the record's property names from the runtime cache into `_propNames`, at the
574
+ /// start of `decode` and `encode`.
575
+ private func propNamesLookupLines() -> [String] {
576
+ return [
577
+ " let _propNames = runtime.cached(\(propNamesTypeName)._key) {",
578
+ " return \(propNamesTypeName)(runtime)",
579
+ " }",
580
+ ]
581
+ }
582
+
583
+ /// The first argument of the `withUnownedProperty` and `setProperty` calls for the property at `index`:
584
+ /// its name from `_propNames`, or the key as a string literal when the record can't hold the cache key.
585
+ private func propNameArgument(for property: RecordProperty, at index: Int, cached: Bool) -> String {
586
+ return cached ? "_propNames.\(propNameMember(index))" : "\"\(property.key)\""
587
+ }
588
+
589
+ /// True when the record or a type that encloses it is generic. Swift doesn't support static stored
590
+ /// properties in generic types, so the record can't hold the cache key.
591
+ private func isInGenericContext(_ declaration: some DeclGroupSyntax, context: some MacroExpansionContext) -> Bool {
592
+ // The lexical context of a member macro usually starts with the type it's attached to, but the
593
+ // declaration is checked on its own too.
594
+ return ([Syntax(declaration)] + context.lexicalContext).contains { node in
595
+ return node.asProtocol(WithGenericParametersSyntax.self)?.genericParameterClause != nil
596
+ }
597
+ }
598
+
524
599
  // MARK: - Helpers
525
600
 
526
- /**
527
- The parameter-label list of every initializer the type already declares, used to avoid emitting a
528
- duplicate of one the author hand-wrote. Each entry is the ordered external argument labels of one
529
- `init` — e.g. `init(name: String, count: Int)` → `["name", "count"]`, and `init()` → `[]`. A
530
- parameter written with `_` or with no external label contributes its internal name's absence as an
531
- empty-string label, which simply won't match the macro's always-labeled signatures (a safe miss).
532
- */
601
+ /// The parameter-label list of every initializer the type already declares, used to avoid emitting a
602
+ /// duplicate of one the author hand-wrote. Each entry is the ordered external argument labels of one
603
+ /// `init` — e.g. `init(name: String, count: Int)` → `["name", "count"]`, and `init()` → `[]`. A
604
+ /// parameter written with `_` or with no external label contributes its internal name's absence as an
605
+ /// empty-string label, which simply won't match the macro's always-labeled signatures (a safe miss).
533
606
  private func initializerParameterLabels(of declaration: some DeclGroupSyntax) -> [[String]] {
534
607
  var signatures: [[String]] = []
535
608
  for member in declaration.memberBlock.members {
@@ -546,18 +619,59 @@ private func initializerParameterLabels(of declaration: some DeclGroupSyntax) ->
546
619
  return signatures
547
620
  }
548
621
 
549
- /**
550
- True if the class declaration has any inheritance clause. Used as a heuristic for
551
- whether the superclass also conforms to `Record` and provides the synthesized methods;
552
- the macro emits `override` in this case.
553
- */
554
- private func classHasInheritance(_ declaration: some DeclGroupSyntax) -> Bool {
555
- guard let classDecl = declaration.as(ClassDeclSyntax.self),
556
- let inherited = classDecl.inheritanceClause?.inheritedTypes,
557
- !inherited.isEmpty else {
558
- return false
622
+ /// The errors for a class the macro can't synthesize a record for: one that isn't `final`, or one
623
+ /// with an inheritance clause. Empty for a struct and for a supported class.
624
+ private func unsupportedClassDiagnostics(of declaration: some DeclGroupSyntax) -> [Diagnostic] {
625
+ guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
626
+ return []
627
+ }
628
+ let name = classDecl.name.text
629
+ var diagnostics: [Diagnostic] = []
630
+ if !classDecl.modifiers.contains(where: { $0.name.tokenKind == .keyword(.final) }) {
631
+ // An `open` class can't also be `final`, so changing its access level is left to the author.
632
+ let isOpen = classDecl.modifiers.contains(where: { $0.name.tokenKind == .keyword(.open) })
633
+ diagnostics.append(
634
+ Diagnostic(
635
+ node: classDecl.classKeyword,
636
+ message: RecordDiagnosticMessage(
637
+ "@Record class '\(name)' must be final, because the macro doesn't support subclasses of a record",
638
+ id: "record-class-not-final"
639
+ ),
640
+ fixIts: isOpen ? [] : [markFinalFixIt(for: classDecl)]
641
+ )
642
+ )
643
+ }
644
+ if let inheritanceClause = classDecl.inheritanceClause {
645
+ diagnostics.append(
646
+ Diagnostic(
647
+ node: inheritanceClause,
648
+ message: RecordDiagnosticMessage(
649
+ "@Record class '\(name)' can't have an inheritance clause: a record class can't have a superclass, and the macro can't tell a superclass from a protocol. Declare protocol conformances in an extension",
650
+ id: "record-class-inheritance"
651
+ )
652
+ )
653
+ )
559
654
  }
560
- return true
655
+ return diagnostics
656
+ }
657
+
658
+ /// The fix-it for a record class that isn't `final`: add the modifier right before `class`.
659
+ private func markFinalFixIt(for classDecl: ClassDeclSyntax) -> FixIt {
660
+ // The new modifier takes over the leading trivia of `class` (the line break after the attribute
661
+ // when no other modifier comes first), so it lands where `class` started.
662
+ let classKeyword = classDecl.classKeyword
663
+ var modifiers = classDecl.modifiers
664
+ modifiers.append(
665
+ DeclModifierSyntax(name: .keyword(.final, leadingTrivia: classKeyword.leadingTrivia, trailingTrivia: .space))
666
+ )
667
+ let newDecl =
668
+ classDecl
669
+ .with(\.modifiers, modifiers)
670
+ .with(\.classKeyword, classKeyword.with(\.leadingTrivia, []))
671
+ return FixIt(
672
+ message: RecordFixItMessage("Mark the class 'final'", id: "record-class-insert-final"),
673
+ changes: [.replace(oldNode: Syntax(classDecl), newNode: Syntax(newDecl))]
674
+ )
561
675
  }
562
676
 
563
677
  private struct RecordDiagnosticMessage: DiagnosticMessage {
@@ -570,3 +684,13 @@ private struct RecordDiagnosticMessage: DiagnosticMessage {
570
684
  self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
571
685
  }
572
686
  }
687
+
688
+ private struct RecordFixItMessage: FixItMessage {
689
+ let message: String
690
+ let fixItID: MessageID
691
+
692
+ init(_ message: String, id: String) {
693
+ self.message = message
694
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
695
+ }
696
+ }