@expo/expo-modules-macros-plugin 0.3.0 → 0.5.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.
@@ -245,3 +245,87 @@ extension AttributeListSyntax {
245
245
  return nil
246
246
  }
247
247
  }
248
+
249
+ /// A type spelled so it's valid in expression position (before `.getDynamicType()` or after `as!`).
250
+ /// Implicitly-unwrapped optionals (`T!`) are only allowed in type-annotation position, so a trailing
251
+ /// `!` is rewritten to `?` (`T!` and `T?` are both `Optional<T>`, which the dynamic-type / cast layer
252
+ /// treats identically). Other type spellings pass through unchanged.
253
+ internal func expressionType(_ type: String) -> String {
254
+ guard type.hasSuffix("!") else {
255
+ return type
256
+ }
257
+ return type.dropLast() + "?"
258
+ }
259
+
260
+ // MARK: - @JS property collection
261
+
262
+ /// Collects the `@JS var` bindings of a declaration into `JSProperty` values for direct JSI binding.
263
+ /// Shared between `@ExpoModule` and `@SharedObject` — the resulting properties are receiver-agnostic;
264
+ /// the decorator that emits them picks the receiver (module `self` vs. shared-object `_self`).
265
+ internal func collectProperties(
266
+ varDecl: VariableDeclSyntax,
267
+ attribute: AttributeSyntax
268
+ ) -> [JSProperty] {
269
+ let jsNameOverride = jsNameArgument(of: attribute)
270
+ // A `let` is never settable; only `var` bindings can carry a setter.
271
+ let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
272
+
273
+ return varDecl.bindings.compactMap { binding in
274
+ guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
275
+ return nil
276
+ }
277
+ let swiftName = ident.identifier.text
278
+ // Prefer the explicit annotation; recover the type from a literal default (`var x = false`)
279
+ // when there's none. `nil` falls back to inference at the use site.
280
+ let valueType = binding.typeAnnotation?.type.trimmedDescription
281
+ ?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
282
+ return JSProperty(
283
+ swiftName: swiftName,
284
+ jsName: jsNameOverride ?? swiftName,
285
+ valueType: valueType,
286
+ isSettable: isVar && bindingIsSettable(binding)
287
+ )
288
+ }
289
+ }
290
+
291
+ /// Whether a `var` binding is settable from JS. A stored property (no accessor block) is settable;
292
+ /// a computed property is settable only when it declares an explicit `set` accessor. A getter-only
293
+ /// computed property (`{ get }` or a single getter body) stays read-only. `willSet`/`didSet`
294
+ /// observers imply stored storage, which is also settable.
295
+ private func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
296
+ guard let accessorBlock = binding.accessorBlock else {
297
+ return true
298
+ }
299
+ switch accessorBlock.accessors {
300
+ case .accessors(let accessors):
301
+ return accessors.contains { accessor in
302
+ switch accessor.accessorSpecifier.tokenKind {
303
+ case .keyword(.set), .keyword(.willSet), .keyword(.didSet):
304
+ return true
305
+ default:
306
+ return false
307
+ }
308
+ }
309
+ case .getter:
310
+ return false
311
+ }
312
+ }
313
+
314
+ /// The throwing `JavaScriptUnownedValue` accessor that decodes the given primitive type directly
315
+ /// (`asDouble()` for `Double`, etc.), bypassing the dynamic-type converter. Returns `nil` for types
316
+ /// without a dedicated accessor (arrays, records, optionals, shared objects, other numeric widths),
317
+ /// which decode through `getDynamicType().cast(...)`.
318
+ func fastDecodeAccessor(for type: String) -> String? {
319
+ switch type {
320
+ case "Bool":
321
+ return "asBool"
322
+ case "Int":
323
+ return "asInt"
324
+ case "Double":
325
+ return "asDouble"
326
+ case "String":
327
+ return "asString"
328
+ default:
329
+ return nil
330
+ }
331
+ }
@@ -0,0 +1,58 @@
1
+ import SwiftSyntax
2
+
3
+ /// Where a directly-bound closure gets the Swift value it calls into. A module is a singleton, so its
4
+ /// bindings call `self` and ignore the JS `this`; a shared object has a distinct native instance per JS
5
+ /// object, so its bindings recover the typed receiver from `this`.
6
+ internal enum Receiver {
7
+ /// The module singleton; the closure captures `self` strong.
8
+ case module
9
+ /// A shared object of the given concrete type; the closure captures nothing and recovers the receiver
10
+ /// from `this` per call.
11
+ case sharedObject(typeName: String)
12
+
13
+ /// The expression the body calls members on: `self` for a module, `_self` (bound by `unwrapStatement`)
14
+ /// for a shared object. The leading underscore avoids colliding with a user member like `var owner`.
15
+ var callee: String {
16
+ switch self {
17
+ case .module:
18
+ return "self"
19
+ case .sharedObject:
20
+ return "_self"
21
+ }
22
+ }
23
+
24
+ /// The JS object the decorator binds members onto, matching its first parameter: `object` for a
25
+ /// module (its own JS object), `prototype` for a shared object (the shared class prototype).
26
+ var decoratedObject: String {
27
+ switch self {
28
+ case .module:
29
+ return "object"
30
+ case .sharedObject:
31
+ return "prototype"
32
+ }
33
+ }
34
+
35
+ /// The leading body line binding the receiver, or `nil` for a module (it reads `self` directly). For a
36
+ /// shared object, `native(from:as:)` recovers the typed instance from the borrowed `this`, throwing on
37
+ /// a foreign object or a type mismatch.
38
+ var unwrapStatement: String? {
39
+ switch self {
40
+ case .module:
41
+ return nil
42
+ case .sharedObject(let typeName):
43
+ return "let _self = try SharedObject.native(from: this.asObject(in: runtime), as: \(typeName).self)"
44
+ }
45
+ }
46
+
47
+ /// The capture-clause fragment (with a trailing space, or empty when nothing is captured). A module
48
+ /// captures `self` strong; a shared object captures nothing of the instance. `appContext`, when used,
49
+ /// is captured weak in both cases.
50
+ func captureClause(usesAppContext: Bool) -> String {
51
+ switch self {
52
+ case .module:
53
+ return usesAppContext ? "[weak appContext, self] " : "[self] "
54
+ case .sharedObject:
55
+ return usesAppContext ? "[weak appContext] " : ""
56
+ }
57
+ }
58
+ }
@@ -316,7 +316,8 @@ private func jsObjectReadLines(properties: [RecordProperty]) -> [String] {
316
316
  var lines: [String] = []
317
317
  for property in properties {
318
318
  let valueVar = "\(property.name)JSValue"
319
- let cast = "try \(property.type).getDynamicType().cast(jsValue: \(valueVar), appContext: appContext) as! \(property.type)"
319
+ let exprType = expressionType(property.type)
320
+ let cast = "try \(exprType).getDynamicType().cast(jsValue: \(valueVar), appContext: appContext) as! \(exprType)"
320
321
  lines.append(" let \(valueVar) = object.getProperty(\"\(property.name)\")")
321
322
  if property.isRequired {
322
323
  lines.append(" guard !\(valueVar).isUndefined() else {")
@@ -337,7 +338,8 @@ private func dictionaryReadLines(properties: [RecordProperty]) -> [String] {
337
338
  var lines: [String] = []
338
339
  for property in properties {
339
340
  let valueVar = "\(property.name)Value"
340
- let cast = "try \(property.type).getDynamicType().cast(\(valueVar), appContext: appContext) as! \(property.type)"
341
+ let exprType = expressionType(property.type)
342
+ let cast = "try \(exprType).getDynamicType().cast(\(valueVar), appContext: appContext) as! \(exprType)"
341
343
  lines.append(" let \(valueVar) = dictionary[\"\(property.name)\"]")
342
344
  if property.isRequired {
343
345
  lines.append(" guard let \(valueVar) else {")
@@ -399,7 +401,7 @@ private func toObjectMethod(properties: [RecordProperty], inheritsRecord: Bool)
399
401
  lines.append(" let object = try appContext.runtime.createObject()")
400
402
  }
401
403
  for property in properties {
402
- lines.append(" object.setProperty(\"\(property.name)\", value: try \(property.type).getDynamicType().convertToJS(self.\(property.name), appContext: appContext))")
404
+ lines.append(" object.setProperty(\"\(property.name)\", value: try \(expressionType(property.type)).getDynamicType().convertToJS(self.\(property.name), appContext: appContext))")
403
405
  }
404
406
  lines.append(" return object")
405
407
  let body = lines.joined(separator: "\n")
@@ -41,35 +41,38 @@ public struct SharedObjectMacro: MemberMacro {
41
41
  let typeName = classDecl.name.text
42
42
  let jsName = jsNameArgument(of: node) ?? typeName
43
43
 
44
- var entries: [String] = []
45
- var sawConstructor = false
44
+ // `@JS func`s/`var`s and the `@JS init` are bound directly into the shared object's JS object by
45
+ // the synthesized `_decorateSharedObject` / `_constructSharedObject` rather than described with a
46
+ // `Function(...)` / `Property(...)` / `Constructor { … }` DSL entry, so they're collected here
47
+ // instead of appended to the `Class` block. The block keeps only non-`@JS` definitions (none are
48
+ // collected today), so it's empty when every member is `@JS`.
49
+ let entries: [String] = []
50
+ var functions: [JSFunction] = []
51
+ var properties: [JSProperty] = []
52
+ var constructor: JSConstructor?
46
53
 
47
54
  for member in classDecl.memberBlock.members {
48
55
  let decl = member.decl
49
56
 
50
57
  if let initDecl = decl.as(InitializerDeclSyntax.self),
51
58
  initDecl.attributes.firstAttribute(named: "JS") != nil {
52
- if sawConstructor {
59
+ if constructor != nil {
53
60
  throw MacroExpansionErrorMessage(
54
61
  "@SharedObject classes can have at most one @JS initializer; JavaScript classes have a single constructor.")
55
62
  }
56
- sawConstructor = true
57
- entries.append(buildConstructorEntry(initDecl: initDecl, typeName: typeName))
63
+ constructor = JSConstructor(initDecl: initDecl)
58
64
  continue
59
65
  }
60
66
 
61
67
  if let funcDecl = decl.as(FunctionDeclSyntax.self),
62
68
  let attribute = funcDecl.attributes.firstAttribute(named: "JS") {
63
- entries.append(
64
- buildClassFunctionEntry(funcDecl: funcDecl, attribute: attribute, typeName: typeName))
69
+ functions.append(JSFunction(funcDecl: funcDecl, attribute: attribute))
65
70
  continue
66
71
  }
67
72
 
68
73
  if let varDecl = decl.as(VariableDeclSyntax.self),
69
74
  let attribute = varDecl.attributes.firstAttribute(named: "JS") {
70
- entries.append(
71
- contentsOf: buildClassPropertyEntries(
72
- varDecl: varDecl, attribute: attribute, typeName: typeName))
75
+ properties.append(contentsOf: collectProperties(varDecl: varDecl, attribute: attribute))
73
76
  }
74
77
  }
75
78
 
@@ -78,13 +81,27 @@ public struct SharedObjectMacro: MemberMacro {
78
81
  ? " return Class(\"\(jsName)\", \(typeName).self) {\n }"
79
82
  : " return Class(\"\(jsName)\", \(typeName).self) {\n\(lines)\n }"
80
83
 
81
- let method: DeclSyntax = """
84
+ var emitted: [DeclSyntax] = [
85
+ """
82
86
  public static func _synthesizedClassDefinition() -> ClassDefinition {
83
87
  \(raw: body)
84
88
  }
85
89
  """
90
+ ]
91
+
92
+ // Direct JSI binding: one `_decorateSharedObject` that binds each `@JS func`/`var` onto the JS
93
+ // object (unwrapping the per-call receiver from `this`), and a `_constructSharedObject` that
94
+ // builds an instance from the `@JS init` arguments. Each is emitted only when it has something
95
+ // to do.
96
+ if !functions.isEmpty || !properties.isEmpty {
97
+ emitted.append(
98
+ buildDecorateSharedObject(functions: functions, properties: properties, typeName: typeName))
99
+ }
100
+ if let constructor {
101
+ emitted.append(constructor.buildConstructor(typeName: typeName))
102
+ }
86
103
 
87
- return [method]
104
+ return emitted
88
105
  }
89
106
  }
90
107
 
@@ -111,81 +128,3 @@ extension SharedObjectMacro: MemberAttributeMacro {
111
128
  private func inheritsFromSharedObject(_ classDecl: ClassDeclSyntax) -> Bool {
112
129
  return inheritsFromAny(classDecl, names: ["SharedObject"])
113
130
  }
114
-
115
- // MARK: - Class-scope entry builders
116
-
117
- private func buildClassFunctionEntry(
118
- funcDecl: FunctionDeclSyntax,
119
- attribute: AttributeSyntax,
120
- typeName: String
121
- ) -> String {
122
- let swiftName = funcDecl.name.text
123
- let jsName = jsNameArgument(of: attribute) ?? swiftName
124
- let effects = funcDecl.signature.effectSpecifiers
125
- let isAsync = effects?.asyncSpecifier != nil
126
- let isThrowing = effects?.throwsClause?.throwsSpecifier != nil
127
- let dslEntry = isAsync ? "AsyncFunction" : "Function"
128
-
129
- let params = funcDecl.signature.parameterClause.parameters
130
- let closureParamList: String
131
- let callArgList: String
132
- if params.isEmpty {
133
- closureParamList = "(this: \(typeName))"
134
- callArgList = ""
135
- } else {
136
- let typedParams = params.enumerated().map { index, param in
137
- "_ arg\(index): \(param.type.trimmedDescription)"
138
- }.joined(separator: ", ")
139
- closureParamList = "(this: \(typeName), \(typedParams))"
140
-
141
- callArgList = params.enumerated().map { index, param in
142
- let label = param.firstName.text
143
- return label == "_" ? "arg\(index)" : "\(label): arg\(index)"
144
- }.joined(separator: ", ")
145
- }
146
-
147
- let awaitKeyword = isAsync ? "await " : ""
148
- let tryKeyword = (isAsync || isThrowing) ? "try " : ""
149
- let callExpr = "\(tryKeyword)\(awaitKeyword)this.\(swiftName)(\(callArgList))"
150
-
151
- return "\(dslEntry)(\"\(jsName)\") { \(closureParamList) in \(callExpr) }"
152
- }
153
-
154
- private func buildClassPropertyEntries(
155
- varDecl: VariableDeclSyntax,
156
- attribute: AttributeSyntax,
157
- typeName: String
158
- ) -> [String] {
159
- let jsNameOverride = jsNameArgument(of: attribute)
160
-
161
- return varDecl.bindings.compactMap { binding in
162
- guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
163
- return nil
164
- }
165
- let swiftName = ident.identifier.text
166
- let jsName = jsNameOverride ?? swiftName
167
- return "Property(\"\(jsName)\") { (this: \(typeName)) in this.\(swiftName) }"
168
- }
169
- }
170
-
171
- private func buildConstructorEntry(
172
- initDecl: InitializerDeclSyntax,
173
- typeName: String
174
- ) -> String {
175
- let params = initDecl.signature.parameterClause.parameters
176
-
177
- if params.isEmpty {
178
- return "Constructor { \(typeName)() }"
179
- }
180
-
181
- let argList = params.enumerated().map { index, param in
182
- "_ arg\(index): \(param.type.trimmedDescription)"
183
- }.joined(separator: ", ")
184
-
185
- let callArgs = params.enumerated().map { index, param in
186
- let label = param.firstName.text
187
- return label == "_" ? "arg\(index)" : "\(label): arg\(index)"
188
- }.joined(separator: ", ")
189
-
190
- return "Constructor { (\(argList)) in \(typeName)(\(callArgs)) }"
191
- }
@@ -0,0 +1,61 @@
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 sharedObject = "SharedObject"
9
+ case record = "Record"
10
+ }
11
+
12
+ /// A single argument passed to a macro, e.g. `"Foo"` or `classes: [Bar.self]`. The label is `nil`
13
+ /// for positional arguments; `value` is the argument expression's source text as written.
14
+ struct MacroArgument: Codable, Equatable {
15
+ /// The argument label (`classes` in `classes: [Bar.self]`), or `nil` for a positional argument.
16
+ let label: String?
17
+
18
+ /// The argument value exactly as written in source, e.g. `"Foo"` (including the quotes) or
19
+ /// `[Bar.self]`. Kept as text because a syntactic scan can't resolve these to runtime values.
20
+ let value: String
21
+ }
22
+
23
+ /// A single annotated declaration the scanner found, with just enough to locate it and know
24
+ /// what it is. Member-level details (parameters, types) are intentionally out of scope for this
25
+ /// first prototype — see the `@JS` member walk in the macros for where that would live.
26
+ struct Detection: Codable, Equatable {
27
+ /// The macro spelled on the declaration (without the leading `@`).
28
+ let macro: DetectedMacro
29
+
30
+ /// The declared name, e.g. the class name for `@ExpoModule`, or the func/var/init name for `@JS`.
31
+ let name: String
32
+
33
+ /// The kind of declaration the macro was attached to: `class`, `struct`, `func`, `var`, `init`, …
34
+ let declarationKind: String
35
+
36
+ /// The explicit JS name override when written as `@ExpoModule("Foo")` / `@JS("bar")` /
37
+ /// `@SharedObject("Baz")`, otherwise `nil` (the name defaults to `name` at expansion time).
38
+ let jsName: String?
39
+
40
+ /// Every argument passed to the macro, in source order, e.g. `@ExpoModule("Foo", classes: [Bar.self])`
41
+ /// yields a positional `"Foo"` and a `classes:` argument. Empty when the macro is written bare.
42
+ let arguments: [MacroArgument]
43
+
44
+ /// Source location, relative to the path the scanner was invoked with.
45
+ let file: String
46
+ let line: Int
47
+ let column: Int
48
+ }
49
+
50
+ /// Counts describing how much work the scan did, so callers can see the pre-filter's effect: of all
51
+ /// the `.swift` files read, how many actually needed parsing, and how long the run took.
52
+ struct ScanStats: Codable, Equatable {
53
+ /// `.swift` files the walk found and read (after directory pruning).
54
+ let filesScanned: Int
55
+
56
+ /// Of those, how many contained a macro attribute and so were parsed with SwiftSyntax.
57
+ let filesParsed: Int
58
+
59
+ /// Wall-clock duration of the scan, in milliseconds (walking, reading, filtering, and parsing).
60
+ let durationMs: Double
61
+ }
@@ -0,0 +1,109 @@
1
+ import SwiftSyntax
2
+
3
+ /// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`,
4
+ /// `@SharedObject`, or `@Record`. Only file-scope declarations are considered: these macros apply to
5
+ /// top-level types, so descending into type and function bodies would only surface false positives.
6
+ /// Recognition mirrors the macros themselves — a purely syntactic match on the spelled attribute
7
+ /// name — so it sees the same declarations the compiler would hand the plugin, without compiling
8
+ /// anything.
9
+ final class DetectionVisitor: SyntaxVisitor {
10
+ private let file: String
11
+ private let converter: SourceLocationConverter
12
+ /// Only these macros are recorded; the rest are ignored. Lets a `modules` scan report just
13
+ /// `@ExpoModule` while an `exports` scan covers them all.
14
+ private let detectedMacros: Set<DetectedMacro>
15
+ private(set) var detections: [Detection] = []
16
+
17
+ init(file: String, tree: SourceFileSyntax, detectedMacros: Set<DetectedMacro>) {
18
+ self.file = file
19
+ self.converter = SourceLocationConverter(fileName: file, tree: tree)
20
+ self.detectedMacros = detectedMacros
21
+ super.init(viewMode: .sourceAccurate)
22
+ }
23
+
24
+ override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
25
+ if isTopLevel(node) {
26
+ record(attributes: node.attributes, name: node.name.text, kind: "class", at: node)
27
+ }
28
+ // Members live in the type body; we never report them, so there's no reason to descend.
29
+ return .skipChildren
30
+ }
31
+
32
+ override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
33
+ if isTopLevel(node) {
34
+ record(attributes: node.attributes, name: node.name.text, kind: "struct", at: node)
35
+ }
36
+ return .skipChildren
37
+ }
38
+
39
+ /// True when the declaration sits at file scope: its parent is a `CodeBlockItemSyntax` directly
40
+ /// under the source file's top-level item list. Members of a type are nested in a
41
+ /// `MemberBlockItemSyntax` instead, so they don't match.
42
+ ///
43
+ /// TODO: decide whether to support nested types. A macro on a type nested in another type/enum/
44
+ /// extension is valid Swift but missed here; supporting it means descending into type bodies and
45
+ /// recording the enclosing path for a qualified name (e.g. `Namespace.InnerModule`).
46
+ private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
47
+ guard let item = node.parent?.as(CodeBlockItemSyntax.self) else {
48
+ return false
49
+ }
50
+ return item.parent?.parent?.is(SourceFileSyntax.self) == true
51
+ }
52
+
53
+ /// Emits one detection per recognized Expo attribute on the declaration. A declaration can in
54
+ /// principle carry more than one (uncommon), so each is recorded independently.
55
+ private func record(
56
+ attributes: AttributeListSyntax,
57
+ name: String,
58
+ kind: String,
59
+ at node: some SyntaxProtocol
60
+ ) {
61
+ for element in attributes {
62
+ guard let attribute = element.as(AttributeSyntax.self),
63
+ let macro = DetectedMacro(rawValue: attribute.attributeName.trimmedDescription),
64
+ detectedMacros.contains(macro) else {
65
+ continue
66
+ }
67
+ let location = converter.location(for: node.positionAfterSkippingLeadingTrivia)
68
+ detections.append(
69
+ Detection(
70
+ macro: macro,
71
+ name: name,
72
+ declarationKind: kind,
73
+ jsName: stringArgument(of: attribute),
74
+ arguments: arguments(of: attribute),
75
+ file: file,
76
+ line: location.line,
77
+ column: location.column
78
+ )
79
+ )
80
+ }
81
+ }
82
+ }
83
+
84
+ /// Every argument passed to the attribute, in source order, each as a label (or `nil` when
85
+ /// positional) plus the value expression's source text. Returns an empty array when the attribute
86
+ /// is written bare (`@ExpoModule`) or with empty parens.
87
+ private func arguments(of attribute: AttributeSyntax) -> [MacroArgument] {
88
+ guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
89
+ return []
90
+ }
91
+ return args.map { arg in
92
+ MacroArgument(label: arg.label?.text, value: arg.expression.trimmedDescription)
93
+ }
94
+ }
95
+
96
+ /// The first string-literal argument of an attribute, e.g. `@JS("doWork")` -> "doWork". Returns
97
+ /// `nil` when there's no argument or it isn't a plain string literal. (Same shape the
98
+ /// `jsNameArgument` helper reads inside the macros.)
99
+ private func stringArgument(of attribute: AttributeSyntax) -> String? {
100
+ guard let args = attribute.arguments?.as(LabeledExprListSyntax.self),
101
+ let first = args.first,
102
+ first.label == nil,
103
+ let str = first.expression.as(StringLiteralExprSyntax.self),
104
+ let segment = str.segments.first?.as(StringSegmentSyntax.self),
105
+ str.segments.count == 1 else {
106
+ return nil
107
+ }
108
+ return segment.content.text
109
+ }
@@ -0,0 +1,137 @@
1
+ import Foundation
2
+ import SwiftParser
3
+ import SwiftSyntax
4
+
5
+ /// Walks `paths`, parses each `.swift` file that might contain one of `macros` (the pre-filter), and
6
+ /// returns every detection (in file then source order) with the run's stats. The shared core every
7
+ /// scan command builds on; each command projects these detections into its own output shape.
8
+ func collectDetections(paths: [String], macros: Set<DetectedMacro>) -> (detections: [Detection], stats: ScanStats) {
9
+ let clock = ContinuousClock()
10
+ let start = clock.now
11
+
12
+ var detections: [Detection] = []
13
+ var filesScanned = 0
14
+ var filesParsed = 0
15
+
16
+ // Compile the pre-filter regex once per run, not once per file.
17
+ let prefilter = macroAttributeRegex(for: macros)
18
+
19
+ for file in swiftFiles(in: paths) {
20
+ guard let source = try? String(contentsOfFile: file, encoding: .utf8) else {
21
+ FileHandle.standardError.write(Data("warning: could not read \(file)\n".utf8))
22
+ continue
23
+ }
24
+ filesScanned += 1
25
+ // Skip the (relatively expensive) parse for files that can't contain any of the macros. A plain
26
+ // substring scan is far cheaper than a full parse, and most files in a large tree mention none
27
+ // of these names. See `mightContainMacro` for why this never drops a real match.
28
+ guard mightContainMacro(in: source, prefilter: prefilter) else {
29
+ continue
30
+ }
31
+ filesParsed += 1
32
+ detections.append(contentsOf: detect(source: source, file: file, macros: macros))
33
+ }
34
+
35
+ let elapsed = (clock.now - start).components
36
+ let durationMs = Double(elapsed.seconds) * 1000 + Double(elapsed.attoseconds) / 1e15
37
+
38
+ return (detections, ScanStats(filesScanned: filesScanned, filesParsed: filesParsed, durationMs: durationMs))
39
+ }
40
+
41
+ /// Parses one source string and returns its detections for the given macro set. The unit of work the
42
+ /// tests exercise.
43
+ func detect(source: String, file: String, macros: Set<DetectedMacro>) -> [Detection] {
44
+ let tree = Parser.parse(source: source)
45
+ let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros)
46
+ visitor.walk(tree)
47
+ return visitor.detections
48
+ }
49
+
50
+ // MARK: - Pre-filter
51
+
52
+ /// Builds the pre-filter regex for a macro set, e.g. `@(ExpoModule)` for a `modules` scan or
53
+ /// `@(ExpoModule|JS|Record|SharedObject)` for an `exports` scan. A precompiled `NSRegularExpression`
54
+ /// benchmarked ~20x faster over a large source tree than calling `String.contains` once per macro
55
+ /// name, because it scans each file in a single pass. Compiled once per run and reused per file.
56
+ func macroAttributeRegex(for macros: Set<DetectedMacro>) -> NSRegularExpression {
57
+ // Sort for a stable pattern regardless of the set's iteration order.
58
+ let alternation = macros.map(\.rawValue).sorted().joined(separator: "|")
59
+ return try! NSRegularExpression(pattern: "@(\(alternation))")
60
+ }
61
+
62
+ /// True if the source text contains one of the pre-filter's spelled macro attributes, so it's worth
63
+ /// parsing. A deliberate over-approximation: the pattern can still match inside a comment or string,
64
+ /// in which case the file is parsed and correctly yields no detections — a wasted parse, never a
65
+ /// missed module. It assumes the attribute is written with no space after `@` (`@ExpoModule`, not
66
+ /// `@ ExpoModule`), which is universal in practice; the rare spaced form would be skipped.
67
+ func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Bool {
68
+ let range = NSRange(source.startIndex..., in: source)
69
+ return prefilter.firstMatch(in: source, range: range) != nil
70
+ }
71
+
72
+ // MARK: - File discovery
73
+
74
+ /// Directory names skipped during the recursive walk. These hold build products, dependencies, and
75
+ /// git internals — never source worth scanning — and pruning them keeps the walk from descending
76
+ /// into the bulk of a monorepo's files.
77
+ private let prunedDirectoryNames: Set<String> = [".build", "Pods", ".git"]
78
+
79
+ /// Expands the given paths into the list of `.swift` files to parse: a file path passes through,
80
+ /// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic
81
+ /// so output is stable across runs.
82
+ ///
83
+ /// Reported paths are absolute, so the output is unambiguous and independent of the caller's working
84
+ /// directory. (A future `--root` option could emit paths relative to a given base when a portable,
85
+ /// shorter form is wanted.)
86
+ func swiftFiles(in paths: [String]) -> [String] {
87
+ let fileManager = FileManager.default
88
+ var result: [String] = []
89
+
90
+ for path in paths {
91
+ var isDirectory: ObjCBool = false
92
+ guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else {
93
+ FileHandle.standardError.write(Data("warning: no such path \(path)\n".utf8))
94
+ continue
95
+ }
96
+
97
+ if isDirectory.boolValue {
98
+ result.append(contentsOf: swiftFiles(inDirectory: URL(fileURLWithPath: path), fileManager: fileManager))
99
+ } else if path.hasSuffix(".swift") {
100
+ // A directory walk already yields absolute paths; resolve a directly-passed file the same way
101
+ // so every reported path is absolute regardless of how it was spelled.
102
+ result.append(URL(fileURLWithPath: path).standardizedFileURL.path)
103
+ }
104
+ }
105
+
106
+ return result.sorted()
107
+ }
108
+
109
+ /// Recursively enumerates `.swift` files under a directory, calling `skipDescendants()` on any
110
+ /// pruned directory so its subtree is never read. Uses the URL enumerator (rather than the
111
+ /// path-based one) precisely because it supports skipping a subtree mid-walk.
112
+ ///
113
+ /// Directory-ness is read from `hasDirectoryPath` (the enumerator sets a trailing slash on the URLs
114
+ /// it yields) rather than `resourceValues(forKeys: [.isDirectoryKey])`, which re-`stat`s each entry.
115
+ /// The walk is the dominant cost of a whole-tree scan, and skipping that per-entry stat measurably
116
+ /// shortens it.
117
+ private func swiftFiles(inDirectory directory: URL, fileManager: FileManager) -> [String] {
118
+ guard let enumerator = fileManager.enumerator(
119
+ at: directory,
120
+ includingPropertiesForKeys: nil,
121
+ options: [.skipsHiddenFiles]
122
+ ) else {
123
+ return []
124
+ }
125
+
126
+ var result: [String] = []
127
+ for case let url as URL in enumerator {
128
+ if url.hasDirectoryPath {
129
+ if prunedDirectoryNames.contains(url.lastPathComponent) {
130
+ enumerator.skipDescendants()
131
+ }
132
+ } else if url.pathExtension == "swift" {
133
+ result.append(url.path)
134
+ }
135
+ }
136
+ return result
137
+ }