expo-modules-macros 0.0.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.github/resources/expo-modules-macros.svg +23 -0
  2. package/.github/workflows/publish.yml +122 -0
  3. package/.github/workflows/swift.yml +68 -0
  4. package/AGENTS.md +59 -0
  5. package/CLAUDE.md +1 -0
  6. package/LICENSE +21 -0
  7. package/README.md +119 -0
  8. package/apple/ExpoModulesMacros +0 -0
  9. package/apple/Package.resolved +15 -0
  10. package/apple/Package.swift +61 -0
  11. package/apple/PodTests/StubTest.swift +5 -0
  12. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +444 -0
  13. package/apple/Sources/ExpoModulesMacros/EventMacro.swift +330 -0
  14. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +263 -0
  15. package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +117 -0
  16. package/apple/Sources/ExpoModulesMacros/ExpoViewMacro.swift +259 -0
  17. package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +214 -0
  18. package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +55 -0
  19. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +294 -0
  20. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +433 -0
  21. package/apple/Sources/ExpoModulesMacros/OptimizedFunctionHelpers.swift +55 -0
  22. package/apple/Sources/ExpoModulesMacros/Plugin.swift +34 -0
  23. package/apple/Sources/ExpoModulesMacros/Receiver.swift +79 -0
  24. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +590 -0
  25. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +162 -0
  26. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +190 -0
  27. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +389 -0
  28. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +477 -0
  29. package/apple/Sources/ExpoModulesOptimized/ExpoModulesOptimized.swift +19 -0
  30. package/apple/Sources/ExpoModulesScanner/CLI.swift +114 -0
  31. package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +79 -0
  32. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +181 -0
  33. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +115 -0
  34. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +193 -0
  35. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +319 -0
  36. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +199 -0
  37. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +66 -0
  38. package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +616 -0
  39. package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +284 -0
  40. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +161 -0
  41. package/apple/build.js +138 -0
  42. package/build/index.d.ts +51 -0
  43. package/build/index.js +153 -0
  44. package/build/types.d.ts +264 -0
  45. package/build/types.js +15 -0
  46. package/package.json +22 -5
