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 +2 -2
- package/README.md +37 -4
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Sources/ExpoModulesCLI/CLI.swift +46 -9
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +38 -1
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/MacroExpander.swift +171 -31
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +43 -15
- package/apple/Sources/ExpoModulesCLI/Commands/Fix/RunFix.swift +290 -0
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +14 -7
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +11 -11
- package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +48 -34
- package/apple/Sources/ExpoModulesMacrosCore/Macros/EventMacro.swift +92 -15
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoModuleMacro.swift +8 -4
- package/apple/Sources/ExpoModulesMacrosCore/Macros/JSMacro.swift +54 -1
- package/apple/Sources/ExpoModulesMacrosCore/Macros/SharedObjectMacro.swift +9 -5
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +60 -16
- package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +9 -37
- package/apple/Sources/ExpoModulesMacrosCore/Support/PublicAccess.swift +105 -0
- package/build/types.d.ts +17 -8
- package/build/types.js +1 -1
- 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. 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
|
|
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:
|
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,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
|
-
|
|
38
|
-
let
|
|
39
|
-
|
|
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
|
|
227
|
-
///
|
|
228
|
-
///
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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.
|
|
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
|
|
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
|
-
/// `
|
|
253
|
-
///
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
265
|
-
let
|
|
266
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|