Skip to content

Add a Swift source scanner CLI for the Expo module macros#21

Merged
tsapeta merged 9 commits into
mainfrom
scanner
Jun 17, 2026
Merged

Add a Swift source scanner CLI for the Expo module macros#21
tsapeta merged 9 commits into
mainfrom
scanner

Conversation

@tsapeta

@tsapeta tsapeta commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

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 .swift file or a directory scanned recursively):

  • scan-modules (fast) — reports top-level @ExpoModule types, for expo-modules-autolinking, which just needs module class names. The pre-filter matches @ExpoModule alone, 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/--help prints usage to stdout and exits 0.

scan-modules output

Trimmed to what autolinking needs. Each module carries the Swift class name, the resolved jsName (the @ExpoModule("Foo") override, else the class name — resolved the way the macro derives it, so the consumer never applies the fallback), and the file. A top-level stats reports the scan's effort.

File paths are absolute, so the output is unambiguous and independent of the caller's working directory. (A --root option 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-exports will 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:

  • Pre-filter — a precompiled NSRegularExpression skips parsing any file that doesn't mention a relevant macro attribute. Benchmarked ~20x faster over a large tree than repeated String.contains, since almost no file matches.
  • Directory pruning.build, Pods, .git (and hidden dirs) are skipped during the walk.
  • No per-entry stat — directory-ness is read from hasDirectoryPath rather than re-stat-ing each entry.

Over ~/Work/expo/main (~1600 .swift files): ~3s, only a handful of files parsed.

Structure

  • Library (ExpoModulesScanner), grouped by concern, all internal:
    • Core/ — the detection model (Detection, MacroArgument, ScanStats), the SyntaxVisitor, and the shared scan core (collectDetections, pre-filter, file walk).
    • Modules/ — the scan-modules command: scanModules, its ScannedModule/ScanModulesResult output, and the public Scanner.runModules entry. Exports/ follows when that command lands.
  • ExpoModulesScannerCLI executable — argument parsing, subcommand dispatch, usage text; calls into the library. Exposed as the ExpoModulesScanner product so the binary name matches the tool.
  • ExpoModulesScannerTests — reaches the logic via @testable import.

Scope / follow-ups

  • scan-exports deep member extraction (the @JS/@Record/constructor surface, feeding TS type generation) is a separate PR.
  • Nested @ExpoModule types (valid Swift, inside another type/enum/extension) are currently not detected — flagged with a TODO as a decision to make later.
  • Path scoping (e.g. excluding test fixtures) is left to the caller by design; a --root override for the reported paths' base is a possible future addition.

tsapeta added 3 commits June 16, 2026 21:35
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).
@tsapeta tsapeta marked this pull request as ready for review June 17, 2026 08:17
@tsapeta tsapeta requested a review from Kudo June 17, 2026 08:19
tsapeta added 2 commits June 17, 2026 11:45
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.
@tsapeta tsapeta changed the title Add a Swift source scanner for @ExpoModule/@JS/@SharedObject Add a Swift source scanner CLI for the Expo module macros Jun 17, 2026
tsapeta added 3 commits June 17, 2026 15:15
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.
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.
@tsapeta tsapeta merged commit c34761f into main Jun 17, 2026
1 check passed
@tsapeta tsapeta deleted the scanner branch June 17, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants