Conversation
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`.
`@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.
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).
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`.
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.
`-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.
@ExpoModule/@JS/@SharedObjectRename `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.
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.
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.
Kudo
approved these changes
Jun 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Introduce
ExpoModulesScanner, a SwiftSyntax-based CLI that scans Swift source for declarations annotated with the Expo module macros (@ExpoModule,@JS,@SharedObject,@Record) and emits a JSON report. It parses syntactically (no compilation), so it sees the same declarations the compiler would hand the macro plugin.Subcommands
Two modes for two consumers, selected by subcommand. Both take
<path>...(a.swiftfile or a directory scanned recursively):scan-modules(fast) — reports top-level@ExpoModuletypes, forexpo-modules-autolinking, which just needs module class names. The pre-filter matches@ExpoModulealone, so most files skip parsing.scan-exports(deep) — intended for TypeScript type generation; will walk the full JS-exported surface of each type. The subcommand is recognized but currently exits "not yet implemented"; the member-level extraction lands in a separate PR.-h/--helpprints usage to stdout and exits 0.scan-modulesoutputTrimmed to what autolinking needs. Each module carries the Swift class
name, the resolvedjsName(the@ExpoModule("Foo")override, else the class name — resolved the way the macro derives it, so the consumer never applies the fallback), and thefile. A top-levelstatsreports the scan's effort.File paths are absolute, so the output is unambiguous and independent of the caller's working directory. (A
--rootoption for a portable, relative form may come later.){ "modules": [ { "name": "MyModule", "jsName": "MyModule", "file": "/Users/me/expo/packages/my-module/ios/MyModule.swift" }, { "name": "MacroRenamed", "jsName": "RenamedMacroModule", "file": "/Users/me/expo/packages/expo-modules-core/ios/Tests/Macros/MacroModuleTests.swift" } ], "stats": { "filesScanned": 1621, "filesParsed": 7, "durationMs": 3064.6 } }(
scan-exportswill return its own shape; the two commands serve different consumers and don't share an output envelope.)Performance
A whole-tree scan is dominated by the directory walk, not parsing, so the work went there:
NSRegularExpressionskips parsing any file that doesn't mention a relevant macro attribute. Benchmarked ~20x faster over a large tree than repeatedString.contains, since almost no file matches..build,Pods,.git(and hidden dirs) are skipped during the walk.hasDirectoryPathrather than re-stat-ing each entry.Over
~/Work/expo/main(~1600.swiftfiles): ~3s, only a handful of files parsed.Structure
ExpoModulesScanner), grouped by concern, allinternal:Core/— the detection model (Detection,MacroArgument,ScanStats), theSyntaxVisitor, and the shared scan core (collectDetections, pre-filter, file walk).Modules/— thescan-modulescommand:scanModules, itsScannedModule/ScanModulesResultoutput, and the publicScanner.runModulesentry.Exports/follows when that command lands.ExpoModulesScannerCLIexecutable — argument parsing, subcommand dispatch, usage text; calls into the library. Exposed as theExpoModulesScannerproduct so the binary name matches the tool.ExpoModulesScannerTests— reaches the logic via@testable import.Scope / follow-ups
scan-exportsdeep member extraction (the@JS/@Record/constructor surface, feeding TS type generation) is a separate PR.@ExpoModuletypes (valid Swift, inside another type/enum/extension) are currently not detected — flagged with a TODO as a decision to make later.--rootoverride for the reported paths' base is a possible future addition.