@@ -0,0 +1,79 @@
1
+ import Foundation
2
+
3
+ /// Which Expo macro was found on a declaration. The scanner recognizes the entry-point macros that
4
+ /// mark a type or member as part of a module's JS surface, plus `@Record` for convertible types.
5
+ enum DetectedMacro: String, Codable, CaseIterable {
6
+ case expoModule = "ExpoModule"
7
+ case js = "JS"
8
+ case event = "Event"
9
+ case sharedObject = "SharedObject"
10
+ case record = "Record"
11
+ case union = "Union"
12
+ }
13
+
14
+ /// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil`
15
+ /// for positional arguments; `value` is the argument expression's source text as written.
16
+ struct MacroArgument: Codable, Equatable {
17
+ /// The argument label (`classes` in `classes: [Bar.self]`), or `nil` for a positional argument.
18
+ let label: String?
19
+
20
+ /// The argument value exactly as written in source, e.g. `"Foo"` (including the quotes) or
21
+ /// `[Bar.self]`. Kept as text because a syntactic scan can't resolve these to runtime values.
22
+ let value: String
23
+ }
24
+
25
+ /// A single annotated declaration the scanner found, with just enough to locate it and know
26
+ /// what it is. Member-level details (parameters, types) are intentionally out of scope for this
27
+ /// first prototype — see the `@JS` member walk in the macros for where that would live.
28
+ struct Detection: Codable, Equatable {
29
+ /// The macro spelled on the declaration (without the leading `@`).
30
+ let macro: DetectedMacro
31
+
32
+ /// The declared name, e.g. the class name for `@ExpoModule`, or the func/var/init name for `@JS`.
33
+ let name: String
34
+
35
+ /// The kind of declaration the macro was attached to: `class`, `struct`, `func`, `var`, `init`, …
36
+ let declarationKind: String
37
+
38
+ /// The declaration's spelled access modifier (`open`, `public`, `package`, `fileprivate`,
39
+ /// `private`), or `internal` when none is written. Consumers that reference the declaration from
40
+ /// another Swift module (the generated modules provider does) need `public`/`open` and can reject
41
+ /// the rest up front instead of failing at compile time.
42
+ let accessLevel: String
43
+
44
+ /// The explicit JS name override when written as `@ExpoModule("Foo")` / `@JS("bar")` /
45
+ /// `@SharedObject("Baz")`, otherwise `nil` (the name defaults to `name` at expansion time).
46
+ let jsName: String?
47
+
48
+ /// Every argument passed to the macro, in source order, e.g. `@ExpoModule("Foo", classes: [Bar.self])`
49
+ /// yields a positional `"Foo"` and a `classes:` argument. Empty when the macro is written bare.
50
+ let arguments: [MacroArgument]
51
+
52
+ /// Source location, relative to the path the scanner was invoked with.
53
+ let file: String
54
+ let line: Int
55
+ let column: Int
56
+ }
57
+
58
+ /// A non-fatal problem found while scanning, tied to the source location that caused it — today,
59
+ /// an `#if` condition a static scan cannot answer (`canImport` of a non-SDK module, `arch`,
60
+ /// `targetEnvironment`, …). The affected region counts as inactive on every platform, so the
61
+ /// warning tells the consumer which declarations may be missing platforms and why.
62
+ struct ScanWarning: Codable, Equatable, Hashable {
63
+ let message: String
64
+ let file: String
65
+ let line: Int
66
+ }
67
+
68
+ /// Counts describing how much work the scan did, so callers can see the pre-filter's effect: of all
69
+ /// the `.swift` files read, how many actually needed parsing, and how long the run took.
70
+ struct ScanStats: Codable, Equatable {
71
+ /// `.swift` files the walk found and read (after directory pruning).
72
+ let filesScanned: Int
73
+
74
+ /// Of those, how many contained a macro attribute and so were parsed with SwiftSyntax.
75
+ let filesParsed: Int
76
+
77
+ /// Wall-clock duration of the scan, in milliseconds (walking, reading, filtering, and parsing).
78
+ let durationMs: Double
79
+ }
@@ -0,0 +1,181 @@
1
+ import SwiftDiagnostics
2
+ import SwiftIfConfig
3
+ import SwiftSyntax
4
+
5
+ /// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`,
6
+ /// `@SharedObject`, or `@Record`. Only file-scope declarations are considered: these macros apply to
7
+ /// top-level types, so descending into type and function bodies would only surface false positives.
8
+ /// Recognition mirrors the macros themselves — a purely syntactic match on the spelled attribute
9
+ /// name — so it sees the same declarations the compiler would hand the plugin, without compiling
10
+ /// anything.
11
+ ///
12
+ /// `#if` blocks are handled according to the scan's configuration. With a `ScanBuildConfiguration`,
13
+ /// only the active clause of each `#if` is visited, so a declaration inside `#if os(tvOS)` is
14
+ /// recorded exactly when the scan targets tvOS, and conditions the configuration cannot answer make
15
+ /// their region inactive and surface as warnings. With a `nil` configuration every clause of every
16
+ /// `#if` is visited, so a declaration in any branch is recorded and nothing is evaluated; the
17
+ /// per-platform aggregation in `scanModules` builds on both walks.
18
+ final class DetectionVisitor: SyntaxVisitor {
19
+ private let file: String
20
+ private let converter: SourceLocationConverter
21
+ /// Only these macros are recorded; the rest are ignored. Lets a `modules` scan report just
22
+ /// `@ExpoModule` while an `exports` scan covers them all.
23
+ private let detectedMacros: Set<DetectedMacro>
24
+ /// The configuration `#if` conditions are evaluated against, or `nil` to visit every branch.
25
+ private let configuration: ScanBuildConfiguration?
26
+ private var diagnostics: [Diagnostic] = []
27
+ private(set) var detections: [Detection] = []
28
+
29
+ init(
30
+ file: String,
31
+ tree: SourceFileSyntax,
32
+ detectedMacros: Set<DetectedMacro>,
33
+ configuration: ScanBuildConfiguration?
34
+ ) {
35
+ self.file = file
36
+ self.converter = SourceLocationConverter(fileName: file, tree: tree)
37
+ self.detectedMacros = detectedMacros
38
+ self.configuration = configuration
39
+ super.init(viewMode: .sourceAccurate)
40
+ }
41
+
42
+ /// The accumulated `#if` warnings as `Detection`-style locations plus the message, ready for the
43
+ /// scan report. These come from conditions the configuration cannot answer statically.
44
+ var warnings: [ScanWarning] {
45
+ return diagnostics.map { diagnostic in
46
+ let location = diagnostic.location(converter: converter)
47
+ return ScanWarning(message: diagnostic.message, file: file, line: location.line)
48
+ }
49
+ }
50
+
51
+ override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind {
52
+ guard let configuration else {
53
+ // Every branch is visited, so a declaration in any clause is recorded.
54
+ return .visitChildren
55
+ }
56
+ let (clause, clauseDiagnostics) = node.activeClause(in: configuration)
57
+ diagnostics.append(contentsOf: clauseDiagnostics)
58
+ if let elements = clause?.elements {
59
+ // A nested `#if` inside the active clause comes back through this method.
60
+ walk(elements)
61
+ }
62
+ return .skipChildren
63
+ }
64
+
65
+ override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
66
+ if isTopLevel(node) {
67
+ record(attributes: node.attributes, modifiers: node.modifiers, name: node.name.text, kind: "class", at: node)
68
+ }
69
+ // Members live in the type body; we never report them, so there's no reason to descend.
70
+ return .skipChildren
71
+ }
72
+
73
+ override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
74
+ if isTopLevel(node) {
75
+ record(attributes: node.attributes, modifiers: node.modifiers, name: node.name.text, kind: "struct", at: node)
76
+ }
77
+ return .skipChildren
78
+ }
79
+
80
+ /// True when the declaration sits at file scope: its parent chain reaches the source file through
81
+ /// only code-block items and `#if` structure. Members of a type are nested in a
82
+ /// `MemberBlockItemSyntax` instead, so they don't match. The `#if` wrappers are allowed because a
83
+ /// top-level declaration inside an (active) `#if` clause is still a top-level declaration.
84
+ ///
85
+ /// TODO: decide whether to support nested types. A macro on a type nested in another type/enum/
86
+ /// extension is valid Swift but missed here; supporting it means descending into type bodies and
87
+ /// recording the enclosing path for a qualified name (e.g. `Namespace.InnerModule`).
88
+ private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
89
+ guard node.parent?.is(CodeBlockItemSyntax.self) == true else {
90
+ return false
91
+ }
92
+ var current = node.parent?.parent
93
+ while let node = current {
94
+ if node.is(SourceFileSyntax.self) {
95
+ return true
96
+ }
97
+ guard node.is(CodeBlockItemListSyntax.self)
98
+ || node.is(CodeBlockItemSyntax.self)
99
+ || node.is(IfConfigClauseSyntax.self)
100
+ || node.is(IfConfigClauseListSyntax.self)
101
+ || node.is(IfConfigDeclSyntax.self) else {
102
+ return false
103
+ }
104
+ current = node.parent
105
+ }
106
+ return false
107
+ }
108
+
109
+ /// Emits one detection per recognized Expo attribute on the declaration. A declaration can in
110
+ /// principle carry more than one (uncommon), so each is recorded independently.
111
+ private func record(
112
+ attributes: AttributeListSyntax,
113
+ modifiers: DeclModifierListSyntax,
114
+ name: String,
115
+ kind: String,
116
+ at node: some SyntaxProtocol
117
+ ) {
118
+ for element in attributes {
119
+ guard let attribute = element.as(AttributeSyntax.self),
120
+ let macro = DetectedMacro(rawValue: attribute.attributeName.trimmedDescription),
121
+ detectedMacros.contains(macro) else {
122
+ continue
123
+ }
124
+ let location = converter.location(for: node.positionAfterSkippingLeadingTrivia)
125
+ detections.append(
126
+ Detection(
127
+ macro: macro,
128
+ name: name,
129
+ declarationKind: kind,
130
+ accessLevel: accessLevel(of: modifiers),
131
+ jsName: stringArgument(of: attribute),
132
+ arguments: arguments(of: attribute),
133
+ file: file,
134
+ line: location.line,
135
+ column: location.column
136
+ )
137
+ )
138
+ }
139
+ }
140
+ }
141
+
142
+ /// The spelled access modifiers, in the order Swift defines them. Other modifiers (`final`,
143
+ /// `static`, …) are not access levels and are skipped when resolving one.
144
+ private let accessModifierNames: Set<String> = ["open", "public", "package", "internal", "fileprivate", "private"]
145
+
146
+ /// The declaration's access level: the first spelled access modifier, or Swift's default of
147
+ /// `internal` when none is written. A declaration can spell at most one, so first is the only one.
148
+ private func accessLevel(of modifiers: DeclModifierListSyntax) -> String {
149
+ for modifier in modifiers where accessModifierNames.contains(modifier.name.text) {
150
+ return modifier.name.text
151
+ }
152
+ return "internal"
153
+ }
154
+
155
+ /// Every argument passed to the attribute, in source order, each as a label (or `nil` when
156
+ /// positional) plus the value expression's source text. Returns an empty array when the attribute
157
+ /// is written bare (`@ExpoModule`) or with empty parens.
158
+ private func arguments(of attribute: AttributeSyntax) -> [MacroArgument] {
159
+ guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
160
+ return []
161
+ }
162
+ return args.map { arg in
163
+ MacroArgument(label: arg.label?.text, value: arg.expression.trimmedDescription)
164
+ }
165
+ }
166
+
167
+ /// The first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork". Returns
168
+ /// `nil` when there's no argument or it isn't a plain string literal. (Same shape the
169
+ /// `jsNameArgument` helper reads inside the macros.) Shared with the `scan-exports` surface visitor,
170
+ /// which reads the same JS-name override off `@JS`/`@ExpoModule`/`@SharedObject`.
171
+ func stringArgument(of attribute: AttributeSyntax) -> String? {
172
+ guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
173
+ let first = args.first,
174
+ first.label == nil,
175
+ let str = first.expression.as(StringLiteralExprSyntax.self),
176
+ let segment = str.segments.first?.as(StringSegmentSyntax.self),
177
+ str.segments.count == 1 else {
178
+ return nil
179
+ }
180
+ return segment.content.text
181
+ }
@@ -0,0 +1,115 @@
1
+ import SwiftIfConfig
2
+ import SwiftSyntax
3
+
4
+ /// The build configuration that `#if` conditions are evaluated against during a scan, built from
5
+ /// the CLI's `--platform` and `--define` options. The scan is static: it knows the target OS and
6
+ /// the spelled compilation flags, and nothing else. Every condition it cannot answer throws, which
7
+ /// SwiftIfConfig turns into an inactive region plus a diagnostic, so an unanswerable `#if` skips
8
+ /// its declarations and surfaces a warning instead of guessing.
9
+ struct ScanBuildConfiguration: BuildConfiguration {
10
+ /// The target OS name to answer `os(...)` with, as spelled in the condition (`iOS`, `macOS`,
11
+ /// `tvOS`, `watchOS`, `visionOS`; compared case-insensitively). A scan without a target OS runs
12
+ /// without a configuration at all (the platform-agnostic union scan), so this is always known.
13
+ let platform: String
14
+
15
+ /// The conditional compilation flags treated as set, from repeated `--define` options.
16
+ let defines: Set<String>
17
+
18
+ func isCustomConditionSet(name: String) throws -> Bool {
19
+ return defines.contains(name)
20
+ }
21
+
22
+ func isActiveTargetOS(name: String) throws -> Bool {
23
+ return name.lowercased() == platform.lowercased()
24
+ }
25
+
26
+ // MARK: - Unanswerable conditions
27
+
28
+ // These vary within a single platform's build (device vs simulator, arm64 vs x86_64) or depend
29
+ // on the consumer's toolchain, so a static scan has no correct answer. Throwing makes the region
30
+ // inactive and emits a warning naming the condition.
31
+
32
+ func hasFeature(name: String) throws -> Bool {
33
+ throw ScanConfigurationError("cannot evaluate 'hasFeature(\(name))' in a static scan")
34
+ }
35
+
36
+ func hasAttribute(name: String) throws -> Bool {
37
+ throw ScanConfigurationError("cannot evaluate 'hasAttribute(\(name))' in a static scan")
38
+ }
39
+
40
+ func canImport(importPath: [(TokenSyntax, String)], version: CanImportVersion) throws -> Bool {
41
+ let module = importPath.map(\.1).joined(separator: ".")
42
+
43
+ // A curated set of SDK frameworks is answerable from the target platform alone. Everything
44
+ // else (arbitrary modules, submodule paths, versioned checks) stays unanswerable: a wrong
45
+ // "yes" would surface a declaration that doesn't exist in the real build.
46
+ guard importPath.count == 1,
47
+ case .unversioned = version,
48
+ let frameworkPlatforms = sdkFrameworkPlatforms[module] else {
49
+ throw ScanConfigurationError("cannot evaluate 'canImport(\(module))' in a static scan")
50
+ }
51
+ return frameworkPlatforms.contains(platform.lowercased())
52
+ }
53
+
54
+ func isActiveTargetArchitecture(name: String) throws -> Bool {
55
+ throw ScanConfigurationError("cannot evaluate 'arch(\(name))' in a static scan")
56
+ }
57
+
58
+ func isActiveTargetEnvironment(name: String) throws -> Bool {
59
+ throw ScanConfigurationError("cannot evaluate 'targetEnvironment(\(name))' in a static scan")
60
+ }
61
+
62
+ func isActiveTargetRuntime(name: String) throws -> Bool {
63
+ throw ScanConfigurationError("cannot evaluate '_runtime(\(name))' in a static scan")
64
+ }
65
+
66
+ func isActiveTargetPointerAuthentication(name: String) throws -> Bool {
67
+ throw ScanConfigurationError("cannot evaluate '_ptrauth(\(name))' in a static scan")
68
+ }
69
+
70
+ // MARK: - Fixed answers
71
+
72
+ // Non-throwing protocol requirements, so they need a value. These are constant across Apple
73
+ // targets (the only ones Expo modules compile for), except the versions, which assume a current
74
+ // toolchain; a module class gated on a *lower* Swift version would be wrongly included, which is
75
+ // rare enough to accept for a scan.
76
+
77
+ var targetPointerBitWidth: Int { 64 }
78
+ var targetAtomicBitWidths: [Int] { [32, 64, 128] }
79
+ var endianness: Endianness { .little }
80
+ var languageVersion: VersionTuple { VersionTuple(6) }
81
+ var compilerVersion: VersionTuple { VersionTuple(6, 2) }
82
+ }
83
+
84
+ /// The platforms (lowercased, as compared against `--platform`) that ship each of a curated set of
85
+ /// Apple SDK frameworks, so `canImport` of one is answerable from the platform alone. The list is
86
+ /// deliberately small and high-confidence: it covers the frameworks realistically used to gate a
87
+ /// module class, and a framework missing here degrades to the skip-with-warning path rather than a
88
+ /// wrong answer.
89
+ private let sdkFrameworkPlatforms: [String: Set<String>] = [
90
+ "UIKit": ["ios", "tvos", "watchos", "visionos"],
91
+ "AppKit": ["macos"],
92
+ "SwiftUI": ["ios", "macos", "tvos", "watchos", "visionos"],
93
+ "WatchKit": ["watchos"],
94
+ "TVUIKit": ["tvos"],
95
+ "WebKit": ["ios", "macos", "visionos"],
96
+ "SafariServices": ["ios", "macos", "visionos"],
97
+ "ARKit": ["ios", "visionos"],
98
+ "RealityKit": ["ios", "macos", "visionos"],
99
+ "CarPlay": ["ios"],
100
+ "MessageUI": ["ios"],
101
+ "CoreNFC": ["ios"],
102
+ "HealthKit": ["ios", "watchos", "visionos"],
103
+ "HomeKit": ["ios", "tvos", "watchos", "visionos"],
104
+ "WidgetKit": ["ios", "macos", "watchos", "visionos"],
105
+ ]
106
+
107
+ /// An unanswerable `#if` condition. SwiftIfConfig converts the thrown error into a diagnostic on
108
+ /// the condition's node and treats the region as inactive.
109
+ struct ScanConfigurationError: Error, CustomStringConvertible {
110
+ let description: String
111
+
112
+ init(_ description: String) {
113
+ self.description = description
114
+ }
115
+ }
@@ -0,0 +1,193 @@
1
+ import Foundation
2
+ import SwiftParser
3
+ import SwiftSyntax
4
+
5
+ /// Walks `paths`, and for each `.swift` file that might contain one of `macros` or one of
6
+ /// `conformances` (the pre-filter passes it), reads the source and hands it to `process` along with the
7
+ /// file path. Returns the run's stats.
8
+ ///
9
+ /// The shared core every scan command builds on: the walk, read, pre-filter, and stats are identical;
10
+ /// only what each command does per parsed file differs (`scan-modules` collects `Detection`s,
11
+ /// `scan-exports` walks a `SurfaceVisitor`), and that lives in `process`.
12
+ func scanFiles(
13
+ paths: [String],
14
+ macros: Set<DetectedMacro>,
15
+ conformances: Set<String> = [],
16
+ process: (_ source: String, _ file: String) -> Void
17
+ ) -> ScanStats {
18
+ let clock = ContinuousClock()
19
+ let start = clock.now
20
+
21
+ var filesScanned = 0
22
+ var filesParsed = 0
23
+
24
+ // Compile the pre-filter regex once per run, not once per file.
25
+ let prefilter = macroAttributeRegex(for: macros, conformances: conformances)
26
+
27
+ for file in swiftFiles(in: paths) {
28
+ guard let source = try? String(contentsOfFile: file, encoding: .utf8) else {
29
+ FileHandle.standardError.write(Data("warning: could not read \(file)\n".utf8))
30
+ continue
31
+ }
32
+ filesScanned += 1
33
+ // Skip the (relatively expensive) parse for files that can't contain any of the macros. A plain
34
+ // substring scan is far cheaper than a full parse, and most files in a large tree mention none
35
+ // of these names. See `mightContainMacro` for why this never drops a real match.
36
+ guard mightContainMacro(in: source, prefilter: prefilter) else {
37
+ continue
38
+ }
39
+ filesParsed += 1
40
+ process(source, file)
41
+ }
42
+
43
+ let elapsed = (clock.now - start).components
44
+ let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15
45
+
46
+ return ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs)
47
+ }
48
+
49
+ /// Walks `paths`, parses each `.swift` file that might contain one of `macros`, and returns every
50
+ /// detection (in file then source order) with the accumulated `#if` warnings and the run's stats —
51
+ /// the shape `scan-modules` projects. A thin layer over `scanFiles` that accumulates the per-file
52
+ /// results.
53
+ func collectDetections(
54
+ paths: [String],
55
+ macros: Set<DetectedMacro>,
56
+ configuration: ScanBuildConfiguration? = nil
57
+ ) -> (detections: [Detection], warnings: [ScanWarning], stats: ScanStats) {
58
+ var detections: [Detection] = []
59
+ var warnings: [ScanWarning] = []
60
+ let stats = scanFiles(paths: paths, macros: macros) { source, file in
61
+ let result = detect(source: source, file: file, macros: macros, configuration: configuration)
62
+ detections.append(contentsOf: result.detections)
63
+ warnings.append(contentsOf: result.warnings)
64
+ }
65
+ return (detections, warnings, stats)
66
+ }
67
+
68
+ /// Parses one source string and returns its detections for the given macro set, plus the warnings
69
+ /// for `#if` conditions the configuration couldn't answer. The unit of work the tests exercise.
70
+ func detect(
71
+ source: String,
72
+ file: String,
73
+ macros: Set<DetectedMacro>,
74
+ configuration: ScanBuildConfiguration?
75
+ ) -> (detections: [Detection], warnings: [ScanWarning]) {
76
+ let tree = Parser.parse(source: source)
77
+ let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros, configuration: configuration)
78
+ visitor.walk(tree)
79
+ return (visitor.detections, visitor.warnings)
80
+ }
81
+
82
+ // MARK: - Pre-filter
83
+
84
+ /// Builds the pre-filter regex for a macro set, e.g. `@(ExpoModule)` for a `modules` scan or
85
+ /// `@(ExpoModule|JS|Record|SharedObject)` for an `exports` scan. A precompiled `NSRegularExpression`
86
+ /// benchmarked ~20x faster over a large source tree than calling `String.contains` once per macro
87
+ /// name, because it scans each file in a single pass. Compiled once per run and reused per file.
88
+ ///
89
+ /// `conformances` adds bare (unprefixed) alternatives for types recognized by conformance rather than
90
+ /// by an attribute (`Enumerable` for `scan-exports`). They join the same alternation so the scan stays
91
+ /// one pass; a bare name matches more loosely than an `@`-prefixed one, which costs a wasted parse and
92
+ /// never a miss.
93
+ func macroAttributeRegex(
94
+ for macros: Set<DetectedMacro>,
95
+ conformances: Set<String> = []
96
+ ) -> NSRegularExpression {
97
+ // Sort for a stable pattern regardless of the set's iteration order.
98
+ let attributes = macros.map(\.rawValue).sorted().joined(separator: "|")
99
+ var alternatives: [String] = []
100
+ if !attributes.isEmpty {
101
+ alternatives.append("@(\(attributes))")
102
+ }
103
+ alternatives.append(contentsOf: conformances.sorted())
104
+ return try! NSRegularExpression(pattern: alternatives.joined(separator: "|"))
105
+ }
106
+
107
+ /// True if the source text contains one of the pre-filter's spelled macro attributes, so it's worth
108
+ /// parsing. A deliberate over-approximation: the pattern can still match inside a comment or string,
109
+ /// in which case the file is parsed and correctly yields no detections — a wasted parse, never a
110
+ /// missed module. It assumes the attribute is written with no space after `@` (`@ExpoModule`, not
111
+ /// `@ ExpoModule`), which is universal in practice; the rare spaced form would be skipped.
112
+ func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Bool {
113
+ let range = NSRange(source.startIndex..., in: source)
114
+ return prefilter.firstMatch(in: source, range: range) != nil
115
+ }
116
+
117
+ // MARK: - File discovery
118
+
119
+ /// Directory names skipped during the recursive walk, in two groups:
120
+ /// - Build products, dependencies, and git internals (`.build`, `Pods`, `.git`, `node_modules`) are
121
+ /// never source worth scanning, and pruning them keeps the walk from descending into the bulk of
122
+ /// a monorepo's files. `node_modules` also makes any npm package root safe to pass as a scan
123
+ /// path: nested dependencies are separate packages and get scanned on their own.
124
+ /// - Test and example directories, by the layout conventions of Expo module packages (`Tests`,
125
+ /// `UITests`, `__tests__`, `__mocks__`, `example(s)`, `e2e`). Their sources are not compiled into the
126
+ /// package's product (they belong to a `test_spec` or a standalone example app), so a declaration
127
+ /// found there would name a type the consumer can't reference. This is a name-based heuristic;
128
+ /// a package keeping product sources in such a directory can declare its modules in
129
+ /// `expo-module.config.json` instead.
130
+ private let prunedDirectoryNames: Set<String> = [
131
+ ".build", "Pods", ".git", "node_modules",
132
+ "Tests", "UITests", "__tests__", "__mocks__", "example", "examples", "e2e",
133
+ ]
134
+
135
+ /// Expands the given paths into the list of `.swift` files to parse: a file path passes through,
136
+ /// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic
137
+ /// so output is stable across runs.
138
+ ///
139
+ /// Reported paths are absolute, so the output is unambiguous and independent of the caller's working
140
+ /// directory. (A future `--root` option could emit paths relative to a given base when a portable,
141
+ /// shorter form is wanted.)
142
+ func swiftFiles(in paths: [String]) -> [String] {
143
+ let fileManager = FileManager.default
144
+ var result: [String] = []
145
+
146
+ for path in paths {
147
+ var isDirectory: ObjCBool = false
148
+ guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else {
149
+ FileHandle.standardError.write(Data("warning: no such path \(path)\n".utf8))
150
+ continue
151
+ }
152
+
153
+ if isDirectory.boolValue {
154
+ result.append(contentsOf: swiftFiles(inDirectory: URL(fileURLWithPath: path), fileManager: fileManager))
155
+ } else if path.hasSuffix(".swift") {
156
+ // A directory walk already yields absolute paths; resolve a directly-passed file the same way
157
+ // so every reported path is absolute regardless of how it was spelled.
158
+ result.append(URL(fileURLWithPath: path).standardizedFileURL.path)
159
+ }
160
+ }
161
+
162
+ return result.sorted()
163
+ }
164
+
165
+ /// Recursively enumerates `.swift` files under a directory, calling `skipDescendants()` on any
166
+ /// pruned directory so its subtree is never read. Uses the URL enumerator (rather than the
167
+ /// path-based one) precisely because it supports skipping a subtree mid-walk.
168
+ ///
169
+ /// Directory-ness is read from `hasDirectoryPath` (the enumerator sets a trailing slash on the URLs
170
+ /// it yields) rather than `resourceValues(forKeys: [.isDirectoryKey])`, which re-`stat`s each entry.
171
+ /// The walk is the dominant cost of a whole-tree scan, and skipping that per-entry stat measurably
172
+ /// shortens it.
173
+ private func swiftFiles(inDirectory directory: URL, fileManager: FileManager) -> [String] {
174
+ guard let enumerator = fileManager.enumerator(
175
+ at: directory,
176
+ includingPropertiesForKeys: nil,
177
+ options: [.skipsHiddenFiles]
178
+ ) else {
179
+ return []
180
+ }
181
+
182
+ var result: [String] = []
183
+ for case let url as URL in enumerator {
184
+ if url.hasDirectoryPath {
185
+ if prunedDirectoryNames.contains(url.lastPathComponent) {
186
+ enumerator.skipDescendants()
187
+ }
188
+ } else if url.pathExtension == "swift" {
189
+ result.append(url.path)
190
+ }
191
+ }
192
+ return result
193
+ }