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.
package/AGENTS.md CHANGED
@@ -8,7 +8,7 @@
8
8
  - `apple/Sources/ExpoModulesMacrosCore`: the macro implementations, in a library target so that both executables below can link them. `Macros/` holds one file per macro, and `Support/` the code that several macros share. `ProvidedMacros.swift` lists every macro with its attribute name and conformances in `ProvidedMacro.all`. `PluginServer.swift` runs the compiler plugin protocol with a provider that resolves the macros of `ProvidedMacro.all` by type name, because the default `CompilerPlugin` lookup expects them in a module named `ExpoModulesMacros`. It uses the `@_spi(PluginMessage)` API of swift-syntax.
9
9
  - `apple/Sources/ExpoModulesMacros`: the `.macro` target that the `ExpoModulesMacrosPlugin` product vends to Swift packages. It only runs the plugin server, so Swift packages don't compile the CLI.
10
10
  - `apple/Sources/ExpoModulesMacrosTool`: the executable that ships as `apple/ExpoModulesMacros` and in the Windows and Linux packages. `main.swift` runs the plugin server without arguments and sends any invocation with arguments to the CLI.
11
- - `apple/Sources/ExpoModulesCLI`: the library behind the command-line tool. `CLI.swift` parses the arguments, `Commands/` holds one directory per subcommand (`ScanModules/` for autolinking, `ScanExports/` for type generation, `Expand/` for `expand`, with `MacroExpander.swift`, which runs the macros in process), and `Core/` holds the shared parsing, `#if` evaluation and output.
11
+ - `apple/Sources/ExpoModulesCLI`: the library behind the command-line tool. `CLI.swift` parses the arguments, `Commands/` holds one directory per subcommand (`ScanModules/` for autolinking, `ScanExports/` for type generation, `Expand/` for `expand`, with `MacroExpander.swift`, which runs the macros in process, and `Fix/` for `fix`, which applies the Fix-Its that `expand` reports), and `Core/` holds the shared parsing, `#if` evaluation and output.
12
12
  - `apple/Sources/ExpoModulesOptimized`: declarations for the `@OptimizedFunction` macro.
13
13
  - `apple/Tests`: `ExpoModulesMacrosTests` (expansion tests) and `ExpoModulesCLITests`.
14
14
  - `platforms/`: the Windows packages `@expo/modules-macros-win32-x64` and `-arm64`, and the Linux packages `@expo/modules-macros-linux-x64` and `-arm64` (a `package.json` with `os` and `cpu`, plus `libc` on Linux, a README and a copy of `LICENSE`). The Publish workflow runs `scripts/set-platform-versions.js` to give them the version of this package. Their `ExpoModulesMacros.exe` (Windows) or `ExpoModulesMacros` (Linux) is written there by `pnpm run build` on that platform and isn't committed.
@@ -64,7 +64,7 @@ CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.gi
64
64
 
65
65
  - **Macro declarations live in `expo-modules-core`** (`ios/Core/ExpoModulesMacros.swift` in `expo/expo`), as `#externalMacro(module: "ExpoModulesMacros", type: ...)`. Adding a macro, renaming a macro type or changing a macro's signature needs a matching edit there, and the type must be listed in `ProvidedMacro.all` in `ProvidedMacros.swift`, with the same attribute name and extension conformances as the declaration.
66
66
  - **Generated code calls `expo-modules-core` API.** An expansion that uses a new core symbol only works with a core version that has it.
