@expo/expo-modules-macros-plugin 0.7.0 → 0.9.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 +71 -31
- package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +59 -0
- package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +2 -2
- package/apple/Sources/ExpoModulesMacros/JSMacro.swift +112 -0
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +12 -0
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +18 -1
- package/apple/Sources/ExpoModulesMacros/Receiver.swift +6 -7
- package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +11 -9
- 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 +26 -12
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +32 -6
- package/package.json +1 -1
- package/apple/Sources/ExpoModulesScannerCLI/main.swift +0 -71
|
@@ -54,6 +54,9 @@ jobs:
|
|
|
54
54
|
case "$archs" in *x86_64*) ;; *) echo "Missing x86_64 slice"; exit 1;; esac
|
|
55
55
|
arch -arm64 ./ExpoModulesMacros-tool < /dev/null
|
|
56
56
|
arch -x86_64 ./ExpoModulesMacros-tool < /dev/null
|
|
57
|
+
# With arguments the same binary runs the scanner CLI instead of the plugin server.
|
|
58
|
+
arch -arm64 ./ExpoModulesMacros-tool --help > /dev/null
|
|
59
|
+
arch -x86_64 ./ExpoModulesMacros-tool --help > /dev/null
|
|
57
60
|
|
|
58
61
|
- name: Test
|
|
59
62
|
run: swift test -v
|
|
Binary file
|
package/apple/Package.swift
CHANGED
|
@@ -8,19 +8,19 @@ import PackageDescription
|
|
|
8
8
|
let package = Package(
|
|
9
9
|
name: "ExpoModulesMacros",
|
|
10
10
|
platforms: [.macOS(.v13)],
|
|
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
|
-
],
|
|
17
11
|
dependencies: [
|
|
18
12
|
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest")
|
|
19
13
|
],
|
|
20
14
|
targets: [
|
|
15
|
+
// The plugin executable doubles as the scanner CLI: the compiler launches it without arguments
|
|
16
|
+
// and speaks the plugin protocol over stdin, while an invocation with arguments dispatches into
|
|
17
|
+
// `ScannerCLI` (see the entry point in Plugin.swift). Sharing the binary keeps the package to a
|
|
18
|
+
// single shipped executable; the scanner adds little on top of the SwiftSyntax the macros
|
|
19
|
+
// already link.
|
|
21
20
|
.macro(
|
|
22
21
|
name: "ExpoModulesMacros",
|
|
23
22
|
dependencies: [
|
|
23
|
+
"ExpoModulesScanner",
|
|
24
24
|
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
|
|
25
25
|
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
|
|
26
26
|
]
|
|
@@ -30,12 +30,9 @@ let package = Package(
|
|
|
30
30
|
dependencies: [
|
|
31
31
|
.product(name: "SwiftSyntax", package: "swift-syntax"),
|
|
32
32
|
.product(name: "SwiftParser", package: "swift-syntax"),
|
|
33
|
+
.product(name: "SwiftIfConfig", package: "swift-syntax"),
|
|
33
34
|
]
|
|
34
35
|
),
|
|
35
|
-
.executableTarget(
|
|
36
|
-
name: "ExpoModulesScannerCLI",
|
|
37
|
-
dependencies: ["ExpoModulesScanner"]
|
|
38
|
-
),
|
|
39
36
|
]
|
|
40
37
|
)
|
|
41
38
|
|
|
@@ -4,12 +4,15 @@ import SwiftSyntax
|
|
|
4
4
|
/// `Function(...)` / `AsyncFunction(...)` DSL entry that the runtime interprets per call, the enclosing
|
|
5
5
|
/// macro synthesizes a decorator (`_decorateModule(object:)` / `_decorateSharedObject(prototype:)`) that binds each such
|
|
6
6
|
/// function into the JS object via the closure-taking `JavaScriptObject.setProperty(_:)`, with the
|
|
7
|
-
/// decode-call-encode body inlined into the closure. This omits the
|
|
7
|
+
/// decode-call-encode body inlined into the closure. This omits the dynamic-call path entirely:
|
|
8
8
|
/// every argument is decoded individually by its static type.
|
|
9
9
|
///
|
|
10
10
|
/// The receiver (see `Receiver`) is the module's `self` for a module binding, or the per-call `_self`
|
|
11
|
-
/// unwrapped from the JS `this` for a shared-object binding. An `async` `@JS func`
|
|
12
|
-
///
|
|
11
|
+
/// unwrapped from the JS `this` for a shared-object binding. An `async` `@JS func` binds through the
|
|
12
|
+
/// two-phase async `setProperty(_:)` overload: the closure is the synchronous decode phase (receiver
|
|
13
|
+
/// unwrap, arity guard, argument decode, all while `this` and the arguments are still valid)
|
|
14
|
+
/// and returns the async body (call and result encode), so nothing JSI-owned crosses the asynchronous
|
|
15
|
+
/// boundary and JS gets a promise.
|
|
13
16
|
internal struct JSFunction {
|
|
14
17
|
let swiftName: String
|
|
15
18
|
let jsName: String
|
|
@@ -60,7 +63,7 @@ internal struct JSFunction {
|
|
|
60
63
|
let maximum = parameters.count
|
|
61
64
|
var lines: [String] = []
|
|
62
65
|
|
|
63
|
-
if let unwrap = receiver.unwrapStatement
|
|
66
|
+
if let unwrap = receiver.unwrapStatement {
|
|
64
67
|
lines.append(unwrap)
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -87,8 +90,31 @@ internal struct JSFunction {
|
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
if required == maximum {
|
|
90
|
-
// No omittable trailing run: a single flat call with every argument decoded.
|
|
91
|
-
|
|
93
|
+
// No omittable trailing run: a single flat call with every argument decoded. An async
|
|
94
|
+
// function decodes any remaining slots here (still the synchronous phase) and returns its
|
|
95
|
+
// body; a sync one calls and encodes directly.
|
|
96
|
+
if isAsync {
|
|
97
|
+
for index in required..<maximum {
|
|
98
|
+
lines.append(decodeStatement(at: index))
|
|
99
|
+
}
|
|
100
|
+
lines.append(contentsOf: asyncBodyLines(receiver: receiver, arity: maximum))
|
|
101
|
+
} else {
|
|
102
|
+
lines.append(contentsOf: callAndEncodeLines(receiver: receiver, arity: maximum, decodingFrom: required))
|
|
103
|
+
}
|
|
104
|
+
} else if isAsync {
|
|
105
|
+
// One body per accepted arity, branching on `arguments.count`. A branch decodes its trailing
|
|
106
|
+
// slots synchronously and returns the async body for that call shape, so each `return` ends
|
|
107
|
+
// the decode phase for its arity.
|
|
108
|
+
lines.append("switch arguments.count {")
|
|
109
|
+
for arity in required...maximum {
|
|
110
|
+
let label = arity == maximum ? "default:" : "case \(arity):"
|
|
111
|
+
lines.append(label)
|
|
112
|
+
for index in required..<arity {
|
|
113
|
+
lines.append(" " + decodeStatement(at: index))
|
|
114
|
+
}
|
|
115
|
+
lines.append(contentsOf: asyncBodyLines(receiver: receiver, arity: arity).map { " " + $0 })
|
|
116
|
+
}
|
|
117
|
+
lines.append("}")
|
|
92
118
|
} else {
|
|
93
119
|
// One call shape per accepted arity, branching on `arguments.count`. A branch decodes a
|
|
94
120
|
// trailing slot before the call, so this is a `switch` statement (not an expression): a
|
|
@@ -116,14 +142,32 @@ internal struct JSFunction {
|
|
|
116
142
|
.joined(separator: "\n")
|
|
117
143
|
}
|
|
118
144
|
|
|
145
|
+
/// The `return { … }` statement closing an async binding's decode phase: the returned closure is
|
|
146
|
+
/// the function's async body (`AsyncFunctionBody`), awaiting the call and encoding the result. It
|
|
147
|
+
/// captures only what the call needs — the decoded `arg<i>` locals, the receiver, and `runtime`
|
|
148
|
+
/// for the encode — never `this` or the arguments buffer, which are only valid for the duration
|
|
149
|
+
/// of the host call.
|
|
150
|
+
private func asyncBodyLines(receiver: Receiver, arity: Int) -> [String] {
|
|
151
|
+
var lines: [String] = ["return {"]
|
|
152
|
+
if returnType != nil {
|
|
153
|
+
lines.append(" let result = \(callExpression(receiver: receiver, arity: arity))")
|
|
154
|
+
lines.append(contentsOf: encodeResultLines().map { " " + $0 })
|
|
155
|
+
} else {
|
|
156
|
+
lines.append(" \(callExpression(receiver: receiver, arity: arity))")
|
|
157
|
+
lines.append(" return .undefined")
|
|
158
|
+
}
|
|
159
|
+
lines.append("}")
|
|
160
|
+
return lines
|
|
161
|
+
}
|
|
162
|
+
|
|
119
163
|
/// `let arg<index> = …` decoding the slot at `index` by its static type through
|
|
120
164
|
/// `JavaScriptDecodable.decode` on the borrowed `JavaScriptUnownedValue` — no owning value, no
|
|
121
165
|
/// `jsi::Value` copy, no `Any` boxing, no force-cast; it returns the concrete type directly. A
|
|
122
166
|
/// primitive's `decode` is `@inlinable` and lowers to the same direct accessor a hand-rolled fast
|
|
123
167
|
/// path would use.
|
|
124
168
|
private func decodeStatement(at index: Int) -> String {
|
|
125
|
-
let
|
|
126
|
-
return "let arg\(index) = try \(
|
|
169
|
+
let type = parameters[index].type.trimmedDescription
|
|
170
|
+
return "let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))"
|
|
127
171
|
}
|
|
128
172
|
|
|
129
173
|
/// The `<callee>.<name>(...)` call for the given arity. Slots `0..<arity` are passed their decoded
|
|
@@ -202,28 +246,25 @@ internal struct JSFunction {
|
|
|
202
246
|
/// The `setProperty` statement that installs this function on the JS object. The decode-call-encode
|
|
203
247
|
/// body is inlined directly into the closure passed to the closure-taking `setProperty` overload
|
|
204
248
|
/// (which creates the host function under the hood) — no separate named binding. For an `async`
|
|
205
|
-
/// function the
|
|
206
|
-
/// receives a promise).
|
|
249
|
+
/// function the closure is the synchronous decode phase and returns the async body, which selects
|
|
250
|
+
/// the async `setProperty` overload (so JS receives a promise).
|
|
207
251
|
///
|
|
208
|
-
///
|
|
252
|
+
/// A module captures its `self` **strong** —
|
|
209
253
|
/// the host-function closure is what keeps the native callable alive for as long as JS can invoke
|
|
210
254
|
/// it; its lifetime is bounded by the JS VM's garbage collection of the object. A shared object
|
|
211
255
|
/// captures nothing of the instance: it recovers the typed receiver from the JS `this` per call.
|
|
212
256
|
func decorateStatements(object: String, receiver: Receiver) -> String {
|
|
213
|
-
//
|
|
214
|
-
// `
|
|
215
|
-
//
|
|
216
|
-
// `this`; a shared object unwraps it (still
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
257
|
+
// Every `@JS` binding hands `this` in as a borrowed `JavaScriptUnownedValue` instead of
|
|
258
|
+
// allocating an owning `JavaScriptValue` and forming its `weak`-runtime reference on every call,
|
|
259
|
+
// and consumes the arguments buffer — sync and async closures share one parameter shape and
|
|
260
|
+
// differ only in what they return. A module ignores `this`; a shared object unwraps it (still
|
|
261
|
+
// borrowed) — an async binding does so in its synchronous decode phase, before the borrow ends.
|
|
262
|
+
// The parameter list is parenthesized and fully typed, since Swift rejects a type annotation on
|
|
263
|
+
// a shorthand `{ [capture] name, name in }` parameter; for a sync binding the explicit
|
|
264
|
+
// `JavaScriptUnownedValue` also selects the (otherwise `@_disfavoredOverload`) unowned-`this`
|
|
265
|
+
// overload.
|
|
222
266
|
let captures = receiver.captureClause
|
|
223
|
-
let parameters =
|
|
224
|
-
isAsync
|
|
225
|
-
? "this, arguments"
|
|
226
|
-
: "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
|
|
267
|
+
let parameters = "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
|
|
227
268
|
|
|
228
269
|
return """
|
|
229
270
|
\(object).setProperty("\(jsName)") { \(captures)\(parameters) in
|
|
@@ -237,7 +278,7 @@ internal struct JSFunction {
|
|
|
237
278
|
/// `Property(...)` DSL entry, the enclosing macro synthesizes a get/set accessor into the JS object
|
|
238
279
|
/// inside its decorator (`_decorateModule(object:)` / `_decorateSharedObject(prototype:)`): it builds a descriptor object
|
|
239
280
|
/// (`enumerable` + `get`, and `set` when the property is settable) and installs it with
|
|
240
|
-
/// `object.defineProperty(name, descriptor:)
|
|
281
|
+
/// `object.defineProperty(name, descriptor:)`.
|
|
241
282
|
/// The `get`/`set` host functions are installed the same way `@JS func`s are — the closure-taking
|
|
242
283
|
/// `setProperty(_:)` overload, with the read/write body inlined into the closure.
|
|
243
284
|
///
|
|
@@ -268,7 +309,7 @@ internal struct JSProperty {
|
|
|
268
309
|
// A shared object's accessors unwrap the JS `this` into `_self` before reading/writing; a module
|
|
269
310
|
// reads `self` directly. The unwrap leads each accessor body. Property accessors are synchronous, so
|
|
270
311
|
// they take the borrowed unowned `this`.
|
|
271
|
-
let unwrap = receiver.unwrapStatement
|
|
312
|
+
let unwrap = receiver.unwrapStatement.map { "\($0)\n" } ?? ""
|
|
272
313
|
var lines: [String] = []
|
|
273
314
|
|
|
274
315
|
lines.append("let \(descriptorName) = runtime.createObject()")
|
|
@@ -291,8 +332,7 @@ internal struct JSProperty {
|
|
|
291
332
|
// getter-only (a settable var with neither an annotation nor a literal default is rare and can't
|
|
292
333
|
// be decoded).
|
|
293
334
|
if isSettable, let valueType {
|
|
294
|
-
let
|
|
295
|
-
let setDecode = "\(callee).\(swiftName) = try \(exprType).decode(arguments.unownedValue(at: 0), in: runtime)"
|
|
335
|
+
let setDecode = "\(callee).\(swiftName) = try \(decodeCall(valueType, from: "arguments.unownedValue(at: 0)"))"
|
|
296
336
|
lines.append(
|
|
297
337
|
accessorClosure(
|
|
298
338
|
descriptorName, "set", receiver: receiver, body: "\(unwrap)\(setDecode)\nreturn .undefined"))
|
|
@@ -348,9 +388,9 @@ private func decorateBody(
|
|
|
348
388
|
/// The module decorator: a `_decorateModule(object:)` that binds every `@JS func` (via an inlined
|
|
349
389
|
/// `setProperty` closure) and every `@JS var` (via a `defineProperty` accessor) into the module's own
|
|
350
390
|
/// JS object. Core supplies the object; the bindings call into the module `self`. It's an *instance*
|
|
351
|
-
/// method (a module is a singleton, satisfying the `AnyModule` requirement of the same name),
|
|
352
|
-
///
|
|
353
|
-
///
|
|
391
|
+
/// method (a module is a singleton, satisfying the `AnyModule` requirement of the same name), with a
|
|
392
|
+
/// `borrowing` object parameter (it mutates through the reference without reassigning or taking
|
|
393
|
+
/// ownership). Only emitted when there's at least
|
|
354
394
|
/// one member to bind. Uses the shared body generation with the module `object:` phase and `self`
|
|
355
395
|
/// receiver; the shared-object counterpart is `buildDecorateSharedObjectPhase`.
|
|
356
396
|
internal func buildDecorateJavaScriptObject(functions: [JSFunction], properties: [JSProperty]) -> DeclSyntax {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/// Recognition of the free-form (`Any`-bearing) boundary types and the spellings the macros emit or
|
|
2
|
+
/// suggest for them. None of these types can conform to the JS codable protocols (`Any` can't conform
|
|
3
|
+
/// to a protocol, and the container conditional conformances require the element/value to conform), so
|
|
4
|
+
/// they can't cross the boundary through the usual `T.decode` / `T.encode` path. Instead they're
|
|
5
|
+
/// accepted **only** in decode position (function/constructor arguments), decoded through a dedicated
|
|
6
|
+
/// `JavaScriptValue.decodeAny…` entry point; a free-form return or getter is rejected, since there's
|
|
7
|
+
/// no free-form encode.
|
|
8
|
+
///
|
|
9
|
+
/// Shared across the macro pipeline: `TypeConformanceAssertion` skips them, `MacroHelpers.decodeCall`
|
|
10
|
+
/// reroutes their decode, and `JSMacro` uses them for the steering diagnostics.
|
|
11
|
+
|
|
12
|
+
/// The free-form boundary types: an untyped value (`Any`), an untyped array (`[Any]`), and an untyped
|
|
13
|
+
/// string-keyed dictionary (`[String: Any]`). Matched by whitespace-normalized spelling so
|
|
14
|
+
/// `[String: Any]` and `[String : Any]` both count.
|
|
15
|
+
private let freeFormBoundaryTypes: Set<String> = ["Any", "[Any]", "[String:Any]"]
|
|
16
|
+
|
|
17
|
+
/// The `JavaScriptValue.decodeAny…` method the binding calls to decode a free-form argument, keyed by
|
|
18
|
+
/// the free-form type's normalized spelling. `nil` for any non-free-form type.
|
|
19
|
+
private let freeFormDecodeMethods: [String: String] = [
|
|
20
|
+
"Any": "decodeAny",
|
|
21
|
+
"[Any]": "decodeAnyArray",
|
|
22
|
+
"[String:Any]": "decodeAnyDictionary",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/// The type-safe alternative a free-form type's diagnostic steers to: the same shape with the `Any`
|
|
26
|
+
/// element replaced by `JavaScriptValue`, which conforms to the codable protocols. Keyed by the
|
|
27
|
+
/// free-form type's normalized spelling; spelled with conventional spacing for the message.
|
|
28
|
+
private let typedFreeFormReplacements: [String: String] = [
|
|
29
|
+
"Any": "JavaScriptValue",
|
|
30
|
+
"[Any]": "[JavaScriptValue]",
|
|
31
|
+
"[String:Any]": "[String: JavaScriptValue]",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
/// True when a boundary type (as written) is one of the free-form spellings, ignoring internal
|
|
35
|
+
/// whitespace so `[String: Any]` and `[String :Any]` both match. Optionals are not free-form here: a
|
|
36
|
+
/// trailing `?` would route through `Optional.decode`, which free-form can't satisfy, so an optional
|
|
37
|
+
/// free-form type isn't recognized and stays a normal (failing) assertion.
|
|
38
|
+
internal func isFreeFormBoundaryType(_ type: String) -> Bool {
|
|
39
|
+
return freeFormBoundaryTypes.contains(normalizedTypeSpelling(type))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// The `JavaScriptValue.decodeAny…` method name for a free-form boundary type, or `nil` when the type
|
|
43
|
+
/// isn't free-form. The binding emits `JavaScriptValue.<method>(arguments.unownedValue(at:), in:)` in
|
|
44
|
+
/// place of the type's own `.decode`.
|
|
45
|
+
internal func freeFormDecodeMethod(for type: String) -> String? {
|
|
46
|
+
return freeFormDecodeMethods[normalizedTypeSpelling(type)]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// The type-safe alternative to suggest in place of a free-form type (its `Any` element replaced by
|
|
50
|
+
/// `JavaScriptValue`), or `nil` when the type isn't free-form.
|
|
51
|
+
internal func typedFreeFormReplacement(for type: String) -> String? {
|
|
52
|
+
return typedFreeFormReplacements[normalizedTypeSpelling(type)]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// A type spelling with all whitespace removed, so spelling variations of the same type
|
|
56
|
+
/// (`[String: Any]` vs `[String :Any]`) compare equal.
|
|
57
|
+
private func normalizedTypeSpelling(_ type: String) -> String {
|
|
58
|
+
return type.filter { !$0.isWhitespace }
|
|
59
|
+
}
|
|
@@ -27,8 +27,8 @@ internal struct JSConstructor {
|
|
|
27
27
|
|
|
28
28
|
var callArguments: [String] = []
|
|
29
29
|
for (index, parameter) in parameters.enumerated() {
|
|
30
|
-
let
|
|
31
|
-
lines.append("let arg\(index) = try \(
|
|
30
|
+
let type = parameter.type.trimmedDescription
|
|
31
|
+
lines.append("let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))")
|
|
32
32
|
|
|
33
33
|
let label = parameter.firstName.text
|
|
34
34
|
callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import SwiftDiagnostics
|
|
1
2
|
import SwiftSyntax
|
|
2
3
|
import SwiftSyntaxMacros
|
|
3
4
|
|
|
@@ -30,6 +31,8 @@ public struct JSMacro: PeerMacro {
|
|
|
30
31
|
providingPeersOf declaration: some DeclSyntaxProtocol,
|
|
31
32
|
in context: some MacroExpansionContext
|
|
32
33
|
) throws -> [DeclSyntax] {
|
|
34
|
+
diagnoseFreeFormTypes(in: declaration, in: context)
|
|
35
|
+
|
|
33
36
|
guard let member = boundaryMember(of: declaration),
|
|
34
37
|
let assertion = directionalConformanceAssertion(
|
|
35
38
|
name: member.name,
|
|
@@ -43,6 +46,115 @@ public struct JSMacro: PeerMacro {
|
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/// Emits the free-form (`Any` / `[Any]` / `[String: Any]`) diagnostics for a `@JS` declaration.
|
|
50
|
+
///
|
|
51
|
+
/// A free-form type is only supported crossing the boundary as an **argument**, decoded through
|
|
52
|
+
/// `JavaScriptValue.decodeAny…`; there is no free-form encode, so any position that encodes is a hard
|
|
53
|
+
/// error. That means:
|
|
54
|
+
/// - a function/constructor **parameter** typed free-form gets a warning steering to
|
|
55
|
+
/// `[String: JavaScriptValue]` (the type-safe alternative), but compiles;
|
|
56
|
+
/// - a function **return** typed free-form is an error (it would need to encode);
|
|
57
|
+
/// - a **property** typed free-form is an error regardless of settability, because its getter always
|
|
58
|
+
/// encodes.
|
|
59
|
+
///
|
|
60
|
+
/// Types are matched by their written spelling on the type node, so the diagnostic points at the
|
|
61
|
+
/// offending type in the user's source.
|
|
62
|
+
private func diagnoseFreeFormTypes(
|
|
63
|
+
in declaration: some DeclSyntaxProtocol,
|
|
64
|
+
in context: some MacroExpansionContext
|
|
65
|
+
) {
|
|
66
|
+
if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
|
|
67
|
+
warnFreeFormArguments(funcDecl.signature.parameterClause.parameters, in: context)
|
|
68
|
+
if let returnType = funcDecl.signature.returnClause?.type,
|
|
69
|
+
isFreeFormBoundaryType(returnType.trimmedDescription) {
|
|
70
|
+
context.diagnose(
|
|
71
|
+
Diagnostic(node: returnType, message: freeFormReturnError(for: returnType.trimmedDescription)))
|
|
72
|
+
}
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A constructor decodes its arguments exactly like a function; it has no return value to encode, so
|
|
77
|
+
// only the argument warning applies.
|
|
78
|
+
if let initDecl = declaration.as(InitializerDeclSyntax.self) {
|
|
79
|
+
warnFreeFormArguments(initDecl.signature.parameterClause.parameters, in: context)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if let varDecl = declaration.as(VariableDeclSyntax.self),
|
|
84
|
+
let type = varDecl.bindings.first?.typeAnnotation?.type,
|
|
85
|
+
isFreeFormBoundaryType(type.trimmedDescription) {
|
|
86
|
+
context.diagnose(
|
|
87
|
+
Diagnostic(node: type, message: freeFormPropertyError(for: type.trimmedDescription)))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// The tail of a free-form encode error: the reshaped `JavaScriptValue` alternative when the type is a
|
|
92
|
+
/// container (`[String: Any]` -> `[String: JavaScriptValue]`), otherwise the passthrough suggestion
|
|
93
|
+
/// for a bare `Any`. Both conform to the codable protocols, so either is a valid fix.
|
|
94
|
+
private func suggestedAlternative(for freeFormType: String) -> String {
|
|
95
|
+
if let reshaped = typedFreeFormReplacement(for: freeFormType), reshaped != "JavaScriptValue" {
|
|
96
|
+
return "Use '\(reshaped)', or 'JavaScriptValue' to pass a JS value through unchanged."
|
|
97
|
+
}
|
|
98
|
+
return "Use a concrete type, or 'JavaScriptValue' to pass a JS value through unchanged."
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Emits the steering warning for each free-form parameter in a list. Shared by the function and
|
|
102
|
+
/// constructor cases, which both decode their arguments through the same path.
|
|
103
|
+
private func warnFreeFormArguments(
|
|
104
|
+
_ parameters: FunctionParameterListSyntax,
|
|
105
|
+
in context: some MacroExpansionContext
|
|
106
|
+
) {
|
|
107
|
+
for parameter in parameters {
|
|
108
|
+
let type = parameter.type.trimmedDescription
|
|
109
|
+
guard let suggested = typedFreeFormReplacement(for: type) else {
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
context.diagnose(
|
|
113
|
+
Diagnostic(
|
|
114
|
+
node: parameter.type,
|
|
115
|
+
message: freeFormArgumentWarning(for: type, suggesting: suggested)))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private func freeFormArgumentWarning(
|
|
120
|
+
for freeFormType: String,
|
|
121
|
+
suggesting suggestedType: String
|
|
122
|
+
) -> JSDiagnosticMessage {
|
|
123
|
+
return JSDiagnosticMessage(
|
|
124
|
+
"Prefer '\(suggestedType)' over the free-form '\(freeFormType)' for a @JS argument. Free-form decoding boxes every value as 'Any' (slower, no static typing); the 'JavaScriptValue' element keeps each value inspectable without erasing it.",
|
|
125
|
+
id: "js-free-form-argument",
|
|
126
|
+
severity: .warning
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private func freeFormReturnError(for freeFormType: String) -> JSDiagnosticMessage {
|
|
131
|
+
return JSDiagnosticMessage(
|
|
132
|
+
"A @JS function can't return the free-form '\(freeFormType)': there's no way to encode an untyped value back to JavaScript. \(suggestedAlternative(for: freeFormType))",
|
|
133
|
+
id: "js-free-form-return",
|
|
134
|
+
severity: .error
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private func freeFormPropertyError(for freeFormType: String) -> JSDiagnosticMessage {
|
|
139
|
+
return JSDiagnosticMessage(
|
|
140
|
+
"A @JS property can't have the free-form '\(freeFormType)': its getter would have to encode an untyped value back to JavaScript, which isn't supported. \(suggestedAlternative(for: freeFormType))",
|
|
141
|
+
id: "js-free-form-property",
|
|
142
|
+
severity: .error
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private struct JSDiagnosticMessage: DiagnosticMessage {
|
|
147
|
+
let message: String
|
|
148
|
+
let diagnosticID: MessageID
|
|
149
|
+
let severity: DiagnosticSeverity
|
|
150
|
+
|
|
151
|
+
init(_ message: String, id: String, severity: DiagnosticSeverity) {
|
|
152
|
+
self.message = message
|
|
153
|
+
self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
154
|
+
self.severity = severity
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
46
158
|
/// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer unique
|
|
47
159
|
/// among siblings), the boundary types split by conversion direction, and whether the member is
|
|
48
160
|
/// type-level. Arguments (and a settable property's incoming value) are decoded; return values (and a
|
|
@@ -261,6 +261,18 @@ internal func expressionType(_ type: String) -> String {
|
|
|
261
261
|
return type.dropLast() + "?"
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/// The decode expression for a boundary type read from `valueExpression` (a `JavaScriptUnownedValue`),
|
|
265
|
+
/// as it appears after `try`. A free-form type (`Any`, `[Any]`, `[String: Any]`) can't conform to
|
|
266
|
+
/// `JavaScriptDecodable`, so it decodes through the dedicated `JavaScriptValue.decodeAny…` entry point
|
|
267
|
+
/// keyed to its shape; every other type decodes through its own static `decode`, spelled in expression
|
|
268
|
+
/// position (`T!` rewritten to `T?`).
|
|
269
|
+
internal func decodeCall(_ type: String, from valueExpression: String) -> String {
|
|
270
|
+
if let method = freeFormDecodeMethod(for: type) {
|
|
271
|
+
return "JavaScriptValue.\(method)(\(valueExpression), in: runtime)"
|
|
272
|
+
}
|
|
273
|
+
return "\(expressionType(type)).decode(\(valueExpression), in: runtime)"
|
|
274
|
+
}
|
|
275
|
+
|
|
264
276
|
// MARK: - @JS property collection
|
|
265
277
|
|
|
266
278
|
/// Collects the `@JS var` bindings of a declaration into `JSProperty` values for direct JSI binding.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import ExpoModulesScanner
|
|
2
|
+
import Foundation
|
|
1
3
|
import SwiftCompilerPlugin
|
|
2
4
|
import SwiftSyntaxMacros
|
|
3
5
|
|
|
4
|
-
@main
|
|
5
6
|
struct ExpoModulesMacrosPlugin: CompilerPlugin {
|
|
6
7
|
let providingMacros: [Macro.Type] = [
|
|
7
8
|
OptimizedFunctionAttachedMacro.self,
|
|
@@ -12,3 +13,19 @@ struct ExpoModulesMacrosPlugin: CompilerPlugin {
|
|
|
12
13
|
RecordMacro.self,
|
|
13
14
|
]
|
|
14
15
|
}
|
|
16
|
+
|
|
17
|
+
/// The executable doubles as the scanner CLI. The compiler always launches a plugin executable
|
|
18
|
+
/// without arguments and speaks the plugin protocol over stdin, so any argument means a scanner
|
|
19
|
+
/// invocation (`ExpoModulesMacros-tool scan-modules <path>...`); with none, this starts the plugin
|
|
20
|
+
/// server exactly as `@main` on the `CompilerPlugin` type would.
|
|
21
|
+
@main
|
|
22
|
+
enum EntryPoint {
|
|
23
|
+
static func main() throws {
|
|
24
|
+
let arguments = Array(CommandLine.arguments.dropFirst())
|
|
25
|
+
if arguments.isEmpty {
|
|
26
|
+
try ExpoModulesMacrosPlugin.main()
|
|
27
|
+
} else {
|
|
28
|
+
exit(ScannerCLI.run(arguments: arguments))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -39,16 +39,15 @@ internal enum Receiver {
|
|
|
39
39
|
/// `native(from:as:)` recovers the typed instance from `this`, throwing on a foreign object or a type
|
|
40
40
|
/// mismatch.
|
|
41
41
|
///
|
|
42
|
-
/// The `this` object comes from the borrowed `JavaScriptUnownedValue`
|
|
43
|
-
///
|
|
44
|
-
///
|
|
45
|
-
|
|
42
|
+
/// The `this` object always comes from the borrowed `JavaScriptUnownedValue` (`asObject(in:)`): an
|
|
43
|
+
/// async binding unwraps in its synchronous decode phase, before the borrowed value's lifetime ends,
|
|
44
|
+
/// and only the recovered native instance crosses into the async body.
|
|
45
|
+
var unwrapStatement: String? {
|
|
46
46
|
switch self {
|
|
47
47
|
case .module, .staticMember:
|
|
48
48
|
return nil
|
|
49
49
|
case .sharedObject(let typeName):
|
|
50
|
-
let
|
|
51
|
-
return "let _self = try SharedObject.native(from: \(thisObject), as: \(typeName).self)"
|
|
50
|
+
return "let _self = try SharedObject.native(from: this.asObject(in: runtime), as: \(typeName).self)"
|
|
52
51
|
}
|
|
53
52
|
}
|
|
54
53
|
|
|
@@ -67,7 +66,7 @@ internal enum Receiver {
|
|
|
67
66
|
/// Which JS object a set of bindings is installed on: the second orthogonal axis alongside `Receiver`.
|
|
68
67
|
/// It selects the decorator entry point's argument label and the local name the body binds members onto,
|
|
69
68
|
/// mirroring JS class semantics (a class has a constructor function whose `.prototype` carries instance
|
|
70
|
-
/// members)
|
|
69
|
+
/// members).
|
|
71
70
|
/// The raw value is the argument label of the decorator entry point, which is also the local name the
|
|
72
71
|
/// body binds members onto.
|
|
73
72
|
internal enum Phase: String {
|
|
@@ -116,11 +116,15 @@ internal func typeConformanceAssertions(
|
|
|
116
116
|
"""
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/// The type to assert for a boundary type as written
|
|
120
|
-
///
|
|
121
|
-
///
|
|
122
|
-
///
|
|
119
|
+
/// The type to assert for a boundary type as written, or `nil` when nothing needs asserting: an
|
|
120
|
+
/// exactly-free-form type (handled by the binding's `decodeAny…` reroute) and a known-conforming
|
|
121
|
+
/// primitive both return `nil`; everything else is unwrapped of trailing optionals and asserted. An
|
|
122
|
+
/// *optional* free-form (`[String: Any]?`) isn't rerouted and doesn't conform, so it's kept and
|
|
123
|
+
/// asserted for a clean diagnostic. Shared with `@Event`.
|
|
123
124
|
internal func assertableBoundaryType(_ type: String) -> String? {
|
|
125
|
+
guard !isFreeFormBoundaryType(type) else {
|
|
126
|
+
return nil
|
|
127
|
+
}
|
|
124
128
|
let unwrapped = unwrappedOptional(type)
|
|
125
129
|
return knownConformingPrimitives.contains(unwrapped) ? nil : unwrapped
|
|
126
130
|
}
|
|
@@ -144,14 +148,12 @@ private func conformanceAssertionBody(
|
|
|
144
148
|
return lines.joined(separator: "\n")
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
///
|
|
148
|
-
///
|
|
149
|
-
/// while preserving first-seen order so a type is asserted once even when it appears more than once.
|
|
151
|
+
/// The distinct types to assert from a list, mapping each through `assertableBoundaryType` (which
|
|
152
|
+
/// drops what needs no assertion) and deduping in first-seen order.
|
|
150
153
|
private func distinctAssertableTypes(_ types: [String]) -> [String] {
|
|
151
154
|
var seen: Set<String> = []
|
|
152
155
|
var distinct: [String] = []
|
|
153
|
-
for type in types.
|
|
154
|
-
where !knownConformingPrimitives.contains(type) && seen.insert(type).inserted {
|
|
156
|
+
for type in types.compactMap(assertableBoundaryType) where seen.insert(type).inserted {
|
|
155
157
|
distinct.append(type)
|
|
156
158
|
}
|
|
157
159
|
return distinct
|
|
@@ -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.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import SwiftIfConfig
|
|
2
|
+
import SwiftSyntax
|
|
3
|
+
|
|
4
|
+
/// The build configuration that `#if` conditions are evaluated against during a scan, built from
|
|
5
|
+
/// the CLI's `--platform` and `--define` options. The scan is static: it knows the target OS and
|
|
6
|
+
/// the spelled compilation flags, and nothing else. Every condition it cannot answer throws, which
|
|
7
|
+
/// SwiftIfConfig turns into an inactive region plus a diagnostic, so an unanswerable `#if` skips
|
|
8
|
+
/// its declarations and surfaces a warning instead of guessing.
|
|
9
|
+
struct ScanBuildConfiguration: BuildConfiguration {
|
|
10
|
+
/// The target OS name to answer `os(...)` with, as spelled in the condition (`iOS`, `macOS`,
|
|
11
|
+
/// `tvOS`, `watchOS`, `visionOS`; compared case-insensitively), or `nil` when no `--platform`
|
|
12
|
+
/// was given, in which case `os(...)` conditions are unanswerable.
|
|
13
|
+
let platform: String?
|
|
14
|
+
|
|
15
|
+
/// The conditional compilation flags treated as set, from repeated `--define` options.
|
|
16
|
+
let defines: Set<String>
|
|
17
|
+
|
|
18
|
+
func isCustomConditionSet(name: String) throws -> Bool {
|
|
19
|
+
return defines.contains(name)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func isActiveTargetOS(name: String) throws -> Bool {
|
|
23
|
+
guard let platform else {
|
|
24
|
+
throw ScanConfigurationError("cannot evaluate 'os(\(name))': no --platform was given")
|
|
25
|
+
}
|
|
26
|
+
return name.lowercased() == platform.lowercased()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// MARK: - Unanswerable conditions
|
|
30
|
+
|
|
31
|
+
// These vary within a single platform's build (device vs simulator, arm64 vs x86_64) or depend
|
|
32
|
+
// on the consumer's toolchain, so a static scan has no correct answer. Throwing makes the region
|
|
33
|
+
// inactive and emits a warning naming the condition.
|
|
34
|
+
|
|
35
|
+
func hasFeature(name: String) throws -> Bool {
|
|
36
|
+
throw ScanConfigurationError("cannot evaluate 'hasFeature(\(name))' in a static scan")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func hasAttribute(name: String) throws -> Bool {
|
|
40
|
+
throw ScanConfigurationError("cannot evaluate 'hasAttribute(\(name))' in a static scan")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func canImport(importPath: [(TokenSyntax, String)], version: CanImportVersion) throws -> Bool {
|
|
44
|
+
let module = importPath.map(\.1).joined(separator: ".")
|
|
45
|
+
|
|
46
|
+
// A curated set of SDK frameworks is answerable from the target platform alone. Everything
|
|
47
|
+
// else (arbitrary modules, submodule paths, versioned checks) stays unanswerable: a wrong
|
|
48
|
+
// "yes" would surface a declaration that doesn't exist in the real build.
|
|
49
|
+
guard importPath.count == 1,
|
|
50
|
+
case .unversioned = version,
|
|
51
|
+
let frameworkPlatforms = sdkFrameworkPlatforms[module] else {
|
|
52
|
+
throw ScanConfigurationError("cannot evaluate 'canImport(\(module))' in a static scan")
|
|
53
|
+
}
|
|
54
|
+
guard let platform else {
|
|
55
|
+
throw ScanConfigurationError("cannot evaluate 'canImport(\(module))': no --platform was given")
|
|
56
|
+
}
|
|
57
|
+
return frameworkPlatforms.contains(platform.lowercased())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func isActiveTargetArchitecture(name: String) throws -> Bool {
|
|
61
|
+
throw ScanConfigurationError("cannot evaluate 'arch(\(name))' in a static scan")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func isActiveTargetEnvironment(name: String) throws -> Bool {
|
|
65
|
+
throw ScanConfigurationError("cannot evaluate 'targetEnvironment(\(name))' in a static scan")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func isActiveTargetRuntime(name: String) throws -> Bool {
|
|
69
|
+
throw ScanConfigurationError("cannot evaluate '_runtime(\(name))' in a static scan")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func isActiveTargetPointerAuthentication(name: String) throws -> Bool {
|
|
73
|
+
throw ScanConfigurationError("cannot evaluate '_ptrauth(\(name))' in a static scan")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// MARK: - Fixed answers
|
|
77
|
+
|
|
78
|
+
// Non-throwing protocol requirements, so they need a value. These are constant across Apple
|
|
79
|
+
// targets (the only ones Expo modules compile for), except the versions, which assume a current
|
|
80
|
+
// toolchain; a module class gated on a *lower* Swift version would be wrongly included, which is
|
|
81
|
+
// rare enough to accept for a scan.
|
|
82
|
+
|
|
83
|
+
var targetPointerBitWidth: Int { 64 }
|
|
84
|
+
var targetAtomicBitWidths: [Int] { [32, 64, 128] }
|
|
85
|
+
var endianness: Endianness { .little }
|
|
86
|
+
var languageVersion: VersionTuple { VersionTuple(6) }
|
|
87
|
+
var compilerVersion: VersionTuple { VersionTuple(6, 2) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// The platforms (lowercased, as compared against `--platform`) that ship each of a curated set of
|
|
91
|
+
/// Apple SDK frameworks, so `canImport` of one is answerable from the platform alone. The list is
|
|
92
|
+
/// deliberately small and high-confidence: it covers the frameworks realistically used to gate a
|
|
93
|
+
/// module class, and a framework missing here degrades to the skip-with-warning path rather than a
|
|
94
|
+
/// wrong answer.
|
|
95
|
+
private let sdkFrameworkPlatforms: [String: Set<String>] = [
|
|
96
|
+
"UIKit": ["ios", "tvos", "watchos", "visionos"],
|
|
97
|
+
"AppKit": ["macos"],
|
|
98
|
+
"SwiftUI": ["ios", "macos", "tvos", "watchos", "visionos"],
|
|
99
|
+
"WatchKit": ["watchos"],
|
|
100
|
+
"TVUIKit": ["tvos"],
|
|
101
|
+
"WebKit": ["ios", "macos", "visionos"],
|
|
102
|
+
"SafariServices": ["ios", "macos", "visionos"],
|
|
103
|
+
"ARKit": ["ios", "visionos"],
|
|
104
|
+
"RealityKit": ["ios", "macos", "visionos"],
|
|
105
|
+
"CarPlay": ["ios"],
|
|
106
|
+
"MessageUI": ["ios"],
|
|
107
|
+
"CoreNFC": ["ios"],
|
|
108
|
+
"HealthKit": ["ios", "watchos", "visionos"],
|
|
109
|
+
"HomeKit": ["ios", "tvos", "watchos", "visionos"],
|
|
110
|
+
"WidgetKit": ["ios", "macos", "watchos", "visionos"],
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
/// An unanswerable `#if` condition. SwiftIfConfig converts the thrown error into a diagnostic on
|
|
114
|
+
/// the condition's node and treats the region as inactive.
|
|
115
|
+
struct ScanConfigurationError: Error, CustomStringConvertible {
|
|
116
|
+
let description: String
|
|
117
|
+
|
|
118
|
+
init(_ description: String) {
|
|
119
|
+
self.description = description
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -44,23 +44,36 @@ func scanFiles(
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/// Walks `paths`, parses each `.swift` file that might contain one of `macros`, and returns every
|
|
47
|
-
/// detection (in file then source order) with the run's stats —
|
|
48
|
-
/// A thin layer over `scanFiles` that accumulates the per-file
|
|
49
|
-
|
|
47
|
+
/// detection (in file then source order) with the accumulated `#if` warnings and the run's stats —
|
|
48
|
+
/// the shape `scan-modules` projects. A thin layer over `scanFiles` that accumulates the per-file
|
|
49
|
+
/// results.
|
|
50
|
+
func collectDetections(
|
|
51
|
+
paths: [String],
|
|
52
|
+
macros: Set<DetectedMacro>,
|
|
53
|
+
configuration: ScanBuildConfiguration = .init(platform: nil, defines: [])
|
|
54
|
+
) -> (detections: [Detection], warnings: [ScanWarning], stats: ScanStats) {
|
|
50
55
|
var detections: [Detection] = []
|
|
56
|
+
var warnings: [ScanWarning] = []
|
|
51
57
|
let stats = scanFiles(paths: paths, macros: macros) { source, file in
|
|
52
|
-
|
|
58
|
+
let result = detect(source: source, file: file, macros: macros, configuration: configuration)
|
|
59
|
+
detections.append(contentsOf: result.detections)
|
|
60
|
+
warnings.append(contentsOf: result.warnings)
|
|
53
61
|
}
|
|
54
|
-
return (detections, stats)
|
|
62
|
+
return (detections, warnings, stats)
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
/// Parses one source string and returns its detections for the given macro set
|
|
58
|
-
/// tests exercise.
|
|
59
|
-
func detect(
|
|
65
|
+
/// Parses one source string and returns its detections for the given macro set, plus the warnings
|
|
66
|
+
/// for `#if` conditions the configuration couldn't answer. The unit of work the tests exercise.
|
|
67
|
+
func detect(
|
|
68
|
+
source: String,
|
|
69
|
+
file: String,
|
|
70
|
+
macros: Set<DetectedMacro>,
|
|
71
|
+
configuration: ScanBuildConfiguration
|
|
72
|
+
) -> (detections: [Detection], warnings: [ScanWarning]) {
|
|
60
73
|
let tree = Parser.parse(source: source)
|
|
61
|
-
let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros)
|
|
74
|
+
let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros, configuration: configuration)
|
|
62
75
|
visitor.walk(tree)
|
|
63
|
-
return visitor.detections
|
|
76
|
+
return (visitor.detections, visitor.warnings)
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
// MARK: - Pre-filter
|
|
@@ -89,8 +102,9 @@ func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Boo
|
|
|
89
102
|
|
|
90
103
|
/// Directory names skipped during the recursive walk. These hold build products, dependencies, and
|
|
91
104
|
/// git internals — never source worth scanning — and pruning them keeps the walk from descending
|
|
92
|
-
/// into the bulk of a monorepo's files.
|
|
93
|
-
|
|
105
|
+
/// into the bulk of a monorepo's files. `node_modules` makes any npm package root safe to pass as a
|
|
106
|
+
/// scan path: nested dependencies are separate packages and get scanned on their own.
|
|
107
|
+
private let prunedDirectoryNames: Set<String> = [".build", "Pods", ".git", "node_modules"]
|
|
94
108
|
|
|
95
109
|
/// Expands the given paths into the list of `.swift` files to parse: a file path passes through,
|
|
96
110
|
/// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic
|
|
@@ -9,8 +9,12 @@ public enum Scanner {
|
|
|
9
9
|
/// Runs the `scan-modules` command over `paths`, prints the JSON report to stdout, and returns a
|
|
10
10
|
/// process exit code: `0` on success, `1` if encoding fails. (The deep `scan-exports` command has
|
|
11
11
|
/// its own `runExports` entry returning its own result type.)
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
///
|
|
13
|
+
/// `platform` and `defines` (the `--platform` and `--define` options) form the configuration that
|
|
14
|
+
/// `#if` conditions are evaluated against; see `ScanBuildConfiguration`.
|
|
15
|
+
public static func runModules(paths: [String], platform: String? = nil, defines: [String] = []) -> Int32 {
|
|
16
|
+
let configuration = ScanBuildConfiguration(platform: platform, defines: Set(defines))
|
|
17
|
+
let result = scanModules(paths: paths, configuration: configuration)
|
|
14
18
|
|
|
15
19
|
do {
|
|
16
20
|
let encoder = JSONEncoder()
|
|
@@ -40,29 +44,51 @@ struct ScannedModule: Codable, Equatable {
|
|
|
40
44
|
/// and the consumer never has to apply the fallback itself.
|
|
41
45
|
let jsName: String
|
|
42
46
|
|
|
47
|
+
/// The class's spelled access modifier (`open`, `public`, `package`, `fileprivate`, `private`),
|
|
48
|
+
/// or `internal` when none is written. The generated modules provider references the class from
|
|
49
|
+
/// the app target, which requires `public`/`open`, so the consumer uses this to skip inaccessible
|
|
50
|
+
/// classes with a diagnostic instead of emitting a provider that fails to compile.
|
|
51
|
+
let accessLevel: String
|
|
52
|
+
|
|
43
53
|
/// Source file the module was found in, relative to the path the scanner was invoked with.
|
|
44
54
|
let file: String
|
|
45
55
|
}
|
|
46
56
|
|
|
57
|
+
/// Version of the `scan-modules` output shape. Bumped on any breaking change to the envelope or to
|
|
58
|
+
/// `ScannedModule`, so `expo-modules-autolinking` can verify it understands the output before
|
|
59
|
+
/// trusting it (and fall back to config-declared modules when it doesn't).
|
|
60
|
+
let scanModulesSchemaVersion = 1
|
|
61
|
+
|
|
47
62
|
/// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
|
|
48
63
|
/// command's JSON output. (`scan-exports` returns its own `ScanExportsResult` shape; the two commands
|
|
49
64
|
/// serve different consumers and don't share an envelope.)
|
|
50
65
|
struct ScanModulesResult: Codable, Equatable {
|
|
66
|
+
let schemaVersion: Int
|
|
51
67
|
let modules: [ScannedModule]
|
|
68
|
+
|
|
69
|
+
/// Warnings for `#if` conditions the scan couldn't answer statically (see `ScanWarning`). Carried
|
|
70
|
+
/// in the report rather than on stderr so the consumer can attach them to its own output.
|
|
71
|
+
let warnings: [ScanWarning]
|
|
72
|
+
|
|
52
73
|
let stats: ScanStats
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
/// Scans the given paths for top-level `@ExpoModule` types and returns the modules (in file then
|
|
56
77
|
/// source order) plus the stats for the run — the `scan-modules` command. Kept separate from the
|
|
57
78
|
/// 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])
|
|
79
|
+
func scanModules(paths: [String], configuration: ScanBuildConfiguration = .init(platform: nil, defines: [])) -> ScanModulesResult {
|
|
80
|
+
let scan = collectDetections(paths: paths, macros: [.expoModule], configuration: configuration)
|
|
60
81
|
|
|
61
82
|
let modules = scan.detections.map {
|
|
62
83
|
// Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else the
|
|
63
84
|
// class name.
|
|
64
|
-
ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, file: $0.file)
|
|
85
|
+
ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, accessLevel: $0.accessLevel, file: $0.file)
|
|
65
86
|
}
|
|
66
87
|
|
|
67
|
-
return ScanModulesResult(
|
|
88
|
+
return ScanModulesResult(
|
|
89
|
+
schemaVersion: scanModulesSchemaVersion,
|
|
90
|
+
modules: modules,
|
|
91
|
+
warnings: scan.warnings,
|
|
92
|
+
stats: scan.stats
|
|
93
|
+
)
|
|
68
94
|
}
|
package/package.json
CHANGED
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
guard !paths.isEmpty else {
|
|
65
|
-
fail("scan-exports requires at least one path", usage: true)
|
|
66
|
-
}
|
|
67
|
-
exit(Scanner.runExports(paths: paths))
|
|
68
|
-
|
|
69
|
-
default:
|
|
70
|
-
fail("unknown subcommand '\(subcommand)'", usage: true)
|
|
71
|
-
}
|