@expo/expo-modules-macros-plugin 0.8.0 → 0.10.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/workflows/swift.yml +3 -0
- package/apple/ExpoModulesMacros-tool +0 -0
- package/apple/Package.swift +7 -10
- package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +3 -4
- package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +7 -0
- package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +59 -0
- package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +2 -2
- package/apple/Sources/ExpoModulesMacros/JSMacro.swift +188 -0
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +77 -0
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +19 -1
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +0 -31
- package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +8 -2
- package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +17 -9
- package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +365 -0
- package/apple/Sources/ExpoModulesScanner/CLI.swift +110 -0
- package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +16 -0
- package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +60 -10
- package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +121 -0
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +38 -14
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +32 -6
- package/package.json +1 -1
- package/apple/Sources/ExpoModulesScannerCLI/main.swift +0 -71
|
@@ -0,0 +1,365 @@
|
|
|
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
|
+
/// - `decode(_:in:)`: an ordered decode that tries each case's payload converter in declaration order
|
|
12
|
+
/// and returns the first case that decodes; when none does, it throws
|
|
13
|
+
/// `Exceptions.UnionCaseMismatch` naming the union, the JS kind received, and the alternatives.
|
|
14
|
+
/// - `encode(_:in:)`: a `switch` over the cases, encoding the payload through its own type.
|
|
15
|
+
/// - `as(_:)`: one throwing overload per case, keyed by the payload's metatype, returning that payload
|
|
16
|
+
/// (`try source.as(String.self)` is `String`; `try? source.as(String.self)` is `String?`). It unwraps
|
|
17
|
+
/// by type without naming the case and throws `Exceptions.UnionCaseMismatch` when the union holds a
|
|
18
|
+
/// different case. Since a payload type may appear only once, each overload is unambiguous, and asking
|
|
19
|
+
/// for a type the union doesn't carry is a compile error.
|
|
20
|
+
///
|
|
21
|
+
/// The type is auto-conformed to `JavaScriptDecodable` and `JavaScriptEncodable`, so it can be a `@JS`
|
|
22
|
+
/// argument or return value, an `@Event` payload, or nested inside an optional, array, or dictionary.
|
|
23
|
+
/// Author-facing shape:
|
|
24
|
+
///
|
|
25
|
+
/// @Union
|
|
26
|
+
/// enum Source {
|
|
27
|
+
/// case text(String)
|
|
28
|
+
/// case options(SourceOptions) // a @Record
|
|
29
|
+
/// }
|
|
30
|
+
///
|
|
31
|
+
/// Discrimination is structural and order-dependent: the first case whose payload decodes wins. When
|
|
32
|
+
/// two payload shapes overlap (two records with compatible fields, `Int` and `Double`), the earlier
|
|
33
|
+
/// case matches; the author orders the more specific case first. A case may not repeat another case's
|
|
34
|
+
/// payload type, since it could never be chosen.
|
|
35
|
+
///
|
|
36
|
+
/// The named-union counterpart of core's `Either`: any number of named cases instead of two anonymous
|
|
37
|
+
/// slots, no `Any?` box, and an exhaustive `switch` as the primary way to read it (`as(_:)` covers the
|
|
38
|
+
/// one-type lookup `Either.as(_:)` offered, with the same throwing shape).
|
|
39
|
+
public struct UnionMacro: MemberMacro, ExtensionMacro {
|
|
40
|
+
public static func expansion(
|
|
41
|
+
of node: AttributeSyntax,
|
|
42
|
+
providingMembersOf declaration: some DeclGroupSyntax,
|
|
43
|
+
conformingTo protocols: [TypeSyntax],
|
|
44
|
+
in context: some MacroExpansionContext
|
|
45
|
+
) throws -> [DeclSyntax] {
|
|
46
|
+
let union = try validatedUnion(of: declaration)
|
|
47
|
+
|
|
48
|
+
var members: [DeclSyntax] = []
|
|
49
|
+
|
|
50
|
+
// A single never-called member that makes the compiler verify each payload type converts both
|
|
51
|
+
// ways. Each case keeps its own named assertion inside, so the conformance diagnostic names the
|
|
52
|
+
// offending case (see `typeConformanceAssertions`). Emitted first so a non-conforming payload
|
|
53
|
+
// reports the clear "requires that '…' conform to …" error ahead of the noisier "no member
|
|
54
|
+
// 'decode'"/"'encode'" errors from the conversion code below.
|
|
55
|
+
let assertions = union.cases.map { ConformanceAssertion(name: $0.name, types: [$0.payloadType]) }
|
|
56
|
+
if let assertionMember = typeConformanceAssertions(for: assertions, constraint: unionPayloadProtocolName) {
|
|
57
|
+
members.append(assertionMember)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
members.append(decodeMethod(union: union))
|
|
61
|
+
members.append(encodeMethod(union: union))
|
|
62
|
+
members.append(contentsOf: accessorMethods(union: union))
|
|
63
|
+
members.append(payloadTypeNameProperty(union: union))
|
|
64
|
+
return members
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Auto-conforms the enum to `JavaScriptDecodable` and `JavaScriptEncodable`, the protocols whose
|
|
68
|
+
/// requirements are exactly the `decode`/`encode` members synthesized above. A conformance the author
|
|
69
|
+
/// already spelled out in the inheritance clause is not repeated.
|
|
70
|
+
public static func expansion(
|
|
71
|
+
of node: AttributeSyntax,
|
|
72
|
+
attachedTo declaration: some DeclGroupSyntax,
|
|
73
|
+
providingExtensionsOf type: some TypeSyntaxProtocol,
|
|
74
|
+
conformingTo protocols: [TypeSyntax],
|
|
75
|
+
in context: some MacroExpansionContext
|
|
76
|
+
) throws -> [ExtensionDeclSyntax] {
|
|
77
|
+
// Diagnostics are owned by the member expansion; an invalid declaration silently emits no extension
|
|
78
|
+
// here, so each error is reported once and no witness-less conformance piles "does not conform"
|
|
79
|
+
// errors on top of it.
|
|
80
|
+
guard (try? validatedUnion(of: declaration)) != nil else {
|
|
81
|
+
return []
|
|
82
|
+
}
|
|
83
|
+
// The compiler hands over only the conformances the type still lacks; the test harness passes the
|
|
84
|
+
// declared list verbatim, so filter against the inheritance clause here as well.
|
|
85
|
+
let missing = protocols.filter { protocolType in
|
|
86
|
+
guard let name = baseIdentifier(of: protocolType) else {
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
return !inheritsProtocol(named: name, in: declaration)
|
|
90
|
+
}
|
|
91
|
+
guard !missing.isEmpty else {
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let conformances = missing.map { $0.trimmedDescription }.joined(separator: ", ")
|
|
96
|
+
let ext: DeclSyntax = """
|
|
97
|
+
extension \(type.trimmed): \(raw: conformances) {}
|
|
98
|
+
"""
|
|
99
|
+
guard let extDecl = ext.as(ExtensionDeclSyntax.self) else {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
return [extDecl]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// MARK: - Union model
|
|
107
|
+
|
|
108
|
+
/// A validated `@Union` enum: its spelled name (for the mismatch error) and its cases in declaration
|
|
109
|
+
/// order (the decode order).
|
|
110
|
+
private struct UnionType {
|
|
111
|
+
let name: String
|
|
112
|
+
let cases: [UnionCase]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// One alternative of the union: the case name, its single payload type as written, and the payload's
|
|
116
|
+
/// argument label when the author gave it one (`case id(value: Int)`), needed to construct the case.
|
|
117
|
+
private struct UnionCase {
|
|
118
|
+
let name: String
|
|
119
|
+
let payloadType: String
|
|
120
|
+
let payloadLabel: String?
|
|
121
|
+
|
|
122
|
+
/// The expression constructing this case from a `payload` local: `.id(payload)`, or
|
|
123
|
+
/// `.id(value: payload)` for a labeled associated value.
|
|
124
|
+
var construction: String {
|
|
125
|
+
if let payloadLabel {
|
|
126
|
+
return ".\(name)(\(payloadLabel): payload)"
|
|
127
|
+
}
|
|
128
|
+
return ".\(name)(payload)"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// The pattern binding this case's payload to a `payload` local in a `switch`.
|
|
132
|
+
var pattern: String {
|
|
133
|
+
return ".\(name)(let payload)"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Reads and validates the union off the attached declaration. Every check is a compile error located
|
|
138
|
+
/// on the offending node: the macro must be on a non-generic `enum` with at least one case, and every
|
|
139
|
+
/// case must carry exactly one associated value, its payload type distinct from every earlier case's.
|
|
140
|
+
private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> UnionType {
|
|
141
|
+
guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
|
|
142
|
+
throw MacroExpansionErrorMessage("@Union can only be applied to an enum")
|
|
143
|
+
}
|
|
144
|
+
if let genericParameterClause = enumDecl.genericParameterClause {
|
|
145
|
+
throw DiagnosticsError(diagnostics: [
|
|
146
|
+
Diagnostic(
|
|
147
|
+
node: genericParameterClause,
|
|
148
|
+
message: UnionDiagnosticMessage(
|
|
149
|
+
"@Union cannot be applied to a generic enum: each case's payload type must be concrete so the macro can select its converter.",
|
|
150
|
+
id: "union-generic-enum"
|
|
151
|
+
))
|
|
152
|
+
])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
var cases: [UnionCase] = []
|
|
156
|
+
var seenPayloadTypes: [String: String] = [:]
|
|
157
|
+
var diagnostics: [Diagnostic] = []
|
|
158
|
+
|
|
159
|
+
for member in enumDecl.memberBlock.members {
|
|
160
|
+
guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
for element in caseDecl.elements {
|
|
164
|
+
let name = element.name.text
|
|
165
|
+
guard let parameters = element.parameterClause?.parameters, !parameters.isEmpty else {
|
|
166
|
+
diagnostics.append(
|
|
167
|
+
Diagnostic(
|
|
168
|
+
node: element,
|
|
169
|
+
message: UnionDiagnosticMessage(
|
|
170
|
+
"@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.",
|
|
171
|
+
id: "union-case-without-payload"
|
|
172
|
+
)))
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
guard parameters.count == 1, let parameter = parameters.first else {
|
|
176
|
+
diagnostics.append(
|
|
177
|
+
Diagnostic(
|
|
178
|
+
node: element,
|
|
179
|
+
message: UnionDiagnosticMessage(
|
|
180
|
+
"@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.",
|
|
181
|
+
id: "union-case-with-multiple-payloads"
|
|
182
|
+
)))
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A default on the associated value can never apply: the macro constructs the case from the
|
|
187
|
+
// decoded JS value every time, and there is no "omitted" slot the way a `@Record` property has.
|
|
188
|
+
// Left in place it would read as a JS-side default that doesn't exist, so it's rejected with a
|
|
189
|
+
// fix-it that removes it.
|
|
190
|
+
if let defaultValue = parameter.defaultValue {
|
|
191
|
+
diagnostics.append(defaultValueDiagnostic(for: parameter, defaultValue: defaultValue, caseName: name))
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let payloadType = parameter.type.trimmedDescription
|
|
196
|
+
// Alternatives decode in declaration order and the first success wins, so a case whose payload
|
|
197
|
+
// type repeats an earlier case's is unreachable: an exact-spelling duplicate is an error. (An
|
|
198
|
+
// overlap between *different* types, like `Int` and `Double`, is the author's ordering call and
|
|
199
|
+
// isn't checked here.)
|
|
200
|
+
let normalizedType = payloadType.filter { !$0.isWhitespace }
|
|
201
|
+
if let earlierCase = seenPayloadTypes[normalizedType] {
|
|
202
|
+
diagnostics.append(
|
|
203
|
+
Diagnostic(
|
|
204
|
+
node: element,
|
|
205
|
+
message: UnionDiagnosticMessage(
|
|
206
|
+
"@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.",
|
|
207
|
+
id: "union-duplicate-payload-type"
|
|
208
|
+
)))
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
seenPayloadTypes[normalizedType] = name
|
|
212
|
+
|
|
213
|
+
// `firstName` is the associated value's label (`case id(value: Int)`); a `_` label is the same as
|
|
214
|
+
// none for construction purposes.
|
|
215
|
+
let label = parameter.firstName.flatMap { $0.text == "_" ? nil : $0.text }
|
|
216
|
+
cases.append(UnionCase(name: name, payloadType: payloadType, payloadLabel: label))
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if !diagnostics.isEmpty {
|
|
221
|
+
throw DiagnosticsError(diagnostics: diagnostics)
|
|
222
|
+
}
|
|
223
|
+
if cases.isEmpty {
|
|
224
|
+
throw MacroExpansionErrorMessage("@Union requires at least one case carrying an associated value")
|
|
225
|
+
}
|
|
226
|
+
return UnionType(name: enumDecl.name.text, cases: cases)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// MARK: - Synthesized members
|
|
230
|
+
|
|
231
|
+
/// `decode(_:in:)`: tries each case's payload converter in declaration order and returns the first
|
|
232
|
+
/// case that decodes. `try?` turns a candidate's failure into "try the next one" without erasing the
|
|
233
|
+
/// payload (each `payload` local keeps its concrete type); the candidate's own error is discarded, since
|
|
234
|
+
/// with several alternatives there is no single failure to surface. When no alternative accepts the
|
|
235
|
+
/// value the factory throws `Exceptions.UnionCaseMismatch`, naming the union, the JS kind of the value
|
|
236
|
+
/// received, and every payload type the union accepts.
|
|
237
|
+
private func decodeMethod(union: UnionType) -> DeclSyntax {
|
|
238
|
+
var lines: [String] = []
|
|
239
|
+
for unionCase in union.cases {
|
|
240
|
+
let payloadType = expressionType(unionCase.payloadType)
|
|
241
|
+
lines.append(" if let payload = try? \(payloadType).decode(value, in: runtime) {")
|
|
242
|
+
lines.append(" return \(unionCase.construction)")
|
|
243
|
+
lines.append(" }")
|
|
244
|
+
}
|
|
245
|
+
let expected = union.cases.map { "\"\($0.payloadType)\"" }.joined(separator: ", ")
|
|
246
|
+
let mismatch = "(unionName: \"\(union.name)\", received: value.kind.rawValue, expected: [\(expected)])"
|
|
247
|
+
lines.append(" throw Exceptions.UnionCaseMismatch(\(mismatch))")
|
|
248
|
+
let body = lines.joined(separator: "\n")
|
|
249
|
+
return """
|
|
250
|
+
@JavaScriptActor
|
|
251
|
+
public static func decode(_ value: borrowing JavaScriptValue, in runtime: borrowing JavaScriptRuntime) throws -> Self {
|
|
252
|
+
\(raw: body)
|
|
253
|
+
}
|
|
254
|
+
"""
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// `encode(_:in:)`: a `switch` over the cases, each encoding its payload through the payload type's
|
|
258
|
+
/// own `encode`. Exhaustive by construction, so a case added later without re-expansion can't slip
|
|
259
|
+
/// through silently.
|
|
260
|
+
private func encodeMethod(union: UnionType) -> DeclSyntax {
|
|
261
|
+
var lines: [String] = [" switch value {"]
|
|
262
|
+
for unionCase in union.cases {
|
|
263
|
+
lines.append(" case \(unionCase.pattern):")
|
|
264
|
+
lines.append(" return try \(expressionType(unionCase.payloadType)).encode(payload, in: runtime)")
|
|
265
|
+
}
|
|
266
|
+
lines.append(" }")
|
|
267
|
+
let body = lines.joined(separator: "\n")
|
|
268
|
+
return """
|
|
269
|
+
@JavaScriptActor
|
|
270
|
+
public static func encode(_ value: Self, in runtime: borrowing JavaScriptRuntime) throws -> JavaScriptValue {
|
|
271
|
+
\(raw: body)
|
|
272
|
+
}
|
|
273
|
+
"""
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// `as(_:)`: a typed accessor per case, selected by the payload's metatype rather than the case name,
|
|
277
|
+
/// so a caller that only knows the type it wants writes `try value.as(String.self)` and gets a `String`.
|
|
278
|
+
/// When
|
|
279
|
+
/// the union holds a different case it throws `Exceptions.UnionCaseMismatch`, the same error `decode`
|
|
280
|
+
/// throws, with the held case's payload type as `received` and the requested type as `expected`. The
|
|
281
|
+
/// overloads can't collide because a payload type appears in at most one case (enforced above), and a
|
|
282
|
+
/// metatype the union doesn't carry fails to resolve at compile time. `as` is a keyword, so the
|
|
283
|
+
/// declaration is backticked; a call site after a dot (`value.as(…)`) needs no backticks. The parameter
|
|
284
|
+
/// is spelled in expression form (`T!` rewritten to `T?`), since `T!.Type` isn't valid.
|
|
285
|
+
private func accessorMethods(union: UnionType) -> [DeclSyntax] {
|
|
286
|
+
return union.cases.map { unionCase in
|
|
287
|
+
let payloadType = expressionType(unionCase.payloadType)
|
|
288
|
+
let mismatch = "(unionName: \"\(union.name)\", received: _payloadTypeName, expected: [\"\(unionCase.payloadType)\"])"
|
|
289
|
+
return """
|
|
290
|
+
public func `as`(_ type: \(raw: payloadType).Type) throws -> \(raw: payloadType) {
|
|
291
|
+
if case \(raw: unionCase.pattern) = self {
|
|
292
|
+
return payload
|
|
293
|
+
}
|
|
294
|
+
throw Exceptions.UnionCaseMismatch(\(raw: mismatch))
|
|
295
|
+
}
|
|
296
|
+
"""
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// `_payloadTypeName`: the spelled payload type of the case the union currently holds, as written in the
|
|
301
|
+
/// declaration, so every `as(_:)` overload reports the actual type in its mismatch error through one
|
|
302
|
+
/// shared `switch` instead of each overload enumerating the other cases.
|
|
303
|
+
private func payloadTypeNameProperty(union: UnionType) -> DeclSyntax {
|
|
304
|
+
var lines: [String] = [" switch self {"]
|
|
305
|
+
for unionCase in union.cases {
|
|
306
|
+
lines.append(" case .\(unionCase.name):")
|
|
307
|
+
lines.append(" return \"\(unionCase.payloadType)\"")
|
|
308
|
+
}
|
|
309
|
+
lines.append(" }")
|
|
310
|
+
let body = lines.joined(separator: "\n")
|
|
311
|
+
return """
|
|
312
|
+
private var _payloadTypeName: String {
|
|
313
|
+
\(raw: body)
|
|
314
|
+
}
|
|
315
|
+
"""
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// MARK: - Diagnostics
|
|
319
|
+
|
|
320
|
+
/// The error for a default value on a case's associated value, attached to the `= …` clause and carrying
|
|
321
|
+
/// a fix-it that deletes it (trimming the space the type carried before the `=`).
|
|
322
|
+
private func defaultValueDiagnostic(
|
|
323
|
+
for parameter: EnumCaseParameterSyntax,
|
|
324
|
+
defaultValue: InitializerClauseSyntax,
|
|
325
|
+
caseName: String
|
|
326
|
+
) -> Diagnostic {
|
|
327
|
+
let fixIt = FixIt(
|
|
328
|
+
message: UnionFixItMessage("Remove the default value", id: "union-remove-default-value"),
|
|
329
|
+
changes: [
|
|
330
|
+
.replace(
|
|
331
|
+
oldNode: Syntax(parameter),
|
|
332
|
+
newNode: Syntax(
|
|
333
|
+
parameter
|
|
334
|
+
.with(\.type, parameter.type.with(\.trailingTrivia, []))
|
|
335
|
+
.with(\.defaultValue, nil))
|
|
336
|
+
)
|
|
337
|
+
]
|
|
338
|
+
)
|
|
339
|
+
let message = UnionDiagnosticMessage(
|
|
340
|
+
"@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)'.",
|
|
341
|
+
id: "union-case-default-value"
|
|
342
|
+
)
|
|
343
|
+
return Diagnostic(node: defaultValue, message: message, fixIts: [fixIt])
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private struct UnionDiagnosticMessage: DiagnosticMessage {
|
|
347
|
+
let message: String
|
|
348
|
+
let diagnosticID: MessageID
|
|
349
|
+
let severity: DiagnosticSeverity = .error
|
|
350
|
+
|
|
351
|
+
init(_ message: String, id: String) {
|
|
352
|
+
self.message = message
|
|
353
|
+
self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private struct UnionFixItMessage: FixItMessage {
|
|
358
|
+
let message: String
|
|
359
|
+
let fixItID: MessageID
|
|
360
|
+
|
|
361
|
+
init(_ message: String, id: String) {
|
|
362
|
+
self.message = message
|
|
363
|
+
self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Command-line front end for the scanner: parses the subcommand and paths, then delegates to the
|
|
4
|
+
/// matching `Scanner` entry (which runs the scan and writes its JSON output). Lives in the library
|
|
5
|
+
/// so the macro plugin executable can dispatch into it: the compiler always launches that executable
|
|
6
|
+
/// without arguments and speaks the plugin protocol over stdin, so any argument means a scanner
|
|
7
|
+
/// invocation. Each path may be a `.swift` file or a directory (scanned recursively for `.swift`
|
|
8
|
+
/// files).
|
|
9
|
+
///
|
|
10
|
+
/// Subcommands:
|
|
11
|
+
/// scan-modules <path>... fast: top-level `@ExpoModule` types, for autolinking
|
|
12
|
+
/// scan-exports <path>... deep: full JS-exported surface, for TS type generation
|
|
13
|
+
public enum ScannerCLI {
|
|
14
|
+
/// Runs the CLI for the given arguments (argv without the executable path) and returns the
|
|
15
|
+
/// process exit code: `0` on success, `1` if encoding the report fails, `2` on a usage error.
|
|
16
|
+
public static func run(arguments: [String]) -> Int32 {
|
|
17
|
+
// `-h`/`--help` anywhere is treated as a help request: print usage to stdout and exit 0.
|
|
18
|
+
if arguments.contains(where: { $0 == "-h" || $0 == "--help" }) {
|
|
19
|
+
printUsage(to: .standardOutput)
|
|
20
|
+
return 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
guard let subcommand = arguments.first else {
|
|
24
|
+
printUsage()
|
|
25
|
+
return 2
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
var paths: [String] = []
|
|
29
|
+
var platform: String?
|
|
30
|
+
var defines: [String] = []
|
|
31
|
+
|
|
32
|
+
var rest = arguments.dropFirst().makeIterator()
|
|
33
|
+
while let argument = rest.next() {
|
|
34
|
+
switch argument {
|
|
35
|
+
case "--platform":
|
|
36
|
+
guard let value = rest.next() else {
|
|
37
|
+
return usageError("--platform requires a value")
|
|
38
|
+
}
|
|
39
|
+
platform = value
|
|
40
|
+
case "--define":
|
|
41
|
+
guard let value = rest.next() else {
|
|
42
|
+
return usageError("--define requires a value")
|
|
43
|
+
}
|
|
44
|
+
defines.append(value)
|
|
45
|
+
default:
|
|
46
|
+
paths.append(argument)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
switch subcommand {
|
|
51
|
+
case "scan-modules":
|
|
52
|
+
guard !paths.isEmpty else {
|
|
53
|
+
return usageError("scan-modules requires at least one path")
|
|
54
|
+
}
|
|
55
|
+
return Scanner.runModules(paths: paths, platform: platform, defines: defines)
|
|
56
|
+
|
|
57
|
+
case "scan-exports":
|
|
58
|
+
// The exports surface visitor doesn't evaluate `#if` blocks yet, so accepting the options
|
|
59
|
+
// here would silently do nothing.
|
|
60
|
+
guard platform == nil, defines.isEmpty else {
|
|
61
|
+
return usageError("scan-exports does not support --platform or --define")
|
|
62
|
+
}
|
|
63
|
+
guard !paths.isEmpty else {
|
|
64
|
+
return usageError("scan-exports requires at least one path")
|
|
65
|
+
}
|
|
66
|
+
return Scanner.runExports(paths: paths)
|
|
67
|
+
|
|
68
|
+
default:
|
|
69
|
+
return usageError("unknown subcommand '\(subcommand)'")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// The invoked executable's basename, so the usage text matches however the tool was launched
|
|
75
|
+
/// (the `ExpoModulesMacros-tool` shipped in the package, or a locally built copy).
|
|
76
|
+
private var toolName: String {
|
|
77
|
+
return (CommandLine.arguments.first as NSString?)?.lastPathComponent ?? "ExpoModulesScanner"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private var usageText: String {
|
|
81
|
+
"""
|
|
82
|
+
usage: \(toolName) <subcommand> [options] <path> [<path> ...]
|
|
83
|
+
|
|
84
|
+
subcommands:
|
|
85
|
+
scan-modules fast scan for top-level @ExpoModule types (autolinking)
|
|
86
|
+
scan-exports deep scan of the full JS-exported surface (type generation)
|
|
87
|
+
|
|
88
|
+
options (scan-modules only):
|
|
89
|
+
--platform <os> evaluate '#if os(...)' against this platform (iOS, macOS, tvOS, ...);
|
|
90
|
+
without it, os-conditional declarations are skipped with a warning
|
|
91
|
+
--define <flag> treat a conditional compilation flag (e.g. DEBUG) as set; repeatable
|
|
92
|
+
|
|
93
|
+
options:
|
|
94
|
+
-h, --help print this help and exit
|
|
95
|
+
|
|
96
|
+
"""
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Prints the usage text to the given handle. Goes to stdout when help was explicitly requested
|
|
100
|
+
/// (a successful action), stderr when it accompanies a usage error.
|
|
101
|
+
private func printUsage(to handle: FileHandle = .standardError) {
|
|
102
|
+
handle.write(Data(usageText.utf8))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Reports a usage error on stderr, followed by the usage text, and returns the usage exit code.
|
|
106
|
+
private func usageError(_ message: String) -> Int32 {
|
|
107
|
+
FileHandle.standardError.write(Data("error: \(message)\n".utf8))
|
|
108
|
+
printUsage()
|
|
109
|
+
return 2
|
|
110
|
+
}
|
|
@@ -33,6 +33,12 @@ struct Detection: Codable, Equatable {
|
|
|
33
33
|
/// The kind of declaration the macro was attached to: `class`, `struct`, `func`, `var`, `init`, …
|
|
34
34
|
let declarationKind: String
|
|
35
35
|
|
|
36
|
+
/// The declaration's spelled access modifier (`open`, `public`, `package`, `fileprivate`,
|
|
37
|
+
/// `private`), or `internal` when none is written. Consumers that reference the declaration from
|
|
38
|
+
/// another Swift module (the generated modules provider does) need `public`/`open` and can reject
|
|
39
|
+
/// the rest up front instead of failing at compile time.
|
|
40
|
+
let accessLevel: String
|
|
41
|
+
|
|
36
42
|
/// The explicit JS name override when written as `@ExpoModule("Foo")` / `@JS("bar")` /
|
|
37
43
|
/// `@SharedObject("Baz")`, otherwise `nil` (the name defaults to `name` at expansion time).
|
|
38
44
|
let jsName: String?
|
|
@@ -47,6 +53,16 @@ struct Detection: Codable, Equatable {
|
|
|
47
53
|
let column: Int
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/// A non-fatal problem found while scanning, tied to the source location that caused it — today,
|
|
57
|
+
/// an `#if` condition the scan's static configuration cannot answer (`canImport`, `arch`, an
|
|
58
|
+
/// `os(...)` check with no `--platform` given, …). The affected region is treated as inactive, so
|
|
59
|
+
/// the warning tells the consumer which declarations may have been skipped and why.
|
|
60
|
+
struct ScanWarning: Codable, Equatable {
|
|
61
|
+
let message: String
|
|
62
|
+
let file: String
|
|
63
|
+
let line: Int
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
/// Counts describing how much work the scan did, so callers can see the pre-filter's effect: of all
|
|
51
67
|
/// the `.swift` files read, how many actually needed parsing, and how long the run took.
|
|
52
68
|
struct ScanStats: Codable, Equatable {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import SwiftIfConfig
|
|
1
2
|
import SwiftSyntax
|
|
2
3
|
|
|
3
4
|
/// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`,
|
|
@@ -6,7 +7,12 @@ import SwiftSyntax
|
|
|
6
7
|
/// Recognition mirrors the macros themselves — a purely syntactic match on the spelled attribute
|
|
7
8
|
/// name — so it sees the same declarations the compiler would hand the plugin, without compiling
|
|
8
9
|
/// anything.
|
|
9
|
-
|
|
10
|
+
///
|
|
11
|
+
/// `#if` blocks are handled by the `ActiveSyntaxVisitor` base: only clauses active under the scan's
|
|
12
|
+
/// `ScanBuildConfiguration` are visited, so a declaration inside `#if os(tvOS)` is recorded exactly
|
|
13
|
+
/// when the scan targets tvOS. Conditions the configuration cannot answer make their region
|
|
14
|
+
/// inactive and land in the inherited `diagnostics`, which the scan surfaces as warnings.
|
|
15
|
+
final class DetectionVisitor: ActiveSyntaxVisitor {
|
|
10
16
|
private let file: String
|
|
11
17
|
private let converter: SourceLocationConverter
|
|
12
18
|
/// Only these macros are recorded; the rest are ignored. Lets a `modules` scan report just
|
|
@@ -14,16 +20,30 @@ final class DetectionVisitor: SyntaxVisitor {
|
|
|
14
20
|
private let detectedMacros: Set<DetectedMacro>
|
|
15
21
|
private(set) var detections: [Detection] = []
|
|
16
22
|
|
|
17
|
-
init(
|
|
23
|
+
init(
|
|
24
|
+
file: String,
|
|
25
|
+
tree: SourceFileSyntax,
|
|
26
|
+
detectedMacros: Set<DetectedMacro>,
|
|
27
|
+
configuration: ScanBuildConfiguration
|
|
28
|
+
) {
|
|
18
29
|
self.file = file
|
|
19
30
|
self.converter = SourceLocationConverter(fileName: file, tree: tree)
|
|
20
31
|
self.detectedMacros = detectedMacros
|
|
21
|
-
super.init(viewMode: .sourceAccurate)
|
|
32
|
+
super.init(viewMode: .sourceAccurate, configuration: configuration)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// The accumulated `#if` warnings as `Detection`-style locations plus the message, ready for the
|
|
36
|
+
/// scan report. These come from conditions the configuration cannot answer statically.
|
|
37
|
+
var warnings: [ScanWarning] {
|
|
38
|
+
return diagnostics.map { diagnostic in
|
|
39
|
+
let location = diagnostic.location(converter: converter)
|
|
40
|
+
return ScanWarning(message: diagnostic.message, file: file, line: location.line)
|
|
41
|
+
}
|
|
22
42
|
}
|
|
23
43
|
|
|
24
44
|
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
25
45
|
if isTopLevel(node) {
|
|
26
|
-
record(attributes: node.attributes, name: node.name.text, kind: "class", at: node)
|
|
46
|
+
record(attributes: node.attributes, modifiers: node.modifiers, name: node.name.text, kind: "class", at: node)
|
|
27
47
|
}
|
|
28
48
|
// Members live in the type body; we never report them, so there's no reason to descend.
|
|
29
49
|
return .skipChildren
|
|
@@ -31,29 +51,45 @@ final class DetectionVisitor: SyntaxVisitor {
|
|
|
31
51
|
|
|
32
52
|
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
33
53
|
if isTopLevel(node) {
|
|
34
|
-
record(attributes: node.attributes, name: node.name.text, kind: "struct", at: node)
|
|
54
|
+
record(attributes: node.attributes, modifiers: node.modifiers, name: node.name.text, kind: "struct", at: node)
|
|
35
55
|
}
|
|
36
56
|
return .skipChildren
|
|
37
57
|
}
|
|
38
58
|
|
|
39
|
-
/// True when the declaration sits at file scope: its parent
|
|
40
|
-
///
|
|
41
|
-
/// `MemberBlockItemSyntax` instead, so they don't match.
|
|
59
|
+
/// True when the declaration sits at file scope: its parent chain reaches the source file through
|
|
60
|
+
/// only code-block items and `#if` structure. Members of a type are nested in a
|
|
61
|
+
/// `MemberBlockItemSyntax` instead, so they don't match. The `#if` wrappers are allowed because a
|
|
62
|
+
/// top-level declaration inside an (active) `#if` clause is still a top-level declaration.
|
|
42
63
|
///
|
|
43
64
|
/// TODO: decide whether to support nested types. A macro on a type nested in another type/enum/
|
|
44
65
|
/// extension is valid Swift but missed here; supporting it means descending into type bodies and
|
|
45
66
|
/// recording the enclosing path for a qualified name (e.g. `Namespace.InnerModule`).
|
|
46
67
|
private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
|
|
47
|
-
guard
|
|
68
|
+
guard node.parent?.is(CodeBlockItemSyntax.self) == true else {
|
|
48
69
|
return false
|
|
49
70
|
}
|
|
50
|
-
|
|
71
|
+
var current = node.parent?.parent
|
|
72
|
+
while let node = current {
|
|
73
|
+
if node.is(SourceFileSyntax.self) {
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
guard node.is(CodeBlockItemListSyntax.self)
|
|
77
|
+
|| node.is(CodeBlockItemSyntax.self)
|
|
78
|
+
|| node.is(IfConfigClauseSyntax.self)
|
|
79
|
+
|| node.is(IfConfigClauseListSyntax.self)
|
|
80
|
+
|| node.is(IfConfigDeclSyntax.self) else {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
current = node.parent
|
|
84
|
+
}
|
|
85
|
+
return false
|
|
51
86
|
}
|
|
52
87
|
|
|
53
88
|
/// Emits one detection per recognized Expo attribute on the declaration. A declaration can in
|
|
54
89
|
/// principle carry more than one (uncommon), so each is recorded independently.
|
|
55
90
|
private func record(
|
|
56
91
|
attributes: AttributeListSyntax,
|
|
92
|
+
modifiers: DeclModifierListSyntax,
|
|
57
93
|
name: String,
|
|
58
94
|
kind: String,
|
|
59
95
|
at node: some SyntaxProtocol
|
|
@@ -70,6 +106,7 @@ final class DetectionVisitor: SyntaxVisitor {
|
|
|
70
106
|
macro: macro,
|
|
71
107
|
name: name,
|
|
72
108
|
declarationKind: kind,
|
|
109
|
+
accessLevel: accessLevel(of: modifiers),
|
|
73
110
|
jsName: stringArgument(of: attribute),
|
|
74
111
|
arguments: arguments(of: attribute),
|
|
75
112
|
file: file,
|
|
@@ -81,6 +118,19 @@ final class DetectionVisitor: SyntaxVisitor {
|
|
|
81
118
|
}
|
|
82
119
|
}
|
|
83
120
|
|
|
121
|
+
/// The spelled access modifiers, in the order Swift defines them. Other modifiers (`final`,
|
|
122
|
+
/// `static`, …) are not access levels and are skipped when resolving one.
|
|
123
|
+
private let accessModifierNames: Set<String> = ["open", "public", "package", "internal", "fileprivate", "private"]
|
|
124
|
+
|
|
125
|
+
/// The declaration's access level: the first spelled access modifier, or Swift's default of
|
|
126
|
+
/// `internal` when none is written. A declaration can spell at most one, so first is the only one.
|
|
127
|
+
private func accessLevel(of modifiers: DeclModifierListSyntax) -> String {
|
|
128
|
+
for modifier in modifiers where accessModifierNames.contains(modifier.name.text) {
|
|
129
|
+
return modifier.name.text
|
|
130
|
+
}
|
|
131
|
+
return "internal"
|
|
132
|
+
}
|
|
133
|
+
|
|
84
134
|
/// Every argument passed to the attribute, in source order, each as a label (or `nil` when
|
|
85
135
|
/// positional) plus the value expression's source text. Returns an empty array when the attribute
|
|
86
136
|
/// is written bare (`@ExpoModule`) or with empty parens.
|