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.
- package/.github/resources/expo-modules-macros.svg +23 -0
- package/.github/workflows/publish.yml +122 -0
- package/.github/workflows/swift.yml +68 -0
- package/AGENTS.md +59 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Package.resolved +15 -0
- package/apple/Package.swift +61 -0
- package/apple/PodTests/StubTest.swift +5 -0
- package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +444 -0
- package/apple/Sources/ExpoModulesMacros/EventMacro.swift +330 -0
- package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +263 -0
- package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +117 -0
- package/apple/Sources/ExpoModulesMacros/ExpoViewMacro.swift +259 -0
- package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +214 -0
- package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +55 -0
- package/apple/Sources/ExpoModulesMacros/JSMacro.swift +294 -0
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +433 -0
- package/apple/Sources/ExpoModulesMacros/OptimizedFunctionHelpers.swift +55 -0
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +34 -0
- package/apple/Sources/ExpoModulesMacros/Receiver.swift +79 -0
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +590 -0
- package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +162 -0
- package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +190 -0
- package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +389 -0
- package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +477 -0
- package/apple/Sources/ExpoModulesOptimized/ExpoModulesOptimized.swift +19 -0
- package/apple/Sources/ExpoModulesScanner/CLI.swift +114 -0
- package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +79 -0
- package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +181 -0
- package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +115 -0
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +193 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +319 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +199 -0
- package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +66 -0
- package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +616 -0
- package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +284 -0
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +161 -0
- package/apple/build.js +138 -0
- package/build/index.d.ts +51 -0
- package/build/index.js +153 -0
- package/build/types.d.ts +264 -0
- package/build/types.js +15 -0
- 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
|
+
}
|