@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.
Binary file
@@ -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)") { [weak appContext, self] this, arguments in
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)") { [self] this, arguments in
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] this, arguments in
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] this, arguments in
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/expo-modules-macros-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Swift macro plugin for Expo modules",
5
5
  "license": "MIT",
6
6
  "author": "650 Industries, Inc.",