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