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/Core/Detection.swift b/apple/Sources/ExpoModulesScanner/Core/Detection.swift new file mode 100644 index 0000000..797cb9e --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/Core/Detection.swift @@ -0,0 +1,61 @@ +import Foundation + +/// 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` +/// 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 +} diff --git a/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift b/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift new file mode 100644 index 0000000..8298fc4 --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift @@ -0,0 +1,109 @@ +import SwiftSyntax + +/// 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 + /// 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, detectedMacros: Set) { + self.file = file + self.converter = SourceLocationConverter(fileName: file, tree: tree) + self.detectedMacros = detectedMacros + 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. + /// + /// 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 + } + 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), + detectedMacros.contains(macro) 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/Core/SourceScan.swift b/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift new file mode 100644 index 0000000..9c3c26c --- /dev/null +++ b/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift @@ -0,0 +1,137 @@ +import Foundation +import SwiftParser +import SwiftSyntax + +/// 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 + + 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 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 + } + filesParsed += 1 + detections.append(contentsOf: detect(source: source, file: file, macros: macros)) + } + + let elapsed = (clock.now - start).components + let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15 + + return (detections, 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 +/// 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 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 prefilter.firstMatch(in: source, range: range) != nil +} + +// 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 +/// 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. +/// +/// 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] = [] + + 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") { + // 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) + } + } + + 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. +/// +/// 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] { + guard let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return [] + } + + var result: [String] = [] + for case let url as URL in enumerator { + if url.hasDirectoryPath { + if prunedDirectoryNames.contains(url.lastPathComponent) { + enumerator.skipDescendants() + } + } else if url.pathExtension == "swift" { + result.append(url.path) + } + } + return result +} 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 new file mode 100644 index 0000000..482926e --- /dev/null +++ b/apple/Sources/ExpoModulesScannerCLI/main.swift @@ -0,0 +1,70 @@ +import ExpoModulesScanner +import Foundation + +/// 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: +/// scan-modules ... fast: top-level `@ExpoModule` types, for autolinking +/// scan-exports ... deep: full JS-exported surface, for TS type generation + +let toolName = "ExpoModulesScanner" + +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) + + 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 { + FileHandle.standardError.write(Data("error: \(message)\n".utf8)) + if usage { + printUsage() + } + exit(code) +} + +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) +} + +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.runModules(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 new file mode 100644 index 0000000..de1a2f6 --- /dev/null +++ b/apple/Tests/ExpoModulesScannerTests/DetectionVisitorTests.swift @@ -0,0 +1,323 @@ +@testable import ExpoModulesScanner +import Foundation +import SwiftParser +import Testing + +/// 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, 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 + 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 `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( + """ + @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}")) + #expect(mightContainMacro(in: "@Record\nstruct Options {}")) + } + + @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 `scanModules` on + /// it, and removes the tree afterward. + private func withTree( + _ files: [(String, String)], + _ 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)") + 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(root) + } + + @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.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) + #expect(result.stats.durationMs >= 0) + } + } + + @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 `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 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 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) + } + } + + @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.modules.map(\.name) == ["RealModule"]) + #expect(result.stats.filesScanned == 1) + #expect(result.stats.filesParsed == 1) + } + } +}