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,294 @@
1
+ import SwiftDiagnostics
2
+ import SwiftSyntax
3
+ import SwiftSyntaxMacros
4
+
5
+ /// Marker macro applied to module / shared-object members that should be exposed to JavaScript.
6
+ /// `@ExpoModule` and `@SharedObject` discover declarations carrying this attribute and bind each one
7
+ /// directly into the JS object, with the decode-call-encode body inlined into the binding; that part
8
+ /// of the expansion lives in those macros.
9
+ ///
10
+ /// On its own, `@JS` emits one thing: a never-called peer that asserts each type crossing the JS
11
+ /// boundary is convertible in the direction it travels — arguments are `JavaScriptDecodable`, return
12
+ /// values are `JavaScriptEncodable` (a settable property's value type is both). Because it's a
13
+ /// **peer** of the marked member, a non-conforming type produces a compile error located on the
14
+ /// user's own `@JS` declaration rather than on the enclosing `@ExpoModule`. The assertion mechanism
15
+ /// itself is shared (see `directionalConformanceAssertion`); `@JS` only supplies the boundary types,
16
+ /// split by direction, that it reads off the declaration.
17
+ ///
18
+ /// Usage:
19
+ ///
20
+ /// @JS
21
+ /// func greet(name: String) -> String { ... }
22
+ ///
23
+ /// @JS("doWork")
24
+ /// func performWork() async throws { ... }
25
+ ///
26
+ /// @JS
27
+ /// var status: String { "ok" }
28
+ public struct JSMacro: PeerMacro {
29
+ public static func expansion(
30
+ of node: AttributeSyntax,
31
+ providingPeersOf declaration: some DeclSyntaxProtocol,
32
+ in context: some MacroExpansionContext
33
+ ) throws -> [DeclSyntax] {
34
+ diagnoseFreeFormTypes(in: declaration, in: context)
35
+ diagnoseConcurrentOption(of: node, on: declaration, in: context)
36
+
37
+ guard let member = boundaryMember(of: declaration),
38
+ let assertion = directionalConformanceAssertion(
39
+ name: member.name,
40
+ decodableTypes: member.decodableTypes,
41
+ encodableType: member.encodableType,
42
+ isStatic: member.isStatic
43
+ ) else {
44
+ return []
45
+ }
46
+ return [assertion]
47
+ }
48
+ }
49
+
50
+ /// Emits the diagnostic for `@JS(.concurrent)` on a member that can't take it. The option maps to
51
+ /// Swift's `@concurrent`, which requires an `async` function: a synchronous member has nowhere to
52
+ /// suspend, and a property or initializer can't be async at all. Diagnosing here points at the
53
+ /// user's own `@JS` attribute rather than at the `@concurrent` the module macro would attach.
54
+ private func diagnoseConcurrentOption(
55
+ of node: AttributeSyntax,
56
+ on declaration: some DeclSyntaxProtocol,
57
+ in context: some MacroExpansionContext
58
+ ) {
59
+ guard hasJSOption(node, named: "concurrent") else {
60
+ return
61
+ }
62
+ if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
63
+ guard funcDecl.signature.effectSpecifiers?.asyncSpecifier == nil else {
64
+ return
65
+ }
66
+ context.diagnose(
67
+ Diagnostic(
68
+ node: node,
69
+ message: JSDiagnosticMessage(
70
+ "'.concurrent' needs an 'async' function: a synchronous @JS member runs on the JavaScript thread by definition. Mark the function 'async' to run its body off that thread.",
71
+ id: "js-concurrent-requires-async",
72
+ severity: .error
73
+ ),
74
+ fixIts: [insertAsyncFixIt(for: funcDecl)]))
75
+ return
76
+ }
77
+ context.diagnose(
78
+ Diagnostic(
79
+ node: node,
80
+ message: JSDiagnosticMessage(
81
+ "'.concurrent' applies only to an 'async' @JS function, not to a property or initializer.",
82
+ id: "js-concurrent-requires-function",
83
+ severity: .error
84
+ )))
85
+ }
86
+
87
+ /// The fix-it offered alongside the synchronous-function diagnostic: insert `async` into the
88
+ /// signature so `@JS(.concurrent)` becomes valid. The macro can't add the keyword itself (no macro
89
+ /// role rewrites the declaration it's attached to), but Xcode can apply this in one click.
90
+ ///
91
+ /// `async` goes at the front of the effect specifiers, ahead of any `throws`, which is the only
92
+ /// order Swift accepts. When the signature has no effect specifiers yet, the new clause inherits
93
+ /// what the parameter clause had trailing it and the parameter clause is left with a single space,
94
+ /// so `() -> Int` becomes `() async -> Int` rather than `() async-> Int`.
95
+ private func insertAsyncFixIt(for funcDecl: FunctionDeclSyntax) -> FixIt {
96
+ let signature = funcDecl.signature
97
+ var newSignature = signature
98
+
99
+ if var effectSpecifiers = signature.effectSpecifiers {
100
+ effectSpecifiers.asyncSpecifier = .keyword(.async, trailingTrivia: .space)
101
+ newSignature.effectSpecifiers = effectSpecifiers
102
+ } else {
103
+ newSignature.effectSpecifiers = FunctionEffectSpecifiersSyntax(
104
+ asyncSpecifier: .keyword(.async, trailingTrivia: signature.parameterClause.trailingTrivia)
105
+ )
106
+ newSignature.parameterClause.trailingTrivia = .space
107
+ }
108
+
109
+ return FixIt(
110
+ message: JSFixItMessage("Mark the function 'async'", id: "js-concurrent-insert-async"),
111
+ changes: [.replace(oldNode: Syntax(signature), newNode: Syntax(newSignature))]
112
+ )
113
+ }
114
+
115
+ /// Emits the free-form (`Any` / `[Any]` / `[String: Any]`) diagnostics for a `@JS` declaration.
116
+ ///
117
+ /// A free-form type is only supported crossing the boundary as an **argument**, decoded through
118
+ /// `JavaScriptValue.decodeAny…`; there is no free-form encode, so any position that encodes is a hard
119
+ /// error. That means:
120
+ /// - a function/constructor **parameter** typed free-form gets a warning steering to
121
+ /// `[String: JavaScriptValue]` (the type-safe alternative), but compiles;
122
+ /// - a function **return** typed free-form is an error (it would need to encode);
123
+ /// - a **property** typed free-form is an error regardless of settability, because its getter always
124
+ /// encodes.
125
+ ///
126
+ /// Types are matched by their written spelling on the type node, so the diagnostic points at the
127
+ /// offending type in the user's source.
128
+ private func diagnoseFreeFormTypes(
129
+ in declaration: some DeclSyntaxProtocol,
130
+ in context: some MacroExpansionContext
131
+ ) {
132
+ if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
133
+ warnFreeFormArguments(funcDecl.signature.parameterClause.parameters, in: context)
134
+ if let returnType = funcDecl.signature.returnClause?.type,
135
+ isFreeFormBoundaryType(returnType.trimmedDescription) {
136
+ context.diagnose(
137
+ Diagnostic(node: returnType, message: freeFormReturnError(for: returnType.trimmedDescription)))
138
+ }
139
+ return
140
+ }
141
+
142
+ // A constructor decodes its arguments exactly like a function; it has no return value to encode, so
143
+ // only the argument warning applies.
144
+ if let initDecl = declaration.as(InitializerDeclSyntax.self) {
145
+ warnFreeFormArguments(initDecl.signature.parameterClause.parameters, in: context)
146
+ return
147
+ }
148
+
149
+ if let varDecl = declaration.as(VariableDeclSyntax.self),
150
+ let type = varDecl.bindings.first?.typeAnnotation?.type,
151
+ isFreeFormBoundaryType(type.trimmedDescription) {
152
+ context.diagnose(
153
+ Diagnostic(node: type, message: freeFormPropertyError(for: type.trimmedDescription)))
154
+ }
155
+ }
156
+
157
+ /// The tail of a free-form encode error: the reshaped `JavaScriptValue` alternative when the type is a
158
+ /// container (`[String: Any]` -> `[String: JavaScriptValue]`), otherwise the passthrough suggestion
159
+ /// for a bare `Any`. Both conform to the codable protocols, so either is a valid fix.
160
+ private func suggestedAlternative(for freeFormType: String) -> String {
161
+ if let reshaped = typedFreeFormReplacement(for: freeFormType), reshaped != "JavaScriptValue" {
162
+ return "Use '\(reshaped)', or 'JavaScriptValue' to pass a JS value through unchanged."
163
+ }
164
+ return "Use a concrete type, or 'JavaScriptValue' to pass a JS value through unchanged."
165
+ }
166
+
167
+ /// Emits the steering warning for each free-form parameter in a list. Shared by the function and
168
+ /// constructor cases, which both decode their arguments through the same path.
169
+ private func warnFreeFormArguments(
170
+ _ parameters: FunctionParameterListSyntax,
171
+ in context: some MacroExpansionContext
172
+ ) {
173
+ for parameter in parameters {
174
+ let type = parameter.type.trimmedDescription
175
+ guard let suggested = typedFreeFormReplacement(for: type) else {
176
+ continue
177
+ }
178
+ context.diagnose(
179
+ Diagnostic(
180
+ node: parameter.type,
181
+ message: freeFormArgumentWarning(for: type, suggesting: suggested)))
182
+ }
183
+ }
184
+
185
+ private func freeFormArgumentWarning(
186
+ for freeFormType: String,
187
+ suggesting suggestedType: String
188
+ ) -> JSDiagnosticMessage {
189
+ return JSDiagnosticMessage(
190
+ "Prefer '\(suggestedType)' over the free-form '\(freeFormType)' for a @JS argument. Free-form decoding boxes every value as 'Any' (slower, no static typing); the 'JavaScriptValue' element keeps each value inspectable without erasing it.",
191
+ id: "js-free-form-argument",
192
+ severity: .warning
193
+ )
194
+ }
195
+
196
+ private func freeFormReturnError(for freeFormType: String) -> JSDiagnosticMessage {
197
+ return JSDiagnosticMessage(
198
+ "A @JS function can't return the free-form '\(freeFormType)': there's no way to encode an untyped value back to JavaScript. \(suggestedAlternative(for: freeFormType))",
199
+ id: "js-free-form-return",
200
+ severity: .error
201
+ )
202
+ }
203
+
204
+ private func freeFormPropertyError(for freeFormType: String) -> JSDiagnosticMessage {
205
+ return JSDiagnosticMessage(
206
+ "A @JS property can't have the free-form '\(freeFormType)': its getter would have to encode an untyped value back to JavaScript, which isn't supported. \(suggestedAlternative(for: freeFormType))",
207
+ id: "js-free-form-property",
208
+ severity: .error
209
+ )
210
+ }
211
+
212
+ private struct JSDiagnosticMessage: DiagnosticMessage {
213
+ let message: String
214
+ let diagnosticID: MessageID
215
+ let severity: DiagnosticSeverity
216
+
217
+ init(_ message: String, id: String, severity: DiagnosticSeverity) {
218
+ self.message = message
219
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
220
+ self.severity = severity
221
+ }
222
+ }
223
+
224
+ private struct JSFixItMessage: FixItMessage {
225
+ let message: String
226
+ let fixItID: MessageID
227
+
228
+ init(_ message: String, id: String) {
229
+ self.message = message
230
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
231
+ }
232
+ }
233
+
234
+ /// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer unique
235
+ /// among siblings), the boundary types split by conversion direction, and whether the member is
236
+ /// type-level. Arguments (and a settable property's incoming value) are decoded; return values (and a
237
+ /// property's outgoing value) are encoded, so each is asserted against the protocol for its direction.
238
+ private struct BoundaryMember {
239
+ let name: String
240
+ /// Types decoded from JS: function/constructor arguments, and a settable property's value type.
241
+ let decodableTypes: [String]
242
+ /// The single type encoded to JS: a function's return type, or a property's value type on read;
243
+ /// `nil` when the member produces nothing JS-visible (a `Void` function).
244
+ let encodableType: String?
245
+ /// True for `static`/`class` members, so the peer is emitted in the same metatype context.
246
+ let isStatic: Bool
247
+ }
248
+
249
+ /// Reads the boundary member off a `@JS` declaration, splitting its types by conversion direction. A
250
+ /// function contributes its parameter types (decodable) and its return type when non-Void (encodable);
251
+ /// a property contributes its value type as encodable (the getter) and also as decodable when settable
252
+ /// (the setter). Composed types (`[Int]`, `String?`, …) are kept verbatim, their conditional
253
+ /// conformances transitively constraining the elements. Returns `nil` for declaration kinds `@JS`
254
+ /// doesn't read types from, or a property whose type isn't spelled out (a syntactic macro can't
255
+ /// recover it).
256
+ private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> BoundaryMember? {
257
+ if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
258
+ let decodableTypes = funcDecl.signature.parameterClause.parameters.map { $0.type.trimmedDescription }
259
+ let returnType = funcDecl.signature.returnClause?.type
260
+ let encodableType = returnType.flatMap { isVoidType($0) ? nil : $0.trimmedDescription }
261
+ return BoundaryMember(
262
+ name: funcDecl.name.text,
263
+ decodableTypes: decodableTypes,
264
+ encodableType: encodableType,
265
+ isStatic: isTypeLevel(funcDecl.modifiers)
266
+ )
267
+ }
268
+
269
+ if let varDecl = declaration.as(VariableDeclSyntax.self),
270
+ let binding = varDecl.bindings.first,
271
+ let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
272
+ let type = binding.typeAnnotation?.type {
273
+ // A `let`, or a `var` with no setter, is read-only (encodable only). A settable `var` is also
274
+ // decoded on write, so its value type is asserted in both directions.
275
+ let typeText = type.trimmedDescription
276
+ let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
277
+ let isSettable = isVar && bindingIsSettable(binding)
278
+ return BoundaryMember(
279
+ name: identifier.identifier.text,
280
+ decodableTypes: isSettable ? [typeText] : [],
281
+ encodableType: typeText,
282
+ isStatic: isTypeLevel(varDecl.modifiers)
283
+ )
284
+ }
285
+
286
+ return nil
287
+ }
288
+
289
+ /// True when a return clause is written as `Void` / `()` — nothing crosses the boundary, so it needs
290
+ /// no conformance assertion. (A missing return clause never reaches here: `returnClause` is `nil`.)
291
+ private func isVoidType(_ type: TypeSyntax) -> Bool {
292
+ let text = type.trimmedDescription
293
+ return text == "Void" || text == "()"
294
+ }