expo-modules-macros 0.29.0 → 0.30.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 +2 -2
- package/README.md +35 -4
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Sources/ExpoModulesCLI/CLI.swift +46 -9
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +37 -1
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/MacroExpander.swift +117 -26
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +20 -10
- package/apple/Sources/ExpoModulesCLI/Commands/Fix/RunFix.swift +228 -0
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +1 -1
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +8 -10
- package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +48 -34
- package/apple/Sources/ExpoModulesMacrosCore/Macros/EventMacro.swift +3 -3
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoModuleMacro.swift +8 -1
- package/apple/Sources/ExpoModulesMacrosCore/Macros/JSMacro.swift +54 -1
- package/apple/Sources/ExpoModulesMacrosCore/Macros/SharedObjectMacro.swift +9 -2
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +36 -6
- package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +11 -25
- package/apple/Sources/ExpoModulesMacrosCore/Support/PublicAccess.swift +105 -0
- package/build/types.d.ts +15 -6
- package/package.json +5 -5
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
|
|
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,
|
|
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
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
|
|
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,26 @@ 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). 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.
|
|
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
|
+
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
|
+
|
|
124
155
|
# TypeScript wrapper
|
|
125
156
|
|
|
126
157
|
Node consumers can call the scanner through this package instead of locating the binary and shelling out themselves:
|
package/apple/ExpoModulesMacros
CHANGED
|
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
|
|
18
|
-
/// implementations live there; without it, `expand`
|
|
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" &&
|
|
65
|
-
return usageError("--symbol
|
|
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,26 @@ 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. Empty
|
|
111
|
+
/// for an insertion.
|
|
112
|
+
public let original: String
|
|
113
|
+
|
|
114
|
+
public let replacement: String
|
|
115
|
+
|
|
116
|
+
public init(offset: Int, original: String, replacement: String) {
|
|
117
|
+
self.offset = offset
|
|
118
|
+
self.original = original
|
|
119
|
+
self.replacement = replacement
|
|
84
120
|
}
|
|
85
121
|
}
|
|
86
122
|
|
|
@@ -34,13 +34,18 @@ 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
|
-
|
|
38
|
-
let
|
|
39
|
-
if let original = originalDeclarations.position(of: position, in: node,
|
|
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) {
|
|
40
40
|
return converter.location(for: original)
|
|
41
41
|
}
|
|
42
|
-
return location
|
|
42
|
+
return context.location(for: position, anchoredAt: node, fileName: file)
|
|
43
43
|
}
|
|
44
|
+
var result = expansionDiagnostic(diagnostic, location: location)
|
|
45
|
+
result.fixItEdits = diagnostic.fixIts.map { fixIt in
|
|
46
|
+
originalEdits(of: fixIt) { location($0, $1).offset } ?? []
|
|
47
|
+
}
|
|
48
|
+
return result
|
|
44
49
|
}
|
|
45
50
|
diagnostics.sort { ($0.line, $0.column) < ($1.line, $1.column) }
|
|
46
51
|
|
|
@@ -203,6 +208,93 @@ private func expansionDiagnostic(
|
|
|
203
208
|
)
|
|
204
209
|
}
|
|
205
210
|
|
|
211
|
+
/// The edits of `fixIt` in the original file, or `nil` when one of them isn't in the tree of the node
|
|
212
|
+
/// that it changes. `offset` maps a position in the tree of a node to its UTF-8 offset in the original
|
|
213
|
+
/// file.
|
|
214
|
+
///
|
|
215
|
+
/// A Fix-It that replaces a node replaces all of it, but the tree that a macro gets isn't always the
|
|
216
|
+
/// original declaration: in a module, a `@JS` member also has the attributes that `@ExpoModule` adds.
|
|
217
|
+
/// So each edit is reduced to the bytes that it changes, for example the `async ` that it inserts
|
|
218
|
+
/// into a signature, which are in the original file too. The macro system detaches the attribute
|
|
219
|
+
/// from the declaration, so a change has its own tree, which can differ from the diagnostic's.
|
|
220
|
+
private func originalEdits(
|
|
221
|
+
of fixIt: FixIt,
|
|
222
|
+
offset: (AbsolutePosition, Syntax) -> Int
|
|
223
|
+
) -> [ExpansionEdit]? {
|
|
224
|
+
var edits: [ExpansionEdit] = []
|
|
225
|
+
for change in fixIt.changes {
|
|
226
|
+
// `FixIt.edits` drops an edit that overlaps an earlier one; this applies the same rule.
|
|
227
|
+
guard let edit = FixIt(message: fixIt.message, changes: [change]).edits.first else {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
let node = changedNode(change)
|
|
231
|
+
let root = node.root
|
|
232
|
+
let text = Array(root.description.utf8)
|
|
233
|
+
let start = edit.range.lowerBound.utf8Offset - root.position.utf8Offset
|
|
234
|
+
let end = edit.range.upperBound.utf8Offset - root.position.utf8Offset
|
|
235
|
+
guard 0 <= start, start <= end, end <= text.count else {
|
|
236
|
+
return nil
|
|
237
|
+
}
|
|
238
|
+
let old = text[start..<end]
|
|
239
|
+
let new = Array(edit.replacement.utf8)
|
|
240
|
+
let (prefix, suffix) = commonAffixLengths(Array(old), new)
|
|
241
|
+
guard prefix + suffix < max(old.count, new.count) else {
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
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)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return edits
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// The node in the tree that `change` edits.
|
|
258
|
+
private func changedNode(_ change: FixIt.Change) -> Syntax {
|
|
259
|
+
switch change {
|
|
260
|
+
case .replace(let oldNode, _):
|
|
261
|
+
oldNode
|
|
262
|
+
case .replaceLeadingTrivia(let token, _), .replaceTrailingTrivia(let token, _):
|
|
263
|
+
Syntax(token)
|
|
264
|
+
case .replaceChild(let data):
|
|
265
|
+
Syntax(data.parent)
|
|
266
|
+
case .replaceText(_, _, let node):
|
|
267
|
+
node
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
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
|
+
/// The lengths of the longest common prefix and suffix of `old` and `new` that don't overlap, at
|
|
277
|
+
/// character boundaries, so the bytes between them are valid UTF-8 on both sides.
|
|
278
|
+
private func commonAffixLengths(_ old: [UInt8], _ new: [UInt8]) -> (prefix: Int, suffix: Int) {
|
|
279
|
+
let isContinuation = { (bytes: [UInt8], index: Int) in index < bytes.count && bytes[index] & 0xC0 == 0x80 }
|
|
280
|
+
let shorter = min(old.count, new.count)
|
|
281
|
+
var prefix = 0
|
|
282
|
+
while prefix < shorter && old[prefix] == new[prefix] {
|
|
283
|
+
prefix += 1
|
|
284
|
+
}
|
|
285
|
+
while prefix > 0 && (isContinuation(old, prefix) || isContinuation(new, prefix)) {
|
|
286
|
+
prefix -= 1
|
|
287
|
+
}
|
|
288
|
+
var suffix = 0
|
|
289
|
+
while suffix < shorter - prefix && old[old.count - 1 - suffix] == new[new.count - 1 - suffix] {
|
|
290
|
+
suffix += 1
|
|
291
|
+
}
|
|
292
|
+
while suffix > 0 && (isContinuation(old, old.count - suffix) || isContinuation(new, new.count - suffix)) {
|
|
293
|
+
suffix -= 1
|
|
294
|
+
}
|
|
295
|
+
return (prefix, suffix)
|
|
296
|
+
}
|
|
297
|
+
|
|
206
298
|
private func severity(_ severity: DiagnosticSeverity) -> ExpansionDiagnostic.Severity {
|
|
207
299
|
switch severity {
|
|
208
300
|
case .error: .error
|
|
@@ -223,51 +315,50 @@ private func severity(_ severity: DiagnosticSeverity) -> ExpansionDiagnostic.Sev
|
|
|
223
315
|
/// every position after the added attributes is off by their length. A diagnostic in a parameter
|
|
224
316
|
/// type of a `@JS` function would be on the next line.
|
|
225
317
|
///
|
|
226
|
-
/// The
|
|
227
|
-
///
|
|
228
|
-
///
|
|
318
|
+
/// The start of the declaration is before the added attributes, so the macro system maps it to the
|
|
319
|
+
/// start of the original declaration. The text from the keyword of the declaration (`func`, `var`,
|
|
320
|
+
/// `init`) to its end is the same in both, so this moves the position by the offset of the keyword
|
|
321
|
+
/// in the original declaration. A position before the keyword, in the attributes or the modifiers,
|
|
322
|
+
/// isn't mapped.
|
|
229
323
|
private struct OriginalDeclarations {
|
|
230
|
-
/// The
|
|
231
|
-
/// text from the keyword to the end of the
|
|
232
|
-
private let
|
|
324
|
+
/// The declarations in the original file by the UTF-8 offset of their start, with their leading
|
|
325
|
+
/// 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)]]
|
|
233
327
|
|
|
234
328
|
init(_ tree: SourceFileSyntax) {
|
|
235
329
|
let collector = DeclarationCollector(viewMode: .sourceAccurate)
|
|
236
330
|
collector.walk(tree)
|
|
237
|
-
var
|
|
331
|
+
var declarations: [Int: [(text: String, keyword: AbsolutePosition)]] = [:]
|
|
238
332
|
for declaration in collector.declarations {
|
|
239
333
|
guard let keyword = keywordToken(of: declaration) else {
|
|
240
334
|
continue
|
|
241
335
|
}
|
|
242
|
-
|
|
243
|
-
keyword.positionAfterSkippingLeadingTrivia
|
|
336
|
+
declarations[declaration.position.utf8Offset, default: []].append(
|
|
337
|
+
(textFromKeyword(keyword, of: declaration), keyword.positionAfterSkippingLeadingTrivia)
|
|
244
338
|
)
|
|
245
339
|
}
|
|
246
|
-
self.
|
|
340
|
+
self.declarations = declarations
|
|
247
341
|
}
|
|
248
342
|
|
|
249
343
|
/// 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,
|
|
344
|
+
/// original file, isn't in a detached declaration, is before its keyword, or no declaration with its
|
|
345
|
+
/// text starts at `declarationStart`.
|
|
251
346
|
///
|
|
252
|
-
/// `
|
|
253
|
-
///
|
|
254
|
-
|
|
255
|
-
func position(of position: AbsolutePosition, in node: Syntax, shiftedOffset: Int) -> AbsolutePosition? {
|
|
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? {
|
|
256
350
|
guard !node.root.is(SourceFileSyntax.self),
|
|
257
351
|
let declaration = node.root.as(DeclSyntax.self),
|
|
258
352
|
let keyword = keywordToken(of: declaration),
|
|
259
353
|
position >= keyword.positionAfterSkippingLeadingTrivia,
|
|
260
|
-
let
|
|
354
|
+
let original = declarations[declarationStart]?.first(where: {
|
|
355
|
+
$0.text == textFromKeyword(keyword, of: declaration)
|
|
356
|
+
})
|
|
261
357
|
else {
|
|
262
358
|
return nil
|
|
263
359
|
}
|
|
264
360
|
let offset = position.utf8Offset - keyword.positionAfterSkippingLeadingTrivia.utf8Offset
|
|
265
|
-
|
|
266
|
-
guard let originalKeyword = candidates.last(where: { $0.utf8Offset <= shiftedKeyword }) ?? candidates.first
|
|
267
|
-
else {
|
|
268
|
-
return nil
|
|
269
|
-
}
|
|
270
|
-
return AbsolutePosition(utf8Offset: originalKeyword.utf8Offset + offset)
|
|
361
|
+
return AbsolutePosition(utf8Offset: original.keyword.utf8Offset + offset)
|
|
271
362
|
}
|
|
272
363
|
}
|
|
273
364
|
|
|
@@ -75,6 +75,23 @@ 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, expanded with `expander`, as `expandFiles` describes.
|
|
90
|
+
/// `fix` uses it too, because it edits the sources.
|
|
91
|
+
func expandSources(
|
|
92
|
+
options: ExpandOptions,
|
|
93
|
+
expander: MacroExpander
|
|
94
|
+
) -> Result<[(source: String, expanded: ExpandedFile)], ExpandInputError> {
|
|
78
95
|
let prefilter = MacroPrefilter(attributeNames: expander.attributeNames)
|
|
79
96
|
var sources: [(file: String, source: String)] = []
|
|
80
97
|
var seen: Set<String> = []
|
|
@@ -107,9 +124,9 @@ func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<Expa
|
|
|
107
124
|
}
|
|
108
125
|
}
|
|
109
126
|
|
|
110
|
-
let files = sources.map { expander.expand($0.source, $0.file, options.symbols) }
|
|
127
|
+
let files = sources.map { (source: $0.source, expanded: expander.expand($0.source, $0.file, options.symbols)) }
|
|
111
128
|
|
|
112
|
-
let found = Set(files.flatMap { $0.expansions.compactMap(\.symbol) })
|
|
129
|
+
let found = Set(files.flatMap { $0.expanded.expansions.compactMap(\.symbol) })
|
|
113
130
|
if let missing = options.symbols.first(where: { !found.contains($0) }) {
|
|
114
131
|
return .failure(
|
|
115
132
|
ExpandInputError(
|
|
@@ -119,14 +136,7 @@ func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<Expa
|
|
|
119
136
|
)
|
|
120
137
|
}
|
|
121
138
|
|
|
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
|
-
)
|
|
139
|
+
return .success(files)
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
/// The expanded sources as text. A single expansion is printed as it is; more than one each get a
|