@expo/expo-modules-macros-plugin 0.3.0 → 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 +24 -4
- 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
|
}
|
|
@@ -201,9 +201,24 @@ internal struct JSFunction {
|
|
|
201
201
|
/// body never references `appContext`, so the capture and guard are omitted to avoid the
|
|
202
202
|
/// unused-capture warning.
|
|
203
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
|
+
|
|
204
219
|
if usesAppContext {
|
|
205
220
|
return """
|
|
206
|
-
object.setProperty("\(jsName)") {
|
|
221
|
+
object.setProperty("\(jsName)") { \(captures) \(parameters) in
|
|
207
222
|
guard let appContext else {
|
|
208
223
|
throw Exceptions.AppContextLost()
|
|
209
224
|
}
|
|
@@ -212,7 +227,7 @@ internal struct JSFunction {
|
|
|
212
227
|
"""
|
|
213
228
|
}
|
|
214
229
|
return """
|
|
215
|
-
object.setProperty("\(jsName)") {
|
|
230
|
+
object.setProperty("\(jsName)") { \(captures) \(parameters) in
|
|
216
231
|
\(bodyStatements(indent: " "))
|
|
217
232
|
}
|
|
218
233
|
"""
|
|
@@ -323,9 +338,14 @@ internal struct JSProperty {
|
|
|
323
338
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
|
324
339
|
.map { " \($0)" }
|
|
325
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)"
|
|
326
346
|
if usesAppContext {
|
|
327
347
|
return """
|
|
328
|
-
\(descriptorName).setProperty("\(key)") { [weak appContext, self]
|
|
348
|
+
\(descriptorName).setProperty("\(key)") { [weak appContext, self] \(parameters) in
|
|
329
349
|
guard let appContext else {
|
|
330
350
|
throw Exceptions.AppContextLost()
|
|
331
351
|
}
|
|
@@ -334,7 +354,7 @@ internal struct JSProperty {
|
|
|
334
354
|
"""
|
|
335
355
|
}
|
|
336
356
|
return """
|
|
337
|
-
\(descriptorName).setProperty("\(key)") { [self]
|
|
357
|
+
\(descriptorName).setProperty("\(key)") { [self] \(parameters) in
|
|
338
358
|
\(indentedBody)
|
|
339
359
|
}
|
|
340
360
|
"""
|
|
@@ -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
|
+
}
|