67
- - **Scanner output is versioned.** `scanModulesSchemaVersion` (`Commands/ScanModules/ScanModules.swift`), `scanExportsSchemaVersion` (`Commands/ScanExports/ExportedSurface.swift`) and `expandSchemaVersion` (`Commands/Expand/ExpandReport.swift`) change independently. When the output shape of a command changes, bump its version and update the matching `SUPPORTED_*_SCHEMA_VERSION` constant and mirror types in `src/types.ts`. `expo-modules-autolinking` also checks the `scan-modules` version.
67
+ - **Scanner output is versioned.** `scanModulesSchemaVersion` (`Commands/ScanModules/ScanModules.swift`), `scanExportsSchemaVersion` (`Commands/ScanExports/ExportedSurface.swift`) and `expandSchemaVersion` (`Commands/Expand/ExpandReport.swift`) change independently. When the output shape of a command changes in a way that breaks a consumer, bump its version and update the matching `SUPPORTED_*_SCHEMA_VERSION` constant. A new field that older consumers can ignore doesn't need a bump, but still update the mirror types in `src/types.ts`. The TypeScript wrapper throws when a report's version differs from its constant. `expo-modules-autolinking` gets the `scan-modules` report through the wrapper, and when the call throws, it links only the modules declared in `expo-module.config.json`.
68
68
  - **The binary path is part of the contract.** `expo-modules-autolinking` resolves this package from `expo-modules-core` and passes `-load-plugin-executable <package>/apple/<binary>#ExpoModulesMacros` to the compiler. The scanner wrapper in autolinking uses the same path, and `expo-modules-cli` uses `getScannerBinaryPath()`. On Windows the binary is `ExpoModulesMacros.exe` in `@expo/modules-macros-win32-<arch>`, and on Linux `ExpoModulesMacros` in `@expo/modules-macros-linux-<arch>`. Renaming or moving a binary needs a matching change in `expo/expo`.
69
69
  - **Swift packages depend on the product.** A Swift package that declares or uses the macros depends on the `ExpoModulesMacrosPlugin` product and builds the plugin from source, without the binary. Renaming the product or the macro target, or moving `Package.swift`, needs a matching change in `expo/expo`.
70
70
 
package/README.md CHANGED
@@ -25,8 +25,8 @@ 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. 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, sync: Bool = false)`** 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. `sync: true` dispatches inline instead of asynchronously.
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. 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
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
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
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.
@@ -70,6 +70,10 @@ The author-facing documentation for each macro lives next to its declaration in
70
70
 
71
71
  Native code calls a module or shared object from more than one thread: a `.withAsync` variant runs on the concurrent thread pool, and events and closure arguments can be called from any thread. So the generated code needs the class to be `Sendable`. A subclass of `Module` or `SharedObject` can't be checked `Sendable`, because those base classes aren't, so `@ExpoModule` and `@SharedObject` add `extension MyModule: @unchecked Sendable {}`. The compiler doesn't check the class's mutable state then: keep state that more than one thread uses behind a lock or an actor. The macros add nothing when the class already declares `Sendable` or `@unchecked Sendable`, or inherits it. Swift warns on a subclass of a module or shared object until the subclass restates `@unchecked Sendable`.
72
72
 
73
+ ## Access level
74
+
75
+ Autolinking generates the modules provider in the app target, so it can reference only the `public` and `open` classes of other modules. `@ExpoModule` and `@SharedObject` warn when the class isn't `public` or `open`, with a fix-it that makes it `public`. The fix-it replaces an `internal`, `package`, `fileprivate` or `private` modifier, or adds `public` before the other modifiers. A class declared in a function can't have an access modifier, so it gets no warning.
76
+
73
77
  # Scanner
74
78
 
75
79
  The Swift compiler launches a plugin executable with no arguments and speaks the plugin protocol over stdin, so the binary treats any argument as a scanner invocation instead:
@@ -78,17 +82,24 @@ The Swift compiler launches a plugin executable with no arguments and speaks the
78
82
  ExpoModulesMacros <subcommand> [options] <path> [<path> ...]
79
83
 
80
84
  subcommands:
81
- scan-modules fast scan for top-level @ExpoModule types (autolinking)
85
+ scan-modules fast scan for top-level @ExpoModule and @SharedObject types (autolinking)
82
86
  scan-exports deep scan of the full JS-exported surface (type generation)
83
87
  expand print the code that the macros generate, and the macro diagnostics
88
+ fix apply the Fix-Its of the macro diagnostics to the files
84
89
 
85
90
  options (scan-modules only):
86
91
  --define <flag> treat a conditional compilation flag as set; repeatable
87
92
 
88
- options (expand only):
93
+ options (expand and fix):
89
94
  --symbol <name> only a type ('MyModule', 'MyModule.Options') or a member ('MyModule.greet'); repeatable
95
+
96
+ options (expand only):
90
97
  --diagnostics-only only the diagnostics, followed by a summary
91
98
  --json a JSON report instead of text
99
+
100
+ options (fix only):
101
+ --dry-run print the Fix-Its without changing the files
102
+ --only-errors apply only the Fix-Its of errors, not those of warnings
92
103
  ```
