expo-modules-macros 0.30.0 → 0.32.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.
@@ -1,4 +1,6 @@
1
- name: Swift
1
+ name: macOS
2
+
3
+ # Builds and tests the package on macOS, and checks the universal release tool there.
2
4
 
3
5
  on:
4
6
  push:
@@ -12,8 +14,8 @@ concurrency:
12
14
  cancel-in-progress: true
13
15
 
14
16
  jobs:
15
- build-and-test:
16
- name: Build & test
17
+ macos:
18
+ name: Build & test (macOS)
17
19
  # The package declares swift-tools-version 6.2, which requires Xcode 26+.
18
20
  runs-on: macos-26
19
21
  steps:
@@ -63,3 +65,10 @@ jobs:
63
65
  run: |
64
66
  pnpm install
65
67
  pnpm run typecheck
68
+
69
+ - name: Scan through the TypeScript wrapper
70
+ # The wrapper must find the release tool in `apple/`, and `scanExports` must report the
71
+ # surface in `scripts/check-scan-exports.json`, which the Windows and Linux workflows also check.
72
+ run: |
73
+ pnpm run build:ts
74
+ node scripts/check-scan.js .
package/AGENTS.md CHANGED
@@ -31,7 +31,7 @@ pnpm run swift:format # format the Swift sources in place
31
31
  pnpm run swift:lint # check the formatting, as CI does
32
32
  ```
33
33
 
34
- CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.github/workflows/windows.yml`), and on Linux x64 and arm64 (`.github/workflows/linux.yml`). On macOS it runs the release build, checks both binary slices, then runs `swift test` and `pnpm run typecheck`. On Windows it builds and tests the package, builds the release binary, checks that it starts without the Swift runtime, packs it into its `platforms/` package (checking the files and the `os`/`cpu` fields), installs that package, and scans through the TypeScript wrapper with `scripts/check-scan.js`, which must find the executable there. On Linux it does the same in the `swift:6.4.0-rhel-ubi9` image, and also checks that no Swift library is linked dynamically, the newest glibc symbol version that the binary needs, and that the package keeps the executable bit. The Swift Format workflow (`.github/workflows/swift-format.yml`) builds the pinned swift-format version and runs `pnpm run swift:lint`. Code in `Sources` and `Tests` must not assume Apple platforms or POSIX paths.
34
+ CI runs on macOS (`.github/workflows/macos.yml`), on Windows x64 and arm64 (`.github/workflows/windows.yml`), and on Linux x64 and arm64 (`.github/workflows/linux.yml`). On macOS it runs the release build, checks both binary slices, runs `swift test` and `pnpm run typecheck`, then scans through the TypeScript wrapper with `scripts/check-scan.js`. On Windows it builds and tests the package, builds the release binary, checks that it starts without the Swift runtime, packs it into its `platforms/` package (checking the files and the `os`/`cpu` fields), installs that package, and scans through the TypeScript wrapper with `scripts/check-scan.js`, which must find the executable there. On Linux it does the same in the `swift:6.4.0-rhel-ubi9` image, and also checks that no Swift library is linked dynamically, the newest glibc symbol version that the binary needs, and that the package keeps the executable bit. On every platform, `check-scan.js` also requires `scanExports` to report the surface in `scripts/check-scan-exports.json` for its fixture, so the generated types are the same on each OS. The Swift Format workflow (`.github/workflows/swift-format.yml`) builds the pinned swift-format version and runs `pnpm run swift:lint`. Code in `Sources` and `Tests` must not assume Apple platforms or POSIX paths.
35
35
 
36
36
  ## Windows
37
37
 
@@ -57,6 +57,7 @@ CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.gi
57
57
  ## Changing macros
58
58
 
59
59
  - The scanner re-implements the macros' naming and member rules on the syntax tree; it does not call the macro code. When a macro changes which members it binds or how it names them (the `@JS` name override, the `on` prefix `@Event` strips, which properties `@Record` treats as fields), change `scan-exports` to match, or generated TypeScript types drift from the runtime.
60
+ - When the `scan-exports` output changes on purpose, update `scripts/check-scan-exports.json`: run `swift build`, then `node scripts/check-scan.js . --update .build/debug/ExpoModulesMacrosTool`, and review the diff. CI compares it with the output on macOS, Windows and Linux.
60
61
  - The scanner only parses files that pass the pre-filter, `MacroPrefilter` in `ExpoModulesCLI/Core/SourceScan.swift`. Each command builds it from the `macros` and `conformances` arguments of its `scanFiles` call. A new attribute or conformance the scanner must detect has to be added to those arguments (an attribute also needs a `DetectedMacro` case), or files that use it are skipped without an error.
