expo-modules-macros 0.0.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +365 -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,616 @@
|
|
|
1
|
+
import SwiftSyntax
|
|
2
|
+
|
|
3
|
+
/// Extracts the full JS-exported surface of every top-level `@ExpoModule`, `@SharedObject`, and
|
|
4
|
+
/// `@Record` type: their `@JS` members and record properties. The deep counterpart to
|
|
5
|
+
/// `DetectionVisitor`. Recognition is purely syntactic and re-reads what the macros read (the macro
|
|
6
|
+
/// target can't be imported), so it stays in step with `JSFunction` / `JSProperty` / `JSConstructor` /
|
|
7
|
+
/// `RecordProperty`.
|
|
8
|
+
final class SurfaceVisitor: SyntaxVisitor {
|
|
9
|
+
private let file: String
|
|
10
|
+
private(set) var modules: [ExportedModule] = []
|
|
11
|
+
private(set) var sharedObjects: [ExportedSharedObject] = []
|
|
12
|
+
private(set) var records: [ExportedRecord] = []
|
|
13
|
+
private(set) var enums: [ExportedEnum] = []
|
|
14
|
+
private(set) var unions: [ExportedUnion] = []
|
|
15
|
+
|
|
16
|
+
init(file: String) {
|
|
17
|
+
self.file = file
|
|
18
|
+
super.init(viewMode: .sourceAccurate)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
22
|
+
if isTopLevel(node) {
|
|
23
|
+
classify(name: node.name.text, attributes: node.attributes, members: node.memberBlock.members)
|
|
24
|
+
}
|
|
25
|
+
// The member walk reads the body itself; nested types aren't part of this surface (matching
|
|
26
|
+
// `DetectionVisitor`'s top-level-only scope), so there's no reason to descend.
|
|
27
|
+
return .skipChildren
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
31
|
+
if isTopLevel(node) {
|
|
32
|
+
classify(name: node.name.text, attributes: node.attributes, members: node.memberBlock.members)
|
|
33
|
+
}
|
|
34
|
+
return .skipChildren
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// The two kinds of enum the surface reports: a `@Union` by its attribute, an `Enumerable` enum by
|
|
38
|
+
/// its conformance (core converts it with no macro involved, so there is no attribute to key on).
|
|
39
|
+
///
|
|
40
|
+
/// `@Union` wins when a type carries both. Its cases hold payloads rather than raw values, so there
|
|
41
|
+
/// would be nothing to report as an enum, and listing it in both arrays would describe two
|
|
42
|
+
/// contradictory JS types for one declaration.
|
|
43
|
+
override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
44
|
+
guard isTopLevel(node) else {
|
|
45
|
+
return .skipChildren
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if node.attributes.firstAttribute(named: DetectedMacro.union.rawValue) != nil {
|
|
49
|
+
unions.append(
|
|
50
|
+
ExportedUnion(
|
|
51
|
+
name: node.name.text,
|
|
52
|
+
members: collectUnionMembers(node.memberBlock.members),
|
|
53
|
+
file: file
|
|
54
|
+
))
|
|
55
|
+
} else if inherits(from: enumerableConformanceName, in: node.inheritanceClause) {
|
|
56
|
+
let rawType = rawValueType(of: node.inheritanceClause)
|
|
57
|
+
enums.append(
|
|
58
|
+
ExportedEnum(
|
|
59
|
+
name: node.name.text,
|
|
60
|
+
rawType: rawType,
|
|
61
|
+
cases: collectEnumCases(node.memberBlock.members, rawType: rawType),
|
|
62
|
+
file: file
|
|
63
|
+
))
|
|
64
|
+
}
|
|
65
|
+
return .skipChildren
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Routes a top-level type to the right collector based on which Expo macro it carries. A type
|
|
69
|
+
/// carrying none of them is ignored. `@Record` and `@ExpoModule`/`@SharedObject` are mutually
|
|
70
|
+
/// exclusive in practice, so the first match wins.
|
|
71
|
+
private func classify(name: String, attributes: AttributeListSyntax, members: MemberBlockItemListSyntax) {
|
|
72
|
+
if let attribute = attributes.firstAttribute(named: DetectedMacro.expoModule.rawValue) {
|
|
73
|
+
let (functions, properties, events, _) = collectJSMembers(members)
|
|
74
|
+
modules.append(
|
|
75
|
+
ExportedModule(
|
|
76
|
+
name: name,
|
|
77
|
+
jsName: stringArgument(of: attribute) ?? name,
|
|
78
|
+
functions: functions,
|
|
79
|
+
properties: properties,
|
|
80
|
+
events: events,
|
|
81
|
+
file: file
|
|
82
|
+
))
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if let attribute = attributes.firstAttribute(named: DetectedMacro.sharedObject.rawValue) {
|
|
87
|
+
let (functions, properties, events, constructor) = collectJSMembers(members)
|
|
88
|
+
sharedObjects.append(
|
|
89
|
+
ExportedSharedObject(
|
|
90
|
+
name: name,
|
|
91
|
+
jsName: stringArgument(of: attribute) ?? name,
|
|
92
|
+
constructorParameters: constructor,
|
|
93
|
+
functions: functions,
|
|
94
|
+
properties: properties,
|
|
95
|
+
events: events,
|
|
96
|
+
file: file
|
|
97
|
+
))
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if attributes.firstAttribute(named: DetectedMacro.record.rawValue) != nil {
|
|
102
|
+
records.append(ExportedRecord(name: name, properties: collectRecordProperties(members), file: file))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// The exported members of a module / shared-object body: `@JS` functions and properties, `@Event`
|
|
107
|
+
/// events, and the single `@JS init` constructor parameters (`nil` when absent).
|
|
108
|
+
private func collectJSMembers(
|
|
109
|
+
_ members: MemberBlockItemListSyntax
|
|
110
|
+
) -> (
|
|
111
|
+
functions: [ExportedFunction], properties: [ExportedProperty], events: [ExportedEvent],
|
|
112
|
+
constructor: [ExportedParameter]?
|
|
113
|
+
) {
|
|
114
|
+
var functions: [ExportedFunction] = []
|
|
115
|
+
var properties: [ExportedProperty] = []
|
|
116
|
+
var events: [ExportedEvent] = []
|
|
117
|
+
var constructor: [ExportedParameter]?
|
|
118
|
+
|
|
119
|
+
for member in members {
|
|
120
|
+
let decl = member.decl
|
|
121
|
+
|
|
122
|
+
if let initDecl = decl.as(InitializerDeclSyntax.self),
|
|
123
|
+
initDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) != nil {
|
|
124
|
+
// At most one `@JS init`; keep the first if a malformed source has more (the macro errors).
|
|
125
|
+
if constructor == nil {
|
|
126
|
+
constructor = parameters(of: initDecl.signature.parameterClause)
|
|
127
|
+
}
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if let funcDecl = decl.as(FunctionDeclSyntax.self),
|
|
132
|
+
let attribute = funcDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) {
|
|
133
|
+
functions.append(makeFunction(funcDecl: funcDecl, attribute: attribute))
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if let varDecl = decl.as(VariableDeclSyntax.self),
|
|
138
|
+
let attribute = varDecl.attributes.firstAttribute(named: DetectedMacro.js.rawValue) {
|
|
139
|
+
properties.append(contentsOf: makeProperties(varDecl: varDecl, attribute: attribute))
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if let varDecl = decl.as(VariableDeclSyntax.self),
|
|
144
|
+
let attribute = varDecl.attributes.firstAttribute(named: DetectedMacro.event.rawValue) {
|
|
145
|
+
events.append(contentsOf: makeEvents(varDecl: varDecl, attribute: attribute))
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (functions, properties, events, constructor)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Builds the `ExportedEvent` entries for an `@Event var`, applying the same checks
|
|
153
|
+
/// `EventMacro.validatedEvent(of:on:)` does. A rejected declaration expands to no event, so
|
|
154
|
+
/// reporting one would describe a surface that does not exist. `@JS` on the same property is
|
|
155
|
+
/// caught by the caller, which reaches the `@JS` branch first.
|
|
156
|
+
private func makeEvents(varDecl: VariableDeclSyntax, attribute: AttributeSyntax) -> [ExportedEvent] {
|
|
157
|
+
guard varDecl.bindingSpecifier.tokenKind != .keyword(.let),
|
|
158
|
+
!isTypeLevel(varDecl.modifiers) else {
|
|
159
|
+
return []
|
|
160
|
+
}
|
|
161
|
+
let override = stringArgument(of: attribute)
|
|
162
|
+
let isSync = boolArgument(of: attribute, label: "sync") == true
|
|
163
|
+
var result: [ExportedEvent] = []
|
|
164
|
+
|
|
165
|
+
for binding in varDecl.bindings {
|
|
166
|
+
// Binding-level checks, in the macro's order: a named binding with a function type that takes
|
|
167
|
+
// at most one payload and returns Void, with no initializer or hand-written accessors.
|
|
168
|
+
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self),
|
|
169
|
+
binding.initializer == nil,
|
|
170
|
+
binding.accessorBlock == nil,
|
|
171
|
+
let functionType = underlyingFunctionType(of: binding.typeAnnotation?.type),
|
|
172
|
+
isVoidEventReturn(functionType.returnClause.type),
|
|
173
|
+
functionType.parameters.count <= 1 else {
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
let name = ident.identifier.text
|
|
177
|
+
result.append(
|
|
178
|
+
ExportedEvent(
|
|
179
|
+
name: name,
|
|
180
|
+
jsName: override ?? defaultEventName(for: name),
|
|
181
|
+
payload: functionType.parameters.first.map { typeNode(from: $0.type) },
|
|
182
|
+
isSync: isSync
|
|
183
|
+
))
|
|
184
|
+
}
|
|
185
|
+
return result
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Builds an `ExportedFunction` from a `@JS func`: JS-name fallback, parameters, a `Void` return as
|
|
189
|
+
/// `nil`, and the effect/static flags.
|
|
190
|
+
private func makeFunction(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) -> ExportedFunction {
|
|
191
|
+
let effects = funcDecl.signature.effectSpecifiers
|
|
192
|
+
let returnType = funcDecl.signature.returnClause?.type
|
|
193
|
+
return ExportedFunction(
|
|
194
|
+
name: funcDecl.name.text,
|
|
195
|
+
jsName: stringArgument(of: attribute) ?? funcDecl.name.text,
|
|
196
|
+
parameters: parameters(of: funcDecl.signature.parameterClause),
|
|
197
|
+
returns: isVoidType(returnType) ? nil : returnType.map { typeNode(from: $0) },
|
|
198
|
+
isAsync: effects?.asyncSpecifier != nil,
|
|
199
|
+
isThrowing: effects?.throwsClause?.throwsSpecifier != nil,
|
|
200
|
+
isStatic: isTypeLevel(funcDecl.modifiers)
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Builds the `ExportedProperty` entries for a `@JS var`/`let`. One declaration can introduce several
|
|
205
|
+
/// bindings (`var a, b: Int`), so this returns an array. The value type is the annotation, else the
|
|
206
|
+
/// literal default's inferred type, else `nil`.
|
|
207
|
+
private func makeProperties(varDecl: VariableDeclSyntax, attribute: AttributeSyntax) -> [ExportedProperty] {
|
|
208
|
+
let isLet = varDecl.bindingSpecifier.tokenKind == .keyword(.let)
|
|
209
|
+
let isStatic = isTypeLevel(varDecl.modifiers)
|
|
210
|
+
let override = stringArgument(of: attribute)
|
|
211
|
+
var result: [ExportedProperty] = []
|
|
212
|
+
|
|
213
|
+
for binding in varDecl.bindings {
|
|
214
|
+
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
let name = ident.identifier.text
|
|
218
|
+
let type = valueTypeNode(annotation: binding.typeAnnotation?.type, initializer: binding.initializer?.value)
|
|
219
|
+
result.append(
|
|
220
|
+
ExportedProperty(
|
|
221
|
+
name: name,
|
|
222
|
+
jsName: override ?? name,
|
|
223
|
+
type: type,
|
|
224
|
+
isSettable: isSettable(binding: binding, isLet: isLet),
|
|
225
|
+
isStatic: isStatic
|
|
226
|
+
))
|
|
227
|
+
}
|
|
228
|
+
return result
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// True when a binding is assignable from JS, mirroring the macro's `bindingIsSettable`: a `let` is
|
|
232
|
+
/// never settable; a stored `var` is; a computed `var` is settable iff it declares `set`/`willSet`/
|
|
233
|
+
/// `didSet` (a getter-only `var` is read-only).
|
|
234
|
+
private func isSettable(binding: PatternBindingSyntax, isLet: Bool) -> Bool {
|
|
235
|
+
if isLet {
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
guard let accessorBlock = binding.accessorBlock else {
|
|
239
|
+
// Stored `var`, settable.
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
switch accessorBlock.accessors {
|
|
243
|
+
case .accessors(let list):
|
|
244
|
+
return list.contains { accessor in
|
|
245
|
+
switch accessor.accessorSpecifier.tokenKind {
|
|
246
|
+
case .keyword(.set), .keyword(.willSet), .keyword(.didSet):
|
|
247
|
+
return true
|
|
248
|
+
default:
|
|
249
|
+
return false
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
case .getter:
|
|
253
|
+
// `var x: Int { ... }` shorthand getter, read-only.
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// The `@Record` properties: every stored, non-excluded `var`/`let` binding, mirroring
|
|
259
|
+
/// `RecordMacro.recordProperties`. Computed and modifier-excluded bindings are skipped, as is one
|
|
260
|
+
/// whose type can't be determined (the macro would error, but the scan stays lenient).
|
|
261
|
+
private func collectRecordProperties(_ members: MemberBlockItemListSyntax) -> [ExportedRecordProperty] {
|
|
262
|
+
var properties: [ExportedRecordProperty] = []
|
|
263
|
+
|
|
264
|
+
for member in members {
|
|
265
|
+
guard let varDecl = member.decl.as(VariableDeclSyntax.self),
|
|
266
|
+
!isExcludedRecordModifier(varDecl.modifiers) else {
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
for binding in varDecl.bindings {
|
|
270
|
+
if binding.accessorBlock != nil {
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
let annotation = binding.typeAnnotation?.type
|
|
277
|
+
guard let type = valueTypeNode(annotation: annotation, initializer: binding.initializer?.value) else {
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
properties.append(
|
|
281
|
+
ExportedRecordProperty(
|
|
282
|
+
name: ident.identifier.text,
|
|
283
|
+
type: type,
|
|
284
|
+
isOptional: annotation.map { isOptionalType($0) } ?? false,
|
|
285
|
+
hasDefault: binding.initializer != nil
|
|
286
|
+
))
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return properties
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// The declared cases of an enum, in source order. One `case` declaration can introduce several
|
|
293
|
+
/// cases (`case a, b`), so each element is read separately. A case carrying associated values is
|
|
294
|
+
/// skipped: it has no raw value, so it can't cross the boundary as one.
|
|
295
|
+
///
|
|
296
|
+
/// A `String`-backed case with no written value takes the case's own name, so those are filled in
|
|
297
|
+
/// here and a `String`-backed enum reports a raw value on every case. `Int` is deliberately left
|
|
298
|
+
/// alone: see `derivedStringRawValue(for:rawType:)`.
|
|
299
|
+
private func collectEnumCases(
|
|
300
|
+
_ members: MemberBlockItemListSyntax,
|
|
301
|
+
rawType: TypeNode?
|
|
302
|
+
) -> [ExportedEnumCase] {
|
|
303
|
+
var cases: [ExportedEnumCase] = []
|
|
304
|
+
|
|
305
|
+
for member in members {
|
|
306
|
+
guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
for element in caseDecl.elements where element.parameterClause == nil {
|
|
310
|
+
let name = element.name.text
|
|
311
|
+
cases.append(
|
|
312
|
+
ExportedEnumCase(
|
|
313
|
+
name: name,
|
|
314
|
+
rawValue: writtenRawValue(of: element) ?? derivedStringRawValue(for: name, rawType: rawType)
|
|
315
|
+
))
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return cases
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/// The alternatives of a `@Union`, in declaration order (the decode depends on it). Skips the cases
|
|
322
|
+
/// `UnionMacro.validatedUnion(of:)` rejects (no associated value, more than one, or a default), since
|
|
323
|
+
/// those don't exist at runtime. The scanner never diagnoses, it just declines to report.
|
|
324
|
+
///
|
|
325
|
+
/// A generic `@Union` is rejected wholesale by the macro but still reported, since the declaration
|
|
326
|
+
/// names a type a consumer may meet.
|
|
327
|
+
private func collectUnionMembers(_ members: MemberBlockItemListSyntax) -> [ExportedUnionMember] {
|
|
328
|
+
var result: [ExportedUnionMember] = []
|
|
329
|
+
|
|
330
|
+
for member in members {
|
|
331
|
+
guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
for element in caseDecl.elements {
|
|
335
|
+
guard let parameters = element.parameterClause?.parameters,
|
|
336
|
+
parameters.count == 1, let parameter = parameters.first,
|
|
337
|
+
parameter.defaultValue == nil else {
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
result.append(
|
|
341
|
+
ExportedUnionMember(name: element.name.text, type: typeNode(from: parameter.type)))
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return result
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/// Projects a parameter clause into `ExportedParameter`s: label = first name, name = second (else
|
|
348
|
+
/// first), and `optional` when it has a default value or an optional type.
|
|
349
|
+
private func parameters(of clause: FunctionParameterClauseSyntax) -> [ExportedParameter] {
|
|
350
|
+
clause.parameters.map { parameter in
|
|
351
|
+
let label = parameter.firstName.text
|
|
352
|
+
let name = parameter.secondName?.text ?? label
|
|
353
|
+
return ExportedParameter(
|
|
354
|
+
label: label,
|
|
355
|
+
name: name,
|
|
356
|
+
type: typeNode(from: parameter.type),
|
|
357
|
+
isOptional: parameter.defaultValue != nil || isOptionalType(parameter.type)
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/// True when the declaration sits at file scope. Same rule as `DetectionVisitor.isTopLevel`: its
|
|
363
|
+
/// parent is a `CodeBlockItemSyntax` directly under the source file's top-level item list.
|
|
364
|
+
private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
|
|
365
|
+
guard let item = node.parent?.as(CodeBlockItemSyntax.self) else {
|
|
366
|
+
return false
|
|
367
|
+
}
|
|
368
|
+
return item.parent?.parent?.is(SourceFileSyntax.self) == true
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// MARK: - Syntactic helpers (shared spelling with the macros)
|
|
373
|
+
|
|
374
|
+
/// True when the modifiers make a member type-level (`static` or `class`).
|
|
375
|
+
// MARK: - `@Event` helpers
|
|
376
|
+
|
|
377
|
+
// The macro target can't be imported, so these are deliberate copies of `EventMacro`'s logic. They
|
|
378
|
+
// must stay in step with it: a drift changes the reported surface without failing any build.
|
|
379
|
+
|
|
380
|
+
/// The Swift name with a conventional `on` prefix stripped and the remainder decapitalized
|
|
381
|
+
/// (`onStatusChange` -> `statusChange`); names without the prefix pass through verbatim. A drift
|
|
382
|
+
/// from the macro's copy would produce listener names the module never emits.
|
|
383
|
+
func defaultEventName(for swiftName: String) -> String {
|
|
384
|
+
guard swiftName.hasPrefix("on") else {
|
|
385
|
+
return swiftName
|
|
386
|
+
}
|
|
387
|
+
let rest = swiftName.dropFirst(2)
|
|
388
|
+
guard let first = rest.first, first.isUppercase else {
|
|
389
|
+
return swiftName
|
|
390
|
+
}
|
|
391
|
+
return decapitalized(String(rest))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/// Lowercases the leading uppercase run the way Swift's API importer does: a single leading capital
|
|
395
|
+
/// is lowercased, and a longer acronym run keeps its last capital when a lowercase letter follows it
|
|
396
|
+
/// (`StatusChange` -> `statusChange`, `URLChange` -> `urlChange`, `URL` -> `url`).
|
|
397
|
+
private func decapitalized(_ name: String) -> String {
|
|
398
|
+
let runEnd = name.firstIndex { !$0.isUppercase } ?? name.endIndex
|
|
399
|
+
if name[..<runEnd].count > 1 && runEnd != name.endIndex {
|
|
400
|
+
let lastCapital = name.index(before: runEnd)
|
|
401
|
+
return name[..<lastCapital].lowercased() + name[lastCapital...]
|
|
402
|
+
}
|
|
403
|
+
return name[..<runEnd].lowercased() + name[runEnd...]
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/// The function type underlying a property's type annotation, unwrapping attributes
|
|
407
|
+
/// (`@Sendable (P) -> Void`) and single-element parentheses (`((P) -> Void)`). `nil` when the
|
|
408
|
+
/// annotation is missing or isn't a function type.
|
|
409
|
+
private func underlyingFunctionType(of type: TypeSyntax?) -> FunctionTypeSyntax? {
|
|
410
|
+
guard let type else {
|
|
411
|
+
return nil
|
|
412
|
+
}
|
|
413
|
+
if let attributed = type.as(AttributedTypeSyntax.self) {
|
|
414
|
+
return underlyingFunctionType(of: attributed.baseType)
|
|
415
|
+
}
|
|
416
|
+
if let tuple = type.as(TupleTypeSyntax.self),
|
|
417
|
+
tuple.elements.count == 1, let element = tuple.elements.first, element.firstName == nil {
|
|
418
|
+
return underlyingFunctionType(of: element.type)
|
|
419
|
+
}
|
|
420
|
+
return type.as(FunctionTypeSyntax.self)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/// True when an event's function type returns `Void`. The shared `isVoidType` is not reused: it
|
|
424
|
+
/// accepts neither `Swift.Void` nor a parenthesized `(Void)`, so it would drop valid events.
|
|
425
|
+
private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
|
|
426
|
+
if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1,
|
|
427
|
+
let element = tuple.elements.first, element.firstName == nil {
|
|
428
|
+
return isVoidEventReturn(element.type)
|
|
429
|
+
}
|
|
430
|
+
let text = type.trimmedDescription
|
|
431
|
+
return text == "Void" || text == "()" || text == "Swift.Void"
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/// The value of a labeled boolean macro argument (`@Event(sync: true)`), or `nil` when absent.
|
|
435
|
+
private func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
|
|
436
|
+
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
437
|
+
return nil
|
|
438
|
+
}
|
|
439
|
+
for argument in arguments where argument.label?.text == label {
|
|
440
|
+
guard let literal = argument.expression.as(BooleanLiteralExprSyntax.self) else {
|
|
441
|
+
return nil
|
|
442
|
+
}
|
|
443
|
+
return literal.literal.tokenKind == .keyword(.true)
|
|
444
|
+
}
|
|
445
|
+
return nil
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private func isTypeLevel(_ modifiers: DeclModifierListSyntax) -> Bool {
|
|
449
|
+
modifiers.contains {
|
|
450
|
+
$0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/// True when a modifier excludes a property from being a `@Record` field (`static`, `class`,
|
|
455
|
+
/// `private`, `fileprivate`, `lazy`), mirroring `RecordMacro.isExcludedByModifier`.
|
|
456
|
+
private func isExcludedRecordModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
|
|
457
|
+
modifiers.contains { modifier in
|
|
458
|
+
switch modifier.name.tokenKind {
|
|
459
|
+
case .keyword(.static), .keyword(.class), .keyword(.private), .keyword(.fileprivate), .keyword(.lazy):
|
|
460
|
+
return true
|
|
461
|
+
default:
|
|
462
|
+
return false
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/// The protocol whose conformance marks an enum as convertible at the JS boundary. Core converts such
|
|
468
|
+
/// an enum through `Coding/…+Enumerable`, keyed on this conformance, so the scanner keys on it too.
|
|
469
|
+
let enumerableConformanceName = "Enumerable"
|
|
470
|
+
|
|
471
|
+
/// True when an inheritance clause names `name`. Matched on the trailing component of the written
|
|
472
|
+
/// spelling, so a qualified `ExpoModulesCore.Enumerable` counts. Purely syntactic: a conformance added
|
|
473
|
+
/// in a separate `extension`, or inherited through another protocol, is invisible to a scan and so is
|
|
474
|
+
/// not reported.
|
|
475
|
+
func inherits(from name: String, in clause: InheritanceClauseSyntax?) -> Bool {
|
|
476
|
+
guard let clause else {
|
|
477
|
+
return false
|
|
478
|
+
}
|
|
479
|
+
return clause.inheritedTypes.contains { inherited in
|
|
480
|
+
inherited.type.trimmedDescription.split(separator: ".").last.map(String.init) == name
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/// The raw value a case writes, or `nil` when it writes none.
|
|
485
|
+
///
|
|
486
|
+
/// A string literal is reported **decoded**: `case a = "act"` yields `act`, with no quotes, because a
|
|
487
|
+
/// `String` raw value is always fully known (see `derivedStringRawValue(for:rawType:)`) and a consumer
|
|
488
|
+
/// should not have to unquote it. Every other expression is reported as **source text**, since an
|
|
489
|
+
/// integer raw value may be any literal expression the scanner can't evaluate. Which of the two a
|
|
490
|
+
/// `rawValue` holds follows from the enum's `rawType`, and `ExportedEnumCase` documents that contract.
|
|
491
|
+
///
|
|
492
|
+
/// A literal this can't decode is treated as writing no raw value rather than reported half-read: an
|
|
493
|
+
/// interpolated string (`case a = "x\(y)"`, not a legal raw value anyway), or one whose segment carries
|
|
494
|
+
/// a backslash escape, which would need real unescaping to turn into its value. A `String` case then
|
|
495
|
+
/// falls back to the derived name, keeping that invariant intact.
|
|
496
|
+
private func writtenRawValue(of element: EnumCaseElementSyntax) -> String? {
|
|
497
|
+
guard let value = element.rawValue?.value else {
|
|
498
|
+
return nil
|
|
499
|
+
}
|
|
500
|
+
guard let literal = value.as(StringLiteralExprSyntax.self) else {
|
|
501
|
+
// Not a string: an integer literal, a negative value, or an expression. Source text verbatim.
|
|
502
|
+
return value.trimmedDescription
|
|
503
|
+
}
|
|
504
|
+
guard literal.segments.count == 1,
|
|
505
|
+
let segment = literal.segments.first?.as(StringSegmentSyntax.self) else {
|
|
506
|
+
return nil
|
|
507
|
+
}
|
|
508
|
+
// The segment's text is the literal's content with its delimiters already stripped, so a plain
|
|
509
|
+
// `"act"` and a raw `#"act"#` both read as `act`. An escape is left to the fallback rather than
|
|
510
|
+
// emitted raw, since `\n` here is two characters, not a newline.
|
|
511
|
+
let content = segment.content.text
|
|
512
|
+
return content.contains("\\") ? nil : content
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/// The raw value Swift gives a `String`-backed case that writes none: the case's own name.
|
|
516
|
+
///
|
|
517
|
+
/// Only `String` is derived. Its defaulting is per-case and carry-free, so a case that can't be read
|
|
518
|
+
/// can't affect any other, and every legal spelling is a single-segment literal. That closes the case
|
|
519
|
+
/// and lets a `String`-backed enum report a raw value on *every* case. `Int` continues
|
|
520
|
+
/// from the preceding case's value (`case a = 1; case b` makes `b` 2), whether that value was written
|
|
521
|
+
/// or itself derived, so one unreadable expression would corrupt every case after it. Deriving it
|
|
522
|
+
/// partially would be worse than not deriving it, so integer-backed enums report only what's written
|
|
523
|
+
/// and the consumer applies the continuation rule.
|
|
524
|
+
private func derivedStringRawValue(for caseName: String, rawType: TypeNode?) -> String? {
|
|
525
|
+
// `String` is a `.primitive`, but a qualified `Swift.String` parses as a `.ref`, and both are legal
|
|
526
|
+
// raw types. Matching the trailing component covers each, the same way the conformance check does.
|
|
527
|
+
let name: String?
|
|
528
|
+
switch rawType {
|
|
529
|
+
case .primitive(let spelling, _), .ref(let spelling, _, _):
|
|
530
|
+
name = spelling.split(separator: ".").last.map(String.init)
|
|
531
|
+
default:
|
|
532
|
+
name = nil
|
|
533
|
+
}
|
|
534
|
+
guard name == "String" else {
|
|
535
|
+
return nil
|
|
536
|
+
}
|
|
537
|
+
// Decoded, matching how a written string literal is reported: the value, not its source spelling.
|
|
538
|
+
return caseName
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/// Protocols an `Enumerable` enum commonly adopts, which a syntactic scan would otherwise mistake for
|
|
542
|
+
/// a raw value type when one is written ahead of the conformance (`enum E: Codable, Enumerable`, which
|
|
543
|
+
/// has no raw type). Only the first inherited entry is ever tested against this, so the list needs to
|
|
544
|
+
/// name just what can legally precede `Enumerable`, not every protocol in existence.
|
|
545
|
+
private let knownNonRawValueProtocols: Set<String> = [
|
|
546
|
+
"CaseIterable", "Codable", "Decodable", "Encodable", "Equatable", "Error", "Hashable",
|
|
547
|
+
"Identifiable", "Sendable", enumerableConformanceName,
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
/// The raw value type of an enum, or `nil` when it declares none.
|
|
551
|
+
///
|
|
552
|
+
/// Swift allows a raw type only in first position, so nothing after the first entry can be one. The
|
|
553
|
+
/// first entry is still not necessarily a raw type: `enum E: Codable, Enumerable` is a legal
|
|
554
|
+
/// raw-value-less enum, and a scan can't resolve a bare name to tell a protocol from a type. It's
|
|
555
|
+
/// matched against `knownNonRawValueProtocols` instead, which covers what an `Enumerable` enum
|
|
556
|
+
/// realistically adopts. An unlisted protocol written first would still be misreported as a raw type;
|
|
557
|
+
/// that is the residual limit of reading this syntactically.
|
|
558
|
+
func rawValueType(of clause: InheritanceClauseSyntax?) -> TypeNode? {
|
|
559
|
+
guard let first = clause?.inheritedTypes.first?.type,
|
|
560
|
+
let trailing = first.trimmedDescription.split(separator: ".").last.map(String.init),
|
|
561
|
+
!knownNonRawValueProtocols.contains(trailing) else {
|
|
562
|
+
return nil
|
|
563
|
+
}
|
|
564
|
+
return typeNode(from: first)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/// True when a type is written as an optional: `T?`, `T!`, or `Optional<T>`, mirroring the macros'
|
|
568
|
+
/// `isOptionalType`.
|
|
569
|
+
private func isOptionalType(_ type: TypeSyntax) -> Bool {
|
|
570
|
+
if type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
|
|
571
|
+
return true
|
|
572
|
+
}
|
|
573
|
+
if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional" {
|
|
574
|
+
return true
|
|
575
|
+
}
|
|
576
|
+
return false
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/// The first attribute whose spelled name matches `name`. A local copy of the macros' helper (the
|
|
580
|
+
/// macro target can't be imported here).
|
|
581
|
+
extension AttributeListSyntax {
|
|
582
|
+
fileprivate func firstAttribute(named name: String) -> AttributeSyntax? {
|
|
583
|
+
for element in self {
|
|
584
|
+
if let attribute = element.as(AttributeSyntax.self),
|
|
585
|
+
attribute.attributeName.trimmedDescription == name {
|
|
586
|
+
return attribute
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return nil
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/// The node for a property/field value type: the annotation, else the literal default's inferred
|
|
594
|
+
/// primitive (`var n = 1` -> `Int`), else `nil`.
|
|
595
|
+
private func valueTypeNode(annotation: TypeSyntax?, initializer: ExprSyntax?) -> TypeNode? {
|
|
596
|
+
if let annotation {
|
|
597
|
+
return typeNode(from: annotation)
|
|
598
|
+
}
|
|
599
|
+
return initializer.flatMap(inferredLiteralType)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/// The node for a simple literal default (`var n = 1` -> `Int`); `nil` for anything non-literal.
|
|
603
|
+
private func inferredLiteralType(of expression: ExprSyntax) -> TypeNode? {
|
|
604
|
+
switch expression.kind {
|
|
605
|
+
case .stringLiteralExpr:
|
|
606
|
+
return .primitive(name: "String", jsType: .string)
|
|
607
|
+
case .integerLiteralExpr:
|
|
608
|
+
return .primitive(name: "Int", jsType: .number)
|
|
609
|
+
case .floatLiteralExpr:
|
|
610
|
+
return .primitive(name: "Double", jsType: .number)
|
|
611
|
+
case .booleanLiteralExpr:
|
|
612
|
+
return .primitive(name: "Bool", jsType: .boolean)
|
|
613
|
+
default:
|
|
614
|
+
return nil
|
|
615
|
+
}
|
|
616
|
+
}
|