93
104
 
94
105
  For the scans, each path is a `.swift` file or a directory, scanned recursively for `.swift` files. Both scans print a JSON report to stdout, each carrying its own `schemaVersion` so a consumer can check it understands the shape before trusting it. The two versions are independent: the commands serve different consumers and change for different reasons.
@@ -121,6 +132,28 @@ The macros only read the syntax tree, so the expansion is what the compiler gets
121
132
 
122
133
  Errors that need type checking, for example the conformance assertions in `_assertTypesConformance`, still need a build.
123
134
 
135
+ ## Fix
136
+
137
+ `fix` applies the Fix-Its that the macros attach to their diagnostics, such as "Mark the function 'async'" for a `.concurrent` function, and writes the files in place. It takes the same paths and `--symbol` options as `expand`, and expands the files in the same way.
138
+
139
+ ```sh
140
+ ExpoModulesMacros fix --dry-run ios/
141
+ ExpoModulesMacros fix ios/
142
+ ```
143
+
144
+ Each applied Fix-It is printed to stdout as `<file>:<line>:<column>: fix-it: <message>`, followed by a summary. With `--dry-run`, the files aren't written.
145
+
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
+
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
+ - It applies only the Fix-Its of the macros, not those of syntax errors.
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
+
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
+
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.
156
+
124
157
  # TypeScript wrapper
125
158
 
126
159
  Node consumers can call the scanner through this package instead of locating the binary and shelling out themselves:
Binary file
@@ -6,16 +6,18 @@
6
6
  /// files).
7
7
  ///
8
8
  /// Subcommands:
9
- /// scan-modules <path>... fast: top-level `@ExpoModule` types, for autolinking
9
+ /// scan-modules <path>... fast: top-level `@ExpoModule` and `@SharedObject` types, for autolinking
10
10
  /// scan-exports <path>... deep: full JS-exported surface, for TS type generation
11
11
  /// expand <path>... the code that the macros generate, and their diagnostics
