Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.claude
apple/.build
apple/.swiftpm
29 changes: 27 additions & 2 deletions apple/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
],
Expand All @@ -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"]
),
]
)

Expand All @@ -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"),
]
)
)
}
61 changes: 61 additions & 0 deletions apple/Sources/ExpoModulesScanner/Core/Detection.swift
Original file line number Diff line number Diff line change
@@ -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
}
109 changes: 109 additions & 0 deletions apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift
Original file line number Diff line number Diff line change
@@ -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<DetectedMacro>
private(set) var detections: [Detection] = []

init(file: String, tree: SourceFileSyntax, detectedMacros: Set<DetectedMacro>) {
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
}
137 changes: 137 additions & 0 deletions apple/Sources/ExpoModulesScanner/Core/SourceScan.swift
Original file line number Diff line number Diff line change
@@ -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<DetectedMacro>) -> (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<DetectedMacro>) -> [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<DetectedMacro>) -> 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<String> = [".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
}
Loading
Loading