61
62
  - A new macro needs: the implementation in `apple/Sources/ExpoModulesMacrosCore/Macros`, an entry in `ProvidedMacro.all` in `ProvidedMacros.swift` (with the attribute name and the extension conformances of its declaration), an expansion test file, the `#externalMacro` declaration in `expo-modules-core`, and an entry in the macro list in `README.md`.
62
63
 
package/README.md CHANGED
@@ -25,11 +25,11 @@ This package lists them as optional dependencies, and their `os`, `cpu` and (on
25
25
  # Macros
26
26
 
27
27
  - **`@ExpoModule(_ name: String? = nil, classes: [Any.Type] = [])`** on a class. Turns the class into a module: binds its `@JS` members into the module's JavaScript object and resolves the module name from the argument, falling back to the class name. It also synthesizes everything inheriting from `Module` used to provide, so a module class can carry any superclass, or none. It adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet (see [Sendable conformance](#sendable-conformance)).
28
- - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. Two `@JS` members with the same JS name on one JavaScript object are a compile-time error, since JavaScript could reach only one of them. A shared object's static and instance members bind onto different objects, so they can share a name. The macro also checks that every type crossing the boundary is convertible in the direction it travels. Outside a module or shared object, a bad type is reported on the author's own declaration. In an `@ExpoModule` or `@SharedObject` class, the class macro makes the check, since only it sees the type aliases in the class body, so the error is reported on the class and names the member and its argument labels, such as `local function 'move(_:by:returning:)' requires that 'Point' conform to 'JavaScriptEncodable'`. A `lazy var` is read-only from JavaScript. On the first read, its getter stores the value as a data property on the JavaScript object, so later reads don't call into native code. On a frozen object the value isn't stored, and each read calls the getter. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. The `.withAsync("name")` option binds a synchronous function a second time under that name, as a function that returns a promise and runs the body on the concurrent thread pool. That function isn't isolated to the JavaScript thread, so its body must be safe to run on any thread. Because of that, a `.concurrent` function and a function with `.withAsync` can't take a JavaScript value such as `JavaScriptValue` or `JavaScriptObject`, which is valid only on the JavaScript thread; the macro reports such a parameter. The macro reports a function isolated to a global actor through its own attribute or its type's. When the isolation comes from elsewhere, such as a protocol, the error points into the generated code, so mark the function `nonisolated`. A function or initializer can take a closure argument, such as `(Point) throws -> Size` or `(String) async throws -> Data`. JavaScript passes a function for it, and native code can store the closure and call it from any thread. A closure that throws or returns a value blocks the caller until JavaScript returns, and an `async` closure suspends instead and awaits a promise that the function returns. A closure that returns a value must be `throws`, so that an error from JavaScript can reach the caller. A non-throwing `Void` closure returns before JavaScript reads its arguments, so don't change a class instance after passing it to one.
29
- - **`@Event(_ name: String? = nil, _ options: EventOptions...)`** on a function-typed `var`. Expands the property into a closure that emits the event, so calling the property sends it to JavaScript with the closure's parameter as the payload. The JS name defaults to the property name with a leading `on` stripped. The `.sync` option dispatches inline on the JavaScript thread instead of asynchronously, and `@ExpoModule` and `@SharedObject` isolate the property to `@JavaScriptActor`.
30
- - **`@SharedObject(_ name: String? = nil)`** on a subclass of `SharedObject`, direct or not, such as a `SharedRef<T>` subclass. Binds the class's `@JS` members through the `SharedObject` hooks that core calls when it builds the JavaScript class, and emits the JS class name, which defaults to the class name. A module exposes the class through `@ExpoModule(classes:)`, which only accepts `SharedObject` subclasses. A subclass of another `@SharedObject` class also gets the `@JS` members of that class, and its own members replace inherited members with the same JS name. A subclass that isn't `final` can't be constructed from JavaScript through the `@JS init` of its superclass, so give each subclass that JavaScript constructs its own `@JS init`. Like `@ExpoModule`, it adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet.
31
- - **`@Record()`** on a struct, or on a final class with no inheritance clause (declare a class's protocol conformances in an extension). Treats every non-static, non-private, non-computed stored property as a field, with no per-field wrapper, and synthesizes the memberwise initializer, the conversions in both directions, and the `Record` conformance. A record converts only from and to JavaScript values: its `from(dictionary:appContext:)` throws `RecordDictionaryConversionException`, so a record can't be created from a native `[String: Any]`. A `@Union` can be a record property. Requiredness is inferred: a default value makes a field optional, an optional type makes it nullable and optional.
32
- - **`@Union()`** on an enum whose cases each carry one associated value. Models a TypeScript union. Synthesizes the conversions in both directions, a typed accessor per payload type, and the `JavaScriptDecodable` and `JavaScriptEncodable` conformances. Decoding is ordered: the first case whose payload decodes wins, so the more specific case goes first.
28
+ - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. The class macro reads only the class body, so `@JS` on a member of an extension, or of a type that is neither a module nor a shared object, is an error. So is `@JS` on an initializer or a `static` member of a module, since JavaScript doesn't construct a module and gets only its instance. `@JS` on any other declaration, such as a subscript, is an error too. A `@JS` function or initializer can't be generic, since the binding converts each value by its static type. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. Two `@JS` members with the same JS name on one JavaScript object are a compile-time error, since JavaScript could reach only one of them. A shared object's static and instance members bind onto different objects, so they can share a name. A name that JavaScript already gives a meaning on the object is an error too: `__proto__` on any object, `constructor` on a shared object's prototype, and `prototype` on its constructor. The macro also checks that every type crossing the boundary is convertible in the direction it travels. For a top-level declaration, a bad type is reported on the author's own declaration. In an `@ExpoModule` or `@SharedObject` class, the class macro makes the check, since only it sees the type aliases in the class body, so the error is reported on the class and names the member and its argument labels, such as `local function 'move(_:by:returning:)' requires that 'Point' conform to 'JavaScriptEncodable'`. A `lazy var` is read-only from JavaScript. On the first read, its getter stores the value as a data property on the JavaScript object, so later reads don't call into native code. On a frozen object the value isn't stored, and each read calls the getter. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. The `.withAsync("name")` option binds a synchronous function a second time under that name, as a function that returns a promise and runs the body on the concurrent thread pool. That function isn't isolated to the JavaScript thread, so its body must be safe to run on any thread. Because of that, a `.concurrent` function and a function with `.withAsync` can't take a JavaScript value such as `JavaScriptValue` or `JavaScriptObject`, which is valid only on the JavaScript thread; the macro reports such a parameter. The macro reports a function isolated to a global actor through its own attribute or its type's. When the isolation comes from elsewhere, such as a protocol, the error points into the generated code, so mark the function `nonisolated`. A function or initializer can take a closure argument, such as `(Point) throws -> Size` or `(String) async throws -> Data`. JavaScript passes a function for it, and native code can store the closure and call it from any thread. A closure that throws or returns a value blocks the caller until JavaScript returns, and an `async` closure suspends instead and awaits a promise that the function returns. A closure that returns a value must be `throws`, so that an error from JavaScript can reach the caller. A non-throwing `Void` closure returns before JavaScript reads its arguments, so don't change a class instance after passing it to one.
29
+ - **`@Event(_ name: String? = nil, _ options: EventOptions...)`** on a function-typed `var`. Expands the property into a closure that emits the event, so calling the property sends it to JavaScript with the closure's parameter as the payload. The JS name defaults to the property name with a leading `on` stripped. `@ExpoModule` and `@SharedObject` warn when two events in the class body have the same JS name. By default the event is scheduled on the JavaScript thread. The `.inline` option runs the listeners before the call returns when it is made on the JavaScript thread, and schedules the event otherwise; it never blocks. The `.blocking` option runs the listeners before the call returns on any thread, so a call from another thread blocks until the JavaScript thread runs them. An event property is callable from any thread with each option.
30
+ - **`@SharedObject(_ name: String? = nil)`** on a subclass of `SharedObject`, direct or not, such as a `SharedRef<T>` subclass. Binds the class's `@JS` members through the `SharedObject` hooks that core calls when it builds the JavaScript class, and emits the JS class name, which defaults to the class name. A class without an inheritance clause is an error, with a Fix-It that adds `: SharedObject`. A module exposes the class through `@ExpoModule(classes:)`, which only accepts `SharedObject` subclasses. A subclass of another `@SharedObject` class also gets the `@JS` members of that class, and its own members replace inherited members with the same JS name. A subclass that isn't `final` can't be constructed from JavaScript through the `@JS init` of its superclass, so give each subclass that JavaScript constructs its own `@JS init`. Like `@ExpoModule`, it adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet.
31
+ - **`@Record()`** on a struct, or on a final class with no inheritance clause (declare a class's protocol conformances in an extension). Treats every non-static, non-private, non-computed stored property as a field, with no per-field wrapper, and synthesizes the memberwise initializer, the conversions in both directions, and the `Record` conformance. A record converts only from and to JavaScript values: its `from(dictionary:appContext:)` throws `RecordDictionaryConversionException`, so a record can't be created from a native `[String: Any]`. A `@Union` can be a record property. Requiredness is inferred: a default value makes a field optional, an optional type makes it nullable and optional. The memberwise initializer sets every field, so a field that is a `let` with a default value is an error. It doesn't set a `private` or `fileprivate` stored property, so such a property needs a default value, unless it's an optional `var` or the type declares its own memberwise initializer.
32
+ - **`@Union()`** on an enum whose cases each carry one associated value. Models a TypeScript union. Synthesizes the conversions in both directions, a typed accessor per payload type, and the `JavaScriptDecodable` and `JavaScriptEncodable` conformances. Decoding is ordered: the first case whose payload decodes wins, so the more specific case goes first. The macro warns about a case that an earlier case always decodes first: any case after a `JavaScriptValue` case, and a case with a payload that decodes only numbers, such as `Float` or `Int32`, after a `Double`, `Float` or `CGFloat` case.
33
33
 
34
34
  The members and conformances that `@Record` and `@Union` generate are `nonisolated`, so they also work in a module that builds with a default isolation, such as `-default-isolation MainActor`. The stored properties of a record still take that isolation. A class record, or a struct record with a property whose type isn't `Sendable`, then needs `nonisolated` on its declaration, for example `@Record nonisolated final class Options`.
35
35
 
@@ -145,11 +145,13 @@ Each applied Fix-It is printed to stdout as `<file>:<line>:<column>: fix-it: <me
145
145
 
146
146
  `fix` applies the Fix-Its of errors and of warnings, such as "Make the class 'public'" for a module class that isn't `public`. With `--only-errors`, it applies only those of errors.
147
147
 
148
- - A macro can stop at its first error, so after applying the Fix-Its of a file, `fix` expands it again and applies the new ones, until none applies (at most 10 passes). The location of a Fix-It is in the file as it was when the Fix-It was applied.
149
- - A diagnostic with more than one Fix-It is skipped, because they are alternatives. So is a Fix-It whose edits overlap the edits of another one, or whose text isn't in the file where the edit expects it. Each skipped Fix-It is printed to stderr as a warning.
148
+ - A macro can stop at its first error, so after applying the Fix-Its of a file, `fix` expands it again and applies the new ones, until none applies (at most 10 passes, with a warning when Fix-Its are left after them). The location of a Fix-It is in the file as it was when the Fix-It was applied.
149
+ - A diagnostic with more than one Fix-It is skipped, because they are alternatives. So is a Fix-It whose edits overlap the edits of another one, or whose text isn't in the file where the edit expects it; an insertion is checked against the few bytes after it. Each skipped Fix-It is printed to stderr as a warning.
150
150
  - It applies only the Fix-Its of the macros, not those of syntax errors.
151
151
  - It changes only the text that a Fix-It changes and doesn't format the file. An inserted keyword can make a line longer than your formatter allows, so run your formatter after `fix` if you use one.
152
152
 
153
+ `fix` writes a file only when it still has the contents that `fix` read, so it doesn't overwrite a change saved meanwhile. It writes the file in place, so on macOS and Linux, symbolic and hard links, permissions, the owner, extended attributes and ACLs stay, and a read-only file isn't written. When a write fails, `fix` writes the original contents back and reports whether the file is unchanged; when that fails too, it saves them to a file in the temporary directory and names it. A file that can't be written is reported, and the other files are still fixed. A UTF-8 byte order mark is kept.
154
+
153
155
  The exit code is `0` also when a Fix-It is skipped or errors remain, `1` when a file can't be written, and `2` for an input that `expand` doesn't take.
154
156
 
155
157
  # TypeScript wrapper
Binary file
@@ -107,8 +107,9 @@ public struct ExpansionEdit: Equatable, Sendable {
107
107
  /// The UTF-8 offset in the original file.
108
108
  public let offset: Int
109
109
 
110
- /// The text that the edit replaces, so `fix` can check that the file still has it there. Empty
111
- /// for an insertion.
110
+ /// The text that the edit replaces, so `fix` can check that the file still has it there. An
111
+ /// insertion also replaces a few bytes after it with themselves, so it has text to check too, and
112
+ /// is empty only at the end of a declaration.
112
113
  public let original: String
113
114
 
114
115
  public let replacement: String
@@ -35,8 +35,10 @@ public func expandMacros(source: String, file: String, symbols: [String]) -> Exp
35
35
  let originalDeclarations = OriginalDeclarations(tree)
36
36
  diagnostics += context.diagnostics.map { diagnostic in
37
37
  let location = { (position: AbsolutePosition, node: Syntax) -> SourceLocation in
38
- let declarationStart = context.location(for: node.root.position, anchoredAt: node, fileName: file).offset
39
- if let original = originalDeclarations.position(of: position, in: node, declarationStart: declarationStart) {
38
+ let original = originalDeclarations.position(of: position, in: node) {
39
+ context.location(for: node.root.position, anchoredAt: node, fileName: file).offset
40
+ }
41
+ if let original {
40
42
  return converter.location(for: original)
41
43
  }
42
44
  return context.location(for: position, anchoredAt: node, fileName: file)
@@ -221,13 +223,21 @@ private func originalEdits(
221
223
  of fixIt: FixIt,
222
224
  offset: (AbsolutePosition, Syntax) -> Int
223
225
  ) -> [ExpansionEdit]? {
224
- var edits: [ExpansionEdit] = []
226
+ // The edit of each change in the tree of its node. `FixIt.edits` drops an edit that overlaps an
227
+ // earlier one; this applies the same rule.
228
+ var changes: [(edit: SourceEdit, node: Syntax)] = []
225
229
  for change in fixIt.changes {
226
- // `FixIt.edits` drops an edit that overlaps an earlier one; this applies the same rule.
227
230
  guard let edit = FixIt(message: fixIt.message, changes: [change]).edits.first else {
228
231
  continue
229
232
  }
230
233
  let node = changedNode(change)
234
+ if !changes.contains(where: { $0.node.root.id == node.root.id && $0.edit.range.overlaps(edit.range) }) {
235
+ changes.append((edit, node))
236
+ }
237
+ }
238
+
239
+ var edits: [ExpansionEdit] = []
240
+ for (index, (edit, node)) in changes.enumerated() {
231
241
  let root = node.root
232
242
  let text = Array(root.description.utf8)
233
243
  let start = edit.range.lowerBound.utf8Offset - root.position.utf8Offset
@@ -241,19 +251,36 @@ private func originalEdits(
241
251
  guard prefix + suffix < max(old.count, new.count) else {
242
252
  continue
243
253
  }
244
- let changedStart = edit.range.lowerBound.advanced(by: prefix)
245
- let originalEdit = ExpansionEdit(
246
- offset: offset(changedStart, node),
247
- original: String(decoding: old.dropFirst(prefix).dropLast(suffix), as: UTF8.self),
248
- replacement: String(decoding: new.dropFirst(prefix).dropLast(suffix), as: UTF8.self)
249
- )
250
- if !edits.contains(where: { overlaps($0, originalEdit) }) {
251
- edits.append(originalEdit)
254
+ var original = Array(old.dropFirst(prefix).dropLast(suffix))
255
+ var replacement = Array(new.dropFirst(prefix).dropLast(suffix))
256
+ if original.isEmpty {
257
+ // An insertion has no text that `fix` can check in the file, so it also replaces the bytes after
258
+ // it with themselves, up to a character boundary and not into another change of the Fix-It.
259
+ let insertion = start + prefix
260
+ let nextChange = changes.enumerated().filter { $0.offset != index && $0.element.node.root.id == root.id }
261
+ .map { $0.element.edit.range.lowerBound.utf8Offset - root.position.utf8Offset }
262
+ .filter { $0 >= insertion }.min()
263
+ var contextEnd = min(insertion + insertionContextLength, text.count, nextChange ?? text.count)
264
+ while contextEnd > insertion && contextEnd < text.count && text[contextEnd] & 0xC0 == 0x80 {
265
+ contextEnd -= 1
266
+ }
267
+ original = Array(text[insertion..<contextEnd])
268
+ replacement += original
252
269
  }
270
+ edits.append(
271
+ ExpansionEdit(
272
+ offset: offset(edit.range.lowerBound.advanced(by: prefix), node),
273
+ original: String(decoding: original, as: UTF8.self),
274
+ replacement: String(decoding: replacement, as: UTF8.self)
275
+ )
276
+ )
253
277
  }
254
278
  return edits
255
279
  }
256
280
 
281
+ /// The number of bytes after an insertion that its edit also replaces with themselves.
282
+ private let insertionContextLength = 8
283
+
257
284
  /// The node in the tree that `change` edits.
258
285
  private func changedNode(_ change: FixIt.Change) -> Syntax {
259
286
  switch change {
@@ -268,11 +295,6 @@ private func changedNode(_ change: FixIt.Change) -> Syntax {
268
295
  }
269
296
  }
270
297
 
271
- /// Whether two edits change the same bytes of the original file.
272
- private func overlaps(_ a: ExpansionEdit, _ b: ExpansionEdit) -> Bool {
273
- a.offset < b.offset + b.original.utf8.count && b.offset < a.offset + a.original.utf8.count
274
- }
275
-
276
298
  /// The lengths of the longest common prefix and suffix of `old` and `new` that don't overlap, at
277
299
  /// character boundaries, so the bytes between them are valid UTF-8 on both sides.
278
300
  private func commonAffixLengths(_ old: [UInt8], _ new: [UInt8]) -> (prefix: Int, suffix: Int) {
@@ -320,45 +342,72 @@ private func severity(_ severity: DiagnosticSeverity) -> ExpansionDiagnostic.Sev
320
342
  /// `init`) to its end is the same in both, so this moves the position by the offset of the keyword
321
343
  /// in the original declaration. A position before the keyword, in the attributes or the modifiers,
322
344
  /// isn't mapped.
323
- private struct OriginalDeclarations {
345
+ ///
346
+ /// An accessor macro can get a declaration that swift-syntax rewrote rather than detached, for
347
+ /// example an `@Event` property with a closure in its initializer, and its start doesn't map to the
348
+ /// file. Such a declaration is found by its text when no other declaration in the file has it.
349
+ private final class OriginalDeclarations {
324
350
  /// The declarations in the original file by the UTF-8 offset of their start, with their leading
325
351
  /// trivia: the text from the keyword to the end of each, and the position of its keyword.
326
- private let declarations: [Int: [(text: String, keyword: AbsolutePosition)]]
352
+ private let declarationsByStart: [Int: [(text: String, keyword: AbsolutePosition)]]
353
+
354
+ /// The positions of the keywords of the declarations in the original file, by the text from the
355
+ /// keyword to the end of the declaration.
356
+ private let keywordsByText: [String: [AbsolutePosition]]
357
+
358
+ /// The positions of the keyword of each declaration that a macro got and of its original
359
+ /// declaration, or `nil` when it has no original declaration.
360
+ private var keywords: [SyntaxIdentifier: (keyword: AbsolutePosition, original: AbsolutePosition)?] = [:]
327
361
 
328
362
  init(_ tree: SourceFileSyntax) {
329
363
  let collector = DeclarationCollector(viewMode: .sourceAccurate)
330
364
  collector.walk(tree)
331
- var declarations: [Int: [(text: String, keyword: AbsolutePosition)]] = [:]
365
+ var declarationsByStart: [Int: [(text: String, keyword: AbsolutePosition)]] = [:]
366
+ var keywordsByText: [String: [AbsolutePosition]] = [:]
332
367
  for declaration in collector.declarations {
333
368
  guard let keyword = keywordToken(of: declaration) else {
334
369
  continue
335
370
  }
336
- declarations[declaration.position.utf8Offset, default: []].append(
337
- (textFromKeyword(keyword, of: declaration), keyword.positionAfterSkippingLeadingTrivia)
371
+ let text = textFromKeyword(keyword, of: declaration)
372
+ declarationsByStart[declaration.position.utf8Offset, default: []].append(
373
+ (text, keyword.positionAfterSkippingLeadingTrivia)
338
374
  )
375
+ keywordsByText[text, default: []].append(keyword.positionAfterSkippingLeadingTrivia)
339
376
  }
340
- self.declarations = declarations
377
+ self.declarationsByStart = declarationsByStart
378
+ self.keywordsByText = keywordsByText
341
379
  }
342
380
 
343
381
  /// The position in the original file of `position` in `node`, or `nil` when `node` is in the
344
- /// original file, isn't in a detached declaration, is before its keyword, or no declaration with its
345
- /// text starts at `declarationStart`.
382
+ /// original file, isn't in a declaration that a macro got, is before its keyword, or its original
383
+ /// declaration isn't found.
346
384
  ///
347
- /// `declarationStart` is the offset that the macro system maps the start of the detached
348
- /// declaration to.
349
- func position(of position: AbsolutePosition, in node: Syntax, declarationStart: Int) -> AbsolutePosition? {
350
- guard !node.root.is(SourceFileSyntax.self),
351
- let declaration = node.root.as(DeclSyntax.self),
352
- let keyword = keywordToken(of: declaration),
353
- position >= keyword.positionAfterSkippingLeadingTrivia,
354
- let original = declarations[declarationStart]?.first(where: {
355
- $0.text == textFromKeyword(keyword, of: declaration)
356
- })
357
- else {
385
+ /// `declarationStart` returns the offset that the macro system maps the start of the declaration
386
+ /// to. It is called once for each declaration.
387
+ func position(
388
+ of position: AbsolutePosition,
389
+ in node: Syntax,
390
+ declarationStart: () -> Int
391
+ ) -> AbsolutePosition? {
392
+ guard !node.root.is(SourceFileSyntax.self), let declaration = node.root.as(DeclSyntax.self) else {
393
+ return nil
394
+ }
395
+ let found: (keyword: AbsolutePosition, original: AbsolutePosition)?
396
+ if let cached = keywords[declaration.id] {
397
+ found = cached
398
+ } else {
399
+ found = keywordToken(of: declaration).flatMap { keyword in
400
+ let text = textFromKeyword(keyword, of: declaration)
401
+ let byStart = declarationsByStart[declarationStart()]?.first { $0.text == text }?.keyword
402
+ let byText = keywordsByText[text].flatMap { $0.count == 1 ? $0.first : nil }
403
+ return (byStart ?? byText).map { (keyword.positionAfterSkippingLeadingTrivia, $0) }
404
+ }
405
+ keywords[declaration.id] = found
406
+ }
407
+ guard let found, position >= found.keyword else {
358
408
  return nil
359
409
  }
360
- let offset = position.utf8Offset - keyword.positionAfterSkippingLeadingTrivia.utf8Offset
361
- return AbsolutePosition(utf8Offset: original.keyword.utf8Offset + offset)
410
+ return AbsolutePosition(utf8Offset: found.original.utf8Offset + position.utf8Offset - found.keyword.utf8Offset)
362
411
  }
363
412
  }
364
413
 
@@ -86,14 +86,14 @@ func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<Expa
86
86
  }
87
87
  }
88
88
 
89
- /// The files of `options` with their sources, expanded with `expander`, as `expandFiles` describes.
90
- /// `fix` uses it too, because it edits the sources.
89
+ /// The files of `options` with their sources and the bytes that were read, expanded with `expander`,
90
+ /// as `expandFiles` describes. `fix` uses it too, because it edits the sources.
91
91
  func expandSources(
92
92
  options: ExpandOptions,
93
93
  expander: MacroExpander
94
- ) -> Result<[(source: String, expanded: ExpandedFile)], ExpandInputError> {
94
+ ) -> Result<[(source: String, contents: Data, expanded: ExpandedFile)], ExpandInputError> {
95
95
  let prefilter = MacroPrefilter(attributeNames: expander.attributeNames)
96
- var sources: [(file: String, source: String)] = []
96
+ var sources: [(file: String, source: String, contents: Data)] = []
97
97
  var seen: Set<String> = []
98
98
  for path in options.paths {
99
99
  switch isDirectory(atPath: path) {
@@ -101,12 +101,12 @@ func expandSources(
101
101
  return .failure(ExpandInputError(message: "no such file or directory '\(path)'"))
102
102
  case .some(true):
103
103
  for file in swiftFiles(in: [path]) where !seen.contains(fileIdentity(file)) {
104
- guard let source = try? String(contentsOfFile: file, encoding: .utf8) else {
104
+ guard let (source, contents) = readSource(atPath: file) else {
105
105
  return .failure(ExpandInputError(message: "could not read '\(file)'"))
106
106
  }
107
107
  seen.insert(fileIdentity(file))
108
108
  if mightContainMacro(in: source, prefilter: prefilter) {
109
- sources.append((file, source))
109
+ sources.append((file, source, contents))
110
110
  }
111
111
  }
112
112
  case .some(false):
@@ -116,15 +116,17 @@ func expandSources(
116
116
  guard !seen.contains(fileIdentity(path)) else {
117
117
  continue
118
118
  }
119
- guard let source = try? String(contentsOfFile: path, encoding: .utf8) else {
119
+ guard let (source, contents) = readSource(atPath: path) else {
120
120
  return .failure(ExpandInputError(message: "could not read '\(path)'"))
121
121
  }
122
122
  seen.insert(fileIdentity(path))
123
- sources.append((path, source))
123
+ sources.append((path, source, contents))
124
124
  }
125
125
  }
126
126
 
127
- let files = sources.map { (source: $0.source, expanded: expander.expand($0.source, $0.file, options.symbols)) }
127
+ let files = sources.map {
128
+ (source: $0.source, contents: $0.contents, expanded: expander.expand($0.source, $0.file, options.symbols))
129
+ }
128
130
 
129
131
  let found = Set(files.flatMap { $0.expanded.expansions.compactMap(\.symbol) })
130
132
  if let missing = options.symbols.first(where: { !found.contains($0) }) {
@@ -139,6 +141,22 @@ func expandSources(
139
141
  return .success(files)
140
142
  }
141
143
 
144
+ /// The bytes of a UTF-8 byte order mark.
145
+ let utf8ByteOrderMark: [UInt8] = [0xEF, 0xBB, 0xBF]
146
+
147
+ /// The contents of the file at `path` as text, without a UTF-8 byte order mark, and the bytes that
148
+ /// were read. `nil` when the file can't be read or isn't UTF-8.
149
+ func readSource(atPath path: String) -> (source: String, contents: Data)? {
150
+ guard let contents = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
151
+ return nil
152
+ }
153
+ let text = contents.starts(with: utf8ByteOrderMark) ? contents.dropFirst(utf8ByteOrderMark.count) : contents[...]
154
+ guard let source = String(bytes: text, encoding: .utf8) else {
155
+ return nil
156
+ }
157
+ return (source, contents)
158
+ }
159
+
142
160
  /// The expanded sources as text. A single expansion is printed as it is; more than one each get a
143
161
  /// `// <file>` or `// <file>: <symbol>` header and are separated by an empty line.
144
162
  func renderExpansions(_ files: [ExpandedFile]) -> String {
@@ -28,8 +28,7 @@ struct FixedSource: Equatable {
28
28
  let source: String
29
29
  let fixIts: [FixItReport]
30
30
 
31
- /// Whether `fixSource` stopped after `maxFixPasses` passes while the last pass still applied a
32
- /// Fix-It, so the source can have more.
31
+ /// Whether `fixSource` stopped after `maxFixPasses` passes while Fix-Its were left to apply.
33
32
  var reachedPassLimit = false
34
33
 
35
34
  var applied: [FixItReport] {
@@ -46,8 +45,16 @@ extension Scanner {
46
45
  /// `--dry-run`, the files aren't written. A file that can't be written is reported and the others are
47
46
  /// still fixed. The exit code is `0` also when a Fix-It is skipped, `1` when a file can't be written,
48
47
  /// and `2` for an input that `expand` doesn't take.
49
- static func runFix(options: FixOptions, expander: MacroExpander) -> Int32 {
50
- let sources: [(source: String, expanded: ExpandedFile)]
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)]
51
58
  switch expandSources(options: ExpandOptions(paths: options.paths, symbols: options.symbols), expander: expander) {
52
59
  case .success(let expanded):
53
60
  sources = expanded
@@ -58,8 +65,8 @@ extension Scanner {
58
65
 
59
66
  var appliedCount = 0
60
67
  var fixedFiles = 0
61
- var failed = false
62
- for (source, expanded) in sources {
68
+ var failedFiles = 0
69
+ for (source, contents, expanded) in sources {
63
70
  let fixed = fixSource(
64
71
  source,
65
72
  expanded: expanded,
@@ -73,10 +80,12 @@ extension Scanner {
73
80
  }
74
81
  if !options.dryRun {
75
82
  do {
76
- try writeSource(fixed.source, toFile: expanded.file)
83
+ guard try write(fixed.source, expanded.file, contents) else {
84
+ continue
85
+ }
77
86
  } catch {
78
87
  writeToStandardError("error: could not write '\(expanded.file)': \(error)\n")
79
- failed = true
88
+ failedFiles += 1
80
89
  continue
81
90
  }
82
91
  }
@@ -85,20 +94,62 @@ extension Scanner {
85
94
  fixedFiles += 1
86
95
  }
87
96
 
88
- writeToStandardOutput(renderFixSummary(applied: appliedCount, files: fixedFiles, dryRun: options.dryRun))
89
- return failed ? 1 : 0
97
+ writeToStandardOutput(
98
+ renderFixSummary(applied: appliedCount, files: fixedFiles, failed: failedFiles, dryRun: options.dryRun)
99
+ )
100
+ return failedFiles > 0 ? 1 : 0
90
101
  }
91
102
  }
92
103
 
93
- /// Writes `source` to the file at `path` in place, so a symbolic link keeps pointing to the file and a
94
- /// hard link keeps sharing it, and a writable file can be written in a read-only directory. Keeps the
95
- /// UTF-8 byte order mark of the file, which the source doesn't have because reading it as a `String`
96
- /// removes it.
97
- func writeSource(_ source: String, toFile path: String) throws {
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 {
98
123
  let url = URL(fileURLWithPath: path)
99
- let byteOrderMark: [UInt8] = [0xEF, 0xBB, 0xBF]
100
- let hasByteOrderMark = try Data(contentsOf: url).starts(with: byteOrderMark)
101
- try Data((hasByteOrderMark ? byteOrderMark : []) + Array(source.utf8)).write(to: url)
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
102
153
  }
103
154
 
104
155
  /// The most times that `fixSource` expands a file again after applying Fix-Its.
@@ -126,10 +177,17 @@ func fixSource(
126
177
  fixed = applyFixIts(to: fixed.source, diagnostics: selected(diagnostics))
127
178
  applied += fixed.applied
128
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
129
187
  return FixedSource(
130
188
  source: fixed.source,
131
189
  fixIts: applied + fixed.fixIts.filter { $0.skipReason != nil },
132
- reachedPassLimit: !fixed.applied.isEmpty
190
+ reachedPassLimit: reachedPassLimit
133
191
  )
134
192
  }
135
193
 
@@ -217,12 +275,16 @@ func renderSkippedFixIts(_ fixed: FixedSource, file: String) -> String {
217
275
  return text
218
276
  }
219
277
 
220
- /// The summary line of `fix`, for example `Applied 3 Fix-Its in 2 files.`
221
- func renderFixSummary(applied: Int, files: Int, dryRun: Bool) -> String {
222
- guard applied > 0 else {
223
- return "No Fix-Its to apply.\n"
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")).")
224
288
  }
225
- let fixIts = "\(applied) Fix-It\(applied == 1 ? "" : "s")"
226
- let fileCount = "\(files) file\(files == 1 ? "" : "s")"
227
- return "\(dryRun ? "Would apply" : "Applied") \(fixIts) in \(fileCount).\n"
289
+ return (parts.isEmpty ? "No Fix-Its to apply." : parts.joined(separator: " ")) + "\n"
228
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)`, 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).