From 9c06bf451455e2cb0ab96a5e6307dd30864b95de Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Tue, 16 Jun 2026 21:35:38 +0200 Subject: [PATCH 1/9] Add a Swift source scanner for `@ExpoModule`/`@JS`/`@SharedObject` Introduce `ExpoModulesScanner`, a SwiftSyntax-based tool that scans Swift source for top-level declarations annotated with `@ExpoModule`, `@JS`, and `@SharedObject` and emits a JSON report. - Detects top-level `class`/`struct` declarations carrying a recognized macro, capturing the declared name, declaration kind, explicit JS-name override, and every macro argument (label plus source text) in source order. - Pre-filters files with a precompiled `NSRegularExpression` so only files that mention a macro attribute are parsed (benchmarked ~20x faster than repeated `String.contains` over a large tree, where almost no file matches). - Prunes `.build`, `Pods`, and `.git` (and hidden dirs) during the recursive directory walk. - Reports `stats` alongside `detections`: files scanned, files parsed, and the scan duration in milliseconds. Structured as a library target (the importable logic, kept `internal`) plus a thin `ExpoModulesScannerCLI` executable and an `ExpoModulesScannerTests` target that reaches the logic via `@testable import`. --- .gitignore | 1 + apple/Package.swift | 29 ++- .../ExpoModulesScanner/Detection.swift | 67 +++++ .../ExpoModulesScanner/DetectionVisitor.swift | 100 +++++++ .../Sources/ExpoModulesScanner/Scanner.swift | 157 +++++++++++ .../Sources/ExpoModulesScannerCLI/main.swift | 3 + .../DetectionVisitorTests.swift | 246 ++++++++++++++++++ 7 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 apple/Sources/ExpoModulesScanner/Detection.swift create mode 100644 apple/Sources/ExpoModulesScanner/DetectionVisitor.swift create mode 100644 apple/Sources/ExpoModulesScanner/Scanner.swift create mode 100644 apple/Sources/ExpoModulesScannerCLI/main.swift create mode 100644 apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift diff --git a/.gitignore b/.gitignore index 26c42d4..dba1438 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .claude apple/.build +apple/.swiftpm diff --git a/apple/Package.swift b/apple/Package.swift index 3f25912..d7e3deb 100644 --- a/apple/Package.swift +++ b/apple/Package.swift @@ -8,7 +8,12 @@ import PackageDescription let package = Package( name: "ExpoModulesMacros", platforms: [.macOS(.v13)], - products: [], + products: [ + // The scanner CLI. Named `ExpoModulesScanner` (the user-facing tool name) while its target is + // `ExpoModulesScannerCLI`; the detection logic lives in the importable `ExpoModulesScanner` + // library that both the CLI and the tests depend on. + .executable(name: "ExpoModulesScanner", targets: ["ExpoModulesScannerCLI"]), + ], dependencies: [ .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest") ], @@ -19,7 +24,18 @@ let package = Package( .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), ] - ) + ), + .target( + name: "ExpoModulesScanner", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), + ] + ), + .executableTarget( + name: "ExpoModulesScannerCLI", + dependencies: ["ExpoModulesScanner"] + ), ] ) @@ -36,4 +52,13 @@ if FileManager.default.fileExists(atPath: Context.packageDirectory + "/Tests") { ] ) ) + package.targets.append( + .testTarget( + name: "ExpoModulesScannerTests", + dependencies: [ + "ExpoModulesScanner", + .product(name: "SwiftParser", package: "swift-syntax"), + ] + ) + ) } diff --git a/apple/Sources/ExpoModulesScanner/Detection.swift b/apple/Sources/ExpoModulesScanner/Detection.swift new file mode 100644 index 0000000..455250b --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/Detection.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Which Expo macro was found on a declaration. The scanner recognizes the three entry-point +/// macros that mark a type or member as part of a module's JS surface. +enum DetectedMacro: String, Codable, CaseIterable { + case expoModule = "ExpoModule" + case js = "JS" + case sharedObject = "SharedObject" +} + +/// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil` +/// for positional arguments; `value` is the argument expression's source text as written. +struct MacroArgument: Codable, Equatable { + /// The argument label (`classes` in `classes: [Bar.self]`), or `nil` for a positional argument. + let label: String? + + /// The argument value exactly as written in source, e.g. `"Foo"` (including the quotes) or + /// `[Bar.self]`. Kept as text because a syntactic scan can't resolve these to runtime values. + let value: String +} + +/// A single annotated declaration the scanner found, with just enough to locate it and know +/// what it is. Member-level details (parameters, types) are intentionally out of scope for this +/// first prototype — see the `@JS` member walk in the macros for where that would live. +struct Detection: Codable, Equatable { + /// The macro spelled on the declaration (without the leading `@`). + let macro: DetectedMacro + + /// The declared name, e.g. the class name for `@ExpoModule`, or the func/var/init name for `@JS`. + let name: String + + /// The kind of declaration the macro was attached to: `class`, `struct`, `func`, `var`, `init`, … + let declarationKind: String + + /// The explicit JS name override when written as `@ExpoModule("Foo")` / `@JS("bar")` / + /// `@SharedObject("Baz")`, otherwise `nil` (the name defaults to `name` at expansion time). + let jsName: String? + + /// Every argument passed to the macro, in source order, e.g. `@ExpoModule("Foo", classes: [Bar.self])` + /// yields a positional `"Foo"` and a `classes:` argument. Empty when the macro is written bare. + let arguments: [MacroArgument] + + /// Source location, relative to the path the scanner was invoked with. + let file: String + let line: Int + let column: Int +} + +/// Counts describing how much work the scan did, so callers can see the pre-filter's effect: of all +/// the `.swift` files read, how many actually needed parsing, and how long the run took. +struct ScanStats: Codable, Equatable { + /// `.swift` files the walk found and read (after directory pruning). + let filesScanned: Int + + /// Of those, how many contained a macro attribute and so were parsed with SwiftSyntax. + let filesParsed: Int + + /// Wall-clock duration of the scan, in milliseconds (walking, reading, filtering, and parsing). + let durationMs: Double +} + +/// The scanner's top-level result: the detections plus the stats describing the run. Encoded as the +/// tool's JSON output. +struct ScanResult: Codable, Equatable { + let detections: [Detection] + let stats: ScanStats +} diff --git a/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift b/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift new file mode 100644 index 0000000..a107f3f --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift @@ -0,0 +1,100 @@ +import SwiftSyntax + +/// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`, or +/// `@SharedObject`. Only file-scope declarations are considered: `@ExpoModule`/`@SharedObject` apply +/// to top-level types, so descending into type and function bodies would only surface false +/// positives. Recognition mirrors the macros themselves — a purely syntactic match on the spelled +/// attribute name — so it sees the same declarations the compiler would hand the plugin, without +/// compiling anything. +final class DetectionVisitor: SyntaxVisitor { + private let file: String + private let converter: SourceLocationConverter + private(set) var detections: [Detection] = [] + + init(file: String, tree: SourceFileSyntax) { + self.file = file + self.converter = SourceLocationConverter(fileName: file, tree: tree) + super.init(viewMode: .sourceAccurate) + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + if isTopLevel(node) { + record(attributes: node.attributes, name: node.name.text, kind: "class", at: node) + } + // Members live in the type body; we never report them, so there's no reason to descend. + return .skipChildren + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + if isTopLevel(node) { + record(attributes: node.attributes, name: node.name.text, kind: "struct", at: node) + } + return .skipChildren + } + + /// True when the declaration sits at file scope: its parent is a `CodeBlockItemSyntax` directly + /// under the source file's top-level item list. Members of a type are nested in a + /// `MemberBlockItemSyntax` instead, so they don't match. + private func isTopLevel(_ node: some SyntaxProtocol) -> Bool { + guard let item = node.parent?.as(CodeBlockItemSyntax.self) else { + return false + } + return item.parent?.parent?.is(SourceFileSyntax.self) == true + } + + /// Emits one detection per recognized Expo attribute on the declaration. A declaration can in + /// principle carry more than one (uncommon), so each is recorded independently. + private func record( + attributes: AttributeListSyntax, + name: String, + kind: String, + at node: some SyntaxProtocol + ) { + for element in attributes { + guard let attribute = element.as(AttributeSyntax.self), + let macro = DetectedMacro(rawValue: attribute.attributeName.trimmedDescription) else { + continue + } + let location = converter.location(for: node.positionAfterSkippingLeadingTrivia) + detections.append( + Detection( + macro: macro, + name: name, + declarationKind: kind, + jsName: stringArgument(of: attribute), + arguments: arguments(of: attribute), + file: file, + line: location.line, + column: location.column + ) + ) + } + } +} + +/// Every argument passed to the attribute, in source order, each as a label (or `nil` when +/// positional) plus the value expression's source text. Returns an empty array when the attribute +/// is written bare (`@ExpoModule`) or with empty parens. +private func arguments(of attribute: AttributeSyntax) -> [MacroArgument] { + guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else { + return [] + } + return args.map { arg in + MacroArgument(label: arg.label?.text, value: arg.expression.trimmedDescription) + } +} + +/// The first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork". Returns +/// `nil` when there's no argument or it isn't a plain string literal. (Same shape the +/// `jsNameArgument` helper reads inside the macros.) +private func stringArgument(of attribute: AttributeSyntax) -> String? { + guard let args = attribute.arguments?.as(LabeledExprListSyntax.self), + let first = args.first, + first.label == nil, + let str = first.expression.as(StringLiteralExprSyntax.self), + let segment = str.segments.first?.as(StringSegmentSyntax.self), + str.segments.count == 1 else { + return nil + } + return segment.content.text +} diff --git a/apple/Sources/ExpoModulesScanner/Scanner.swift b/apple/Sources/ExpoModulesScanner/Scanner.swift new file mode 100644 index 0000000..f3c5cf8 --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/Scanner.swift @@ -0,0 +1,157 @@ +import Foundation +import SwiftParser +import SwiftSyntax + +/// The scanner's command-line entry point. Lives in the library (rather than the executable's +/// top-level code) so the parsing/detection logic stays `@testable`-importable; the executable +/// target is a one-line call to `Scanner.main()`. +/// +/// The detection model (`Detection`, `DetectionVisitor`, …) stays `internal`: tests reach it via +/// `@testable import`, and the CLI only needs this one public entry, so nothing else is exposed. +public enum Scanner { + /// Reads paths from the process arguments, scans them, prints the JSON report to stdout, and + /// exits non-zero on a usage error. Each path may be a `.swift` file or a directory (scanned + /// recursively for `.swift` files). + public static func main() { + let arguments = Array(CommandLine.arguments.dropFirst()) + + guard !arguments.isEmpty else { + FileHandle.standardError.write(Data("usage: \(toolName) [ ...]\n".utf8)) + exit(2) + } + + let result = scan(paths: arguments) + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(result) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) + } catch { + FileHandle.standardError.write(Data("error: failed to encode results: \(error)\n".utf8)) + exit(1) + } + } +} + +private let toolName = "ExpoModulesScanner" + +/// Scans the given paths and returns the detections (in file then source order) plus the stats for +/// the run. Kept separate from `main()` (and `internal`) so tests can drive it without going through +/// argv/stdout. +func scan(paths: [String]) -> ScanResult { + let clock = ContinuousClock() + let start = clock.now + + var detections: [Detection] = [] + var filesScanned = 0 + var filesParsed = 0 + + for file in swiftFiles(in: paths) { + guard let source = try? String(contentsOfFile: file, encoding: .utf8) else { + FileHandle.standardError.write(Data("warning: could not read \(file)\n".utf8)) + continue + } + filesScanned += 1 + // Skip the (relatively expensive) parse for files that can't contain any recognized macro. + // A plain substring scan is far cheaper than a full parse, and most files in a large tree + // mention none of these names. See `mightContainMacro` for why this never drops a real match. + guard mightContainMacro(in: source) else { + continue + } + filesParsed += 1 + detections.append(contentsOf: detect(source: source, file: file)) + } + + let elapsed = (clock.now - start).components + let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15 + + return ScanResult( + detections: detections, + stats: ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs) + ) +} + +/// Matches a spelled macro attribute, e.g. `@ExpoModule`. Including the `@` keeps the check specific: +/// `@JS` won't collide with common substrings like `JSON` the way a bare `JS` would. Compiled once +/// and reused — a precompiled `NSRegularExpression` benchmarked ~20x faster over a large source tree +/// than calling `String.contains` once per macro name, because it scans each file in a single pass. +private let macroAttributeRegex: NSRegularExpression = { + let alternation = DetectedMacro.allCases.map(\.rawValue).joined(separator: "|") + return try! NSRegularExpression(pattern: "@(\(alternation))") +}() + +/// True if the source text contains a spelled macro attribute, so it's worth parsing. A deliberate +/// over-approximation: the pattern can still match inside a comment or string, in which case the +/// file is parsed and correctly yields no detections — a wasted parse, never a missed module. It +/// assumes the attribute is written with no space after `@` (`@ExpoModule`, not `@ ExpoModule`), +/// which is universal in practice; the rare spaced form would be skipped. +func mightContainMacro(in source: String) -> Bool { + let range = NSRange(source.startIndex..., in: source) + return macroAttributeRegex.firstMatch(in: source, range: range) != nil +} + +/// Parses one source string and returns its detections. The unit of work the tests exercise. +func detect(source: String, file: String) -> [Detection] { + let tree = Parser.parse(source: source) + let visitor = DetectionVisitor(file: file, tree: tree) + visitor.walk(tree) + return visitor.detections +} + +/// Directory names skipped during the recursive walk. These hold build products, dependencies, and +/// git internals — never source worth scanning — and pruning them keeps the walk from descending +/// into the bulk of a monorepo's files. +private let prunedDirectoryNames: Set = [".build", "Pods", ".git"] + +/// Expands the given paths into the list of `.swift` files to parse: a file path passes through, +/// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic +/// so output is stable across runs. +func swiftFiles(in paths: [String]) -> [String] { + let fileManager = FileManager.default + var result: [String] = [] + + for path in paths { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else { + FileHandle.standardError.write(Data("warning: no such path \(path)\n".utf8)) + continue + } + + if isDirectory.boolValue { + result.append(contentsOf: swiftFiles(inDirectory: URL(fileURLWithPath: path), fileManager: fileManager)) + } else if path.hasSuffix(".swift") { + result.append(path) + } + } + + return result.sorted() +} + +/// Recursively enumerates `.swift` files under a directory, calling `skipDescendants()` on any +/// pruned directory so its subtree is never read. Uses the URL enumerator (rather than the +/// path-based one) precisely because it supports skipping a subtree mid-walk. +private func swiftFiles(inDirectory directory: URL, fileManager: FileManager) -> [String] { + let keys: [URLResourceKey] = [.isDirectoryKey] + guard let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles] + ) else { + return [] + } + + var result: [String] = [] + for case let url as URL in enumerator { + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + if isDirectory { + if prunedDirectoryNames.contains(url.lastPathComponent) { + enumerator.skipDescendants() + } + } else if url.pathExtension == "swift" { + result.append(url.path) + } + } + return result +} diff --git a/apple/Sources/ExpoModulesScannerCLI/main.swift b/apple/Sources/ExpoModulesScannerCLI/main.swift new file mode 100644 index 0000000..20ed5ad --- /dev/null +++ b/apple/Sources/ExpoModulesScannerCLI/main.swift @@ -0,0 +1,3 @@ +import ExpoModulesScanner + +Scanner.main() diff --git a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift new file mode 100644 index 0000000..78c932b --- /dev/null +++ b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift @@ -0,0 +1,246 @@ +@testable import ExpoModulesScanner +import Foundation +import SwiftParser +import Testing + +/// Parses a source string and returns the detections the visitor records for it. The file name is +/// fixed so location assertions are stable; only `line`/`column` vary per test. +private func detect(_ source: String) -> [Detection] { + let tree = Parser.parse(source: source) + let visitor = DetectionVisitor(file: "Test.swift", tree: tree) + visitor.walk(tree) + return visitor.detections +} + +@Suite("Scanner detection") +struct DetectionVisitorTests { + @Test + func `Detects a top-level @ExpoModule class`() throws { + let detections = detect( + """ + @ExpoModule + final class GreeterModule {} + """ + ) + #expect(detections.count == 1) + let detection = try #require(detections.first) + #expect(detection.macro == .expoModule) + #expect(detection.name == "GreeterModule") + #expect(detection.declarationKind == "class") + #expect(detection.jsName == nil) + #expect(detection.arguments.isEmpty) + // The location points at the declaration's leading attribute, i.e. the `@ExpoModule` line. + #expect(detection.line == 1) + } + + @Test + func `Detects a top-level @SharedObject class`() { + let detections = detect( + """ + @SharedObject + final class Cache: SharedObject {} + """ + ) + #expect(detections.count == 1) + #expect(detections.first?.macro == .sharedObject) + #expect(detections.first?.name == "Cache") + } + + @Test + func `Detects a top-level @ExpoModule struct`() { + let detections = detect( + """ + @ExpoModule + struct Bare {} + """ + ) + #expect(detections.first?.declarationKind == "struct") + } + + @Test + func `Ignores @JS members nested in a type body`() { + let detections = detect( + """ + @ExpoModule + final class GreeterModule { + @JS + func greet(name: String) -> String { "Hi" } + + @JS + var status: String { "ok" } + } + """ + ) + // Only the top-level class is reported; the nested @JS members are not. + #expect(detections.count == 1) + #expect(detections.first?.macro == .expoModule) + } + + @Test + func `Ignores a nested type even when it carries a recognized macro`() { + let detections = detect( + """ + enum Namespace { + @SharedObject + final class Cache: SharedObject {} + } + """ + ) + #expect(detections.isEmpty) + } + + @Test + func `Captures the positional string argument as jsName and as an argument`() throws { + let detections = detect( + """ + @ExpoModule("Greeter") + final class GreeterModule {} + """ + ) + let detection = try #require(detections.first) + #expect(detection.jsName == "Greeter") + #expect(detection.arguments == [MacroArgument(label: nil, value: "\"Greeter\"")]) + } + + @Test + func `Captures positional and labeled arguments in source order`() throws { + let detections = detect( + """ + @ExpoModule("Greeter", classes: [Cache.self, Store.self]) + final class GreeterModule {} + """ + ) + let detection = try #require(detections.first) + #expect(detection.jsName == "Greeter") + #expect(detection.arguments == [ + MacroArgument(label: nil, value: "\"Greeter\""), + MacroArgument(label: "classes", value: "[Cache.self, Store.self]"), + ]) + } + + @Test + func `A bare attribute has no arguments and no jsName`() { + let detections = detect( + """ + @ExpoModule + final class GreeterModule {} + """ + ) + #expect(detections.first?.arguments.isEmpty == true) + #expect(detections.first?.jsName == nil) + } + + @Test + func `Ignores unannotated top-level declarations`() { + let detections = detect( + """ + final class PlainClass {} + struct PlainStruct {} + @objc final class ObjCClass {} + """ + ) + #expect(detections.isEmpty) + } + + @Test + func `Does not match a macro name appearing inside a string literal`() { + let detections = detect( + """ + let source = "@ExpoModule final class Fake {}" + """ + ) + #expect(detections.isEmpty) + } + + @Test + func `Detects multiple top-level types in one file`() { + let detections = detect( + """ + @ExpoModule + final class ModuleA {} + + @SharedObject + final class ObjectB: SharedObject {} + """ + ) + #expect(detections.map(\.name) == ["ModuleA", "ObjectB"]) + #expect(detections.map(\.macro) == [.expoModule, .sharedObject]) + } +} + +@Suite("Macro pre-filter") +struct MacroPrefilterTests { + @Test + func `Recognizes each spelled macro attribute`() { + #expect(mightContainMacro(in: "@ExpoModule\nclass M {}")) + #expect(mightContainMacro(in: "@SharedObject\nclass C: SharedObject {}")) + #expect(mightContainMacro(in: "struct S {\n @JS func f() {}\n}")) + } + + @Test + func `Skips source with no macro attribute`() { + #expect(!mightContainMacro(in: "final class Plain {}\nlet x = 1")) + } + + @Test + func `Does not collide with similar bare identifiers`() { + // `JS` appears as a substring here, but only inside identifiers — not as the `@JS` attribute, + // which is what keeps the `@`-prefixed pattern from force-parsing every file that uses JSON. + #expect(!mightContainMacro(in: "let data = JSONDecoder()\nstruct JSValue {}")) + } + + @Test + func `Over-approximates: matches the attribute inside a string literal`() { + // A false positive here is acceptable — the file is parsed and then yields no detections. + #expect(mightContainMacro(in: #"let s = "@ExpoModule""#)) + } +} + +@Suite("Scanning a directory") +struct ScanTests { + /// Creates a temporary directory tree of `(relativePath, contents)` files, runs `scan` on it, and + /// removes the tree afterward. + private func withTree(_ files: [(String, String)], _ body: (ScanResult) throws -> Void) throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("scanner-test-\(ProcessInfo.processInfo.globallyUniqueString)") + defer { try? fileManager.removeItem(at: root) } + + for (relativePath, contents) in files { + let url = root.appendingPathComponent(relativePath) + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: url, atomically: true, encoding: .utf8) + } + + try body(scan(paths: [root.path])) + } + + @Test + func `Reports detections and stats over a tree`() throws { + try withTree([ + ("Module.swift", "@ExpoModule\nfinal class MyModule {}"), + ("Plain.swift", "final class Plain {}"), + ("Notes.swift", "// just a comment, no macros here"), + ]) { result in + #expect(result.detections.map(\.name) == ["MyModule"]) + // All three .swift files are read; only the one mentioning a macro is parsed. + #expect(result.stats.filesScanned == 3) + #expect(result.stats.filesParsed == 1) + #expect(result.stats.durationMs >= 0) + } + } + + @Test + func `Prunes .build, Pods, and .git directories`() throws { + try withTree([ + ("Real.swift", "@ExpoModule\nfinal class RealModule {}"), + (".build/Generated.swift", "@ExpoModule\nfinal class BuildArtifact {}"), + ("Pods/Vendored.swift", "@ExpoModule\nfinal class Vendored {}"), + (".git/hooks/Sneaky.swift", "@ExpoModule\nfinal class Sneaky {}"), + ]) { result in + // Only the file outside the pruned directories is seen at all. + #expect(result.detections.map(\.name) == ["RealModule"]) + #expect(result.stats.filesScanned == 1) + #expect(result.stats.filesParsed == 1) + } + } +} From 788e306a7ed22efcd057234a175211c95738e26a Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Tue, 16 Jun 2026 21:44:25 +0200 Subject: [PATCH 2/9] Detect the `@Record` macro `@Record` applies to a top-level `struct`/`class` like the other entry-point macros, so the scanner picks it up by adding the case to `DetectedMacro`; the visitor's attribute match and the pre-filter regex both derive from it. It takes no arguments, so its `arguments` array is always empty. --- .../ExpoModulesScanner/Detection.swift | 5 +++-- .../ExpoModulesScanner/DetectionVisitor.swift | 12 ++++++------ .../DetectionVisitorTests.swift | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apple/Sources/ExpoModulesScanner/Detection.swift b/apple/Sources/ExpoModulesScanner/Detection.swift index 455250b..4081ed6 100644 --- a/apple/Sources/ExpoModulesScanner/Detection.swift +++ b/apple/Sources/ExpoModulesScanner/Detection.swift @@ -1,11 +1,12 @@ import Foundation -/// Which Expo macro was found on a declaration. The scanner recognizes the three entry-point -/// macros that mark a type or member as part of a module's JS surface. +/// Which Expo macro was found on a declaration. The scanner recognizes the entry-point macros that +/// mark a type or member as part of a module's JS surface, plus `@Record` for convertible types. enum DetectedMacro: String, Codable, CaseIterable { case expoModule = "ExpoModule" case js = "JS" case sharedObject = "SharedObject" + case record = "Record" } /// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil` diff --git a/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift b/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift index a107f3f..e1d9b72 100644 --- a/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift +++ b/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift @@ -1,11 +1,11 @@ import SwiftSyntax -/// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`, or -/// `@SharedObject`. Only file-scope declarations are considered: `@ExpoModule`/`@SharedObject` apply -/// to top-level types, so descending into type and function bodies would only surface false -/// positives. Recognition mirrors the macros themselves — a purely syntactic match on the spelled -/// attribute name — so it sees the same declarations the compiler would hand the plugin, without -/// compiling anything. +/// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`, +/// `@SharedObject`, or `@Record`. Only file-scope declarations are considered: these macros apply to +/// top-level types, so descending into type and function bodies would only surface false positives. +/// Recognition mirrors the macros themselves — a purely syntactic match on the spelled attribute +/// name — so it sees the same declarations the compiler would hand the plugin, without compiling +/// anything. final class DetectionVisitor: SyntaxVisitor { private let file: String private let converter: SourceLocationConverter diff --git a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift index 78c932b..b86116d 100644 --- a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift +++ b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift @@ -57,6 +57,24 @@ struct DetectionVisitorTests { #expect(detections.first?.declarationKind == "struct") } + @Test + func `Detects a top-level @Record struct`() { + let detections = detect( + """ + @Record + struct Options { + var name: String + var count: Int = 0 + } + """ + ) + #expect(detections.count == 1) + #expect(detections.first?.macro == .record) + #expect(detections.first?.name == "Options") + #expect(detections.first?.declarationKind == "struct") + #expect(detections.first?.arguments.isEmpty == true) + } + @Test func `Ignores @JS members nested in a type body`() { let detections = detect( @@ -175,6 +193,7 @@ struct MacroPrefilterTests { #expect(mightContainMacro(in: "@ExpoModule\nclass M {}")) #expect(mightContainMacro(in: "@SharedObject\nclass C: SharedObject {}")) #expect(mightContainMacro(in: "struct S {\n @JS func f() {}\n}")) + #expect(mightContainMacro(in: "@Record\nstruct Options {}")) } @Test From 1aad61eb84a56f849d4ae05abdd49272fe0e021c Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Tue, 16 Jun 2026 21:54:45 +0200 Subject: [PATCH 3/9] Skip the per-entry stat in the directory walk Read directory-ness from `hasDirectoryPath` (the enumerator sets a trailing slash on the URLs it yields) instead of `resourceValues(forKeys:)`, which re-`stat`s every entry. The walk dominates a whole-tree scan, so dropping that per-entry stat shaves ~15% off it (and removes the prefetch-keys ceremony). --- apple/Sources/ExpoModulesScanner/Scanner.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apple/Sources/ExpoModulesScanner/Scanner.swift b/apple/Sources/ExpoModulesScanner/Scanner.swift index f3c5cf8..3d3e49e 100644 --- a/apple/Sources/ExpoModulesScanner/Scanner.swift +++ b/apple/Sources/ExpoModulesScanner/Scanner.swift @@ -132,11 +132,15 @@ func swiftFiles(in paths: [String]) -> [String] { /// Recursively enumerates `.swift` files under a directory, calling `skipDescendants()` on any /// pruned directory so its subtree is never read. Uses the URL enumerator (rather than the /// path-based one) precisely because it supports skipping a subtree mid-walk. +/// +/// Directory-ness is read from `hasDirectoryPath` (the enumerator sets a trailing slash on the URLs +/// it yields) rather than `resourceValues(forKeys: [.isDirectoryKey])`, which re-`stat`s each entry. +/// The walk is the dominant cost of a whole-tree scan, and skipping that per-entry stat measurably +/// shortens it. private func swiftFiles(inDirectory directory: URL, fileManager: FileManager) -> [String] { - let keys: [URLResourceKey] = [.isDirectoryKey] guard let enumerator = fileManager.enumerator( at: directory, - includingPropertiesForKeys: keys, + includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) else { return [] @@ -144,8 +148,7 @@ private func swiftFiles(inDirectory directory: URL, fileManager: FileManager) -> var result: [String] = [] for case let url as URL in enumerator { - let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false - if isDirectory { + if url.hasDirectoryPath { if prunedDirectoryNames.contains(url.lastPathComponent) { enumerator.skipDescendants() } From 87ad8483a0e97915d9e60ad5bd58fac6e8052498 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Wed, 17 Jun 2026 11:45:33 +0200 Subject: [PATCH 4/9] Add `scan-modules`/`scan-exports` subcommands Split the scanner into two CLI modes for its two consumers: - `scan-modules` (fast) reports only top-level `@ExpoModule` types, for `expo-modules-autolinking`, which only needs module class names. Its pre-filter matches `@ExpoModule` alone, so most files skip parsing. - `scan-exports` (deep) is intended for TypeScript type generation and will walk the full JS-exported surface of each type. The subcommand is recognized but currently errors as not-yet-implemented; the member-level extraction lands in a separate PR. A `ScanMode` drives which macros each mode detects (and feeds the per-mode pre-filter regex). Argument parsing, subcommand dispatch, and usage text move to the CLI target; the library exposes `Scanner.run(mode:paths:)` plus `ScanMode`. --- .../ExpoModulesScanner/Detection.swift | 23 +++++ .../ExpoModulesScanner/DetectionVisitor.swift | 9 +- .../Sources/ExpoModulesScanner/Scanner.swift | 85 +++++++++---------- .../Sources/ExpoModulesScannerCLI/main.swift | 57 ++++++++++++- .../DetectionVisitorTests.swift | 42 +++++++-- 5 files changed, 163 insertions(+), 53 deletions(-) diff --git a/apple/Sources/ExpoModulesScanner/Detection.swift b/apple/Sources/ExpoModulesScanner/Detection.swift index 4081ed6..4ebb6ea 100644 --- a/apple/Sources/ExpoModulesScanner/Detection.swift +++ b/apple/Sources/ExpoModulesScanner/Detection.swift @@ -9,6 +9,29 @@ enum DetectedMacro: String, Codable, CaseIterable { case record = "Record" } +/// What a scan looks for. The CLI exposes one subcommand per mode; they serve different consumers and +/// will eventually produce different output shapes, so this drives both the macro filter and (later) +/// the depth of extraction. +public enum ScanMode { + /// Fast path for `expo-modules-autolinking`: only top-level `@ExpoModule` types (the module class + /// names autolinking registers). The narrowest pre-filter and no member walking. + case modules + + /// Deep path for TypeScript type generation: every entry-point macro and (eventually) the full + /// member surface each type exports to JS. Not yet implemented — see the `scan-exports` stub. + case exports + + /// The macros a scan in this mode reports. `modules` is intentionally `@ExpoModule`-only. + var detectedMacros: Set { + switch self { + case .modules: + return [.expoModule] + case .exports: + return Set(DetectedMacro.allCases) + } + } +} + /// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil` /// for positional arguments; `value` is the argument expression's source text as written. struct MacroArgument: Codable, Equatable { diff --git a/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift b/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift index e1d9b72..bc8e431 100644 --- a/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift +++ b/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift @@ -9,11 +9,15 @@ import SwiftSyntax final class DetectionVisitor: SyntaxVisitor { private let file: String private let converter: SourceLocationConverter + /// Only these macros are recorded; the rest are ignored. Lets a `modules` scan report just + /// `@ExpoModule` while an `exports` scan covers them all. + private let detectedMacros: Set private(set) var detections: [Detection] = [] - init(file: String, tree: SourceFileSyntax) { + init(file: String, tree: SourceFileSyntax, detectedMacros: Set) { self.file = file self.converter = SourceLocationConverter(fileName: file, tree: tree) + self.detectedMacros = detectedMacros super.init(viewMode: .sourceAccurate) } @@ -52,7 +56,8 @@ final class DetectionVisitor: SyntaxVisitor { ) { for element in attributes { guard let attribute = element.as(AttributeSyntax.self), - let macro = DetectedMacro(rawValue: attribute.attributeName.trimmedDescription) else { + let macro = DetectedMacro(rawValue: attribute.attributeName.trimmedDescription), + detectedMacros.contains(macro) else { continue } let location = converter.location(for: node.positionAfterSkippingLeadingTrivia) diff --git a/apple/Sources/ExpoModulesScanner/Scanner.swift b/apple/Sources/ExpoModulesScanner/Scanner.swift index 3d3e49e..a287c02 100644 --- a/apple/Sources/ExpoModulesScanner/Scanner.swift +++ b/apple/Sources/ExpoModulesScanner/Scanner.swift @@ -2,25 +2,17 @@ import Foundation import SwiftParser import SwiftSyntax -/// The scanner's command-line entry point. Lives in the library (rather than the executable's -/// top-level code) so the parsing/detection logic stays `@testable`-importable; the executable -/// target is a one-line call to `Scanner.main()`. +/// The scanner's public entry point. Argument parsing, subcommand dispatch, and usage text live in +/// the CLI target; this just runs a scan in a given mode and writes its JSON report to stdout. /// /// The detection model (`Detection`, `DetectionVisitor`, …) stays `internal`: tests reach it via -/// `@testable import`, and the CLI only needs this one public entry, so nothing else is exposed. +/// `@testable import`, and the CLI only needs `ScanMode` plus this entry, so nothing else is exposed. public enum Scanner { - /// Reads paths from the process arguments, scans them, prints the JSON report to stdout, and - /// exits non-zero on a usage error. Each path may be a `.swift` file or a directory (scanned - /// recursively for `.swift` files). - public static func main() { - let arguments = Array(CommandLine.arguments.dropFirst()) - - guard !arguments.isEmpty else { - FileHandle.standardError.write(Data("usage: \(toolName) [ ...]\n".utf8)) - exit(2) - } - - let result = scan(paths: arguments) + /// Scans `paths` in `mode`, prints the JSON report to stdout, and returns a process exit code: + /// `0` on success, `1` if encoding fails. The CLI maps its subcommand to a `ScanMode` and exits + /// with the returned code. + public static func run(mode: ScanMode, paths: [String]) -> Int32 { + let result = scan(paths: paths, mode: mode) do { let encoder = JSONEncoder() @@ -28,40 +20,43 @@ public enum Scanner { let data = try encoder.encode(result) FileHandle.standardOutput.write(data) FileHandle.standardOutput.write(Data("\n".utf8)) + return 0 } catch { FileHandle.standardError.write(Data("error: failed to encode results: \(error)\n".utf8)) - exit(1) + return 1 } } } -private let toolName = "ExpoModulesScanner" - -/// Scans the given paths and returns the detections (in file then source order) plus the stats for -/// the run. Kept separate from `main()` (and `internal`) so tests can drive it without going through -/// argv/stdout. -func scan(paths: [String]) -> ScanResult { +/// Scans the given paths in the given mode and returns the detections (in file then source order) +/// plus the stats for the run. Kept separate from `main()` (and `internal`) so tests can drive it +/// without going through argv/stdout. +func scan(paths: [String], mode: ScanMode) -> ScanResult { let clock = ContinuousClock() let start = clock.now + let macros = mode.detectedMacros var detections: [Detection] = [] var filesScanned = 0 var filesParsed = 0 + // Compile the pre-filter regex once per run, not once per file. + let prefilter = macroAttributeRegex(for: macros) + for file in swiftFiles(in: paths) { guard let source = try? String(contentsOfFile: file, encoding: .utf8) else { FileHandle.standardError.write(Data("warning: could not read \(file)\n".utf8)) continue } filesScanned += 1 - // Skip the (relatively expensive) parse for files that can't contain any recognized macro. + // Skip the (relatively expensive) parse for files that can't contain any of the mode's macros. // A plain substring scan is far cheaper than a full parse, and most files in a large tree // mention none of these names. See `mightContainMacro` for why this never drops a real match. - guard mightContainMacro(in: source) else { + guard mightContainMacro(in: source, prefilter: prefilter) else { continue } filesParsed += 1 - detections.append(contentsOf: detect(source: source, file: file)) + detections.append(contentsOf: detect(source: source, file: file, macros: macros)) } let elapsed = (clock.now - start).components @@ -73,29 +68,31 @@ func scan(paths: [String]) -> ScanResult { ) } -/// Matches a spelled macro attribute, e.g. `@ExpoModule`. Including the `@` keeps the check specific: -/// `@JS` won't collide with common substrings like `JSON` the way a bare `JS` would. Compiled once -/// and reused — a precompiled `NSRegularExpression` benchmarked ~20x faster over a large source tree -/// than calling `String.contains` once per macro name, because it scans each file in a single pass. -private let macroAttributeRegex: NSRegularExpression = { - let alternation = DetectedMacro.allCases.map(\.rawValue).joined(separator: "|") +/// Builds the pre-filter regex for a macro set, e.g. `@(ExpoModule)` for a `modules` scan or +/// `@(ExpoModule|JS|Record|SharedObject)` for an `exports` scan. A precompiled `NSRegularExpression` +/// benchmarked ~20x faster over a large source tree than calling `String.contains` once per macro +/// name, because it scans each file in a single pass. Compiled once per run and reused per file. +func macroAttributeRegex(for macros: Set) -> NSRegularExpression { + // Sort for a stable pattern regardless of the set's iteration order. + let alternation = macros.map(\.rawValue).sorted().joined(separator: "|") return try! NSRegularExpression(pattern: "@(\(alternation))") -}() - -/// True if the source text contains a spelled macro attribute, so it's worth parsing. A deliberate -/// over-approximation: the pattern can still match inside a comment or string, in which case the -/// file is parsed and correctly yields no detections — a wasted parse, never a missed module. It -/// assumes the attribute is written with no space after `@` (`@ExpoModule`, not `@ ExpoModule`), -/// which is universal in practice; the rare spaced form would be skipped. -func mightContainMacro(in source: String) -> Bool { +} + +/// True if the source text contains one of the pre-filter's spelled macro attributes, so it's worth +/// parsing. A deliberate over-approximation: the pattern can still match inside a comment or string, +/// in which case the file is parsed and correctly yields no detections — a wasted parse, never a +/// missed module. It assumes the attribute is written with no space after `@` (`@ExpoModule`, not +/// `@ ExpoModule`), which is universal in practice; the rare spaced form would be skipped. +func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Bool { let range = NSRange(source.startIndex..., in: source) - return macroAttributeRegex.firstMatch(in: source, range: range) != nil + return prefilter.firstMatch(in: source, range: range) != nil } -/// Parses one source string and returns its detections. The unit of work the tests exercise. -func detect(source: String, file: String) -> [Detection] { +/// Parses one source string and returns its detections for the given macro set. The unit of work the +/// tests exercise. +func detect(source: String, file: String, macros: Set) -> [Detection] { let tree = Parser.parse(source: source) - let visitor = DetectionVisitor(file: file, tree: tree) + let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros) visitor.walk(tree) return visitor.detections } diff --git a/apple/Sources/ExpoModulesScannerCLI/main.swift b/apple/Sources/ExpoModulesScannerCLI/main.swift index 20ed5ad..dbeca49 100644 --- a/apple/Sources/ExpoModulesScannerCLI/main.swift +++ b/apple/Sources/ExpoModulesScannerCLI/main.swift @@ -1,3 +1,58 @@ import ExpoModulesScanner +import Foundation -Scanner.main() +/// Command-line front end for the scanner. Parses the subcommand and paths, maps the subcommand to a +/// `ScanMode`, and delegates the actual scan + JSON output to the library. Each path may be a +/// `.swift` file or a directory (scanned recursively for `.swift` files). +/// +/// Subcommands: +/// scan-modules ... fast: top-level `@ExpoModule` types, for autolinking +/// scan-exports ... deep: full JS-exported surface, for TS type generation + +let toolName = "ExpoModulesScanner" + +func printUsage() { + let usage = """ + usage: \(toolName) [ ...] + + subcommands: + scan-modules fast scan for top-level @ExpoModule types (autolinking) + scan-exports deep scan of the full JS-exported surface (type generation) + + """ + FileHandle.standardError.write(Data(usage.utf8)) +} + +func fail(_ message: String, usage: Bool = false, code: Int32 = 2) -> Never { + FileHandle.standardError.write(Data("error: \(message)\n".utf8)) + if usage { + printUsage() + } + exit(code) +} + +var arguments = Array(CommandLine.arguments.dropFirst()) + +guard !arguments.isEmpty else { + printUsage() + exit(2) +} + +let subcommand = arguments.removeFirst() +let paths = arguments + +switch subcommand { +case "scan-modules": + guard !paths.isEmpty else { + fail("scan-modules requires at least one path", usage: true) + } + exit(Scanner.run(mode: .modules, paths: paths)) + +case "scan-exports": + // Deep extraction (members, record fields, the JS surface of each type) lands in a separate PR. + // The subcommand is recognized so the CLI surface is stable, but it isn't implemented yet. + fail("scan-exports is not yet implemented", code: 1) + +default: + fail("unknown subcommand '\(subcommand)'", usage: true) +} diff --git a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift index b86116d..67ccf62 100644 --- a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift +++ b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift @@ -3,15 +3,22 @@ import Foundation import SwiftParser import Testing -/// Parses a source string and returns the detections the visitor records for it. The file name is -/// fixed so location assertions are stable; only `line`/`column` vary per test. -private func detect(_ source: String) -> [Detection] { +/// Parses a source string and returns the detections the visitor records for it, considering all +/// macros by default. The file name is fixed so location assertions are stable; only `line`/`column` +/// vary per test. +private func detect(_ source: String, macros: Set = Set(DetectedMacro.allCases)) -> [Detection] { let tree = Parser.parse(source: source) - let visitor = DetectionVisitor(file: "Test.swift", tree: tree) + let visitor = DetectionVisitor(file: "Test.swift", tree: tree, detectedMacros: macros) visitor.walk(tree) return visitor.detections } +/// Convenience matching the production pre-filter: builds the regex for `macros` (all by default) +/// and tests whether the source might contain one. +private func mightContainMacro(in source: String, macros: Set = Set(DetectedMacro.allCases)) -> Bool { + return mightContainMacro(in: source, prefilter: macroAttributeRegex(for: macros)) +} + @Suite("Scanner detection") struct DetectionVisitorTests { @Test @@ -219,7 +226,11 @@ struct MacroPrefilterTests { struct ScanTests { /// Creates a temporary directory tree of `(relativePath, contents)` files, runs `scan` on it, and /// removes the tree afterward. - private func withTree(_ files: [(String, String)], _ body: (ScanResult) throws -> Void) throws { + private func withTree( + _ files: [(String, String)], + mode: ScanMode = .modules, + _ body: (ScanResult) throws -> Void + ) throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent("scanner-test-\(ProcessInfo.processInfo.globallyUniqueString)") defer { try? fileManager.removeItem(at: root) } @@ -230,7 +241,7 @@ struct ScanTests { try contents.write(to: url, atomically: true, encoding: .utf8) } - try body(scan(paths: [root.path])) + try body(scan(paths: [root.path], mode: mode)) } @Test @@ -248,6 +259,25 @@ struct ScanTests { } } + @Test + func `modules mode reports only @ExpoModule, ignoring other macros`() throws { + let files = [ + ("Module.swift", "@ExpoModule\nfinal class MyModule {}"), + ("Shared.swift", "@SharedObject\nfinal class Cache: SharedObject {}"), + ("Options.swift", "@Record\nstruct Options { var name: String }"), + ] + try withTree(files, mode: .modules) { result in + #expect(result.detections.map(\.name) == ["MyModule"]) + // @SharedObject / @Record files aren't even parsed: the modules pre-filter is @ExpoModule-only. + #expect(result.stats.filesParsed == 1) + } + // The same tree in exports mode surfaces all three. + try withTree(files, mode: .exports) { result in + #expect(result.detections.map(\.name).sorted() == ["Cache", "MyModule", "Options"]) + #expect(result.stats.filesParsed == 3) + } + } + @Test func `Prunes .build, Pods, and .git directories`() throws { try withTree([ From 3e725b07de1b6445e846d34a0fe222949e36bd07 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Wed, 17 Jun 2026 12:18:50 +0200 Subject: [PATCH 5/9] Note nested `@ExpoModule` types as a follow-up A macro on a type nested in another type/enum/extension is valid Swift but is currently missed: the visitor only records file-scope declarations. Add a TODO at the `isTopLevel` check flagging it as a decision to make later. --- apple/Sources/ExpoModulesScanner/DetectionVisitor.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift b/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift index bc8e431..8298fc4 100644 --- a/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift +++ b/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift @@ -39,6 +39,10 @@ final class DetectionVisitor: SyntaxVisitor { /// True when the declaration sits at file scope: its parent is a `CodeBlockItemSyntax` directly /// under the source file's top-level item list. Members of a type are nested in a /// `MemberBlockItemSyntax` instead, so they don't match. + /// + /// TODO: decide whether to support nested types. A macro on a type nested in another type/enum/ + /// extension is valid Swift but missed here; supporting it means descending into type bodies and + /// recording the enclosing path for a qualified name (e.g. `Namespace.InnerModule`). private func isTopLevel(_ node: some SyntaxProtocol) -> Bool { guard let item = node.parent?.as(CodeBlockItemSyntax.self) else { return false From d9c060ede2de0d09d5d8e6f36fc01638621eb501 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Wed, 17 Jun 2026 13:01:45 +0200 Subject: [PATCH 6/9] Support `-h`/`--help` in the scanner CLI `-h`/`--help` anywhere on the command line prints the usage to stdout and exits 0, distinct from the usage-error paths that print to stderr and exit non-zero. The help text now also lists the option itself. --- .../Sources/ExpoModulesScannerCLI/main.swift | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apple/Sources/ExpoModulesScannerCLI/main.swift b/apple/Sources/ExpoModulesScannerCLI/main.swift index dbeca49..06dd7e8 100644 --- a/apple/Sources/ExpoModulesScannerCLI/main.swift +++ b/apple/Sources/ExpoModulesScannerCLI/main.swift @@ -11,16 +11,22 @@ import Foundation let toolName = "ExpoModulesScanner" -func printUsage() { - let usage = """ - usage: \(toolName) [ ...] +let usageText = """ + usage: \(toolName) [ ...] - subcommands: - scan-modules fast scan for top-level @ExpoModule types (autolinking) - scan-exports deep scan of the full JS-exported surface (type generation) + subcommands: + scan-modules fast scan for top-level @ExpoModule types (autolinking) + scan-exports deep scan of the full JS-exported surface (type generation) - """ - FileHandle.standardError.write(Data(usage.utf8)) + options: + -h, --help print this help and exit + + """ + +/// Prints the usage text to the given handle. Goes to stdout when help was explicitly requested +/// (a successful action), stderr when it accompanies a usage error. +func printUsage(to handle: FileHandle = .standardError) { + handle.write(Data(usageText.utf8)) } func fail(_ message: String, usage: Bool = false, code: Int32 = 2) -> Never { @@ -33,6 +39,12 @@ func fail(_ message: String, usage: Bool = false, code: Int32 = 2) -> Never { var arguments = Array(CommandLine.arguments.dropFirst()) +// `-h`/`--help` anywhere is treated as a help request: print usage to stdout and exit 0. +if arguments.contains(where: { $0 == "-h" || $0 == "--help" }) { + printUsage(to: .standardOutput) + exit(0) +} + guard !arguments.isEmpty else { printUsage() exit(2) From 7f359657d9694bb2a6e4957c4b35bd1339a730d5 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Wed, 17 Jun 2026 15:15:17 +0200 Subject: [PATCH 7/9] Trim `scan-modules` output to what autolinking needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename `ScanResult` to `ScanModulesResult` and its `detections` key to `modules`, since the two subcommands serve different consumers and aren't expected to share an output envelope (`scan-exports` will return its own shape). Each module entry is now a lean `ScannedModule` with just `name`, `jsName`, and `file` — dropping `macro`, `declarationKind`, `arguments`, and `line`/`column`, which are redundant for autolinking (the macro is always `@ExpoModule` on a class). `jsName` is fully resolved here (the `@ExpoModule("…")` override, else the class name), matching how the macro derives it, so the consumer never applies the fallback itself. `stats` is retained. --- .../ExpoModulesScanner/Detection.swift | 27 ++++++++++++++++--- .../Sources/ExpoModulesScanner/Scanner.swift | 12 ++++++--- .../DetectionVisitorTests.swift | 24 +++++++++++++---- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/apple/Sources/ExpoModulesScanner/Detection.swift b/apple/Sources/ExpoModulesScanner/Detection.swift index 4ebb6ea..642822f 100644 --- a/apple/Sources/ExpoModulesScanner/Detection.swift +++ b/apple/Sources/ExpoModulesScanner/Detection.swift @@ -83,9 +83,28 @@ struct ScanStats: Codable, Equatable { let durationMs: Double } -/// The scanner's top-level result: the detections plus the stats describing the run. Encoded as the -/// tool's JSON output. -struct ScanResult: Codable, Equatable { - let detections: [Detection] +/// One module in the `scan-modules` output. Trimmed to what `expo-modules-autolinking` needs to +/// register a module: the Swift class name, the JS name it registers under, and the file it's in. +/// The richer fields the visitor captures (declaration kind, raw macro arguments, line/column) are +/// dropped here — they're redundant for this command (the macro is always `@ExpoModule` on a class) +/// and belong to the deep `scan-exports` surface instead. +struct ScannedModule: Codable, Equatable { + /// The Swift class name the module is declared as. + let name: String + + /// The fully-resolved JS module name: the `@ExpoModule("Foo")` override when present, otherwise the + /// class name. Resolved here (rather than left `nil`) so it matches how the macro derives the name + /// and the consumer never has to apply the fallback itself. + let jsName: String + + /// Source file the module was found in, relative to the path the scanner was invoked with. + let file: String +} + +/// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the +/// command's JSON output. (`scan-exports` will return its own shape when implemented; the two +/// commands serve different consumers and aren't expected to share an envelope.) +struct ScanModulesResult: Codable, Equatable { + let modules: [ScannedModule] let stats: ScanStats } diff --git a/apple/Sources/ExpoModulesScanner/Scanner.swift b/apple/Sources/ExpoModulesScanner/Scanner.swift index a287c02..ff7de7a 100644 --- a/apple/Sources/ExpoModulesScanner/Scanner.swift +++ b/apple/Sources/ExpoModulesScanner/Scanner.swift @@ -31,7 +31,7 @@ public enum Scanner { /// Scans the given paths in the given mode and returns the detections (in file then source order) /// plus the stats for the run. Kept separate from `main()` (and `internal`) so tests can drive it /// without going through argv/stdout. -func scan(paths: [String], mode: ScanMode) -> ScanResult { +func scan(paths: [String], mode: ScanMode) -> ScanModulesResult { let clock = ContinuousClock() let start = clock.now let macros = mode.detectedMacros @@ -62,8 +62,14 @@ func scan(paths: [String], mode: ScanMode) -> ScanResult { let elapsed = (clock.now - start).components let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15 - return ScanResult( - detections: detections, + let modules = detections.map { + // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else the + // class name. + ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, file: $0.file) + } + + return ScanModulesResult( + modules: modules, stats: ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs) ) } diff --git a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift index 67ccf62..b2c8ed5 100644 --- a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift +++ b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift @@ -229,7 +229,7 @@ struct ScanTests { private func withTree( _ files: [(String, String)], mode: ScanMode = .modules, - _ body: (ScanResult) throws -> Void + _ body: (ScanModulesResult) throws -> Void ) throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent("scanner-test-\(ProcessInfo.processInfo.globallyUniqueString)") @@ -251,7 +251,7 @@ struct ScanTests { ("Plain.swift", "final class Plain {}"), ("Notes.swift", "// just a comment, no macros here"), ]) { result in - #expect(result.detections.map(\.name) == ["MyModule"]) + #expect(result.modules.map(\.name) == ["MyModule"]) // All three .swift files are read; only the one mentioning a macro is parsed. #expect(result.stats.filesScanned == 3) #expect(result.stats.filesParsed == 1) @@ -259,6 +259,20 @@ struct ScanTests { } } + @Test + func `Resolves jsName: explicit override, else the class name`() throws { + try withTree([ + ("Plain.swift", "@ExpoModule\nfinal class PlainModule {}"), + ("Renamed.swift", "@ExpoModule(\"JSName\")\nfinal class RenamedModule {}"), + ]) { result in + let byName = Dictionary(uniqueKeysWithValues: result.modules.map { ($0.name, $0.jsName) }) + // No override -> jsName falls back to the class name. + #expect(byName["PlainModule"] == "PlainModule") + // Override -> jsName is the argument. + #expect(byName["RenamedModule"] == "JSName") + } + } + @Test func `modules mode reports only @ExpoModule, ignoring other macros`() throws { let files = [ @@ -267,13 +281,13 @@ struct ScanTests { ("Options.swift", "@Record\nstruct Options { var name: String }"), ] try withTree(files, mode: .modules) { result in - #expect(result.detections.map(\.name) == ["MyModule"]) + #expect(result.modules.map(\.name) == ["MyModule"]) // @SharedObject / @Record files aren't even parsed: the modules pre-filter is @ExpoModule-only. #expect(result.stats.filesParsed == 1) } // The same tree in exports mode surfaces all three. try withTree(files, mode: .exports) { result in - #expect(result.detections.map(\.name).sorted() == ["Cache", "MyModule", "Options"]) + #expect(result.modules.map(\.name).sorted() == ["Cache", "MyModule", "Options"]) #expect(result.stats.filesParsed == 3) } } @@ -287,7 +301,7 @@ struct ScanTests { (".git/hooks/Sneaky.swift", "@ExpoModule\nfinal class Sneaky {}"), ]) { result in // Only the file outside the pruned directories is seen at all. - #expect(result.detections.map(\.name) == ["RealModule"]) + #expect(result.modules.map(\.name) == ["RealModule"]) #expect(result.stats.filesScanned == 1) #expect(result.stats.filesParsed == 1) } From 96bad66f0a9dc3696923de06184c2e21e16c33bf Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Wed, 17 Jun 2026 15:24:49 +0200 Subject: [PATCH 8/9] Make `scanModules` honest and group files by concern The scan entry took a `ScanMode` but always returned `ScanModulesResult`, so the parameter implied a generality it didn't have. Replace it with `scanModules(paths:)` returning `ScanModulesResult`, built on a shared `collectDetections(paths:macros:)` core that both scan commands will reuse. Remove `ScanMode` (one live case); the modules command passes `[.expoModule]` directly. `scan-exports` will get its own `scanExports` + result type when implemented. Group the library by concern: `Core/` for the shared model, walk, pre-filter, and visitor; `Modules/` for the `scan-modules` command (its result types, `scanModules`, and `Scanner.runModules`). `Exports/` follows when that command lands. --- .../{ => Core}/Detection.swift | 49 ------------- .../{ => Core}/DetectionVisitor.swift | 0 .../{Scanner.swift => Core/SourceScan.swift} | 70 +++++-------------- .../Modules/ScanModules.swift | 68 ++++++++++++++++++ .../Sources/ExpoModulesScannerCLI/main.swift | 6 +- .../DetectionVisitorTests.swift | 33 ++++++--- 6 files changed, 112 insertions(+), 114 deletions(-) rename apple/Sources/ExpoModulesScanner/{ => Core}/Detection.swift (54%) rename apple/Sources/ExpoModulesScanner/{ => Core}/DetectionVisitor.swift (100%) rename apple/Sources/ExpoModulesScanner/{Scanner.swift => Core/SourceScan.swift} (70%) create mode 100644 apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift diff --git a/apple/Sources/ExpoModulesScanner/Detection.swift b/apple/Sources/ExpoModulesScanner/Core/Detection.swift similarity index 54% rename from apple/Sources/ExpoModulesScanner/Detection.swift rename to apple/Sources/ExpoModulesScanner/Core/Detection.swift index 642822f..797cb9e 100644 --- a/apple/Sources/ExpoModulesScanner/Detection.swift +++ b/apple/Sources/ExpoModulesScanner/Core/Detection.swift @@ -9,29 +9,6 @@ enum DetectedMacro: String, Codable, CaseIterable { case record = "Record" } -/// What a scan looks for. The CLI exposes one subcommand per mode; they serve different consumers and -/// will eventually produce different output shapes, so this drives both the macro filter and (later) -/// the depth of extraction. -public enum ScanMode { - /// Fast path for `expo-modules-autolinking`: only top-level `@ExpoModule` types (the module class - /// names autolinking registers). The narrowest pre-filter and no member walking. - case modules - - /// Deep path for TypeScript type generation: every entry-point macro and (eventually) the full - /// member surface each type exports to JS. Not yet implemented — see the `scan-exports` stub. - case exports - - /// The macros a scan in this mode reports. `modules` is intentionally `@ExpoModule`-only. - var detectedMacros: Set { - switch self { - case .modules: - return [.expoModule] - case .exports: - return Set(DetectedMacro.allCases) - } - } -} - /// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil` /// for positional arguments; `value` is the argument expression's source text as written. struct MacroArgument: Codable, Equatable { @@ -82,29 +59,3 @@ struct ScanStats: Codable, Equatable { /// Wall-clock duration of the scan, in milliseconds (walking, reading, filtering, and parsing). let durationMs: Double } - -/// One module in the `scan-modules` output. Trimmed to what `expo-modules-autolinking` needs to -/// register a module: the Swift class name, the JS name it registers under, and the file it's in. -/// The richer fields the visitor captures (declaration kind, raw macro arguments, line/column) are -/// dropped here — they're redundant for this command (the macro is always `@ExpoModule` on a class) -/// and belong to the deep `scan-exports` surface instead. -struct ScannedModule: Codable, Equatable { - /// The Swift class name the module is declared as. - let name: String - - /// The fully-resolved JS module name: the `@ExpoModule("Foo")` override when present, otherwise the - /// class name. Resolved here (rather than left `nil`) so it matches how the macro derives the name - /// and the consumer never has to apply the fallback itself. - let jsName: String - - /// Source file the module was found in, relative to the path the scanner was invoked with. - let file: String -} - -/// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the -/// command's JSON output. (`scan-exports` will return its own shape when implemented; the two -/// commands serve different consumers and aren't expected to share an envelope.) -struct ScanModulesResult: Codable, Equatable { - let modules: [ScannedModule] - let stats: ScanStats -} diff --git a/apple/Sources/ExpoModulesScanner/DetectionVisitor.swift b/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift similarity index 100% rename from apple/Sources/ExpoModulesScanner/DetectionVisitor.swift rename to apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift diff --git a/apple/Sources/ExpoModulesScanner/Scanner.swift b/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift similarity index 70% rename from apple/Sources/ExpoModulesScanner/Scanner.swift rename to apple/Sources/ExpoModulesScanner/Core/SourceScan.swift index ff7de7a..b12f53f 100644 --- a/apple/Sources/ExpoModulesScanner/Scanner.swift +++ b/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift @@ -2,39 +2,12 @@ import Foundation import SwiftParser import SwiftSyntax -/// The scanner's public entry point. Argument parsing, subcommand dispatch, and usage text live in -/// the CLI target; this just runs a scan in a given mode and writes its JSON report to stdout. -/// -/// The detection model (`Detection`, `DetectionVisitor`, …) stays `internal`: tests reach it via -/// `@testable import`, and the CLI only needs `ScanMode` plus this entry, so nothing else is exposed. -public enum Scanner { - /// Scans `paths` in `mode`, prints the JSON report to stdout, and returns a process exit code: - /// `0` on success, `1` if encoding fails. The CLI maps its subcommand to a `ScanMode` and exits - /// with the returned code. - public static func run(mode: ScanMode, paths: [String]) -> Int32 { - let result = scan(paths: paths, mode: mode) - - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(result) - FileHandle.standardOutput.write(data) - FileHandle.standardOutput.write(Data("\n".utf8)) - return 0 - } catch { - FileHandle.standardError.write(Data("error: failed to encode results: \(error)\n".utf8)) - return 1 - } - } -} - -/// Scans the given paths in the given mode and returns the detections (in file then source order) -/// plus the stats for the run. Kept separate from `main()` (and `internal`) so tests can drive it -/// without going through argv/stdout. -func scan(paths: [String], mode: ScanMode) -> ScanModulesResult { +/// Walks `paths`, parses each `.swift` file that might contain one of `macros` (the pre-filter), and +/// returns every detection (in file then source order) with the run's stats. The shared core every +/// scan command builds on; each command projects these detections into its own output shape. +func collectDetections(paths: [String], macros: Set) -> (detections: [Detection], stats: ScanStats) { let clock = ContinuousClock() let start = clock.now - let macros = mode.detectedMacros var detections: [Detection] = [] var filesScanned = 0 @@ -49,9 +22,9 @@ func scan(paths: [String], mode: ScanMode) -> ScanModulesResult { continue } filesScanned += 1 - // Skip the (relatively expensive) parse for files that can't contain any of the mode's macros. - // A plain substring scan is far cheaper than a full parse, and most files in a large tree - // mention none of these names. See `mightContainMacro` for why this never drops a real match. + // Skip the (relatively expensive) parse for files that can't contain any of the macros. A plain + // substring scan is far cheaper than a full parse, and most files in a large tree mention none + // of these names. See `mightContainMacro` for why this never drops a real match. guard mightContainMacro(in: source, prefilter: prefilter) else { continue } @@ -62,18 +35,20 @@ func scan(paths: [String], mode: ScanMode) -> ScanModulesResult { let elapsed = (clock.now - start).components let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15 - let modules = detections.map { - // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else the - // class name. - ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, file: $0.file) - } + return (detections, ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs)) +} - return ScanModulesResult( - modules: modules, - stats: ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs) - ) +/// Parses one source string and returns its detections for the given macro set. The unit of work the +/// tests exercise. +func detect(source: String, file: String, macros: Set) -> [Detection] { + let tree = Parser.parse(source: source) + let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros) + visitor.walk(tree) + return visitor.detections } +// MARK: - Pre-filter + /// Builds the pre-filter regex for a macro set, e.g. `@(ExpoModule)` for a `modules` scan or /// `@(ExpoModule|JS|Record|SharedObject)` for an `exports` scan. A precompiled `NSRegularExpression` /// benchmarked ~20x faster over a large source tree than calling `String.contains` once per macro @@ -94,14 +69,7 @@ func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Boo return prefilter.firstMatch(in: source, range: range) != nil } -/// Parses one source string and returns its detections for the given macro set. The unit of work the -/// tests exercise. -func detect(source: String, file: String, macros: Set) -> [Detection] { - let tree = Parser.parse(source: source) - let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros) - visitor.walk(tree) - return visitor.detections -} +// MARK: - File discovery /// Directory names skipped during the recursive walk. These hold build products, dependencies, and /// git internals — never source worth scanning — and pruning them keeps the walk from descending diff --git a/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift b/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift new file mode 100644 index 0000000..d9cd002 --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift @@ -0,0 +1,68 @@ +import Foundation + +/// The scanner's public entry point. Argument parsing, subcommand dispatch, and usage text live in +/// the CLI target; this just runs a command and writes its JSON report to stdout. +/// +/// The detection model (`Detection`, `DetectionVisitor`, …) stays `internal`: tests reach it via +/// `@testable import`, and the CLI only needs these entries, so nothing else is exposed. +public enum Scanner { + /// Runs the `scan-modules` command over `paths`, prints the JSON report to stdout, and returns a + /// process exit code: `0` on success, `1` if encoding fails. (`scan-exports` will get its own + /// `run`-style entry returning its own result type when implemented.) + public static func runModules(paths: [String]) -> Int32 { + let result = scanModules(paths: paths) + + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(result) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) + return 0 + } catch { + FileHandle.standardError.write(Data("error: failed to encode results: \(error)\n".utf8)) + return 1 + } + } +} + +/// One module in the `scan-modules` output. Trimmed to what `expo-modules-autolinking` needs to +/// register a module: the Swift class name, the JS name it registers under, and the file it's in. +/// The richer fields the visitor captures (declaration kind, raw macro arguments, line/column) are +/// dropped here — they're redundant for this command (the macro is always `@ExpoModule` on a class) +/// and belong to the deep `scan-exports` surface instead. +struct ScannedModule: Codable, Equatable { + /// The Swift class name the module is declared as. + let name: String + + /// The fully-resolved JS module name: the `@ExpoModule("Foo")` override when present, otherwise the + /// class name. Resolved here (rather than left `nil`) so it matches how the macro derives the name + /// and the consumer never has to apply the fallback itself. + let jsName: String + + /// Source file the module was found in, relative to the path the scanner was invoked with. + let file: String +} + +/// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the +/// command's JSON output. (`scan-exports` will return its own shape when implemented; the two +/// commands serve different consumers and aren't expected to share an envelope.) +struct ScanModulesResult: Codable, Equatable { + let modules: [ScannedModule] + let stats: ScanStats +} + +/// Scans the given paths for top-level `@ExpoModule` types and returns the modules (in file then +/// source order) plus the stats for the run — the `scan-modules` command. Kept separate from the +/// public entry (and `internal`) so tests can drive it without going through argv/stdout. +func scanModules(paths: [String]) -> ScanModulesResult { + let scan = collectDetections(paths: paths, macros: [.expoModule]) + + let modules = scan.detections.map { + // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else the + // class name. + ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, file: $0.file) + } + + return ScanModulesResult(modules: modules, stats: scan.stats) +} diff --git a/apple/Sources/ExpoModulesScannerCLI/main.swift b/apple/Sources/ExpoModulesScannerCLI/main.swift index 06dd7e8..482926e 100644 --- a/apple/Sources/ExpoModulesScannerCLI/main.swift +++ b/apple/Sources/ExpoModulesScannerCLI/main.swift @@ -1,8 +1,8 @@ import ExpoModulesScanner import Foundation -/// Command-line front end for the scanner. Parses the subcommand and paths, maps the subcommand to a -/// `ScanMode`, and delegates the actual scan + JSON output to the library. Each path may be a +/// Command-line front end for the scanner. Parses the subcommand and paths, then delegates to the +/// matching library entry (which runs the scan and writes its JSON output). Each path may be a /// `.swift` file or a directory (scanned recursively for `.swift` files). /// /// Subcommands: @@ -58,7 +58,7 @@ case "scan-modules": guard !paths.isEmpty else { fail("scan-modules requires at least one path", usage: true) } - exit(Scanner.run(mode: .modules, paths: paths)) + exit(Scanner.runModules(paths: paths)) case "scan-exports": // Deep extraction (members, record fields, the JS surface of each type) lands in a separate PR. diff --git a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift index b2c8ed5..ab76695 100644 --- a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift +++ b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift @@ -224,12 +224,21 @@ struct MacroPrefilterTests { @Suite("Scanning a directory") struct ScanTests { - /// Creates a temporary directory tree of `(relativePath, contents)` files, runs `scan` on it, and - /// removes the tree afterward. + /// Creates a temporary directory tree of `(relativePath, contents)` files, runs `scanModules` on + /// it, and removes the tree afterward. private func withTree( _ files: [(String, String)], - mode: ScanMode = .modules, _ body: (ScanModulesResult) throws -> Void + ) throws { + try withTreeRoot(files) { root in + try body(scanModules(paths: [root.path])) + } + } + + /// Materializes a temporary tree and hands its root to `body`, removing it afterward. + private func withTreeRoot( + _ files: [(String, String)], + _ body: (URL) throws -> Void ) throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent("scanner-test-\(ProcessInfo.processInfo.globallyUniqueString)") @@ -241,7 +250,7 @@ struct ScanTests { try contents.write(to: url, atomically: true, encoding: .utf8) } - try body(scan(paths: [root.path], mode: mode)) + try body(root) } @Test @@ -274,21 +283,23 @@ struct ScanTests { } @Test - func `modules mode reports only @ExpoModule, ignoring other macros`() throws { + func `scanModules reports only @ExpoModule, ignoring other macros`() throws { let files = [ ("Module.swift", "@ExpoModule\nfinal class MyModule {}"), ("Shared.swift", "@SharedObject\nfinal class Cache: SharedObject {}"), ("Options.swift", "@Record\nstruct Options { var name: String }"), ] - try withTree(files, mode: .modules) { result in + try withTreeRoot(files) { root in + let result = scanModules(paths: [root.path]) #expect(result.modules.map(\.name) == ["MyModule"]) // @SharedObject / @Record files aren't even parsed: the modules pre-filter is @ExpoModule-only. #expect(result.stats.filesParsed == 1) - } - // The same tree in exports mode surfaces all three. - try withTree(files, mode: .exports) { result in - #expect(result.modules.map(\.name).sorted() == ["Cache", "MyModule", "Options"]) - #expect(result.stats.filesParsed == 3) + + // The shared core, given the full macro set, surfaces all three — confirming it's the + // @ExpoModule-only filter, not the walk, that scopes scanModules. + let all = collectDetections(paths: [root.path], macros: Set(DetectedMacro.allCases)) + #expect(all.detections.map(\.name).sorted() == ["Cache", "MyModule", "Options"]) + #expect(all.stats.filesParsed == 3) } } From bda7b28632fec9c6afe1002ec02822584bcb207e Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Wed, 17 Jun 2026 16:03:05 +0200 Subject: [PATCH 9/9] Emit absolute paths from `scan-modules` Reported file paths are now always absolute, normalizing a directly-passed file arg the way the directory walk already resolves paths. Absolute paths are unambiguous and independent of the caller's working directory; a `--root` option for a portable relative form can come later if a consumer needs it. --- apple/Sources/ExpoModulesScanner/Core/SourceScan.swift | 8 +++++++- .../ExpoModulesScannerTests/DetectionVisitorTests.swift | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift b/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift index b12f53f..9c3c26c 100644 --- a/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +++ b/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift @@ -79,6 +79,10 @@ private let prunedDirectoryNames: Set = [".build", "Pods", ".git"] /// Expands the given paths into the list of `.swift` files to parse: a file path passes through, /// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic /// so output is stable across runs. +/// +/// Reported paths are absolute, so the output is unambiguous and independent of the caller's working +/// directory. (A future `--root` option could emit paths relative to a given base when a portable, +/// shorter form is wanted.) func swiftFiles(in paths: [String]) -> [String] { let fileManager = FileManager.default var result: [String] = [] @@ -93,7 +97,9 @@ func swiftFiles(in paths: [String]) -> [String] { if isDirectory.boolValue { result.append(contentsOf: swiftFiles(inDirectory: URL(fileURLWithPath: path), fileManager: fileManager)) } else if path.hasSuffix(".swift") { - result.append(path) + // A directory walk already yields absolute paths; resolve a directly-passed file the same way + // so every reported path is absolute regardless of how it was spelled. + result.append(URL(fileURLWithPath: path).standardizedFileURL.path) } } diff --git a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift index ab76695..de1a2f6 100644 --- a/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift +++ b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift @@ -222,6 +222,7 @@ struct MacroPrefilterTests { } } + @Suite("Scanning a directory") struct ScanTests { /// Creates a temporary directory tree of `(relativePath, contents)` files, runs `scanModules` on @@ -261,6 +262,8 @@ struct ScanTests { ("Notes.swift", "// just a comment, no macros here"), ]) { result in #expect(result.modules.map(\.name) == ["MyModule"]) + // Reported paths are absolute. + #expect(result.modules.first?.file.hasPrefix("/") == true) // All three .swift files are read; only the one mentioning a macro is parsed. #expect(result.stats.filesScanned == 3) #expect(result.stats.filesParsed == 1)