expo-modules-macros 0.0.1 → 0.15.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 +389 -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,389 @@
1
+ import SwiftDiagnostics
2
+ import SwiftSyntax
3
+ import SwiftSyntaxBuilder
4
+ import SwiftSyntaxMacros
5
+
6
+ /// Member + extension macro applied to an `enum` whose cases each carry one associated value: a typed
7
+ /// union of the payload types (`A | B | C` in TypeScript). The enum is a tagged union at the Swift
8
+ /// level, so the author switches over it exhaustively with each payload keeping its static type, and
9
+ /// the macro synthesizes the conversion surface that makes it a JS boundary type:
10
+ ///
11
+ /// - `decodableKinds`: the union of the payloads' `decodableKinds`, the JS kinds the union can decode.
12
+ /// - `decode(_:in:)`: an ordered decode that tries each case's payload converter in declaration order
13
+ /// and returns the first case that decodes; when none does, it throws
14
+ /// `Exceptions.UnionCaseMismatch` naming the union, the JS kind received, and the alternatives.
15
+ /// Only the overload taking a borrowed `JavaScriptUnownedValue` is emitted. The overload taking an
16
+ /// owning `JavaScriptValue` comes from the `JavaScriptDecodable` default, which borrows the value
17
+ /// and calls this one.
18
+ /// - `encode(_:in:)`: a `switch` over the cases, encoding the payload through its own type.
19
+ /// - `as(_:)`: one throwing overload per case, keyed by the payload's metatype, returning that payload
20
+ /// (`try source.as(String.self)` is `String`; `try? source.as(String.self)` is `String?`). It unwraps
21
+ /// by type without naming the case and throws `Exceptions.UnionCaseMismatch` when the union holds a
22
+ /// different case. Since a payload type may appear only once, each overload is unambiguous, and asking
23
+ /// for a type the union doesn't carry is a compile error.
24
+ ///
25
+ /// The type is auto-conformed to `JavaScriptDecodable` and `JavaScriptEncodable`, so it can be a `@JS`
26
+ /// argument or return value, an `@Event` payload, or nested inside an optional, array, or dictionary.
27
+ /// Author-facing shape:
28
+ ///
29
+ /// @Union
30
+ /// enum Source {
31
+ /// case text(String)
32
+ /// case options(SourceOptions) // a @Record
33
+ /// }
34
+ ///
35
+ /// Discrimination is structural and order-dependent: the first case whose payload decodes wins. When
36
+ /// two payload shapes overlap (two records with compatible fields, `Int` and `Double`), the earlier
37
+ /// case matches; the author orders the more specific case first. A case may not repeat another case's
38
+ /// payload type, since it could never be chosen.
39
+ ///
40
+ /// The named-union counterpart of core's `Either`: any number of named cases instead of two anonymous
41
+ /// slots, no `Any?` box, and an exhaustive `switch` as the primary way to read it (`as(_:)` covers the
42
+ /// one-type lookup `Either.as(_:)` offered, with the same throwing shape).
43
+ public struct UnionMacro: MemberMacro, ExtensionMacro {
44
+ public static func expansion(
45
+ of node: AttributeSyntax,
46
+ providingMembersOf declaration: some DeclGroupSyntax,
47
+ conformingTo protocols: [TypeSyntax],
48
+ in context: some MacroExpansionContext
49
+ ) throws -> [DeclSyntax] {
50
+ let union = try validatedUnion(of: declaration)
51
+
52
+ var members: [DeclSyntax] = []
53
+
54
+ // A single never-called member that makes the compiler verify each payload type converts both
55
+ // ways. Each case keeps its own named assertion inside, so the conformance diagnostic names the
56
+ // offending case (see `typeConformanceAssertions`). Emitted first so a non-conforming payload
57
+ // reports the clear "requires that '…' conform to …" error ahead of the noisier "no member
58
+ // 'decode'"/"'encode'" errors from the conversion code below.
59
+ let assertions = union.cases.map { ConformanceAssertion(name: $0.name, types: [$0.payloadType]) }
60
+ if let assertionMember = typeConformanceAssertions(for: assertions, constraint: unionPayloadProtocolName) {
61
+ members.append(assertionMember)
62
+ }
63
+
64
+ members.append(decodableKindsProperty(union: union))
65
+ members.append(decodeMethod(union: union))
66
+ members.append(encodeMethod(union: union))
67
+ members.append(contentsOf: accessorMethods(union: union))
68
+ members.append(payloadTypeNameProperty(union: union))
69
+ return members
70
+ }
71
+
72
+ /// Auto-conforms the enum to `JavaScriptDecodable` and `JavaScriptEncodable`, the protocols whose
73
+ /// requirements are exactly the `decode`/`encode` members synthesized above. A conformance the author
74
+ /// already spelled out in the inheritance clause is not repeated.
75
+ public static func expansion(
76
+ of node: AttributeSyntax,
77
+ attachedTo declaration: some DeclGroupSyntax,
78
+ providingExtensionsOf type: some TypeSyntaxProtocol,
79
+ conformingTo protocols: [TypeSyntax],
80
+ in context: some MacroExpansionContext
81
+ ) throws -> [ExtensionDeclSyntax] {
82
+ // Diagnostics are owned by the member expansion; an invalid declaration silently emits no extension
83
+ // here, so each error is reported once and no witness-less conformance piles "does not conform"
84
+ // errors on top of it.
85
+ guard (try? validatedUnion(of: declaration)) != nil else {
86
+ return []
87
+ }
88
+ // The compiler hands over only the conformances the type still lacks; the test harness passes the
89
+ // declared list verbatim, so filter against the inheritance clause here as well.
90
+ let missing = protocols.filter { protocolType in
91
+ guard let name = baseIdentifier(of: protocolType) else {
92
+ return true
93
+ }
94
+ return !inheritsProtocol(named: name, in: declaration)
95
+ }
96
+ guard !missing.isEmpty else {
97
+ return []
98
+ }
99
+
100
+ let conformances = missing.map { $0.trimmedDescription }.joined(separator: ", ")
101
+ let ext: DeclSyntax = """
102
+ extension \(type.trimmed): \(raw: conformances) {}
103
+ """
104
+ guard let extDecl = ext.as(ExtensionDeclSyntax.self) else {
105
+ return []
106
+ }
107
+ return [extDecl]
108
+ }
109
+ }
110
+
111
+ // MARK: - Union model
112
+
113
+ /// A validated `@Union` enum: its spelled name (for the mismatch error) and its cases in declaration
114
+ /// order (the decode order).
115
+ private struct UnionType {
116
+ let name: String
117
+ let cases: [UnionCase]
118
+ }
119
+
120
+ /// One alternative of the union: the case name, its single payload type as written, and the payload's
121
+ /// argument label when the author gave it one (`case id(value: Int)`), needed to construct the case.
122
+ private struct UnionCase {
123
+ let name: String
124
+ let payloadType: String
125
+ let payloadLabel: String?
126
+
127
+ /// The expression constructing this case from a `payload` local: `.id(payload)`, or
128
+ /// `.id(value: payload)` for a labeled associated value.
129
+ var construction: String {
130
+ if let payloadLabel {
131
+ return ".\(name)(\(payloadLabel): payload)"
132
+ }
133
+ return ".\(name)(payload)"
134
+ }
135
+
136
+ /// The pattern binding this case's payload to a `payload` local in a `switch`.
137
+ var pattern: String {
138
+ return ".\(name)(let payload)"
139
+ }
140
+ }
141
+
142
+ /// Reads and validates the union off the attached declaration. Every check is a compile error located
143
+ /// on the offending node: the macro must be on a non-generic `enum` with at least one case, and every
144
+ /// case must carry exactly one associated value, its payload type distinct from every earlier case's.
145
+ private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> UnionType {
146
+ guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
147
+ throw MacroExpansionErrorMessage("@Union can only be applied to an enum")
148
+ }
149
+ if let genericParameterClause = enumDecl.genericParameterClause {
150
+ throw DiagnosticsError(diagnostics: [
151
+ Diagnostic(
152
+ node: genericParameterClause,
153
+ message: UnionDiagnosticMessage(
154
+ "@Union cannot be applied to a generic enum: each case's payload type must be concrete so the macro can select its converter.",
155
+ id: "union-generic-enum"
156
+ ))
157
+ ])
158
+ }
159
+
160
+ var cases: [UnionCase] = []
161
+ var seenPayloadTypes: [String: String] = [:]
162
+ var diagnostics: [Diagnostic] = []
163
+
164
+ for member in enumDecl.memberBlock.members {
165
+ guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
166
+ continue
167
+ }
168
+ for element in caseDecl.elements {
169
+ let name = element.name.text
170
+ guard let parameters = element.parameterClause?.parameters, !parameters.isEmpty else {
171
+ diagnostics.append(
172
+ Diagnostic(
173
+ node: element,
174
+ message: UnionDiagnosticMessage(
175
+ "@Union case '\(name)' must carry exactly one associated value: the type this alternative decodes from. A payload-less enum is not a union; make it a raw-value enum conforming to 'Enumerable' instead.",
176
+ id: "union-case-without-payload"
177
+ )))
178
+ continue
179
+ }
180
+ guard parameters.count == 1, let parameter = parameters.first else {
181
+ diagnostics.append(
182
+ Diagnostic(
183
+ node: element,
184
+ message: UnionDiagnosticMessage(
185
+ "@Union case '\(name)' must carry exactly one associated value, but has \(parameters.count). Group them in a @Record type and use it as the single payload.",
186
+ id: "union-case-with-multiple-payloads"
187
+ )))
188
+ continue
189
+ }
190
+
191
+ // A default on the associated value can never apply: the macro constructs the case from the
192
+ // decoded JS value every time, and there is no "omitted" slot the way a `@Record` property has.
193
+ // Left in place it would read as a JS-side default that doesn't exist, so it's rejected with a
194
+ // fix-it that removes it.
195
+ if let defaultValue = parameter.defaultValue {
196
+ diagnostics.append(defaultValueDiagnostic(for: parameter, defaultValue: defaultValue, caseName: name))
197
+ continue
198
+ }
199
+
200
+ let payloadType = parameter.type.trimmedDescription
201
+ // Alternatives decode in declaration order and the first success wins, so a case whose payload
202
+ // type repeats an earlier case's is unreachable: an exact-spelling duplicate is an error. (An
203
+ // overlap between *different* types, like `Int` and `Double`, is the author's ordering call and
204
+ // isn't checked here.)
205
+ let normalizedType = payloadType.filter { !$0.isWhitespace }
206
+ if let earlierCase = seenPayloadTypes[normalizedType] {
207
+ diagnostics.append(
208
+ Diagnostic(
209
+ node: element,
210
+ message: UnionDiagnosticMessage(
211
+ "@Union case '\(name)' repeats the payload type '\(payloadType)' of case '\(earlierCase)' and can never be decoded: alternatives are tried in declaration order and the first match wins.",
212
+ id: "union-duplicate-payload-type"
213
+ )))
214
+ continue
215
+ }
216
+ seenPayloadTypes[normalizedType] = name
217
+
218
+ // `firstName` is the associated value's label (`case id(value: Int)`); a `_` label is the same as
219
+ // none for construction purposes.
220
+ let label = parameter.firstName.flatMap { $0.text == "_" ? nil : $0.text }
221
+ cases.append(UnionCase(name: name, payloadType: payloadType, payloadLabel: label))
222
+ }
223
+ }
224
+
225
+ if !diagnostics.isEmpty {
226
+ throw DiagnosticsError(diagnostics: diagnostics)
227
+ }
228
+ if cases.isEmpty {
229
+ throw MacroExpansionErrorMessage("@Union requires at least one case carrying an associated value")
230
+ }
231
+ return UnionType(name: enumDecl.name.text, cases: cases)
232
+ }
233
+
234
+ // MARK: - Synthesized members
235
+
236
+ /// `decode(_:in:)`: tries each case's payload converter in declaration order and returns the first
237
+ /// case that decodes. Only the overload taking a borrowed `JavaScriptUnownedValue` is emitted; the
238
+ /// overload taking an owning `JavaScriptValue` comes from the `JavaScriptDecodable` default, which
239
+ /// borrows the value and calls this one. Each payload decode receives the same borrowed value. The
240
+ /// value's kind is read once, and each candidate is gated on its payload's `decodableKinds`, so a case
241
+ /// that can't accept that kind is skipped with a mask test instead of a thrown and discarded error. `try?` turns a candidate's failure into "try the next one" without erasing the
242
+ /// payload (each `payload` local keeps its concrete type); the candidate's own error is discarded, since
243
+ /// with several alternatives there is no single failure to surface. When no alternative accepts the
244
+ /// value the factory throws `Exceptions.UnionCaseMismatch`, naming the union, the JS kind of the value
245
+ /// received, and every payload type the union accepts. `JavaScriptUnownedValue` has no `kind`, so the
246
+ /// error reads it from an owning copy; the copy is made only on this failure path.
247
+ private func decodeMethod(union: UnionType) -> DeclSyntax {
248
+ var lines: [String] = [" let kind = JavaScriptValueKinds(of: value)"]
249
+ for unionCase in union.cases {
250
+ let payloadType = expressionType(unionCase.payloadType)
251
+ lines.append(" if \(payloadType).decodableKinds.contains(kind), let payload = try? \(payloadType).decode(value, in: runtime) {")
252
+ lines.append(" return \(unionCase.construction)")
253
+ lines.append(" }")
254
+ }
255
+ let expected = union.cases.map { "\"\($0.payloadType)\"" }.joined(separator: ", ")
256
+ let mismatch = "(unionName: \"\(union.name)\", received: value.copied(in: runtime).kind.rawValue, expected: [\(expected)])"
257
+ lines.append(" throw Exceptions.UnionCaseMismatch(\(mismatch))")
258
+ let body = lines.joined(separator: "\n")
259
+ return """
260
+ @JavaScriptActor
261
+ public static func decode(_ value: borrowing JavaScriptUnownedValue, in runtime: borrowing JavaScriptRuntime) throws -> Self {
262
+ \(raw: body)
263
+ }
264
+ """
265
+ }
266
+
267
+ /// `decodableKinds`: the union of the payloads' kinds, so a union nested in another union, or wrapped
268
+ /// in an optional, is skipped as precisely as its payloads are.
269
+ private func decodableKindsProperty(union: UnionType) -> DeclSyntax {
270
+ let kinds = union.cases.map { "\(expressionType($0.payloadType)).decodableKinds" }
271
+ // An option set's array literal is the union of its elements, which reads like the case list. The
272
+ // optimizer folds it to a constant after specialization, the same as a chain of `union(_:)` calls.
273
+ let expression = kinds.count == 1 ? kinds[0] : "[\(kinds.joined(separator: ", "))]"
274
+ return """
275
+ public static var decodableKinds: JavaScriptValueKinds {
276
+ return \(raw: expression)
277
+ }
278
+ """
279
+ }
280
+
281
+ /// `encode(_:in:)`: a `switch` over the cases, each encoding its payload through the payload type's
282
+ /// own `encode`. Exhaustive by construction, so a case added later without re-expansion can't slip
283
+ /// through silently.
284
+ private func encodeMethod(union: UnionType) -> DeclSyntax {
285
+ var lines: [String] = [" switch value {"]
286
+ for unionCase in union.cases {
287
+ lines.append(" case \(unionCase.pattern):")
288
+ lines.append(" return try \(expressionType(unionCase.payloadType)).encode(payload, in: runtime)")
289
+ }
290
+ lines.append(" }")
291
+ let body = lines.joined(separator: "\n")
292
+ return """
293
+ @JavaScriptActor
294
+ public static func encode(_ value: Self, in runtime: borrowing JavaScriptRuntime) throws -> JavaScriptValue {
295
+ \(raw: body)
296
+ }
297
+ """
298
+ }
299
+
300
+ /// `as(_:)`: a typed accessor per case, selected by the payload's metatype rather than the case name,
301
+ /// so a caller that only knows the type it wants writes `try value.as(String.self)` and gets a `String`.
302
+ /// When
303
+ /// the union holds a different case it throws `Exceptions.UnionCaseMismatch`, the same error `decode`
304
+ /// throws, with the held case's payload type as `received` and the requested type as `expected`. The
305
+ /// overloads can't collide because a payload type appears in at most one case (enforced above), and a
306
+ /// metatype the union doesn't carry fails to resolve at compile time. `as` is a keyword, so the
307
+ /// declaration is backticked; a call site after a dot (`value.as(…)`) needs no backticks. The parameter
308
+ /// is spelled in expression form (`T!` rewritten to `T?`), since `T!.Type` isn't valid.
309
+ private func accessorMethods(union: UnionType) -> [DeclSyntax] {
310
+ return union.cases.map { unionCase in
311
+ let payloadType = expressionType(unionCase.payloadType)
312
+ let mismatch = "(unionName: \"\(union.name)\", received: _payloadTypeName, expected: [\"\(unionCase.payloadType)\"])"
313
+ return """
314
+ public func `as`(_ type: \(raw: payloadType).Type) throws -> \(raw: payloadType) {
315
+ if case \(raw: unionCase.pattern) = self {
316
+ return payload
317
+ }
318
+ throw Exceptions.UnionCaseMismatch(\(raw: mismatch))
319
+ }
320
+ """
321
+ }
322
+ }
323
+
324
+ /// `_payloadTypeName`: the spelled payload type of the case the union currently holds, as written in the
325
+ /// declaration, so every `as(_:)` overload reports the actual type in its mismatch error through one
326
+ /// shared `switch` instead of each overload enumerating the other cases.
327
+ private func payloadTypeNameProperty(union: UnionType) -> DeclSyntax {
328
+ var lines: [String] = [" switch self {"]
329
+ for unionCase in union.cases {
330
+ lines.append(" case .\(unionCase.name):")
331
+ lines.append(" return \"\(unionCase.payloadType)\"")
332
+ }
333
+ lines.append(" }")
334
+ let body = lines.joined(separator: "\n")
335
+ return """
336
+ private var _payloadTypeName: String {
337
+ \(raw: body)
338
+ }
339
+ """
340
+ }
341
+
342
+ // MARK: - Diagnostics
343
+
344
+ /// The error for a default value on a case's associated value, attached to the `= …` clause and carrying
345
+ /// a fix-it that deletes it (trimming the space the type carried before the `=`).
346
+ private func defaultValueDiagnostic(
347
+ for parameter: EnumCaseParameterSyntax,
348
+ defaultValue: InitializerClauseSyntax,
349
+ caseName: String
350
+ ) -> Diagnostic {
351
+ let fixIt = FixIt(
352
+ message: UnionFixItMessage("Remove the default value", id: "union-remove-default-value"),
353
+ changes: [
354
+ .replace(
355
+ oldNode: Syntax(parameter),
356
+ newNode: Syntax(
357
+ parameter
358
+ .with(\.type, parameter.type.with(\.trailingTrivia, []))
359
+ .with(\.defaultValue, nil))
360
+ )
361
+ ]
362
+ )
363
+ let message = UnionDiagnosticMessage(
364
+ "@Union case '\(caseName)' cannot give its associated value a default: the payload is always decoded from the JavaScript value, so the default would never apply. Remove '\(defaultValue.trimmedDescription)'.",
365
+ id: "union-case-default-value"
366
+ )
367
+ return Diagnostic(node: defaultValue, message: message, fixIts: [fixIt])
368
+ }
369
+
370
+ private struct UnionDiagnosticMessage: DiagnosticMessage {
371
+ let message: String
372
+ let diagnosticID: MessageID
373
+ let severity: DiagnosticSeverity = .error
374
+
375
+ init(_ message: String, id: String) {
376
+ self.message = message
377
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
378
+ }
379
+ }
380
+
381
+ private struct UnionFixItMessage: FixItMessage {
382
+ let message: String
383
+ let fixItID: MessageID
384
+
385
+ init(_ message: String, id: String) {
386
+ self.message = message
387
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
388
+ }
389
+ }