@expo/expo-modules-macros-plugin 0.2.2 → 0.4.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/apple/ExpoModulesMacros-tool +0 -0
- package/apple/Package.swift +27 -2
- package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +157 -44
- package/apple/Sources/ExpoModulesMacros/EventMacro.swift +330 -0
- package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +28 -9
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +58 -0
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +1 -0
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +0 -13
- package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +4 -1
- package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +14 -0
- package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +61 -0
- package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +109 -0
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +137 -0
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +68 -0
- package/apple/Sources/ExpoModulesScannerCLI/main.swift +70 -0
- package/package.json +1 -1
|
Binary file
|
package/apple/Package.swift
CHANGED
|
@@ -8,7 +8,12 @@ import PackageDescription
|
|
|
8
8
|
let package = Package(
|
|
9
9
|
name: "ExpoModulesMacros",
|
|
10
10
|
platforms: [.macOS(.v13)],
|
|
11
|
-
products: [
|
|
11
|
+
products: [
|
|
12
|
+
// The scanner CLI. Named `ExpoModulesScanner` (the user-facing tool name) while its target is
|
|
13
|
+
// `ExpoModulesScannerCLI`; the detection logic lives in the importable `ExpoModulesScanner`
|
|
14
|
+
// library that both the CLI and the tests depend on.
|
|
15
|
+
.executable(name: "ExpoModulesScanner", targets: ["ExpoModulesScannerCLI"]),
|
|
16
|
+
],
|
|
12
17
|
dependencies: [
|
|
13
18
|
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest")
|
|
14
19
|
],
|
|
@@ -19,7 +24,18 @@ let package = Package(
|
|
|
19
24
|
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
|
|
20
25
|
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
|
|
21
26
|
]
|
|
22
|
-
)
|
|
27
|
+
),
|
|
28
|
+
.target(
|
|
29
|
+
name: "ExpoModulesScanner",
|
|
30
|
+
dependencies: [
|
|
31
|
+
.product(name: "SwiftSyntax", package: "swift-syntax"),
|
|
32
|
+
.product(name: "SwiftParser", package: "swift-syntax"),
|
|
33
|
+
]
|
|
34
|
+
),
|
|
35
|
+
.executableTarget(
|
|
36
|
+
name: "ExpoModulesScannerCLI",
|
|
37
|
+
dependencies: ["ExpoModulesScanner"]
|
|
38
|
+
),
|
|
23
39
|
]
|
|
24
40
|
)
|
|
25
41
|
|
|
@@ -36,4 +52,13 @@ if FileManager.default.fileExists(atPath: Context.packageDirectory + "/Tests") {
|
|
|
36
52
|
]
|
|
37
53
|
)
|
|
38
54
|
)
|
|
55
|
+
package.targets.append(
|
|
56
|
+
.testTarget(
|
|
57
|
+
name: "ExpoModulesScannerTests",
|
|
58
|
+
dependencies: [
|
|
59
|
+
"ExpoModulesScanner",
|
|
60
|
+
.product(name: "SwiftParser", package: "swift-syntax"),
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
)
|
|
39
64
|
}
|
|
@@ -34,64 +34,157 @@ internal struct JSFunction {
|
|
|
34
34
|
self.isAsync = effectSpecifiers?.asyncSpecifier != nil
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/// The number of leading parameters that must always be supplied: the total minus the maximal
|
|
38
|
+
/// trailing run of *omittable* parameters (each having a default value or an optional type). A
|
|
39
|
+
/// non-omittable parameter part-way through stops the run, since arguments are positional — a
|
|
40
|
+
/// required parameter after an omittable one forces the earlier one to be supplied too.
|
|
41
|
+
private var requiredArgumentCount: Int {
|
|
42
|
+
var required = parameters.count
|
|
43
|
+
for parameter in parameters.reversed() {
|
|
44
|
+
guard isOmittable(parameter) else {
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
required -= 1
|
|
48
|
+
}
|
|
49
|
+
return required
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
/// The decode-call-encode statements that form the host-function body, indented with the given
|
|
38
|
-
/// prefix.
|
|
39
|
-
///
|
|
40
|
-
///
|
|
41
|
-
///
|
|
53
|
+
/// prefix. An arity guard (an exact check when every parameter is required, otherwise a range
|
|
54
|
+
/// check) throwing `Exceptions.ArgumentsRangeMismatch`; then the decode of the always-present
|
|
55
|
+
/// required prefix (primitives via a direct typed accessor like `asDouble()` on a zero-copy
|
|
56
|
+
/// `arguments.unownedValue(at:)`, others via `getDynamicType().cast(...)`); then the call and
|
|
57
|
+
/// result encode (primitives via `toJavaScriptValue(in:)`, others via `castToJS(...)`). When a
|
|
58
|
+
/// trailing run of parameters is omittable the call branches on `arguments.count`, decoding only
|
|
59
|
+
/// the slots that branch actually has — `arguments[i]` traps past `count`, so a slot the caller
|
|
60
|
+
/// didn't pass is never indexed.
|
|
42
61
|
private func bodyStatements(indent: String) -> String {
|
|
62
|
+
let required = requiredArgumentCount
|
|
63
|
+
let maximum = parameters.count
|
|
43
64
|
var lines: [String] = []
|
|
44
65
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
66
|
+
if required == maximum {
|
|
67
|
+
lines.append(
|
|
68
|
+
"""
|
|
69
|
+
guard arguments.count == \(maximum) else {
|
|
70
|
+
throw Exceptions.ArgumentsRangeMismatch((functionName: "\(jsName)", received: arguments.count, required: \(required), maximum: \(maximum)))
|
|
71
|
+
}
|
|
72
|
+
""")
|
|
73
|
+
} else {
|
|
74
|
+
lines.append(
|
|
75
|
+
"""
|
|
76
|
+
guard arguments.count >= \(required) && arguments.count <= \(maximum) else {
|
|
77
|
+
throw Exceptions.ArgumentsRangeMismatch((functionName: "\(jsName)", received: arguments.count, required: \(required), maximum: \(maximum)))
|
|
78
|
+
}
|
|
79
|
+
""")
|
|
80
|
+
}
|
|
51
81
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
82
|
+
// Decode the required prefix once — these slots are present in every accepted arity, so the
|
|
83
|
+
// decode is shared rather than repeated per branch.
|
|
84
|
+
for index in 0..<required {
|
|
85
|
+
lines.append(decodeStatement(at: index))
|
|
86
|
+
}
|
|
55
87
|
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
if required == maximum {
|
|
89
|
+
// No omittable trailing run: a single flat call with every argument decoded.
|
|
90
|
+
lines.append(contentsOf: callAndEncodeLines(arity: maximum, decodingFrom: required))
|
|
91
|
+
} else {
|
|
92
|
+
// One call shape per accepted arity. Each branch decodes only the trailing slots it has and
|
|
93
|
+
// fills the rest (defaulted params drop their label so Swift applies the default; optional
|
|
94
|
+
// params are passed `nil`). A value-returning function binds the result from a `switch`
|
|
95
|
+
// expression and encodes once after it; a no-return one calls inline in a `switch` statement
|
|
96
|
+
// and returns `.undefined`.
|
|
97
|
+
if returnType != nil {
|
|
98
|
+
lines.append("let result = switch arguments.count {")
|
|
63
99
|
} else {
|
|
64
|
-
lines.append(
|
|
65
|
-
|
|
100
|
+
lines.append("switch arguments.count {")
|
|
101
|
+
}
|
|
102
|
+
for arity in required...maximum {
|
|
103
|
+
let label = arity == maximum ? "default:" : "case \(arity):"
|
|
104
|
+
lines.append(label)
|
|
105
|
+
for index in required..<arity {
|
|
106
|
+
lines.append(" " + decodeStatement(at: index))
|
|
107
|
+
}
|
|
108
|
+
lines.append(" \(callExpression(arity: arity))")
|
|
66
109
|
}
|
|
110
|
+
lines.append("}")
|
|
111
|
+
lines.append(contentsOf: encodeResultLines())
|
|
112
|
+
}
|
|
67
113
|
|
|
68
|
-
|
|
69
|
-
|
|
114
|
+
return lines
|
|
115
|
+
.flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
|
|
116
|
+
.map { indent + $0 }
|
|
117
|
+
.joined(separator: "\n")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// `let arg<index> = …` decoding the slot at `index` by its static type: a primitive through a
|
|
121
|
+
/// direct typed accessor on a borrowed `JavaScriptUnownedValue` (no owning value, no `jsi::Value`
|
|
122
|
+
/// copy, no `getDynamicType()` allocation, no `Any` boxing, no force-cast — still validating and
|
|
123
|
+
/// throwing `TypeError` on a mismatch), any other type through the dynamic converter (which needs
|
|
124
|
+
/// an owning value, so it indexes the buffer directly).
|
|
125
|
+
private func decodeStatement(at index: Int) -> String {
|
|
126
|
+
let type = parameters[index].type.trimmedDescription
|
|
127
|
+
if let accessor = fastDecodeAccessor(for: type) {
|
|
128
|
+
return "let arg\(index) = try arguments.unownedValue(at: \(index)).\(accessor)()"
|
|
70
129
|
}
|
|
130
|
+
return "let arg\(index) = try \(type).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(type)"
|
|
131
|
+
}
|
|
71
132
|
|
|
133
|
+
/// The `self.<name>(...)` call for the given arity. Slots `0..<arity` are passed their decoded
|
|
134
|
+
/// `arg<i>`; a trailing optional-without-default slot that this arity omits is passed `nil`; a
|
|
135
|
+
/// trailing defaulted slot that this arity omits is dropped entirely so Swift applies its default.
|
|
136
|
+
private func callExpression(arity: Int) -> String {
|
|
137
|
+
var callArguments: [String] = []
|
|
138
|
+
for (index, parameter) in parameters.enumerated() {
|
|
139
|
+
let label = parameter.firstName.text
|
|
140
|
+
let value: String?
|
|
141
|
+
if index < arity {
|
|
142
|
+
value = "arg\(index)"
|
|
143
|
+
} else if hasDefaultValue(parameter) {
|
|
144
|
+
// Omitted defaulted slot: drop it from the call so Swift fills in the default.
|
|
145
|
+
value = nil
|
|
146
|
+
} else {
|
|
147
|
+
// Omitted optional-without-default slot: pass `nil`.
|
|
148
|
+
value = "nil"
|
|
149
|
+
}
|
|
150
|
+
guard let value else {
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
callArguments.append(label == "_" ? value : "\(label): \(value)")
|
|
154
|
+
}
|
|
72
155
|
let tryKeyword = (isThrowing || isAsync) ? "try " : ""
|
|
73
156
|
let awaitKeyword = isAsync ? "await " : ""
|
|
74
|
-
|
|
75
|
-
|
|
157
|
+
return "\(tryKeyword)\(awaitKeyword)self.\(swiftName)(\(callArguments.joined(separator: ", ")))"
|
|
158
|
+
}
|
|
76
159
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
160
|
+
/// The flat (single-arity) call-and-encode lines used when no trailing parameter is omittable:
|
|
161
|
+
/// `let result = self.f(...)` then the return encode (or the no-return `self.f(...)` + `.undefined`).
|
|
162
|
+
private func callAndEncodeLines(arity: Int, decodingFrom: Int) -> [String] {
|
|
163
|
+
var lines: [String] = []
|
|
164
|
+
for index in decodingFrom..<arity {
|
|
165
|
+
lines.append(decodeStatement(at: index))
|
|
166
|
+
}
|
|
167
|
+
if returnType != nil {
|
|
168
|
+
lines.append("let result = \(callExpression(arity: arity))")
|
|
169
|
+
lines.append(contentsOf: encodeResultLines())
|
|
86
170
|
} else {
|
|
87
|
-
lines.append(callExpression)
|
|
171
|
+
lines.append(callExpression(arity: arity))
|
|
88
172
|
lines.append("return .undefined")
|
|
89
173
|
}
|
|
90
|
-
|
|
91
174
|
return lines
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Encode the `result` local back to JS and return it: a primitive through `toJavaScriptValue(in:)`
|
|
178
|
+
/// (the typed `JavaScriptRepresentable` conversion — no `Any`, no dynamic-type allocation), any
|
|
179
|
+
/// other type through the dynamic converter. A no-return function returns `.undefined` instead.
|
|
180
|
+
private func encodeResultLines() -> [String] {
|
|
181
|
+
guard let returnType else {
|
|
182
|
+
return ["return .undefined"]
|
|
183
|
+
}
|
|
184
|
+
if fastDecodeAccessor(for: returnType) != nil {
|
|
185
|
+
return ["return result.toJavaScriptValue(in: runtime)"]
|
|
186
|
+
}
|
|
187
|
+
return ["return try \(returnType).getDynamicType().castToJS(result, appContext: appContext, in: runtime)"]
|
|
95
188
|
}
|
|
96
189
|
|
|
97
190
|
/// The `setProperty` statement that installs this function on the JS object. The decode-call-encode
|
|
@@ -108,9 +201,24 @@ internal struct JSFunction {
|
|
|
108
201
|
/// body never references `appContext`, so the capture and guard are omitted to avoid the
|
|
109
202
|
/// unused-capture warning.
|
|
110
203
|
var decorateStatements: String {
|
|
204
|
+
// Synchronous `@JS` functions never decode `this` (the receiver is the module's real `self`), so
|
|
205
|
+
// they bind through the unowned-`this` `setProperty` overload, which hands `this` in as a borrowed
|
|
206
|
+
// `JavaScriptUnownedValue` instead of allocating an owning `JavaScriptValue` and forming its
|
|
207
|
+
// `weak`-runtime reference on every call. The first parameter is typed `borrowing
|
|
208
|
+
// JavaScriptUnownedValue` to select that (otherwise `@_disfavoredOverload`) overload — which
|
|
209
|
+
// requires the *parenthesized, fully typed* parameter list, since Swift rejects a type annotation
|
|
210
|
+
// on a shorthand `{ [capture] name, name in }` parameter. Async functions keep the untyped
|
|
211
|
+
// shorthand and the owning-`this` overload: there is no unowned-`this` async variant and the buffer
|
|
212
|
+
// escapes into the task anyway.
|
|
213
|
+
let captures = usesAppContext ? "[weak appContext, self]" : "[self]"
|
|
214
|
+
let parameters =
|
|
215
|
+
isAsync
|
|
216
|
+
? "this, arguments"
|
|
217
|
+
: "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
|
|
218
|
+
|
|
111
219
|
if usesAppContext {
|
|
112
220
|
return """
|
|
113
|
-
object.setProperty("\(jsName)") {
|
|
221
|
+
object.setProperty("\(jsName)") { \(captures) \(parameters) in
|
|
114
222
|
guard let appContext else {
|
|
115
223
|
throw Exceptions.AppContextLost()
|
|
116
224
|
}
|
|
@@ -119,7 +227,7 @@ internal struct JSFunction {
|
|
|
119
227
|
"""
|
|
120
228
|
}
|
|
121
229
|
return """
|
|
122
|
-
object.setProperty("\(jsName)") {
|
|
230
|
+
object.setProperty("\(jsName)") { \(captures) \(parameters) in
|
|
123
231
|
\(bodyStatements(indent: " "))
|
|
124
232
|
}
|
|
125
233
|
"""
|
|
@@ -230,9 +338,14 @@ internal struct JSProperty {
|
|
|
230
338
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
|
231
339
|
.map { " \($0)" }
|
|
232
340
|
.joined(separator: "\n")
|
|
341
|
+
// Property `get`/`set` accessors are always synchronous and never decode `this`, so they bind
|
|
342
|
+
// through the unowned-`this` `setProperty` overload like sync functions. The parameter list is
|
|
343
|
+
// parenthesized and fully typed because Swift rejects a type annotation on a shorthand closure
|
|
344
|
+
// parameter; the explicit `borrowing JavaScriptUnownedValue` selects the unowned-`this` overload.
|
|
345
|
+
let parameters = "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
|
|
233
346
|
if usesAppContext {
|
|
234
347
|
return """
|
|
235
|
-
\(descriptorName).setProperty("\(key)") { [weak appContext, self]
|
|
348
|
+
\(descriptorName).setProperty("\(key)") { [weak appContext, self] \(parameters) in
|
|
236
349
|
guard let appContext else {
|
|
237
350
|
throw Exceptions.AppContextLost()
|
|
238
351
|
}
|
|
@@ -241,7 +354,7 @@ internal struct JSProperty {
|
|
|
241
354
|
"""
|
|
242
355
|
}
|
|
243
356
|
return """
|
|
244
|
-
\(descriptorName).setProperty("\(key)") { [self]
|
|
357
|
+
\(descriptorName).setProperty("\(key)") { [self] \(parameters) in
|
|
245
358
|
\(indentedBody)
|
|
246
359
|
}
|
|
247
360
|
"""
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import SwiftDiagnostics
|
|
2
|
+
import SwiftSyntax
|
|
3
|
+
import SwiftSyntaxMacros
|
|
4
|
+
|
|
5
|
+
/// Accessor macro applied to a function-typed `var` on a module or shared object, turning it into a
|
|
6
|
+
/// typed JavaScript event. A function-typed `var` can't be a stored property without an initializer,
|
|
7
|
+
/// so the macro expands it into a computed getter returning a closure that dispatches by name into
|
|
8
|
+
/// the `EventEmitter` `emit` overloads (core conforms both `BaseModule` and `SharedObject` to that
|
|
9
|
+
/// protocol, so `self.emit` resolves on each):
|
|
10
|
+
///
|
|
11
|
+
/// @Event
|
|
12
|
+
/// var onProgress: (ProgressEvent) -> Void
|
|
13
|
+
/// // expands to:
|
|
14
|
+
/// var onProgress: (ProgressEvent) -> Void {
|
|
15
|
+
/// get {
|
|
16
|
+
/// { [weak self] payload in self?.emit(event: "progress", payload: payload) }
|
|
17
|
+
/// }
|
|
18
|
+
/// }
|
|
19
|
+
///
|
|
20
|
+
/// A no-payload event (`() -> Void`) dispatches through the dedicated `emit(event:)` overload.
|
|
21
|
+
/// The JS event name defaults to the property name with the conventional `on` prefix stripped
|
|
22
|
+
/// (see `defaultEventName(for:)`); `@Event("customName")` overrides it verbatim.
|
|
23
|
+
///
|
|
24
|
+
/// The closure captures `self` **weakly**: it's usually invoked inline (`self.onProgress(…)`), but an
|
|
25
|
+
/// author may store it or hand it to a delegate, and a strong capture would then extend the module's
|
|
26
|
+
/// lifetime. After the emitter deallocates the closure silently no-ops, which matches what `emit`
|
|
27
|
+
/// already does once the runtime is gone.
|
|
28
|
+
///
|
|
29
|
+
/// The synthesized property is deliberately **not** isolated to `@JavaScriptActor`, unlike `@JS`
|
|
30
|
+
/// members: `emit` is itself non-isolated and schedules the dispatch onto the JS thread internally,
|
|
31
|
+
/// so the event is callable from any thread or isolation with no actor hop at the call site. It is
|
|
32
|
+
/// also self-contained: `@ExpoModule`/`@SharedObject` neither collect `@Event` members nor register
|
|
33
|
+
/// their names anywhere.
|
|
34
|
+
///
|
|
35
|
+
/// `@Event(sync: true)` opts into **synchronous dispatch**: the closure calls `emitSync` (inline
|
|
36
|
+
/// conversion + dispatch, no scheduling) instead of `emit`, and `@ExpoModule`/`@SharedObject` stamp
|
|
37
|
+
/// the member `@JavaScriptActor` so the compiler forces the call site onto the JS thread, the
|
|
38
|
+
/// inverse of the async default. The isolation is on the property access, so it guards the inline
|
|
39
|
+
/// `self.onTick(…)` usage; a closure stored or handed off escapes it, after which `emitSync` runs
|
|
40
|
+
/// wherever the caller invokes it.
|
|
41
|
+
///
|
|
42
|
+
/// As a **peer**, the macro emits a never-called conformance assertion (see
|
|
43
|
+
/// `TypeConformanceAssertion.swift`) checking that the payload type is JS-convertible and that the
|
|
44
|
+
/// enclosing type conforms to `EventEmitter`, so both failure modes surface as clear conformance
|
|
45
|
+
/// errors on the user's own declaration.
|
|
46
|
+
public struct EventMacro: AccessorMacro {
|
|
47
|
+
public static func expansion(
|
|
48
|
+
of node: AttributeSyntax,
|
|
49
|
+
providingAccessorsOf declaration: some DeclSyntaxProtocol,
|
|
50
|
+
in context: some MacroExpansionContext
|
|
51
|
+
) throws -> [AccessorDeclSyntax] {
|
|
52
|
+
let event = try validatedEvent(of: node, on: declaration)
|
|
53
|
+
// The closure's parameter and return types are inferred from the property's declared type
|
|
54
|
+
// through the getter, so the body never has to spell the payload type. A sync event calls
|
|
55
|
+
// `emitSync` (inline dispatch, JS thread only); the default calls the scheduling `emit`.
|
|
56
|
+
let emitMethod = event.isSync ? "emitSync" : "emit"
|
|
57
|
+
let closure = event.hasPayload
|
|
58
|
+
? "{ [weak self] payload in self?.\(emitMethod)(event: \"\(event.jsName)\", payload: payload) }"
|
|
59
|
+
: "{ [weak self] in self?.\(emitMethod)(event: \"\(event.jsName)\") }"
|
|
60
|
+
return [
|
|
61
|
+
"""
|
|
62
|
+
get {
|
|
63
|
+
\(raw: closure)
|
|
64
|
+
}
|
|
65
|
+
"""
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
extension EventMacro: PeerMacro {
|
|
71
|
+
public static func expansion(
|
|
72
|
+
of node: AttributeSyntax,
|
|
73
|
+
providingPeersOf declaration: some DeclSyntaxProtocol,
|
|
74
|
+
in context: some MacroExpansionContext
|
|
75
|
+
) throws -> [DeclSyntax] {
|
|
76
|
+
// Diagnostics are owned by the accessor expansion; an invalid declaration silently emits no
|
|
77
|
+
// peer here so each error is reported once.
|
|
78
|
+
guard let event = try? validatedEvent(of: node, on: declaration) else {
|
|
79
|
+
return []
|
|
80
|
+
}
|
|
81
|
+
// One nested helper asserts everything in a single call: the payload type is JS-convertible
|
|
82
|
+
// (the `P` parameter, dropped for no-payload events and known-conforming primitives) and the
|
|
83
|
+
// enclosing type can emit (the `E` parameter, always present since any `@Event` dispatches
|
|
84
|
+
// through `self.emit`). Named after the member so the member shows up in either diagnostic,
|
|
85
|
+
// and asserting the enclosing type by its spelled name so the conformance error names the
|
|
86
|
+
// user's type rather than 'Self'.
|
|
87
|
+
let name = event.swiftName
|
|
88
|
+
let payload = event.payloadType.flatMap(assertableBoundaryType)
|
|
89
|
+
let owner = enclosingTypeName(in: context) ?? "Self"
|
|
90
|
+
let helper = payload != nil
|
|
91
|
+
? "func \(name)<P: \(jsConvertibleProtocolName), E: \(eventEmitterProtocolName)>(_: P.Type, _: E.Type) {}"
|
|
92
|
+
: "func \(name)<E: \(eventEmitterProtocolName)>(_: E.Type) {}"
|
|
93
|
+
let call = payload.map { "\(name)(\($0).self, \(owner).self)" } ?? "\(name)(\(owner).self)"
|
|
94
|
+
return [
|
|
95
|
+
"""
|
|
96
|
+
private func _assertTypesConformance_\(raw: name)() {
|
|
97
|
+
\(raw: helper)
|
|
98
|
+
\(raw: call)
|
|
99
|
+
}
|
|
100
|
+
"""
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// What the expansions need to know about a validated `@Event` declaration: the property name, the
|
|
106
|
+
/// JS event name (after an `@Event("…")` override), and the payload type when the function type
|
|
107
|
+
/// takes one.
|
|
108
|
+
private struct EventMember {
|
|
109
|
+
let swiftName: String
|
|
110
|
+
let jsName: String
|
|
111
|
+
/// The payload type as written, or `nil` for a no-payload `() -> Void` event.
|
|
112
|
+
let payloadType: String?
|
|
113
|
+
/// Whether the event dispatches synchronously (`@Event(sync: true)`) via `emitSync`.
|
|
114
|
+
let isSync: Bool
|
|
115
|
+
|
|
116
|
+
var hasPayload: Bool {
|
|
117
|
+
return payloadType != nil
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Validates the declaration `@Event` is attached to and reads the event out of it. The checks
|
|
122
|
+
/// mirror what the expansion relies on: a single-binding instance `var` (the macro synthesizes a
|
|
123
|
+
/// computed getter, so `let`, accessors, and initializers are all incompatible) whose type is a
|
|
124
|
+
/// function type returning `Void` with at most one payload parameter.
|
|
125
|
+
private func validatedEvent(
|
|
126
|
+
of node: AttributeSyntax,
|
|
127
|
+
on declaration: some DeclSyntaxProtocol
|
|
128
|
+
) throws -> EventMember {
|
|
129
|
+
guard let varDecl = declaration.as(VariableDeclSyntax.self) else {
|
|
130
|
+
throw MacroExpansionErrorMessage("@Event can only be applied to a property")
|
|
131
|
+
}
|
|
132
|
+
if varDecl.attributes.firstAttribute(named: "JS") != nil {
|
|
133
|
+
throw MacroExpansionErrorMessage(
|
|
134
|
+
"@Event and @JS cannot be combined on the same property; an event is exposed to JS on its own, so remove one of the attributes")
|
|
135
|
+
}
|
|
136
|
+
// The compiler also rejects accessor macros on a `let`, but with a generic message; this one says
|
|
137
|
+
// what to do instead and carries the fix-it doing it. The synthesized property is getter-only, so
|
|
138
|
+
// switching to `var` loses nothing.
|
|
139
|
+
if varDecl.bindingSpecifier.tokenKind == .keyword(.let) {
|
|
140
|
+
throw letBindingDiagnostic(for: varDecl)
|
|
141
|
+
}
|
|
142
|
+
if varDecl.modifiers.contains(where: isTypeLevelModifier) {
|
|
143
|
+
throw MacroExpansionErrorMessage(
|
|
144
|
+
"@Event must be an instance property; events are emitted from a module or shared object instance.")
|
|
145
|
+
}
|
|
146
|
+
guard varDecl.bindings.count == 1, let binding = varDecl.bindings.first,
|
|
147
|
+
let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
|
148
|
+
throw MacroExpansionErrorMessage(
|
|
149
|
+
"@Event must be applied to a single named property; declare each event separately")
|
|
150
|
+
}
|
|
151
|
+
if binding.initializer != nil {
|
|
152
|
+
throw MacroExpansionErrorMessage(
|
|
153
|
+
"@Event property cannot have an initial value; the macro synthesizes the closure")
|
|
154
|
+
}
|
|
155
|
+
if binding.accessorBlock != nil {
|
|
156
|
+
throw MacroExpansionErrorMessage(
|
|
157
|
+
"@Event property cannot declare its own accessors; the macro synthesizes the getter")
|
|
158
|
+
}
|
|
159
|
+
guard let functionType = underlyingFunctionType(of: binding.typeAnnotation?.type) else {
|
|
160
|
+
throw MacroExpansionErrorMessage(
|
|
161
|
+
"@Event property must declare a function type, such as '(Payload) -> Void' or '() -> Void'")
|
|
162
|
+
}
|
|
163
|
+
guard isVoidReturn(functionType.returnClause.type) else {
|
|
164
|
+
throw MacroExpansionErrorMessage(
|
|
165
|
+
"@Event function type must return 'Void'; an event dispatches to JS and has no return value")
|
|
166
|
+
}
|
|
167
|
+
guard functionType.parameters.count <= 1 else {
|
|
168
|
+
throw MacroExpansionErrorMessage(
|
|
169
|
+
"@Event function type takes at most one payload parameter; combine multiple values into a single record")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let swiftName = identifier.identifier.text
|
|
173
|
+
return EventMember(
|
|
174
|
+
swiftName: swiftName,
|
|
175
|
+
jsName: jsNameArgument(of: node) ?? defaultEventName(for: swiftName),
|
|
176
|
+
payloadType: functionType.parameters.first?.type.trimmedDescription,
|
|
177
|
+
isSync: boolArgument(of: node, label: "sync") == true
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// MARK: - Default event name
|
|
182
|
+
|
|
183
|
+
/// The default JS event name for a property: the Swift name with the conventional `on` prefix
|
|
184
|
+
/// stripped and the remainder decapitalized (`onStatusChange` → `statusChange`). The two sides
|
|
185
|
+
/// idiomatically want different names: the Swift property reads as invoking a handler
|
|
186
|
+
/// (`self.onStatusChange(…)`) and the prefix keeps it from colliding with a state property
|
|
187
|
+
/// (`status`), while JS listens by bare name (`addListener("statusChange")`, the Node/DOM idiom
|
|
188
|
+
/// that module and shared-object events follow). Names without the prefix (`statusChange`,
|
|
189
|
+
/// `online`) pass through verbatim, and an explicit `@Event("name")` override is never
|
|
190
|
+
/// transformed — that's also the escape hatch for legacy `onX` wire names.
|
|
191
|
+
private func defaultEventName(for swiftName: String) -> String {
|
|
192
|
+
guard swiftName.hasPrefix("on") else {
|
|
193
|
+
return swiftName
|
|
194
|
+
}
|
|
195
|
+
let rest = swiftName.dropFirst(2)
|
|
196
|
+
guard let first = rest.first, first.isUppercase else {
|
|
197
|
+
return swiftName
|
|
198
|
+
}
|
|
199
|
+
return decapitalized(String(rest))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Lowercases the leading uppercase run the way Swift's API importer does: a single leading
|
|
203
|
+
/// capital is lowercased (`StatusChange` → `statusChange`); a longer acronym run keeps its last
|
|
204
|
+
/// capital when a lowercase letter follows it, since that capital starts the next word
|
|
205
|
+
/// (`URLChange` → `urlChange`, `URL` → `url`).
|
|
206
|
+
private func decapitalized(_ name: String) -> String {
|
|
207
|
+
let runEnd = name.firstIndex { !$0.isUppercase } ?? name.endIndex
|
|
208
|
+
if name[..<runEnd].count > 1 && runEnd != name.endIndex {
|
|
209
|
+
let lastCapital = name.index(before: runEnd)
|
|
210
|
+
return name[..<lastCapital].lowercased() + name[lastCapital...]
|
|
211
|
+
}
|
|
212
|
+
return name[..<runEnd].lowercased() + name[runEnd...]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// MARK: - The `let` diagnostic
|
|
216
|
+
|
|
217
|
+
/// The error for `@Event let`, attached to the `let` keyword itself and carrying a fix-it that
|
|
218
|
+
/// replaces it with `var`. Unlike the other checks (plain thrown messages located at the attribute),
|
|
219
|
+
/// this one is a structured `Diagnostic` so Xcode can offer the one-click fix.
|
|
220
|
+
private func letBindingDiagnostic(for varDecl: VariableDeclSyntax) -> DiagnosticsError {
|
|
221
|
+
let specifier = varDecl.bindingSpecifier
|
|
222
|
+
let fixIt = FixIt(
|
|
223
|
+
message: EventFixItMessage("Replace 'let' with 'var'", id: "event-let-to-var"),
|
|
224
|
+
changes: [
|
|
225
|
+
// Rewriting just the token's kind keeps its surrounding trivia (indentation, the space
|
|
226
|
+
// before the property name) intact.
|
|
227
|
+
.replace(
|
|
228
|
+
oldNode: Syntax(specifier),
|
|
229
|
+
newNode: Syntax(specifier.with(\.tokenKind, .keyword(.var)))
|
|
230
|
+
)
|
|
231
|
+
]
|
|
232
|
+
)
|
|
233
|
+
let message = EventDiagnosticMessage(
|
|
234
|
+
"@Event must be applied to a 'var': it expands into a computed property, which a 'let' cannot be. The synthesized property is read-only anyway.",
|
|
235
|
+
id: "event-on-let"
|
|
236
|
+
)
|
|
237
|
+
return DiagnosticsError(diagnostics: [
|
|
238
|
+
Diagnostic(node: specifier, message: message, fixIts: [fixIt])
|
|
239
|
+
])
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private struct EventDiagnosticMessage: DiagnosticMessage {
|
|
243
|
+
let message: String
|
|
244
|
+
let diagnosticID: MessageID
|
|
245
|
+
let severity: DiagnosticSeverity = .error
|
|
246
|
+
|
|
247
|
+
init(_ message: String, id: String) {
|
|
248
|
+
self.message = message
|
|
249
|
+
self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private struct EventFixItMessage: FixItMessage {
|
|
254
|
+
let message: String
|
|
255
|
+
let fixItID: MessageID
|
|
256
|
+
|
|
257
|
+
init(_ message: String, id: String) {
|
|
258
|
+
self.message = message
|
|
259
|
+
self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// MARK: - Declaration shape helpers
|
|
264
|
+
|
|
265
|
+
/// The spelled name of the innermost type declaration enclosing the macro, read from the lexical
|
|
266
|
+
/// context, so the emitter assertion can name the user's type in the conformance diagnostic
|
|
267
|
+
/// ("requires that 'MyModule' conform to 'EventEmitter'" instead of "'Self'"). Returns `nil` (the
|
|
268
|
+
/// caller falls back to `Self`) when there's no enclosing type, or when it's a generic type
|
|
269
|
+
/// declaration: `Foo.self` isn't valid for an unbound generic, while `Self` works anywhere. An
|
|
270
|
+
/// extension can't be detected as generic syntactically (see the extension case below).
|
|
271
|
+
private func enclosingTypeName(in context: some MacroExpansionContext) -> String? {
|
|
272
|
+
for scope in context.lexicalContext {
|
|
273
|
+
if let classDecl = scope.as(ClassDeclSyntax.self) {
|
|
274
|
+
return classDecl.genericParameterClause == nil ? classDecl.name.text : nil
|
|
275
|
+
}
|
|
276
|
+
if let structDecl = scope.as(StructDeclSyntax.self) {
|
|
277
|
+
return structDecl.genericParameterClause == nil ? structDecl.name.text : nil
|
|
278
|
+
}
|
|
279
|
+
if let actorDecl = scope.as(ActorDeclSyntax.self) {
|
|
280
|
+
return actorDecl.genericParameterClause == nil ? actorDecl.name.text : nil
|
|
281
|
+
}
|
|
282
|
+
if let extensionDecl = scope.as(ExtensionDeclSyntax.self) {
|
|
283
|
+
// An extension carries no generic-parameter clause of its own, so a bare extended type
|
|
284
|
+
// (`extension Box`) is indistinguishable from a non-generic one (`extension Foo`); both read
|
|
285
|
+
// as a plain identifier here. A written bound form (`extension Box<Int>`) is valid as `.self`,
|
|
286
|
+
// and the common non-generic case keeps its spelled name in the diagnostic. The unguarded gap
|
|
287
|
+
// is `extension <Generic>` with the parameters omitted, where the spelled name is an unbound
|
|
288
|
+
// generic invalid as `.self`; events on generic types in an extension are rare enough that the
|
|
289
|
+
// resulting compile error is an acceptable price for naming the user's type everywhere else.
|
|
290
|
+
return extensionDecl.extendedType.trimmedDescription
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return nil
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private func isTypeLevelModifier(_ modifier: DeclModifierSyntax) -> Bool {
|
|
297
|
+
return modifier.name.tokenKind == .keyword(.static) || modifier.name.tokenKind == .keyword(.class)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// The function type underlying a property's type annotation, unwrapping attributes
|
|
301
|
+
/// (`@Sendable (P) -> Void`) and single-element parentheses (`((P) -> Void)`). Returns `nil` when
|
|
302
|
+
/// the annotation is missing or isn't a function type, including an optional function type:
|
|
303
|
+
/// an event is always present, never `nil`.
|
|
304
|
+
private func underlyingFunctionType(of type: TypeSyntax?) -> FunctionTypeSyntax? {
|
|
305
|
+
guard let type else {
|
|
306
|
+
return nil
|
|
307
|
+
}
|
|
308
|
+
if let attributed = type.as(AttributedTypeSyntax.self) {
|
|
309
|
+
return underlyingFunctionType(of: attributed.baseType)
|
|
310
|
+
}
|
|
311
|
+
if let tuple = type.as(TupleTypeSyntax.self),
|
|
312
|
+
tuple.elements.count == 1, let element = tuple.elements.first, element.firstName == nil {
|
|
313
|
+
return underlyingFunctionType(of: element.type)
|
|
314
|
+
}
|
|
315
|
+
return type.as(FunctionTypeSyntax.self)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// True when the function type's return is written as `Void` / `()`. Function types always carry an
|
|
319
|
+
/// explicit return clause, so unlike a function declaration there's no "absent" case. A module-qualified
|
|
320
|
+
/// `Swift.Void` and redundant parentheses (`(Void)`, `(())`) are accepted too, so a valid void return
|
|
321
|
+
/// written one of those ways isn't rejected with a misleading "must return 'Void'" diagnostic.
|
|
322
|
+
private func isVoidReturn(_ type: TypeSyntax) -> Bool {
|
|
323
|
+
// Peel single-element, unlabeled parentheses: `(Void)` and `(())` are the same type as their content.
|
|
324
|
+
if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1,
|
|
325
|
+
let element = tuple.elements.first, element.firstName == nil {
|
|
326
|
+
return isVoidReturn(element.type)
|
|
327
|
+
}
|
|
328
|
+
let text = type.trimmedDescription
|
|
329
|
+
return text == "Void" || text == "()" || text == "Swift.Void"
|
|
330
|
+
}
|
|
@@ -6,11 +6,13 @@ import SwiftSyntaxMacros
|
|
|
6
6
|
Macro applied to a module class. It plays three roles, each implemented in its own
|
|
7
7
|
extension below:
|
|
8
8
|
|
|
9
|
-
- `MemberMacro`:
|
|
10
|
-
|
|
11
|
-
`
|
|
12
|
-
|
|
13
|
-
`
|
|
9
|
+
- `MemberMacro`: binds `@JS`-marked members directly into the module's JS object via the
|
|
10
|
+
synthesized `_decorateModule`, and emits the resolved module name as a non-optional
|
|
11
|
+
`_jsName` static (no `Name(…)` DSL element). It also synthesizes a
|
|
12
|
+
framework-internal `_synthesizedDefinition()` returning `[AnyDefinition]` (now carrying
|
|
13
|
+
only nested `classes` entries) that `expo-modules-core` merges into the module's
|
|
14
|
+
definition. When the class doesn't already inherit `Module`/`BaseModule`, it also
|
|
15
|
+
synthesizes the `appContext` storage and `init(appContext:)` those base classes provide.
|
|
14
16
|
- `MemberAttributeMacro`: stamps `@JavaScriptActor` on `@JS` sync members and
|
|
15
17
|
`@ModuleDefinitionBuilder` on a `definition()` method.
|
|
16
18
|
- `ExtensionMacro`: adds the `AnyModule` conformance when the class doesn't inherit it.
|
|
@@ -39,8 +41,12 @@ public struct ExpoModuleMacro: MemberMacro {
|
|
|
39
41
|
throw MacroExpansionErrorMessage("@ExpoModule can only be applied to a class")
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
// The module name is fully resolved here (the explicit `@ExpoModule("…")` argument, else the
|
|
45
|
+
// class name) and emitted as a non-optional `_jsName` static below — so the
|
|
46
|
+
// macro no longer emits a `Name(…)` DSL entry. Core reads the static for the module name,
|
|
47
|
+
// ahead of its type-name fallback (which then only applies to non-macro DSL modules).
|
|
42
48
|
let moduleName = jsNameArgument(of: node) ?? classDecl.name.text
|
|
43
|
-
var entries: [String] = [
|
|
49
|
+
var entries: [String] = []
|
|
44
50
|
|
|
45
51
|
// `@JS func`s (sync and async) and `@JS var`s are bound directly into the JS object by the
|
|
46
52
|
// synthesized `_decorateModule` rather than described with a `Function(...)` / `Property(...)`
|
|
@@ -67,11 +73,21 @@ public struct ExpoModuleMacro: MemberMacro {
|
|
|
67
73
|
}
|
|
68
74
|
}
|
|
69
75
|
|
|
70
|
-
let
|
|
71
|
-
|
|
76
|
+
let body: String
|
|
77
|
+
if entries.isEmpty {
|
|
78
|
+
body = " return []"
|
|
79
|
+
} else {
|
|
80
|
+
let lines = entries.map { " \($0)" }.joined(separator: ",\n")
|
|
81
|
+
body = " return [\n\(lines)\n ]"
|
|
82
|
+
}
|
|
72
83
|
|
|
73
84
|
var emitted: [DeclSyntax] = []
|
|
74
85
|
|
|
86
|
+
// The fully-resolved module name as a non-optional stored constant. Core reads this
|
|
87
|
+
// instead of the retired `Name(…)` DSL element; it feeds both native registration and the
|
|
88
|
+
// JS object name, so they can't diverge.
|
|
89
|
+
emitted.append("public static let _jsName = \"\(raw: moduleName)\"")
|
|
90
|
+
|
|
75
91
|
// `Module`/`BaseModule` already provide `appContext` storage and the
|
|
76
92
|
// `init(appContext:)` requirement, so we only synthesize them for classes that
|
|
77
93
|
// inherit from neither. Each is skipped individually if the user wrote their own,
|
|
@@ -127,7 +143,10 @@ extension ExpoModuleMacro: MemberAttributeMacro {
|
|
|
127
143
|
// `@JS` sync members run on the JS thread; stamp `@JavaScriptActor` so isolation is
|
|
128
144
|
// checked at compile time. Skipped when the member already chose an isolation
|
|
129
145
|
// (`async`, `nonisolated`, or another global actor) — see `shouldStampJavaScriptActor`.
|
|
130
|
-
|
|
146
|
+
// `@Event(sync: true)` members get the stamp too: a sync event dispatches inline, so the
|
|
147
|
+
// isolation forces its call site onto the JS thread. Async events (the default) are
|
|
148
|
+
// deliberately left unstamped — their `emit` schedules onto the JS thread itself.
|
|
149
|
+
if memberHasJSAttribute(member) || isSyncEventMember(member),
|
|
131
150
|
shouldStampJavaScriptActor(on: member, enclosedBy: declaration) {
|
|
132
151
|
attributes.append("@JavaScriptActor")
|
|
133
152
|
}
|
|
@@ -14,6 +14,64 @@ internal func jsNameArgument(of attribute: AttributeSyntax) -> String? {
|
|
|
14
14
|
return segment.content.text
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
Reads a labeled boolean-literal argument of an attribute, e.g. `@Event(sync: true)` -> true.
|
|
19
|
+
Returns nil if the attribute has no argument with that label or its value isn't a boolean literal.
|
|
20
|
+
*/
|
|
21
|
+
internal func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
|
|
22
|
+
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
23
|
+
return nil
|
|
24
|
+
}
|
|
25
|
+
for arg in args where arg.label?.text == label {
|
|
26
|
+
guard let literal = arg.expression.as(BooleanLiteralExprSyntax.self) else {
|
|
27
|
+
return nil
|
|
28
|
+
}
|
|
29
|
+
return literal.literal.tokenKind == .keyword(.true)
|
|
30
|
+
}
|
|
31
|
+
return nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// True if the type is written as an optional: `T?`, `T!`, or the explicit `Optional<T>`. Used to
|
|
35
|
+
/// decide argument requiredness (an optional parameter may be omitted) and record-field nullability.
|
|
36
|
+
internal func isOptionalType(_ type: TypeSyntax) -> Bool {
|
|
37
|
+
if type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional" {
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// True if a trailing occurrence of this parameter may be omitted by the JS caller: it either has a
|
|
47
|
+
/// default value (Swift applies it) or is an optional type (an absent slot becomes `nil`). The arity
|
|
48
|
+
/// range and the per-arity call branches are derived from this.
|
|
49
|
+
internal func isOmittable(_ parameter: FunctionParameterSyntax) -> Bool {
|
|
50
|
+
return hasDefaultValue(parameter) || isOptionalType(parameter.type)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// True if the parameter declares a default value (`b: Int = 5`). An omitted defaulted slot is left
|
|
54
|
+
/// out of the call so Swift fills in the default, distinguishing it from an omitted optional slot
|
|
55
|
+
/// (passed `nil`).
|
|
56
|
+
internal func hasDefaultValue(_ parameter: FunctionParameterSyntax) -> Bool {
|
|
57
|
+
return parameter.defaultValue != nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
True if the declaration is a `@Event(sync: true)` property. A sync event dispatches inline on the
|
|
62
|
+
JS thread instead of scheduling, so `@ExpoModule`/`@SharedObject` stamp it with `@JavaScriptActor`,
|
|
63
|
+
making "must be called on the JS thread" a compile-time guarantee at the call site. Async events
|
|
64
|
+
(the default) are deliberately not stamped: their `emit` schedules onto the JS thread itself, so
|
|
65
|
+
they stay callable from any thread.
|
|
66
|
+
*/
|
|
67
|
+
internal func isSyncEventMember(_ decl: DeclSyntaxProtocol) -> Bool {
|
|
68
|
+
guard let varDecl = decl.as(VariableDeclSyntax.self),
|
|
69
|
+
let attribute = varDecl.attributes.firstAttribute(named: "Event") else {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
return boolArgument(of: attribute, label: "sync") == true
|
|
73
|
+
}
|
|
74
|
+
|
|
17
75
|
/**
|
|
18
76
|
Reads a labeled array-literal argument of an attribute, e.g. `@ExpoModule(classes: [Foo.self, Bar.self])`,
|
|
19
77
|
and returns the type names referenced (e.g. `["Foo", "Bar"]`). Each element must be a
|
|
@@ -453,19 +453,6 @@ private func isExcludedByModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
|
|
|
453
453
|
return false
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
-
/**
|
|
457
|
-
True if the type syntax is optional: `T?`, `T!`, or the spelled-out `Optional<T>`.
|
|
458
|
-
*/
|
|
459
|
-
private func isOptionalType(_ type: TypeSyntax) -> Bool {
|
|
460
|
-
if type.is(OptionalTypeSyntax.self) || type.is(ImplicitlyUnwrappedOptionalTypeSyntax.self) {
|
|
461
|
-
return true
|
|
462
|
-
}
|
|
463
|
-
if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == "Optional" {
|
|
464
|
-
return true
|
|
465
|
-
}
|
|
466
|
-
return false
|
|
467
|
-
}
|
|
468
|
-
|
|
469
456
|
/**
|
|
470
457
|
True if the type's inheritance clause already lists a protocol with the given name.
|
|
471
458
|
Matches either the bare identifier (`Record`) or a qualified member access ending in
|
|
@@ -95,7 +95,10 @@ extension SharedObjectMacro: MemberAttributeMacro {
|
|
|
95
95
|
providingAttributesFor member: some DeclSyntaxProtocol,
|
|
96
96
|
in context: some MacroExpansionContext
|
|
97
97
|
) throws -> [AttributeSyntax] {
|
|
98
|
-
|
|
98
|
+
// `@Event(sync: true)` members are stamped alongside `@JS` ones: a sync event dispatches
|
|
99
|
+
// inline, so the isolation forces its call site onto the JS thread. Async events (the
|
|
100
|
+
// default) stay unstamped — their `emit` schedules onto the JS thread itself.
|
|
101
|
+
guard memberHasJSAttribute(member) || isSyncEventMember(member),
|
|
99
102
|
shouldStampJavaScriptActor(on: member, enclosedBy: declaration) else {
|
|
100
103
|
return []
|
|
101
104
|
}
|
|
@@ -5,6 +5,11 @@ import SwiftSyntax
|
|
|
5
5
|
/// asserts the conformance (`@JS`, `@Record`, …).
|
|
6
6
|
internal let jsConvertibleProtocolName = "AnyArgument"
|
|
7
7
|
|
|
8
|
+
/// The protocol a type must conform to for `self.emit(event:…)` to resolve; core conforms
|
|
9
|
+
/// `BaseModule` and `SharedObject` to it. Asserted by `@Event` so attaching it to a type that can't
|
|
10
|
+
/// emit fails with a conformance diagnostic instead of an opaque "no member 'emit'" error.
|
|
11
|
+
internal let eventEmitterProtocolName = "EventEmitter"
|
|
12
|
+
|
|
8
13
|
/// Types we never assert because they're statically known to conform and never reach the dynamic
|
|
9
14
|
/// converter: the JS primitives. Asserting them would only add noise to the expansion. Kept here
|
|
10
15
|
/// (rather than reusing the decode-path's `fastDecodeAccessor`) because "known-to-conform" is a
|
|
@@ -66,6 +71,15 @@ internal func typeConformanceAssertions(for assertions: [ConformanceAssertion])
|
|
|
66
71
|
"""
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
/// The type to assert for a boundary type as written: trailing optional markers are unwrapped to the
|
|
75
|
+
/// core type, and a known-conforming primitive returns `nil` (nothing to assert). Shared with
|
|
76
|
+
/// `@Event`, which folds its single payload type into a combined assertion of its own shape rather
|
|
77
|
+
/// than reusing the whole body fragment below.
|
|
78
|
+
internal func assertableBoundaryType(_ type: String) -> String? {
|
|
79
|
+
let unwrapped = unwrappedOptional(type)
|
|
80
|
+
return knownConformingPrimitives.contains(unwrapped) ? nil : unwrapped
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
/// The assertion's body fragment: a nested generic helper named after the member, plus one call per
|
|
70
84
|
/// distinct non-primitive type. Nesting the helper keeps the constraint entirely local — no shared
|
|
71
85
|
/// symbol, nothing to collide, nothing left in the type's namespace — and naming it after the member
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Which Expo macro was found on a declaration. The scanner recognizes the entry-point macros that
|
|
4
|
+
/// mark a type or member as part of a module's JS surface, plus `@Record` for convertible types.
|
|
5
|
+
enum DetectedMacro: String, Codable, CaseIterable {
|
|
6
|
+
case expoModule = "ExpoModule"
|
|
7
|
+
case js = "JS"
|
|
8
|
+
case sharedObject = "SharedObject"
|
|
9
|
+
case record = "Record"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil`
|
|
13
|
+
/// for positional arguments; `value` is the argument expression's source text as written.
|
|
14
|
+
struct MacroArgument: Codable, Equatable {
|
|
15
|
+
/// The argument label (`classes` in `classes: [Bar.self]`), or `nil` for a positional argument.
|
|
16
|
+
let label: String?
|
|
17
|
+
|
|
18
|
+
/// The argument value exactly as written in source, e.g. `"Foo"` (including the quotes) or
|
|
19
|
+
/// `[Bar.self]`. Kept as text because a syntactic scan can't resolve these to runtime values.
|
|
20
|
+
let value: String
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// A single annotated declaration the scanner found, with just enough to locate it and know
|
|
24
|
+
/// what it is. Member-level details (parameters, types) are intentionally out of scope for this
|
|
25
|
+
/// first prototype — see the `@JS` member walk in the macros for where that would live.
|
|
26
|
+
struct Detection: Codable, Equatable {
|
|
27
|
+
/// The macro spelled on the declaration (without the leading `@`).
|
|
28
|
+
let macro: DetectedMacro
|
|
29
|
+
|
|
30
|
+
/// The declared name, e.g. the class name for `@ExpoModule`, or the func/var/init name for `@JS`.
|
|
31
|
+
let name: String
|
|
32
|
+
|
|
33
|
+
/// The kind of declaration the macro was attached to: `class`, `struct`, `func`, `var`, `init`, …
|
|
34
|
+
let declarationKind: String
|
|
35
|
+
|
|
36
|
+
/// The explicit JS name override when written as `@ExpoModule("Foo")` / `@JS("bar")` /
|
|
37
|
+
/// `@SharedObject("Baz")`, otherwise `nil` (the name defaults to `name` at expansion time).
|
|
38
|
+
let jsName: String?
|
|
39
|
+
|
|
40
|
+
/// Every argument passed to the macro, in source order, e.g. `@ExpoModule("Foo", classes: [Bar.self])`
|
|
41
|
+
/// yields a positional `"Foo"` and a `classes:` argument. Empty when the macro is written bare.
|
|
42
|
+
let arguments: [MacroArgument]
|
|
43
|
+
|
|
44
|
+
/// Source location, relative to the path the scanner was invoked with.
|
|
45
|
+
let file: String
|
|
46
|
+
let line: Int
|
|
47
|
+
let column: Int
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Counts describing how much work the scan did, so callers can see the pre-filter's effect: of all
|
|
51
|
+
/// the `.swift` files read, how many actually needed parsing, and how long the run took.
|
|
52
|
+
struct ScanStats: Codable, Equatable {
|
|
53
|
+
/// `.swift` files the walk found and read (after directory pruning).
|
|
54
|
+
let filesScanned: Int
|
|
55
|
+
|
|
56
|
+
/// Of those, how many contained a macro attribute and so were parsed with SwiftSyntax.
|
|
57
|
+
let filesParsed: Int
|
|
58
|
+
|
|
59
|
+
/// Wall-clock duration of the scan, in milliseconds (walking, reading, filtering, and parsing).
|
|
60
|
+
let durationMs: Double
|
|
61
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import SwiftSyntax
|
|
2
|
+
|
|
3
|
+
/// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`,
|
|
4
|
+
/// `@SharedObject`, or `@Record`. Only file-scope declarations are considered: these macros apply to
|
|
5
|
+
/// top-level types, so descending into type and function bodies would only surface false positives.
|
|
6
|
+
/// Recognition mirrors the macros themselves — a purely syntactic match on the spelled attribute
|
|
7
|
+
/// name — so it sees the same declarations the compiler would hand the plugin, without compiling
|
|
8
|
+
/// anything.
|
|
9
|
+
final class DetectionVisitor: SyntaxVisitor {
|
|
10
|
+
private let file: String
|
|
11
|
+
private let converter: SourceLocationConverter
|
|
12
|
+
/// Only these macros are recorded; the rest are ignored. Lets a `modules` scan report just
|
|
13
|
+
/// `@ExpoModule` while an `exports` scan covers them all.
|
|
14
|
+
private let detectedMacros: Set<DetectedMacro>
|
|
15
|
+
private(set) var detections: [Detection] = []
|
|
16
|
+
|
|
17
|
+
init(file: String, tree: SourceFileSyntax, detectedMacros: Set<DetectedMacro>) {
|
|
18
|
+
self.file = file
|
|
19
|
+
self.converter = SourceLocationConverter(fileName: file, tree: tree)
|
|
20
|
+
self.detectedMacros = detectedMacros
|
|
21
|
+
super.init(viewMode: .sourceAccurate)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
25
|
+
if isTopLevel(node) {
|
|
26
|
+
record(attributes: node.attributes, name: node.name.text, kind: "class", at: node)
|
|
27
|
+
}
|
|
28
|
+
// Members live in the type body; we never report them, so there's no reason to descend.
|
|
29
|
+
return .skipChildren
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
33
|
+
if isTopLevel(node) {
|
|
34
|
+
record(attributes: node.attributes, name: node.name.text, kind: "struct", at: node)
|
|
35
|
+
}
|
|
36
|
+
return .skipChildren
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// True when the declaration sits at file scope: its parent is a `CodeBlockItemSyntax` directly
|
|
40
|
+
/// under the source file's top-level item list. Members of a type are nested in a
|
|
41
|
+
/// `MemberBlockItemSyntax` instead, so they don't match.
|
|
42
|
+
///
|
|
43
|
+
/// TODO: decide whether to support nested types. A macro on a type nested in another type/enum/
|
|
44
|
+
/// extension is valid Swift but missed here; supporting it means descending into type bodies and
|
|
45
|
+
/// recording the enclosing path for a qualified name (e.g. `Namespace.InnerModule`).
|
|
46
|
+
private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
|
|
47
|
+
guard let item = node.parent?.as(CodeBlockItemSyntax.self) else {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
return item.parent?.parent?.is(SourceFileSyntax.self) == true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Emits one detection per recognized Expo attribute on the declaration. A declaration can in
|
|
54
|
+
/// principle carry more than one (uncommon), so each is recorded independently.
|
|
55
|
+
private func record(
|
|
56
|
+
attributes: AttributeListSyntax,
|
|
57
|
+
name: String,
|
|
58
|
+
kind: String,
|
|
59
|
+
at node: some SyntaxProtocol
|
|
60
|
+
) {
|
|
61
|
+
for element in attributes {
|
|
62
|
+
guard let attribute = element.as(AttributeSyntax.self),
|
|
63
|
+
let macro = DetectedMacro(rawValue: attribute.attributeName.trimmedDescription),
|
|
64
|
+
detectedMacros.contains(macro) else {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
let location = converter.location(for: node.positionAfterSkippingLeadingTrivia)
|
|
68
|
+
detections.append(
|
|
69
|
+
Detection(
|
|
70
|
+
macro: macro,
|
|
71
|
+
name: name,
|
|
72
|
+
declarationKind: kind,
|
|
73
|
+
jsName: stringArgument(of: attribute),
|
|
74
|
+
arguments: arguments(of: attribute),
|
|
75
|
+
file: file,
|
|
76
|
+
line: location.line,
|
|
77
|
+
column: location.column
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Every argument passed to the attribute, in source order, each as a label (or `nil` when
|
|
85
|
+
/// positional) plus the value expression's source text. Returns an empty array when the attribute
|
|
86
|
+
/// is written bare (`@ExpoModule`) or with empty parens.
|
|
87
|
+
private func arguments(of attribute: AttributeSyntax) -> [MacroArgument] {
|
|
88
|
+
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
return args.map { arg in
|
|
92
|
+
MacroArgument(label: arg.label?.text, value: arg.expression.trimmedDescription)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork". Returns
|
|
97
|
+
/// `nil` when there's no argument or it isn't a plain string literal. (Same shape the
|
|
98
|
+
/// `jsNameArgument` helper reads inside the macros.)
|
|
99
|
+
private func stringArgument(of attribute: AttributeSyntax) -> String? {
|
|
100
|
+
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
|
|
101
|
+
let first = args.first,
|
|
102
|
+
first.label == nil,
|
|
103
|
+
let str = first.expression.as(StringLiteralExprSyntax.self),
|
|
104
|
+
let segment = str.segments.first?.as(StringSegmentSyntax.self),
|
|
105
|
+
str.segments.count == 1 else {
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
108
|
+
return segment.content.text
|
|
109
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SwiftParser
|
|
3
|
+
import SwiftSyntax
|
|
4
|
+
|
|
5
|
+
/// Walks `paths`, parses each `.swift` file that might contain one of `macros` (the pre-filter), and
|
|
6
|
+
/// returns every detection (in file then source order) with the run's stats. The shared core every
|
|
7
|
+
/// scan command builds on; each command projects these detections into its own output shape.
|
|
8
|
+
func collectDetections(paths: [String], macros: Set<DetectedMacro>) -> (detections: [Detection], stats: ScanStats) {
|
|
9
|
+
let clock = ContinuousClock()
|
|
10
|
+
let start = clock.now
|
|
11
|
+
|
|
12
|
+
var detections: [Detection] = []
|
|
13
|
+
var filesScanned = 0
|
|
14
|
+
var filesParsed = 0
|
|
15
|
+
|
|
16
|
+
// Compile the pre-filter regex once per run, not once per file.
|
|
17
|
+
let prefilter = macroAttributeRegex(for: macros)
|
|
18
|
+
|
|
19
|
+
for file in swiftFiles(in: paths) {
|
|
20
|
+
guard let source = try? String(contentsOfFile: file, encoding: .utf8) else {
|
|
21
|
+
FileHandle.standardError.write(Data("warning: could not read \(file)\n".utf8))
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
filesScanned += 1
|
|
25
|
+
// Skip the (relatively expensive) parse for files that can't contain any of the macros. A plain
|
|
26
|
+
// substring scan is far cheaper than a full parse, and most files in a large tree mention none
|
|
27
|
+
// of these names. See `mightContainMacro` for why this never drops a real match.
|
|
28
|
+
guard mightContainMacro(in: source, prefilter: prefilter) else {
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
filesParsed += 1
|
|
32
|
+
detections.append(contentsOf: detect(source: source, file: file, macros: macros))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let elapsed = (clock.now - start).components
|
|
36
|
+
let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15
|
|
37
|
+
|
|
38
|
+
return (detections, ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Parses one source string and returns its detections for the given macro set. The unit of work the
|
|
42
|
+
/// tests exercise.
|
|
43
|
+
func detect(source: String, file: String, macros: Set<DetectedMacro>) -> [Detection] {
|
|
44
|
+
let tree = Parser.parse(source: source)
|
|
45
|
+
let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros)
|
|
46
|
+
visitor.walk(tree)
|
|
47
|
+
return visitor.detections
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// MARK: - Pre-filter
|
|
51
|
+
|
|
52
|
+
/// Builds the pre-filter regex for a macro set, e.g. `@(ExpoModule)` for a `modules` scan or
|
|
53
|
+
/// `@(ExpoModule|JS|Record|SharedObject)` for an `exports` scan. A precompiled `NSRegularExpression`
|
|
54
|
+
/// benchmarked ~20x faster over a large source tree than calling `String.contains` once per macro
|
|
55
|
+
/// name, because it scans each file in a single pass. Compiled once per run and reused per file.
|
|
56
|
+
func macroAttributeRegex(for macros: Set<DetectedMacro>) -> NSRegularExpression {
|
|
57
|
+
// Sort for a stable pattern regardless of the set's iteration order.
|
|
58
|
+
let alternation = macros.map(\.rawValue).sorted().joined(separator: "|")
|
|
59
|
+
return try! NSRegularExpression(pattern: "@(\(alternation))")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// True if the source text contains one of the pre-filter's spelled macro attributes, so it's worth
|
|
63
|
+
/// parsing. A deliberate over-approximation: the pattern can still match inside a comment or string,
|
|
64
|
+
/// in which case the file is parsed and correctly yields no detections — a wasted parse, never a
|
|
65
|
+
/// missed module. It assumes the attribute is written with no space after `@` (`@ExpoModule`, not
|
|
66
|
+
/// `@ ExpoModule`), which is universal in practice; the rare spaced form would be skipped.
|
|
67
|
+
func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Bool {
|
|
68
|
+
let range = NSRange(source.startIndex..., in: source)
|
|
69
|
+
return prefilter.firstMatch(in: source, range: range) != nil
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// MARK: - File discovery
|
|
73
|
+
|
|
74
|
+
/// Directory names skipped during the recursive walk. These hold build products, dependencies, and
|
|
75
|
+
/// git internals — never source worth scanning — and pruning them keeps the walk from descending
|
|
76
|
+
/// into the bulk of a monorepo's files.
|
|
77
|
+
private let prunedDirectoryNames: Set<String> = [".build", "Pods", ".git"]
|
|
78
|
+
|
|
79
|
+
/// Expands the given paths into the list of `.swift` files to parse: a file path passes through,
|
|
80
|
+
/// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic
|
|
81
|
+
/// so output is stable across runs.
|
|
82
|
+
///
|
|
83
|
+
/// Reported paths are absolute, so the output is unambiguous and independent of the caller's working
|
|
84
|
+
/// directory. (A future `--root` option could emit paths relative to a given base when a portable,
|
|
85
|
+
/// shorter form is wanted.)
|
|
86
|
+
func swiftFiles(in paths: [String]) -> [String] {
|
|
87
|
+
let fileManager = FileManager.default
|
|
88
|
+
var result: [String] = []
|
|
89
|
+
|
|
90
|
+
for path in paths {
|
|
91
|
+
var isDirectory: ObjCBool = false
|
|
92
|
+
guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else {
|
|
93
|
+
FileHandle.standardError.write(Data("warning: no such path \(path)\n".utf8))
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if isDirectory.boolValue {
|
|
98
|
+
result.append(contentsOf: swiftFiles(inDirectory: URL(fileURLWithPath: path), fileManager: fileManager))
|
|
99
|
+
} else if path.hasSuffix(".swift") {
|
|
100
|
+
// A directory walk already yields absolute paths; resolve a directly-passed file the same way
|
|
101
|
+
// so every reported path is absolute regardless of how it was spelled.
|
|
102
|
+
result.append(URL(fileURLWithPath: path).standardizedFileURL.path)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result.sorted()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Recursively enumerates `.swift` files under a directory, calling `skipDescendants()` on any
|
|
110
|
+
/// pruned directory so its subtree is never read. Uses the URL enumerator (rather than the
|
|
111
|
+
/// path-based one) precisely because it supports skipping a subtree mid-walk.
|
|
112
|
+
///
|
|
113
|
+
/// Directory-ness is read from `hasDirectoryPath` (the enumerator sets a trailing slash on the URLs
|
|
114
|
+
/// it yields) rather than `resourceValues(forKeys: [.isDirectoryKey])`, which re-`stat`s each entry.
|
|
115
|
+
/// The walk is the dominant cost of a whole-tree scan, and skipping that per-entry stat measurably
|
|
116
|
+
/// shortens it.
|
|
117
|
+
private func swiftFiles(inDirectory directory: URL, fileManager: FileManager) -> [String] {
|
|
118
|
+
guard let enumerator = fileManager.enumerator(
|
|
119
|
+
at: directory,
|
|
120
|
+
includingPropertiesForKeys: nil,
|
|
121
|
+
options: [.skipsHiddenFiles]
|
|
122
|
+
) else {
|
|
123
|
+
return []
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
var result: [String] = []
|
|
127
|
+
for case let url as URL in enumerator {
|
|
128
|
+
if url.hasDirectoryPath {
|
|
129
|
+
if prunedDirectoryNames.contains(url.lastPathComponent) {
|
|
130
|
+
enumerator.skipDescendants()
|
|
131
|
+
}
|
|
132
|
+
} else if url.pathExtension == "swift" {
|
|
133
|
+
result.append(url.path)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return result
|
|
137
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The scanner's public entry point. Argument parsing, subcommand dispatch, and usage text live in
|
|
4
|
+
/// the CLI target; this just runs a command and writes its JSON report to stdout.
|
|
5
|
+
///
|
|
6
|
+
/// The detection model (`Detection`, `DetectionVisitor`, …) stays `internal`: tests reach it via
|
|
7
|
+
/// `@testable import`, and the CLI only needs these entries, so nothing else is exposed.
|
|
8
|
+
public enum Scanner {
|
|
9
|
+
/// Runs the `scan-modules` command over `paths`, prints the JSON report to stdout, and returns a
|
|
10
|
+
/// process exit code: `0` on success, `1` if encoding fails. (`scan-exports` will get its own
|
|
11
|
+
/// `run`-style entry returning its own result type when implemented.)
|
|
12
|
+
public static func runModules(paths: [String]) -> Int32 {
|
|
13
|
+
let result = scanModules(paths: paths)
|
|
14
|
+
|
|
15
|
+
do {
|
|
16
|
+
let encoder = JSONEncoder()
|
|
17
|
+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
18
|
+
let data = try encoder.encode(result)
|
|
19
|
+
FileHandle.standardOutput.write(data)
|
|
20
|
+
FileHandle.standardOutput.write(Data("\n".utf8))
|
|
21
|
+
return 0
|
|
22
|
+
} catch {
|
|
23
|
+
FileHandle.standardError.write(Data("error: failed to encode results: \(error)\n".utf8))
|
|
24
|
+
return 1
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// One module in the `scan-modules` output. Trimmed to what `expo-modules-autolinking` needs to
|
|
30
|
+
/// register a module: the Swift class name, the JS name it registers under, and the file it's in.
|
|
31
|
+
/// The richer fields the visitor captures (declaration kind, raw macro arguments, line/column) are
|
|
32
|
+
/// dropped here — they're redundant for this command (the macro is always `@ExpoModule` on a class)
|
|
33
|
+
/// and belong to the deep `scan-exports` surface instead.
|
|
34
|
+
struct ScannedModule: Codable, Equatable {
|
|
35
|
+
/// The Swift class name the module is declared as.
|
|
36
|
+
let name: String
|
|
37
|
+
|
|
38
|
+
/// The fully-resolved JS module name: the `@ExpoModule("Foo")` override when present, otherwise the
|
|
39
|
+
/// class name. Resolved here (rather than left `nil`) so it matches how the macro derives the name
|
|
40
|
+
/// and the consumer never has to apply the fallback itself.
|
|
41
|
+
let jsName: String
|
|
42
|
+
|
|
43
|
+
/// Source file the module was found in, relative to the path the scanner was invoked with.
|
|
44
|
+
let file: String
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
|
|
48
|
+
/// command's JSON output. (`scan-exports` will return its own shape when implemented; the two
|
|
49
|
+
/// commands serve different consumers and aren't expected to share an envelope.)
|
|
50
|
+
struct ScanModulesResult: Codable, Equatable {
|
|
51
|
+
let modules: [ScannedModule]
|
|
52
|
+
let stats: ScanStats
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Scans the given paths for top-level `@ExpoModule` types and returns the modules (in file then
|
|
56
|
+
/// source order) plus the stats for the run — the `scan-modules` command. Kept separate from the
|
|
57
|
+
/// public entry (and `internal`) so tests can drive it without going through argv/stdout.
|
|
58
|
+
func scanModules(paths: [String]) -> ScanModulesResult {
|
|
59
|
+
let scan = collectDetections(paths: paths, macros: [.expoModule])
|
|
60
|
+
|
|
61
|
+
let modules = scan.detections.map {
|
|
62
|
+
// Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else the
|
|
63
|
+
// class name.
|
|
64
|
+
ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, file: $0.file)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return ScanModulesResult(modules: modules, stats: scan.stats)
|
|
68
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import ExpoModulesScanner
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Command-line front end for the scanner. Parses the subcommand and paths, then delegates to the
|
|
5
|
+
/// matching library entry (which runs the scan and writes its JSON output). Each path may be a
|
|
6
|
+
/// `.swift` file or a directory (scanned recursively for `.swift` files).
|
|
7
|
+
///
|
|
8
|
+
/// Subcommands:
|
|
9
|
+
/// scan-modules <path>... fast: top-level `@ExpoModule` types, for autolinking
|
|
10
|
+
/// scan-exports <path>... deep: full JS-exported surface, for TS type generation
|
|
11
|
+
|
|
12
|
+
let toolName = "ExpoModulesScanner"
|
|
13
|
+
|
|
14
|
+
let usageText = """
|
|
15
|
+
usage: \(toolName) <subcommand> <path> [<path> ...]
|
|
16
|
+
|
|
17
|
+
subcommands:
|
|
18
|
+
scan-modules fast scan for top-level @ExpoModule types (autolinking)
|
|
19
|
+
scan-exports deep scan of the full JS-exported surface (type generation)
|
|
20
|
+
|
|
21
|
+
options:
|
|
22
|
+
-h, --help print this help and exit
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
/// Prints the usage text to the given handle. Goes to stdout when help was explicitly requested
|
|
27
|
+
/// (a successful action), stderr when it accompanies a usage error.
|
|
28
|
+
func printUsage(to handle: FileHandle = .standardError) {
|
|
29
|
+
handle.write(Data(usageText.utf8))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func fail(_ message: String, usage: Bool = false, code: Int32 = 2) -> Never {
|
|
33
|
+
FileHandle.standardError.write(Data("error: \(message)\n".utf8))
|
|
34
|
+
if usage {
|
|
35
|
+
printUsage()
|
|
36
|
+
}
|
|
37
|
+
exit(code)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var arguments = Array(CommandLine.arguments.dropFirst())
|
|
41
|
+
|
|
42
|
+
// `-h`/`--help` anywhere is treated as a help request: print usage to stdout and exit 0.
|
|
43
|
+
if arguments.contains(where: { $0 == "-h" || $0 == "--help" }) {
|
|
44
|
+
printUsage(to: .standardOutput)
|
|
45
|
+
exit(0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
guard !arguments.isEmpty else {
|
|
49
|
+
printUsage()
|
|
50
|
+
exit(2)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let subcommand = arguments.removeFirst()
|
|
54
|
+
let paths = arguments
|
|
55
|
+
|
|
56
|
+
switch subcommand {
|
|
57
|
+
case "scan-modules":
|
|
58
|
+
guard !paths.isEmpty else {
|
|
59
|
+
fail("scan-modules requires at least one path", usage: true)
|
|
60
|
+
}
|
|
61
|
+
exit(Scanner.runModules(paths: paths))
|
|
62
|
+
|
|
63
|
+
case "scan-exports":
|
|
64
|
+
// Deep extraction (members, record fields, the JS surface of each type) lands in a separate PR.
|
|
65
|
+
// The subcommand is recognized so the CLI surface is stable, but it isn't implemented yet.
|
|
66
|
+
fail("scan-exports is not yet implemented", code: 1)
|
|
67
|
+
|
|
68
|
+
default:
|
|
69
|
+
fail("unknown subcommand '\(subcommand)'", usage: true)
|
|
70
|
+
}
|