expo-modules-macros 0.29.0 → 0.31.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.
@@ -0,0 +1,290 @@
1
+ #if canImport(FoundationEssentials)
2
+ import FoundationEssentials
3
+ #else
4
+ import Foundation
5
+ #endif
6
+
7
+ /// The options of `fix`, parsed by `ScannerCLI`.
8
+ struct FixOptions: Equatable {
9
+ var paths: [String] = []
10
+ var symbols: [String] = []
11
+ var dryRun = false
12
+ var onlyErrors = false
13
+ }
14
+
15
+ /// A Fix-It that `fix` applied or skipped, at the location of its diagnostic.
16
+ struct FixItReport: Equatable {
17
+ let line: Int
18
+ let column: Int
19
+ let message: String
20
+
21
+ /// Why `fix` skipped the Fix-It, or `nil` when it applied it.
22
+ let skipReason: String?
23
+ }
24
+
25
+ /// The result of applying the Fix-Its of one file.
26
+ struct FixedSource: Equatable {
27
+ /// The source with the applied Fix-Its.
28
+ let source: String
29
+ let fixIts: [FixItReport]
30
+
31
+ /// Whether `fixSource` stopped after `maxFixPasses` passes while Fix-Its were left to apply.
32
+ var reachedPassLimit = false
33
+
34
+ var applied: [FixItReport] {
35
+ fixIts.filter { $0.skipReason == nil }
36
+ }
37
+ }
38
+
39
+ extension Scanner {
40
+ /// Runs `fix`: expands the macros in each file like `expand` and applies the Fix-Its of their
41
+ /// diagnostics to the file, in place (see `fixSource`). It doesn't format the file.
42
+ ///
43
+ /// Each applied Fix-It is printed to stdout as `<file>:<line>:<column>: fix-it: <message>` after its
44
+ /// file is written, followed by a summary, and each skipped one to stderr as a warning. With
45
+ /// `--dry-run`, the files aren't written. A file that can't be written is reported and the others are
46
+ /// still fixed. The exit code is `0` also when a Fix-It is skipped, `1` when a file can't be written,
47
+ /// and `2` for an input that `expand` doesn't take.
48
+ ///
49
+ /// `write` writes a fixed file and returns whether it changed it (see `writeFixedSource`); tests
50
+ /// replace it. A file that another path already fixed, through a hard link, isn't printed or counted
51
+ /// again.
52
+ static func runFix(
53
+ options: FixOptions,
54
+ expander: MacroExpander,
55
+ write: (_ source: String, _ path: String, _ original: Data) throws -> Bool = writeFixedSource
56
+ ) -> Int32 {
57
+ let sources: [(source: String, contents: Data, expanded: ExpandedFile)]
58
+ switch expandSources(options: ExpandOptions(paths: options.paths, symbols: options.symbols), expander: expander) {
59
+ case .success(let expanded):
60
+ sources = expanded
61
+ case .failure(let error):
62
+ writeToStandardError("error: \(error.message)\n")
63
+ return 2
64
+ }
65
+
66
+ var appliedCount = 0
67
+ var fixedFiles = 0
68
+ var failedFiles = 0
69
+ for (source, contents, expanded) in sources {
70
+ let fixed = fixSource(
71
+ source,
72
+ expanded: expanded,
73
+ symbols: options.symbols,
74
+ onlyErrors: options.onlyErrors,
75
+ expander: expander
76
+ )
77
+ writeToStandardError(renderSkippedFixIts(fixed, file: expanded.file))
78
+ guard !fixed.applied.isEmpty else {
79
+ continue
80
+ }
81
+ if !options.dryRun {
82
+ do {
83
+ guard try write(fixed.source, expanded.file, contents) else {
84
+ continue
85
+ }
86
+ } catch {
87
+ writeToStandardError("error: could not write '\(expanded.file)': \(error)\n")
88
+ failedFiles += 1
89
+ continue
90
+ }
91
+ }
92
+ writeToStandardOutput(renderAppliedFixIts(fixed, file: expanded.file))
93
+ appliedCount += fixed.applied.count
94
+ fixedFiles += 1
95
+ }
96
+
97
+ writeToStandardOutput(
98
+ renderFixSummary(applied: appliedCount, files: fixedFiles, failed: failedFiles, dryRun: options.dryRun)
99
+ )
100
+ return failedFiles > 0 ? 1 : 0
101
+ }
102
+ }
103
+
104
+ /// Why `writeFixedSource` didn't write a file.
105
+ struct FixWriteError: Error, CustomStringConvertible {
106
+ let description: String
107
+ }
108
+
109
+ /// Writes `source`, the fixed source of the file at `path`, whose contents were `original`, and
110
+ /// returns whether it changed the file.
111
+ ///
112
+ /// The file must still have the contents that `fix` read, so a change that an editor saved meanwhile
113
+ /// isn't lost. A file that already has the fixed contents, because `fix` wrote it through another
114
+ /// hard link, is left as it is. It keeps the UTF-8 byte order mark of the file, which `readSource`
115
+ /// removes from the source.
116
+ ///
117
+ /// The file is written in place, so on macOS and Linux its links, permissions, owner, extended
118
+ /// attributes and ACLs stay, and a read-only file isn't written. When the write fails, the original contents are written back,
119
+ /// and the error tells whether the file is unchanged. When that fails too, the original contents are
120
+ /// saved to a file in the temporary directory, which the error names.
121
+ @discardableResult
122
+ func writeFixedSource(_ source: String, toFile path: String, original: Data) throws -> Bool {
123
+ let url = URL(fileURLWithPath: path)
124
+ let contents = Data((original.starts(with: utf8ByteOrderMark) ? utf8ByteOrderMark : []) + Array(source.utf8))
125
+ guard let current = try? Data(contentsOf: url) else {
126
+ throw FixWriteError(description: "the file can't be read anymore")
127
+ }
128
+ guard current != contents else {
129
+ return false
130
+ }
131
+ guard current == original else {
132
+ throw FixWriteError(description: "the file changed after fix read it")
133
+ }
134
+ do {
135
+ try contents.write(to: url)
136
+ } catch {
137
+ if (try? Data(contentsOf: url)) != original {
138
+ try? original.write(to: url)
139
+ }
140
+ if (try? Data(contentsOf: url)) == original {
141
+ throw FixWriteError(description: "\(error); the file is unchanged")
142
+ }
143
+ let backup = FileManager.default.temporaryDirectory.appendingPathComponent(
144
+ "\(url.lastPathComponent).\(UUID().uuidString).original"
145
+ )
146
+ let saved = (try? original.write(to: backup)) != nil
147
+ throw FixWriteError(
148
+ description: "\(error); the file can be incomplete"
149
+ + (saved ? ", and its original contents are in '\(backup.path)'" : "")
150
+ )
151
+ }
152
+ return true
153
+ }
154
+
155
+ /// The most times that `fixSource` expands a file again after applying Fix-Its.
156
+ let maxFixPasses = 10
157
+
158
+ /// Applies the Fix-Its of `expanded`, the expansion of `source`, to `source`. A macro can stop at its
159
+ /// first error, so after applying Fix-Its, this expands the result again and applies the new Fix-Its,
160
+ /// until none applies or after `maxFixPasses` passes. The location of an applied Fix-It is in the
161
+ /// source of its pass. The skipped Fix-Its are those of the last pass. With `onlyErrors`, the Fix-Its
162
+ /// of warnings and other diagnostics that aren't errors are left out.
163
+ func fixSource(
164
+ _ source: String,
165
+ expanded: ExpandedFile,
166
+ symbols: [String],
167
+ onlyErrors: Bool = false,
168
+ expander: MacroExpander
169
+ ) -> FixedSource {
170
+ let selected = { (diagnostics: [ExpansionDiagnostic]) in
171
+ onlyErrors ? diagnostics.filter { $0.severity == .error } : diagnostics
172
+ }
173
+ var fixed = applyFixIts(to: source, diagnostics: selected(expanded.diagnostics))
174
+ var applied = fixed.applied
175
+ for _ in 1..<maxFixPasses where !fixed.applied.isEmpty {
176
+ let diagnostics = expander.expand(fixed.source, expanded.file, symbols).diagnostics
177
+ fixed = applyFixIts(to: fixed.source, diagnostics: selected(diagnostics))
178
+ applied += fixed.applied
179
+ }
180
+ // After the last pass, expand once more to tell whether Fix-Its are left.
181
+ let reachedPassLimit =
182
+ !fixed.applied.isEmpty
183
+ && !applyFixIts(
184
+ to: fixed.source,
185
+ diagnostics: selected(expander.expand(fixed.source, expanded.file, symbols).diagnostics)
186
+ ).applied.isEmpty
187
+ return FixedSource(
188
+ source: fixed.source,
189
+ fixIts: applied + fixed.fixIts.filter { $0.skipReason != nil },
190
+ reachedPassLimit: reachedPassLimit
191
+ )
192
+ }
193
+
194
+ /// Applies the Fix-Its of `diagnostics` to `source`, which is the file that they were reported for.
195
+ ///
196
+ /// A diagnostic with more than one Fix-It is skipped, because they are alternatives. So is a Fix-It
197
+ /// whose edits aren't in the file, or whose edits overlap the edits of a Fix-It before it. A Fix-It
198
+ /// with the same edits as one before it, which a macro can report twice, is left out. A diagnostic
199
+ /// without `fixItEdits`, such as a syntax error, is left out too.
200
+ func applyFixIts(to source: String, diagnostics: [ExpansionDiagnostic]) -> FixedSource {
201
+ let text = Array(source.utf8)
202
+ var accepted: [ExpansionEdit] = []
203
+ var reports: [FixItReport] = []
204
+
205
+ for diagnostic in diagnostics
206
+ where !diagnostic.fixIts.isEmpty && diagnostic.fixItEdits.count == diagnostic.fixIts.count {
207
+ let report = { (message: String, reason: String?) in
208
+ reports.append(
209
+ FixItReport(line: diagnostic.line, column: diagnostic.column, message: message, skipReason: reason)
210
+ )
211
+ }
212
+ guard diagnostic.fixIts.count == 1, let message = diagnostic.fixIts.first, let edits = diagnostic.fixItEdits.first
213
+ else {
214
+ for message in diagnostic.fixIts {
215
+ report(message, "the diagnostic has \(diagnostic.fixIts.count) Fix-Its to choose from")
216
+ }
217
+ continue
218
+ }
219
+ if !edits.isEmpty && edits.allSatisfy(accepted.contains) {
220
+ continue
221
+ }
222
+ guard !edits.isEmpty, edits.allSatisfy({ matches($0, in: text) }) else {
223
+ report(message, "its edits aren't in the file")
224
+ continue
225
+ }
226
+ let overlaps = edits.enumerated().contains { index, edit in
227
+ (accepted + edits.prefix(index)).contains { overlap(edit, $0) }
228
+ }
229
+ guard !overlaps else {
230
+ report(message, "it overlaps another Fix-It")
231
+ continue
232
+ }
233
+ accepted += edits
234
+ report(message, nil)
235
+ }
236
+
237
+ var fixed = text
238
+ for edit in accepted.sorted(by: { $0.offset > $1.offset }) {
239
+ fixed.replaceSubrange(edit.offset..<(edit.offset + edit.original.utf8.count), with: Array(edit.replacement.utf8))
240
+ }
241
+ return FixedSource(source: String(decoding: fixed, as: UTF8.self), fixIts: reports)
242
+ }
243
+
244
+ /// Whether `text` has the original text of `edit` at its offset.
245
+ private func matches(_ edit: ExpansionEdit, in text: [UInt8]) -> Bool {
246
+ let original = Array(edit.original.utf8)
247
+ return edit.offset >= 0 && edit.offset + original.count <= text.count
248
+ && text[edit.offset..<(edit.offset + original.count)].elementsEqual(original)
249
+ }
250
+
251
+ /// Whether two edits change the same bytes, or insert at the same offset, where their order would
252
+ /// matter.
253
+ private func overlap(_ a: ExpansionEdit, _ b: ExpansionEdit) -> Bool {
254
+ let aEnd = a.offset + a.original.utf8.count
255
+ let bEnd = b.offset + b.original.utf8.count
256
+ return a.offset == b.offset || (a.offset < bEnd && b.offset < aEnd)
257
+ }
258
+
259
+ /// The applied Fix-Its of `fixed`, one line each.
260
+ func renderAppliedFixIts(_ fixed: FixedSource, file: String) -> String {
261
+ fixed.applied.map { "\(file):\($0.line):\($0.column): fix-it: \($0.message)\n" }.joined()
262
+ }
263
+
264
+ /// The warnings for the skipped Fix-Its of `fixed`, and for reaching the pass limit.
265
+ func renderSkippedFixIts(_ fixed: FixedSource, file: String) -> String {
266
+ var text = ""
267
+ for fixIt in fixed.fixIts {
268
+ if let reason = fixIt.skipReason {
269
+ text += "\(file):\(fixIt.line):\(fixIt.column): warning: skipped fix-it '\(fixIt.message)': \(reason)\n"
270
+ }
271
+ }
272
+ if fixed.reachedPassLimit {
273
+ text += "\(file): warning: stopped after \(maxFixPasses) passes; run fix again for the Fix-Its left\n"
274
+ }
275
+ return text
276
+ }
277
+
278
+ /// The summary line of `fix`, for example `Applied 3 Fix-Its in 2 files.` or, when files couldn't be
279
+ /// written, `Applied 1 Fix-It in 1 file. Could not write 1 file.`
280
+ func renderFixSummary(applied: Int, files: Int, failed: Int = 0, dryRun: Bool) -> String {
281
+ let count = { (n: Int, noun: String) in "\(n) \(noun)\(n == 1 ? "" : "s")" }
282
+ var parts: [String] = []
283
+ if applied > 0 {
284
+ parts.append("\(dryRun ? "Would apply" : "Applied") \(count(applied, "Fix-It")) in \(count(files, "file")).")
285
+ }
286
+ if failed > 0 {
287
+ parts.append("Could not write \(count(failed, "file")).")
288
+ }
289
+ return (parts.isEmpty ? "No Fix-Its to apply." : parts.joined(separator: " ")) + "\n"
290
+ }
@@ -207,8 +207,8 @@ struct ExportedEvent: Encodable, Equatable {
207
207
  /// The single payload parameter's type, or `nil` for a no-payload `() -> Void` event.
208
208
  var payload: TypeNode?
209
209
 
210
- /// `@Event(sync: true)`, dispatching inline on the JS thread instead of scheduling.
211
- let isSync: Bool
210
+ /// How the event is dispatched: the default `scheduled`, or the `.inline` or `.blocking` option.
211
+ let dispatch: ExportedEventDispatch
212
212
 
213
213
  /// The spelled access modifier, or `nil` when none is written.
214
214
  let accessLevel: String?
@@ -216,8 +216,7 @@ struct ExportedEvent: Encodable, Equatable {
216
216
  let platforms: [ExportedPlatform]
217
217
 
218
218
  private enum CodingKeys: String, CodingKey {
219
- case name, jsName, payload, accessLevel, platforms
220
- case isSync = "sync"
219
+ case name, jsName, payload, dispatch, accessLevel, platforms
221
220
  }
222
221
 
223
222
  func encode(to encoder: Encoder) throws {
@@ -225,7 +224,7 @@ struct ExportedEvent: Encodable, Equatable {
225
224
  try container.encode(name, forKey: .name)
226
225
  try container.encode(jsName, forKey: .jsName)
227
226
  try container.encodeIfPresent(payload, forKey: .payload)
228
- try container.encode(isSync, forKey: .isSync)
227
+ try container.encode(dispatch, forKey: .dispatch)
229
228
  try container.encodeIfPresent(accessLevel, forKey: .accessLevel)
230
229
  try container.encode(platforms, forKey: .platforms)
231
230
  }
@@ -471,6 +470,13 @@ struct ExportedSurface: Encodable, Equatable {
471
470
  let unions: [ExportedUnion]
472
471
  }
473
472
 
473
+ /// How an `@Event` is dispatched to JavaScript, from its option. Mirrors `EventDispatch` in the macros.
474
+ enum ExportedEventDispatch: String, Encodable, Equatable {
475
+ case scheduled
476
+ case inline
477
+ case blocking
478
+ }
479
+
474
480
  /// Version of the `scan-exports` output shape. Bumped on any breaking change to the envelope or to
475
481
  /// anything under `exports`, so a consumer can verify it understands the output before trusting it.
476
482
  /// Versioned independently of `scanModulesSchemaVersion`: the two commands serve different consumers
@@ -481,8 +487,9 @@ struct ExportedSurface: Encodable, Equatable {
481
487
  /// declarations in `#if` blocks, adds `platforms` (with deprecations), `accessLevel`, and
482
488
  /// `defaultValue`, and replaces `constructorParameters` with `initializer`; version 9 adds the
483
489
  /// `sharedRef` type kind and the `sharedRef` of a shared object; version 10 adds the `tuple` and `object` type kinds (named after a tuple alias)
484
- /// and resolves the tuple type aliases declared in `@ExpoModule` and `@SharedObject` classes.
485
- let scanExportsSchemaVersion = 10
490
+ /// and resolves the tuple type aliases declared in `@ExpoModule` and `@SharedObject` classes; version 11
491
+ /// replaces the `sync` flag of an event with `dispatch`.
492
+ let scanExportsSchemaVersion = 11
486
493
 
487
494
  /// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from
488
495
  /// `ScanModulesResult` (different consumer: TS generation vs. autolinking).
@@ -285,7 +285,9 @@ final class SurfaceVisitor: SyntaxVisitor {
285
285
  return []
286
286
  }
287
287
  let override = stringArgument(of: attribute)
288
- let isSync = boolArgument(of: attribute, label: "sync") == true
288
+ let dispatch: ExportedEventDispatch =
289
+ hasEventOption(attribute, named: "inline")
290
+ ? .inline : hasEventOption(attribute, named: "blocking") ? .blocking : .scheduled
289
291
  var result: [ExportedEvent] = []
290
292
 
291
293
  for binding in varDecl.bindings {
@@ -306,7 +308,7 @@ final class SurfaceVisitor: SyntaxVisitor {
306
308
  name: name,
307
309
  jsName: override ?? defaultEventName(for: name),
308
310
  payload: functionType.parameters.first.map { typeNode(from: $0.type) },
309
- isSync: isSync,
311
+ dispatch: dispatch,
310
312
  accessLevel: spelledAccessLevel(of: varDecl.modifiers),
311
313
  platforms: platforms
312
314
  )
@@ -704,18 +706,16 @@ private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
704
706
  return text == "Void" || text == "()" || text == "Swift.Void"
705
707
  }
706
708
 
707
- /// The value of a labeled boolean macro argument (`@Event(sync: true)`), or `nil` when absent.
708
- private func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
709
+ /// True if an `@Event` attribute lists the given option member (`@Event(.inline)` or
710
+ /// `@Event("name", EventOptions.inline)`). Like `hasOption` in the macros, but without the array-literal
711
+ /// branch, which `EventOptions...` doesn't accept.
712
+ private func hasEventOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
709
713
  guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
710
- return nil
714
+ return false
711
715
  }
712
- for argument in arguments where argument.label?.text == label {
713
- guard let literal = argument.expression.as(BooleanLiteralExprSyntax.self) else {
714
- return nil
715
- }
716
- return literal.literal.tokenKind == .keyword(.true)
716
+ return arguments.contains { argument in
717
+ argument.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text == option
717
718
  }
718
- return nil
719
719
  }
720
720
 
721
721
  /// The name in a `@JS` attribute's `.withAsync("name")` option, in the arguments or inside an array
@@ -34,19 +34,19 @@ public enum Scanner {
34
34
  }
35
35
  }
36
36
 
37
- /// One module in the `scan-modules` output. Trimmed to what `expo-modules-autolinking` needs to
38
- /// register a module: the Swift class name, the JS name it registers under, the platforms that
39
- /// include it, and the file it's in. The richer fields the visitor captures (declaration kind, raw
40
- /// macro arguments, line/column) are dropped here — they're redundant for this command (the macro
41
- /// is always `@ExpoModule` on a class) and the deep `scan-exports` surface carries the richer
42
- /// per-member detail instead.
43
- struct ScannedModule: Codable, Equatable {
44
- /// The Swift class name the module is declared as.
37
+ /// One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, or a `@SharedObject`
38
+ /// class in `sharedObjects`. Trimmed to what `expo-modules-autolinking` needs to register the class:
39
+ /// the Swift class name, the JS name it registers under, the platforms that include it, and the file
40
+ /// it's in. The richer fields the visitor captures (declaration kind, raw macro arguments,
41
+ /// line/column) are dropped here, because this command doesn't need them (the macro is always on a
42
+ /// class) and the deep `scan-exports` surface carries the richer per-member detail instead.
43
+ struct ScannedClass: Codable, Equatable {
44
+ /// The Swift class name.
45
45
  let name: String
46
46
 
47
- /// The fully-resolved JS module name: the `@ExpoModule("Foo")` override when present, otherwise the
48
- /// class name. Resolved here (rather than left `nil`) so it matches how the macro derives the name
49
- /// and the consumer never has to apply the fallback itself.
47
+ /// The fully-resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override when
48
+ /// present, otherwise the class name. Resolved here (rather than left `nil`) so it matches how the
49
+ /// macro derives the name and the consumer never has to apply the fallback itself.
50
50
  let jsName: String
51
51
 
52
52
  /// The class's spelled access modifier (`open`, `public`, `package`, `fileprivate`, `private`),
@@ -56,20 +56,21 @@ struct ScannedModule: Codable, Equatable {
56
56
  let accessLevel: String
57
57
 
58
58
  /// The Apple OSes whose builds include this class, spelled as `os(...)` spells them and compared
59
- /// case-sensitively. An unconditional module lists every OS. Empty means no build is known to
59
+ /// case-sensitively. An unconditional class lists every OS. Empty means no build is known to
60
60
  /// include it, because the enclosing conditions depend on flags not asserted with `--define` or
61
61
  /// on conditions a static scan cannot answer (those are reported in `warnings`). The consumer
62
62
  /// must not assume such a class exists.
63
63
  let platforms: [String]
64
64
 
65
- /// Source file the module was found in, relative to the path the scanner was invoked with.
65
+ /// Source file the class was found in, relative to the path the scanner was invoked with.
66
66
  let file: String
67
67
  }
68
68
 
69
69
  /// Version of the `scan-modules` output shape. Bumped on any breaking change to the envelope or to
70
- /// `ScannedModule`, so `expo-modules-autolinking` can verify it understands the output before
70
+ /// `ScannedClass`, so `expo-modules-autolinking` can verify it understands the output before
71
71
  /// trusting it (and fall back to config-declared modules when it doesn't). Version 2 added the
72
- /// per-module `platforms` list and made the module list platform-agnostic.
72
+ /// per-module `platforms` list and made the module list platform-agnostic. A new field that older
73
+ /// consumers can ignore, such as `sharedObjects`, doesn't change the version.
73
74
  let scanModulesSchemaVersion = 2
74
75
 
75
76
  /// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
@@ -77,7 +78,11 @@ let scanModulesSchemaVersion = 2
77
78
  /// serve different consumers and don't share an envelope.)
78
79
  struct ScanModulesResult: Codable, Equatable {
79
80
  let schemaVersion: Int
80
- let modules: [ScannedModule]
81
+ let modules: [ScannedClass]
82
+
83
+ /// The top-level `@SharedObject` classes, in the same order and with the same platform rules as
84
+ /// `modules`.
85
+ let sharedObjects: [ScannedClass]
81
86
 
82
87
  /// Warnings for `#if` conditions the scan couldn't answer statically (see `ScanWarning`). Carried
83
88
  /// in the report rather than on stderr so the consumer can attach them to its own output.
@@ -97,25 +102,28 @@ private let platformUniverse: [(condition: String, reported: String)] = [
97
102
  (condition: "visionOS", reported: "visionOS"),
98
103
  ]
99
104
 
100
- /// Scans the given paths for top-level `@ExpoModule` types and returns every module found in any
105
+ /// Scans the given paths for top-level `@ExpoModule` and `@SharedObject` types and returns every one
106
+ /// found in any
101
107
  /// `#if` branch (in file then source order), each with the platforms whose builds include it, plus
102
108
  /// the stats for the run — the `scan-modules` command. Kept separate from the public entry (and
103
109
  /// `internal`) so tests can drive it without going through argv/stdout.
104
110
  ///
105
111
  /// Each file is parsed once and walked once per platform (plus once unconditionally to enumerate
106
- /// every module): a module's `platforms` are the OSes whose evaluated walk reached it, given the
112
+ /// every type): a type's `platforms` are the OSes whose evaluated walk reached it, given the
107
113
  /// asserted `defines`. The scanner reports the facts; filtering to the platform being linked is the
108
114
  /// consumer's call.
109
115
  func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResult {
110
- var modules: [ScannedModule] = []
116
+ var modules: [ScannedClass] = []
117
+ var sharedObjects: [ScannedClass] = []
111
118
  var warnings: [ScanWarning] = []
112
119
  var seenWarnings = Set<ScanWarning>()
113
120
 
114
- let stats = scanFiles(paths: paths, macros: [.expoModule]) { source, file in
121
+ let macros: Set<DetectedMacro> = [.expoModule, .sharedObject]
122
+ let stats = scanFiles(paths: paths, macros: macros) { source, file in
115
123
  let tree = Parser.parse(source: source)
116
124
 
117
- // The unconditional walk enumerates every module in the file, in source order.
118
- let allModules = DetectionVisitor(file: file, tree: tree, detectedMacros: [.expoModule], configuration: nil)
125
+ // The unconditional walk enumerates every type in the file, in source order.
126
+ let allModules = DetectionVisitor(file: file, tree: tree, detectedMacros: macros, configuration: nil)
119
127
  allModules.walk(tree)
120
128
 
121
129
  // One evaluated walk per OS attributes each module to the platforms that include it. The walks
@@ -126,7 +134,7 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
126
134
  let visitor = DetectionVisitor(
127
135
  file: file,
128
136
  tree: tree,
129
- detectedMacros: [.expoModule],
137
+ detectedMacros: macros,
130
138
  configuration: configuration
131
139
  )
132
140
  visitor.walk(tree)
@@ -140,24 +148,30 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
140
148
  }
141
149
  }
142
150
 
143
- for detection in allModules.detections {
144
- // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else
145
- // the class name.
146
- modules.append(
147
- ScannedModule(
148
- name: detection.name,
149
- jsName: detection.jsName ?? detection.name,
150
- accessLevel: detection.accessLevel,
151
- platforms: platformsByDetection[detectionKey(detection)] ?? [],
152
- file: detection.file
153
- )
151
+ // The macros reject anything but a class, so a struct with one of them is never registered.
152
+ for detection in allModules.detections where detection.declarationKind == "class" {
153
+ // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` or
154
+ // `@SharedObject("Foo")` override, else the class name.
155
+ let scanned = ScannedClass(
156
+ name: detection.name,
157
+ jsName: detection.jsName ?? detection.name,
158
+ accessLevel: detection.accessLevel,
159
+ platforms: platformsByDetection[detectionKey(detection)] ?? [],
160
+ file: detection.file
154
161
  )
162
+ switch detection.macro {
163
+ case .sharedObject:
164
+ sharedObjects.append(scanned)
165
+ default:
166
+ modules.append(scanned)
167
+ }
155
168
  }
156
169
  }
157
170
 
158
171
  return ScanModulesResult(
159
172
  schemaVersion: scanModulesSchemaVersion,
160
173
  modules: modules,
174
+ sharedObjects: sharedObjects,
161
175
  warnings: warnings,
162
176
  stats: stats
163
177
  )