12
+ /// fix <path>... applies the Fix-Its of the macro diagnostics to the files
12
13
  public enum ScannerCLI {
13
14
  /// Runs the CLI for the given arguments (argv without the executable path) and returns the
14
15
  /// process exit code: `0` on success, `1` if encoding the report fails, `2` on a usage error.
15
- /// `expand` also returns `1` for an error diagnostic (see `Scanner.runExpand`).
16
+ /// `expand` also returns `1` for an error diagnostic (see `Scanner.runExpand`), and `fix` when it
17
+ /// can't write a file (see `Scanner.runFix`).
16
18
  ///
17
- /// `expander` expands the macros for `expand`. The plugin executable passes it, because the macro
18
- /// implementations live there; without it, `expand` is a usage error.
19
+ /// `expander` expands the macros for `expand` and `fix`. The plugin executable passes it, because the
20
+ /// macro implementations live there; without it, `expand` and `fix` are usage errors.
19
21
  public static func run(arguments: [String], expander: MacroExpander? = nil) -> Int32 {
20
22
  // `-h`/`--help` anywhere is treated as a help request: print usage to stdout and exit 0.
21
23
  if arguments.contains(where: { $0 == "-h" || $0 == "--help" }) {
@@ -32,6 +34,8 @@ public enum ScannerCLI {
32
34
  var platform: String?
33
35
  var defines: [String] = []
34
36
  var expandOptions = ExpandOptions()
37
+ var dryRun = false
38
+ var onlyErrors = false
35
39
 
36
40
  var rest = arguments.dropFirst().makeIterator()
37
41
  while let argument = rest.next() {
@@ -55,14 +59,24 @@ public enum ScannerCLI {
55
59
  expandOptions.json = true
56
60
  case "--diagnostics-only":
57
61
  expandOptions.diagnosticsOnly = true
62
+ case "--dry-run":
63
+ dryRun = true
64
+ case "--only-errors":
65
+ onlyErrors = true
58
66
  default:
59
67
  paths.append(argument)
60
68
  }
61
69
  }
62
70
 
63
71
  // The scans print JSON in any case and have no symbols to filter.
64
- if subcommand != "expand" && expandOptions != ExpandOptions() {
65
- return usageError("--symbol, --json and --diagnostics-only are options of expand")
72
+ if subcommand != "expand" && subcommand != "fix" && !expandOptions.symbols.isEmpty {
73
+ return usageError("--symbol is an option of expand and fix")
74
+ }
75
+ if subcommand != "expand" && (expandOptions.json || expandOptions.diagnosticsOnly) {
76
+ return usageError("--json and --diagnostics-only are options of expand")
77
+ }
78
+ if subcommand != "fix" && (dryRun || onlyErrors) {
79
+ return usageError("--dry-run and --only-errors are options of fix")
66
80
  }
67
81
 
68
82
  switch subcommand {
@@ -103,6 +117,21 @@ public enum ScannerCLI {
103
117
  expandOptions.paths = paths
104
118
  return Scanner.runExpand(options: expandOptions, expander: expander)
105
119
 
120
+ case "fix":
121
+ guard platform == nil, defines.isEmpty else {
122
+ return usageError("fix does not support --platform or --define")
123
+ }
124
+ guard !paths.isEmpty else {
125
+ return usageError("fix requires at least one path")
126
+ }
127
+ guard let expander else {
128
+ return usageError("fix is only available in the macro plugin executable")
129
+ }
130
+ return Scanner.runFix(
131
+ options: FixOptions(paths: paths, symbols: expandOptions.symbols, dryRun: dryRun, onlyErrors: onlyErrors),
132
+ expander: expander
133
+ )
134
+
106
135
  default:
107
136
  return usageError("unknown subcommand '\(subcommand)'")
108
137
  }
@@ -124,23 +153,31 @@ private var usageText: String {
124
153
  usage: \(toolName) <subcommand> [options] <path> [<path> ...]
125
154
 
126
155
  subcommands:
127
- scan-modules fast scan for top-level @ExpoModule types (autolinking)
156
+ scan-modules fast scan for top-level @ExpoModule and @SharedObject types (autolinking)
128
157
  scan-exports deep scan of the full JS-exported surface (type generation)
129
158
  expand print the code that the macros generate in the given .swift files (and in the
130
159
  files of the given directories that use a macro), and the macro diagnostics
131
160
  on stderr; exits with 1 when there is an error
161
+ fix apply the Fix-Its of the macro diagnostics to the files that expand takes, in
162
+ place, and print each one; doesn't format the files
132
163
 
133
164
  options (scan-modules only):
134
165
  --define <flag> treat a conditional compilation flag (e.g. DEBUG) as set when resolving
135
166
  each module's 'platforms' list; repeatable
136
167
 
168
+ options (expand and fix):
169
+ --symbol <name> expand only a type ('MyModule', 'MyModule.Options') or a member
170
+ ('MyModule.greet'), and use only its diagnostics; repeatable
171
+
137
172
  options (expand only):
138
- --symbol <name> print only the expansion of a type ('MyModule', 'MyModule.Options') or a
139
- member ('MyModule.greet'), and only its diagnostics; repeatable
140
173
  --diagnostics-only print only the diagnostics, followed by a summary
141
174
  --json print a JSON report with the expansions and diagnostics instead; exits with
142
175
  0 also when there is an error
143
176
 
177
+ options (fix only):
178
+ --dry-run print the Fix-Its without changing the files
179
+ --only-errors apply only the Fix-Its of errors, not those of warnings
180
+
144
181
  options:
145
182
  -h, --help print this help and exit
146
183
 
@@ -67,13 +67,29 @@ public struct ExpansionDiagnostic: Codable, Equatable, Sendable {
67
67
  /// The messages of the Fix-Its, for example `replace 'var' with 'let'`.
68
68
  public let fixIts: [String]
69
69
 
70
+ /// The edits of each Fix-It in `fixIts`, in the original file, for `fix`. The edits of a Fix-It are
71
+ /// empty when they aren't in the original file, and this is empty for a diagnostic whose Fix-Its
72
+ /// `fix` doesn't apply, such as a syntax error. Not part of the JSON report, nor of equality, so a
73
+ /// diagnostic is equal to itself after a JSON round trip.
74
+ public var fixItEdits: [[ExpansionEdit]] = []
75
+
76
+ private enum CodingKeys: String, CodingKey {
77
+ case severity, message, line, column, notes, fixIts
78
+ }
79
+
80
+ public static func == (lhs: Self, rhs: Self) -> Bool {
81
+ lhs.severity == rhs.severity && lhs.message == rhs.message && lhs.line == rhs.line
82
+ && lhs.column == rhs.column && lhs.notes == rhs.notes && lhs.fixIts == rhs.fixIts
83
+ }
84
+
70
85
  public init(
71
86
  severity: Severity,
72
87
  message: String,
73
88
  line: Int,
74
89
  column: Int,
75
90
  notes: [ExpansionNote] = [],
76
- fixIts: [String] = []
91
+ fixIts: [String] = [],
92
+ fixItEdits: [[ExpansionEdit]] = []
77
93
  ) {
78
94
  self.severity = severity
79
95
  self.message = message
@@ -81,6 +97,27 @@ public struct ExpansionDiagnostic: Codable, Equatable, Sendable {
81
97
  self.column = column
82
98
  self.notes = notes
83
99
  self.fixIts = fixIts
100
+ self.fixItEdits = fixItEdits
101
+ }
102
+ }
103
+
104
+ /// A change of one range of the original file: `original`, which starts at `offset`, becomes
105
+ /// `replacement`.
106
+ public struct ExpansionEdit: Equatable, Sendable {
107
+ /// The UTF-8 offset in the original file.
108
+ public let offset: Int
109
+
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.
113
+ public let original: String
114
+
115
+ public let replacement: String
116
+
117
+ public init(offset: Int, original: String, replacement: String) {
118
+ self.offset = offset
119
+ self.original = original
120
+ self.replacement = replacement
84
121
  }
85
122
  }
86
123
 
@@ -34,13 +34,20 @@ public func expandMacros(source: String, file: String, symbols: [String]) -> Exp
34
34
  }
35
35
  let originalDeclarations = OriginalDeclarations(tree)
36
36
  diagnostics += context.diagnostics.map { diagnostic in
37
- expansionDiagnostic(diagnostic) { position, node in
38
- let location = context.location(for: position, anchoredAt: node, fileName: file)
39
- if let original = originalDeclarations.position(of: position, in: node, shiftedOffset: location.offset) {
37
+ let location = { (position: AbsolutePosition, node: Syntax) -> SourceLocation in
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
- return location
44
+ return context.location(for: position, anchoredAt: node, fileName: file)
45
+ }
46
+ var result = expansionDiagnostic(diagnostic, location: location)
47
+ result.fixItEdits = diagnostic.fixIts.map { fixIt in
48
+ originalEdits(of: fixIt) { location($0, $1).offset } ?? []
43
49
  }
50
+ return result
44
51
  }
45
52
  diagnostics.sort { ($0.line, $0.column) < ($1.line, $1.column) }
46
53
 
@@ -203,6 +210,113 @@ private func expansionDiagnostic(
203
210
  )
204
211
  }
205
212
 
213
+ /// The edits of `fixIt` in the original file, or `nil` when one of them isn't in the tree of the node
214
+ /// that it changes. `offset` maps a position in the tree of a node to its UTF-8 offset in the original
215
+ /// file.
216
+ ///
217
+ /// A Fix-It that replaces a node replaces all of it, but the tree that a macro gets isn't always the
218
+ /// original declaration: in a module, a `@JS` member also has the attributes that `@ExpoModule` adds.
219
+ /// So each edit is reduced to the bytes that it changes, for example the `async ` that it inserts
220
+ /// into a signature, which are in the original file too. The macro system detaches the attribute
221
+ /// from the declaration, so a change has its own tree, which can differ from the diagnostic's.
222
+ private func originalEdits(
223
+ of fixIt: FixIt,
224
+ offset: (AbsolutePosition, Syntax) -> Int
225
+ ) -> [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)] = []
229
+ for change in fixIt.changes {
230
+ guard let edit = FixIt(message: fixIt.message, changes: [change]).edits.first else {
231
+ continue
232
+ }
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() {
241
+ let root = node.root
242
+ let text = Array(root.description.utf8)
243
+ let start = edit.range.lowerBound.utf8Offset - root.position.utf8Offset
244
+ let end = edit.range.upperBound.utf8Offset - root.position.utf8Offset
245
+ guard 0 <= start, start <= end, end <= text.count else {
246
+ return nil
247
+ }
248
+ let old = text[start..<end]
249
+ let new = Array(edit.replacement.utf8)
250
+ let (prefix, suffix) = commonAffixLengths(Array(old), new)
251
+ guard prefix + suffix < max(old.count, new.count) else {
252
+ continue
253
+ }
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
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
+ )
277
+ }
278
+ return edits
279
+ }
280
+
281
+ /// The number of bytes after an insertion that its edit also replaces with themselves.
282
+ private let insertionContextLength = 8
283
+
284
+ /// The node in the tree that `change` edits.
285
+ private func changedNode(_ change: FixIt.Change) -> Syntax {
286
+ switch change {
287
+ case .replace(let oldNode, _):
288
+ oldNode
289
+ case .replaceLeadingTrivia(let token, _), .replaceTrailingTrivia(let token, _):
290
+ Syntax(token)
291
+ case .replaceChild(let data):
292
+ Syntax(data.parent)
293
+ case .replaceText(_, _, let node):
294
+ node
295
+ }
296
+ }
297
+
298
+ /// The lengths of the longest common prefix and suffix of `old` and `new` that don't overlap, at
299
+ /// character boundaries, so the bytes between them are valid UTF-8 on both sides.
300
+ private func commonAffixLengths(_ old: [UInt8], _ new: [UInt8]) -> (prefix: Int, suffix: Int) {
301
+ let isContinuation = { (bytes: [UInt8], index: Int) in index < bytes.count && bytes[index] & 0xC0 == 0x80 }
302
+ let shorter = min(old.count, new.count)
303
+ var prefix = 0
304
+ while prefix < shorter && old[prefix] == new[prefix] {
305
+ prefix += 1
306
+ }
307
+ while prefix > 0 && (isContinuation(old, prefix) || isContinuation(new, prefix)) {
308
+ prefix -= 1
309
+ }
310
+ var suffix = 0
311
+ while suffix < shorter - prefix && old[old.count - 1 - suffix] == new[new.count - 1 - suffix] {
312
+ suffix += 1
313
+ }
314
+ while suffix > 0 && (isContinuation(old, old.count - suffix) || isContinuation(new, new.count - suffix)) {
315
+ suffix -= 1
316
+ }
317
+ return (prefix, suffix)
318
+ }
319
+
206
320
  private func severity(_ severity: DiagnosticSeverity) -> ExpansionDiagnostic.Severity {
207
321
  switch severity {
208
322
  case .error: .error
@@ -223,51 +337,77 @@ private func severity(_ severity: DiagnosticSeverity) -> ExpansionDiagnostic.Sev
223
337
  /// every position after the added attributes is off by their length. A diagnostic in a parameter
224
338
  /// type of a `@JS` function would be on the next line.
225
339
  ///
226
- /// The text from the keyword of the declaration (`func`, `var`, `init`) to its end is the same in
227
- /// both, so this finds the original declaration by that text and moves the position by the offset of
228
- /// the keyword. A position before the keyword, in the attributes or the modifiers, isn't mapped.
229
- private struct OriginalDeclarations {
230
- /// The positions of the keywords of the declarations in the original file, in source order, by the
231
- /// text from the keyword to the end of the declaration.
232
- private let keywordPositions: [String: [AbsolutePosition]]
340
+ /// The start of the declaration is before the added attributes, so the macro system maps it to the
341
+ /// start of the original declaration. The text from the keyword of the declaration (`func`, `var`,
342
+ /// `init`) to its end is the same in both, so this moves the position by the offset of the keyword
343
+ /// in the original declaration. A position before the keyword, in the attributes or the modifiers,
344
+ /// isn't mapped.
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 {
350
+ /// The declarations in the original file by the UTF-8 offset of their start, with their leading
351
+ /// trivia: the text from the keyword to the end of each, and the position of its keyword.
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)?] = [:]
233
361
 
234
362
  init(_ tree: SourceFileSyntax) {
235
363
  let collector = DeclarationCollector(viewMode: .sourceAccurate)
236
364
  collector.walk(tree)
237
- var keywordPositions: [String: [AbsolutePosition]] = [:]
365
+ var declarationsByStart: [Int: [(text: String, keyword: AbsolutePosition)]] = [:]
366
+ var keywordsByText: [String: [AbsolutePosition]] = [:]
238
367
  for declaration in collector.declarations {
239
368
  guard let keyword = keywordToken(of: declaration) else {
240
369
  continue
241
370
  }
242
- keywordPositions[textFromKeyword(keyword, of: declaration), default: []].append(
243
- keyword.positionAfterSkippingLeadingTrivia
371
+ let text = textFromKeyword(keyword, of: declaration)
372
+ declarationsByStart[declaration.position.utf8Offset, default: []].append(
373
+ (text, keyword.positionAfterSkippingLeadingTrivia)
244
374
  )
375
+ keywordsByText[text, default: []].append(keyword.positionAfterSkippingLeadingTrivia)
245
376
  }
246
- self.keywordPositions = keywordPositions
377
+ self.declarationsByStart = declarationsByStart
378
+ self.keywordsByText = keywordsByText
247
379
  }
248
380
 
249
381
  /// The position in the original file of `position` in `node`, or `nil` when `node` is in the
250
- /// original file, isn't in a detached declaration, or is before its keyword.
382
+ /// original file, isn't in a declaration that a macro got, is before its keyword, or its original
383
+ /// declaration isn't found.
251
384
  ///
252
- /// `shiftedOffset` is the offset that the macro system maps `position` to. The added attributes
253
- /// only move it forward, so when declarations with the same text are in the file, the one it is
254
- /// in is the last one that starts at or before that offset.
255
- func position(of position: AbsolutePosition, in node: Syntax, shiftedOffset: Int) -> AbsolutePosition? {
256
- guard !node.root.is(SourceFileSyntax.self),
257
- let declaration = node.root.as(DeclSyntax.self),
258
- let keyword = keywordToken(of: declaration),
259
- position >= keyword.positionAfterSkippingLeadingTrivia,
260
- let candidates = keywordPositions[textFromKeyword(keyword, of: declaration)]
261
- 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 {
262
393
  return nil
263
394
  }
264
- let offset = position.utf8Offset - keyword.positionAfterSkippingLeadingTrivia.utf8Offset
265
- let shiftedKeyword = shiftedOffset - offset
266
- guard let originalKeyword = candidates.last(where: { $0.utf8Offset <= shiftedKeyword }) ?? candidates.first
267
- else {
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 {
268
408
  return nil
269
409
  }
270
- return AbsolutePosition(utf8Offset: originalKeyword.utf8Offset + offset)
410
+ return AbsolutePosition(utf8Offset: found.original.utf8Offset + position.utf8Offset - found.keyword.utf8Offset)
271
411
  }
272
412
  }
273
413
 
@@ -75,8 +75,25 @@ struct ExpandInputError: Error, Equatable {
75
75
  /// and only the files that use one of the macros are expanded, with their absolute paths. A file that
76
76
  /// a directory also contains is expanded once.
77
77
  func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<ExpandResult, ExpandInputError> {
78
+ expandSources(options: options, expander: expander).map { sources in
79
+ let files = sources.map(\.expanded)
80
+ return ExpandResult(
81
+ schemaVersion: expandSchemaVersion,
82
+ files: options.diagnosticsOnly
83
+ ? files.map { ExpandedFile(file: $0.file, expansions: [], diagnostics: $0.diagnostics) }
84
+ : files
85
+ )
86
+ }
87
+ }
88
+
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
+ func expandSources(
92
+ options: ExpandOptions,
93
+ expander: MacroExpander
94
+ ) -> Result<[(source: String, contents: Data, expanded: ExpandedFile)], ExpandInputError> {
78
95
  let prefilter = MacroPrefilter(attributeNames: expander.attributeNames)
79
- var sources: [(file: String, source: String)] = []
96
+ var sources: [(file: String, source: String, contents: Data)] = []
80
97
  var seen: Set<String> = []
81
98
  for path in options.paths {
82
99
  switch isDirectory(atPath: path) {
@@ -84,12 +101,12 @@ func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<Expa
84
101
  return .failure(ExpandInputError(message: "no such file or directory '\(path)'"))
85
102
  case .some(true):
86
103
  for file in swiftFiles(in: [path]) where !seen.contains(fileIdentity(file)) {
87
- guard let source = try? String(contentsOfFile: file, encoding: .utf8) else {
104
+ guard let (source, contents) = readSource(atPath: file) else {
88
105
  return .failure(ExpandInputError(message: "could not read '\(file)'"))
89
106
  }
90
107
  seen.insert(fileIdentity(file))
91
108
  if mightContainMacro(in: source, prefilter: prefilter) {
92
- sources.append((file, source))
109
+ sources.append((file, source, contents))
93
110
  }
94
111
  }
95
112
  case .some(false):
@@ -99,17 +116,19 @@ func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<Expa
99
116
  guard !seen.contains(fileIdentity(path)) else {
100
117
  continue
101
118
  }
102
- guard let source = try? String(contentsOfFile: path, encoding: .utf8) else {
119
+ guard let (source, contents) = readSource(atPath: path) else {
103
120
  return .failure(ExpandInputError(message: "could not read '\(path)'"))
104
121
  }
105
122
  seen.insert(fileIdentity(path))
106
- sources.append((path, source))
123
+ sources.append((path, source, contents))
107
124
  }
108
125
  }
109
126
 
110
- let files = sources.map { 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
+ }
111
130
 
112
- let found = Set(files.flatMap { $0.expansions.compactMap(\.symbol) })
131
+ let found = Set(files.flatMap { $0.expanded.expansions.compactMap(\.symbol) })
113
132
  if let missing = options.symbols.first(where: { !found.contains($0) }) {
114
133
  return .failure(
115
134
  ExpandInputError(
@@ -119,14 +138,23 @@ func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<Expa
119
138
  )
120
139
  }
121
140
 
122
- return .success(
123
- ExpandResult(
124
- schemaVersion: expandSchemaVersion,
125
- files: options.diagnosticsOnly
126
- ? files.map { ExpandedFile(file: $0.file, expansions: [], diagnostics: $0.diagnostics) }
127
- : files
128
- )
129
- )
141
+ return .success(files)
142
+ }
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)
130
158
  }
131
159
 
132
160
  /// The expanded sources as text. A single expansion is printed as it is; more than one each get a