expo-modules-macros 0.26.0 → 0.27.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 +8 -8
- package/README.md +37 -2
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Package.swift +10 -4
- package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/CLI.swift +45 -2
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +120 -0
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +183 -0
- package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/SourceScan.swift +8 -2
- package/apple/Sources/ExpoModulesMacros/Expand/MacroExpander.swift +484 -0
- package/apple/Sources/ExpoModulesMacros/{EventMacro.swift → Macros/EventMacro.swift} +1 -1
- package/apple/Sources/ExpoModulesMacros/{RecordMacro.swift → Macros/RecordMacro.swift} +4 -2
- package/apple/Sources/ExpoModulesMacros/{UnionMacro.swift → Macros/UnionMacro.swift} +2 -2
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +9 -14
- package/apple/Sources/ExpoModulesMacros/ProvidedMacros.swift +31 -0
- package/apple/Sources/ExpoModulesMacros/{ClosureArguments.swift → Support/ClosureArguments.swift} +4 -2
- package/apple/Sources/ExpoModulesMacros/{DecorateModuleBuilder.swift → Support/DecorateModuleBuilder.swift} +6 -3
- package/apple/Sources/ExpoModulesMacros/{TupleTypes.swift → Support/TupleTypes.swift} +50 -12
- package/build/index.d.ts +31 -1
- package/build/index.js +34 -0
- package/build/types.d.ts +45 -1
- package/build/types.js +3 -2
- package/package.json +5 -5
- /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/Availability.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ExportedSurface.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ResolveRefs.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ScanExports.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/SurfaceVisitor.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/TupleTypeAliases.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/TypeNode.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner/Modules → ExpoModulesCLI/Commands/ScanModules}/ScanModules.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/Detection.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/DetectionVisitor.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/ScanBuildConfiguration.swift +0 -0
- /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/StandardStreams.swift +0 -0
- /package/apple/Sources/ExpoModulesMacros/{ExpoModuleMacro.swift → Macros/ExpoModuleMacro.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{ExpoModulesOptimizedMacro.swift → Macros/ExpoModulesOptimizedMacro.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{ExpoViewMacro.swift → Macros/ExpoViewMacro.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{JSMacro.swift → Macros/JSMacro.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{SharedObjectMacro.swift → Macros/SharedObjectMacro.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{ViewPropsMacro.swift → Macros/ViewPropsMacro.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{FreeFormTypes.swift → Support/FreeFormTypes.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{JSConstructor.swift → Support/JSConstructor.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{JSNameCollisions.swift → Support/JSNameCollisions.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{MacroHelpers.swift → Support/MacroHelpers.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{OptimizedFunctionHelpers.swift → Support/OptimizedFunctionHelpers.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{Receiver.swift → Support/Receiver.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{TypeAliases.swift → Support/TypeAliases.swift} +0 -0
- /package/apple/Sources/ExpoModulesMacros/{TypeConformanceAssertion.swift → Support/TypeConformanceAssertion.swift} +0 -0
package/AGENTS.md
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
## Layout
|
|
6
6
|
|
|
7
7
|
- `apple/Package.swift`: the SwiftPM package. The test targets are declared only when `apple/Tests` exists, because the published npm package leaves it out.
|
|
8
|
-
- `apple/Sources/ExpoModulesMacros`: the
|
|
9
|
-
- `apple/Sources/
|
|
8
|
+
- `apple/Sources/ExpoModulesMacros`: the compiler plugin executable. `Plugin.swift` registers the macros of `ProvidedMacro.all` in `providingMacros` and holds the entry point that sends any invocation with arguments to the CLI. `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`, and `Expand/MacroExpander.swift` runs the macros in process for `expand`, because only this executable target can see them.
|
|
9
|
+
- `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 the report types and the output of `expand`, which gets the expansion from the executable as a `MacroExpander`), and `Core/` holds the shared parsing, `#if` evaluation and output.
|
|
10
10
|
- `apple/Sources/ExpoModulesOptimized`: declarations for the `@OptimizedFunction` macro.
|
|
11
|
-
- `apple/Tests`: `ExpoModulesMacrosTests` (expansion tests) and `
|
|
11
|
+
- `apple/Tests`: `ExpoModulesMacrosTests` (expansion tests) and `ExpoModulesCLITests`.
|
|
12
12
|
- `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.
|
|
13
13
|
- `apple/PodTests`: a stub test so that `expo/expo` native tests can install this package as a pod. Do not add real tests here.
|
|
14
14
|
- `src/`: the TypeScript wrapper around the scanner CLI. `types.ts` mirrors the Swift `Codable` output types by hand.
|
|
@@ -48,21 +48,21 @@ CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.gi
|
|
|
48
48
|
- Write new doc comments as `///` line comments, not `/** */` blocks.
|
|
49
49
|
- Tests use Swift Testing (`import Testing`, `@Test`) with backtick-quoted descriptive names, for example ``func `Prunes .build, Pods, .git, and node_modules directories`()``.
|
|
50
50
|
- Macro tests compare full expansions with `assertMacroExpansion` from `SwiftSyntaxMacrosGenericTestSupport`, using `indentationWidth: .spaces(2)`. Each test file wraps it in a local `assertExpansion` helper that passes `recordIssue` (`IssueRecording.swift`) as the failure handler.
|
|
51
|
-
-
|
|
51
|
+
- CLI tests reach internal API with `@testable import ExpoModulesCLI`. Do not make its types `public` only for tests.
|
|
52
52
|
- `@JS` applies to functions, properties and initializers. Do not write code or comments that assume it marks only functions.
|
|
53
53
|
- Code comments describe the code as it is. Do not refer to open PRs, branches or planned work.
|
|
54
54
|
|
|
55
55
|
## Changing macros
|
|
56
56
|
|
|
57
57
|
- 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.
|
|
58
|
-
- The scanner only parses files that pass the pre-filter, `MacroPrefilter` in `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.
|
|
59
|
-
- A new macro needs: the implementation in `apple/Sources/ExpoModulesMacros`, an entry in `
|
|
58
|
+
- 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.
|
|
59
|
+
- A new macro needs: the implementation in `apple/Sources/ExpoModulesMacros/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`.
|
|
60
60
|
|
|
61
61
|
## Contracts with other packages
|
|
62
62
|
|
|
63
|
-
- **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 `
|
|
63
|
+
- **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.
|
|
64
64
|
- **Generated code calls `expo-modules-core` API.** An expansion that uses a new core symbol only works with a core version that has it.
|
|
65
|
-
- **Scanner output is versioned.** `scanModulesSchemaVersion` (`
|
|
65
|
+
- **Scanner output is versioned.** `scanModulesSchemaVersion` (`Commands/ScanModules/ScanModules.swift`), `scanExportsSchemaVersion` (`Commands/ScanExports/ExportedSurface.swift`) and `expandSchemaVersion` (`Commands/Expand/ExpandReport.swift`) change independently. When the output shape of a command changes, bump its version and update the matching `SUPPORTED_*_SCHEMA_VERSION` constant and mirror types in `src/types.ts`. `expo-modules-autolinking` also checks the `scan-modules` version.
|
|
66
66
|
- **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`.
|
|
67
67
|
|
|
68
68
|
## Releases
|
package/README.md
CHANGED
|
@@ -74,22 +74,57 @@ ExpoModulesMacros <subcommand> [options] <path> [<path> ...]
|
|
|
74
74
|
subcommands:
|
|
75
75
|
scan-modules fast scan for top-level @ExpoModule types (autolinking)
|
|
76
76
|
scan-exports deep scan of the full JS-exported surface (type generation)
|
|
77
|
+
expand print the code that the macros generate, and the macro diagnostics
|
|
77
78
|
|
|
78
79
|
options (scan-modules only):
|
|
79
80
|
--define <flag> treat a conditional compilation flag as set; repeatable
|
|
81
|
+
|
|
82
|
+
options (expand only):
|
|
83
|
+
--symbol <name> only a type ('MyModule', 'MyModule.Options') or a member ('MyModule.greet'); repeatable
|
|
84
|
+
--diagnostics-only only the diagnostics, followed by a summary
|
|
85
|
+
--json a JSON report instead of text
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
## Expand
|
|
91
|
+
|
|
92
|
+
`expand` shows the code that the macros generate, as "Expand Macro" does in Xcode, but in a terminal and without a build. It takes `.swift` files and directories, runs the macros of this executable on them in process with swift-syntax, and prints the expanded source to stdout. The syntax errors and the macro diagnostics go to stderr in the compiler's `<file>:<line>:<column>: error: <message>` format, and the exit code is `1` when there is an error. With `--diagnostics-only`, it prints only the diagnostics, so it can check a module in CI or for an agent.
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
ExpoModulesMacros expand ios/HapticsModule.swift
|
|
96
|
+
ExpoModulesMacros expand --symbol HapticsModule.impactAsync ios/HapticsModule.swift
|
|
97
|
+
ExpoModulesMacros expand --diagnostics-only ios/
|
|
80
98
|
```
|
|
81
99
|
|
|
82
|
-
|
|
100
|
+
A file is always expanded and keeps its path as it was passed. A directory is walked like the scans walk it, skipping dependency, build, test and example directories, and only its files that use one of the macros are expanded, with their absolute paths.
|
|
101
|
+
|
|
102
|
+
`--symbol` limits the output to one declaration:
|
|
103
|
+
|
|
104
|
+
- A type (`HapticsModule`, `HapticsModule.ImpactStyle`) prints the expanded type and the extensions that its macros add.
|
|
105
|
+
- A member (`HapticsModule.impactAsync`, `HapticsModule.init`) prints its type, expanded with only the declarations of that name (all overloads). So `_decorateModule` holds only the code for that member.
|
|
106
|
+
|
|
107
|
+
With `--symbol`, only the diagnostics inside those declarations are printed.
|
|
108
|
+
|
|
109
|
+
With `--json`, `expand` prints an `ExpandResult` with its own `schemaVersion`, and the exit code is `0` also with error diagnostics, because the report holds them. A missing path, a file that isn't Swift, or a symbol that no file declares is a usage error (`2`) in both modes.
|
|
110
|
+
|
|
111
|
+
The macros only read the syntax tree, so the expansion is what the compiler gets, with two differences:
|
|
112
|
+
|
|
113
|
+
- The names that a macro makes unique (`makeUniqueName`) can differ.
|
|
114
|
+
- The compiler passes an extension macro only the conformances that the type doesn't have yet. `expand` passes all of them, and the macros skip the ones in the inheritance clause, so the result differs only for a conformance inherited from somewhere else.
|
|
115
|
+
|
|
116
|
+
Errors that need type checking, for example the conformance assertions in `_assertTypesConformance`, still need a build.
|
|
83
117
|
|
|
84
118
|
# TypeScript wrapper
|
|
85
119
|
|
|
86
120
|
Node consumers can call the scanner through this package instead of locating the binary and shelling out themselves:
|
|
87
121
|
|
|
88
122
|
```ts
|
|
89
|
-
import { scanModules, scanExports } from 'expo-modules-macros';
|
|
123
|
+
import { expandMacros, scanModules, scanExports } from 'expo-modules-macros';
|
|
90
124
|
|
|
91
125
|
const { modules, warnings } = await scanModules(['ios/'], { defines: ['DEBUG'], timeout: 30_000 });
|
|
92
126
|
const { exports } = await scanExports(['ios/']);
|
|
127
|
+
const { files } = await expandMacros(['ios/HapticsModule.swift'], { symbols: ['HapticsModule.impactAsync'] });
|
|
93
128
|
```
|
|
94
129
|
|
|
95
130
|
The binary is a compiled executable, so each call still spawns a process. What the wrapper owns is the part consumers would otherwise duplicate: resolving the shipped binary, building the arguments, parsing the JSON, checking `schemaVersion`, and turning a non-zero exit or an exceeded `timeout` into a `ScannerError`. The result types are hand-written mirrors of the Swift `Codable` types, which is what the version check guards against drifting.
|
package/apple/ExpoModulesMacros
CHANGED
|
Binary file
|
package/apple/Package.swift
CHANGED
|
@@ -20,13 +20,18 @@ let package = Package(
|
|
|
20
20
|
.macro(
|
|
21
21
|
name: "ExpoModulesMacros",
|
|
22
22
|
dependencies: [
|
|
23
|
-
"
|
|
23
|
+
"ExpoModulesCLI",
|
|
24
24
|
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
|
|
25
25
|
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
|
|
26
|
+
// For `expand`, which runs the macros in process.
|
|
27
|
+
.product(name: "SwiftSyntaxMacroExpansion", package: "swift-syntax"),
|
|
28
|
+
.product(name: "SwiftParser", package: "swift-syntax"),
|
|
29
|
+
.product(name: "SwiftParserDiagnostics", package: "swift-syntax"),
|
|
30
|
+
.product(name: "SwiftBasicFormat", package: "swift-syntax"),
|
|
26
31
|
]
|
|
27
32
|
),
|
|
28
33
|
.target(
|
|
29
|
-
name: "
|
|
34
|
+
name: "ExpoModulesCLI",
|
|
30
35
|
dependencies: [
|
|
31
36
|
.product(name: "SwiftSyntax", package: "swift-syntax"),
|
|
32
37
|
.product(name: "SwiftParser", package: "swift-syntax"),
|
|
@@ -44,15 +49,16 @@ if FileManager.default.fileExists(atPath: Context.packageDirectory + "/Tests") {
|
|
|
44
49
|
name: "ExpoModulesMacrosTests",
|
|
45
50
|
dependencies: [
|
|
46
51
|
"ExpoModulesMacros",
|
|
52
|
+
"ExpoModulesCLI",
|
|
47
53
|
.product(name: "SwiftSyntaxMacrosGenericTestSupport", package: "swift-syntax"),
|
|
48
54
|
]
|
|
49
55
|
)
|
|
50
56
|
)
|
|
51
57
|
package.targets.append(
|
|
52
58
|
.testTarget(
|
|
53
|
-
name: "
|
|
59
|
+
name: "ExpoModulesCLITests",
|
|
54
60
|
dependencies: [
|
|
55
|
-
"
|
|
61
|
+
"ExpoModulesCLI",
|
|
56
62
|
.product(name: "SwiftParser", package: "swift-syntax"),
|
|
57
63
|
]
|
|
58
64
|
)
|
|
@@ -8,10 +8,15 @@
|
|
|
8
8
|
/// Subcommands:
|
|
9
9
|
/// scan-modules <path>... fast: top-level `@ExpoModule` types, for autolinking
|
|
10
10
|
/// scan-exports <path>... deep: full JS-exported surface, for TS type generation
|
|
11
|
+
/// expand <path>... the code that the macros generate, and their diagnostics
|
|
11
12
|
public enum ScannerCLI {
|
|
12
13
|
/// Runs the CLI for the given arguments (argv without the executable path) and returns the
|
|
13
14
|
/// process exit code: `0` on success, `1` if encoding the report fails, `2` on a usage error.
|
|
14
|
-
|
|
15
|
+
/// `expand` also returns `1` for an error diagnostic (see `Scanner.runExpand`).
|
|
16
|
+
///
|
|
17
|
+
/// `expander` expands the macros for `expand`. The plugin executable passes it, because the macro
|
|
18
|
+
/// implementations live there; without it, `expand` is a usage error.
|
|
19
|
+
public static func run(arguments: [String], expander: MacroExpander? = nil) -> Int32 {
|
|
15
20
|
// `-h`/`--help` anywhere is treated as a help request: print usage to stdout and exit 0.
|
|
16
21
|
if arguments.contains(where: { $0 == "-h" || $0 == "--help" }) {
|
|
17
22
|
writeToStandardOutput(usageText)
|
|
@@ -26,6 +31,7 @@ public enum ScannerCLI {
|
|
|
26
31
|
var paths: [String] = []
|
|
27
32
|
var platform: String?
|
|
28
33
|
var defines: [String] = []
|
|
34
|
+
var expandOptions = ExpandOptions()
|
|
29
35
|
|
|
30
36
|
var rest = arguments.dropFirst().makeIterator()
|
|
31
37
|
while let argument = rest.next() {
|
|
@@ -40,11 +46,25 @@ public enum ScannerCLI {
|
|
|
40
46
|
return usageError("--define requires a value")
|
|
41
47
|
}
|
|
42
48
|
defines.append(value)
|
|
49
|
+
case "--symbol":
|
|
50
|
+
guard let value = rest.next() else {
|
|
51
|
+
return usageError("--symbol requires a value")
|
|
52
|
+
}
|
|
53
|
+
expandOptions.symbols.append(value)
|
|
54
|
+
case "--json":
|
|
55
|
+
expandOptions.json = true
|
|
56
|
+
case "--diagnostics-only":
|
|
57
|
+
expandOptions.diagnosticsOnly = true
|
|
43
58
|
default:
|
|
44
59
|
paths.append(argument)
|
|
45
60
|
}
|
|
46
61
|
}
|
|
47
62
|
|
|
63
|
+
// The scans print JSON in any case and have no symbols to filter.
|
|
64
|
+
if subcommand != "expand" && expandOptions != ExpandOptions() {
|
|
65
|
+
return usageError("--symbol, --json and --diagnostics-only are options of expand")
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
switch subcommand {
|
|
49
69
|
case "scan-modules":
|
|
50
70
|
// scan-modules is platform-agnostic: each reported module carries the platforms that include
|
|
@@ -70,6 +90,19 @@ public enum ScannerCLI {
|
|
|
70
90
|
}
|
|
71
91
|
return Scanner.runExports(paths: paths)
|
|
72
92
|
|
|
93
|
+
case "expand":
|
|
94
|
+
guard platform == nil, defines.isEmpty else {
|
|
95
|
+
return usageError("expand does not support --platform or --define")
|
|
96
|
+
}
|
|
97
|
+
guard !paths.isEmpty else {
|
|
98
|
+
return usageError("expand requires at least one path")
|
|
99
|
+
}
|
|
100
|
+
guard let expander else {
|
|
101
|
+
return usageError("expand is only available in the macro plugin executable")
|
|
102
|
+
}
|
|
103
|
+
expandOptions.paths = paths
|
|
104
|
+
return Scanner.runExpand(options: expandOptions, expander: expander)
|
|
105
|
+
|
|
73
106
|
default:
|
|
74
107
|
return usageError("unknown subcommand '\(subcommand)'")
|
|
75
108
|
}
|
|
@@ -80,7 +113,7 @@ public enum ScannerCLI {
|
|
|
80
113
|
/// (the `ExpoModulesMacros` shipped in the package, or a locally built `ExpoModulesMacros-tool`).
|
|
81
114
|
private var toolName: String {
|
|
82
115
|
guard let path = CommandLine.arguments.first else {
|
|
83
|
-
return "
|
|
116
|
+
return "ExpoModulesMacros"
|
|
84
117
|
}
|
|
85
118
|
// Windows paths may use either separator.
|
|
86
119
|
return String(path.split(whereSeparator: { $0 == "/" || $0 == "\\" }).last ?? Substring(path))
|
|
@@ -93,11 +126,21 @@ private var usageText: String {
|
|
|
93
126
|
subcommands:
|
|
94
127
|
scan-modules fast scan for top-level @ExpoModule types (autolinking)
|
|
95
128
|
scan-exports deep scan of the full JS-exported surface (type generation)
|
|
129
|
+
expand print the code that the macros generate in the given .swift files (and in the
|
|
130
|
+
files of the given directories that use a macro), and the macro diagnostics
|
|
131
|
+
on stderr; exits with 1 when there is an error
|
|
96
132
|
|
|
97
133
|
options (scan-modules only):
|
|
98
134
|
--define <flag> treat a conditional compilation flag (e.g. DEBUG) as set when resolving
|
|
99
135
|
each module's 'platforms' list; repeatable
|
|
100
136
|
|
|
137
|
+
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
|
+
--diagnostics-only print only the diagnostics, followed by a summary
|
|
141
|
+
--json print a JSON report with the expansions and diagnostics instead; exits with
|
|
142
|
+
0 also when there is an error
|
|
143
|
+
|
|
101
144
|
options:
|
|
102
145
|
-h, --help print this help and exit
|
|
103
146
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/// Version of the `expand --json` output shape. Independent of the scan schema versions: bump it when
|
|
2
|
+
/// the shape of `ExpandResult` changes, together with `SUPPORTED_EXPAND_SCHEMA_VERSION` and the mirror
|
|
3
|
+
/// types in `src/types.ts`.
|
|
4
|
+
public let expandSchemaVersion = 1
|
|
5
|
+
|
|
6
|
+
/// The JSON report of `expand --json`.
|
|
7
|
+
public struct ExpandResult: Codable, Equatable, Sendable {
|
|
8
|
+
public let schemaVersion: Int
|
|
9
|
+
|
|
10
|
+
/// One entry per expanded file: the files in argument order, the files of a directory sorted.
|
|
11
|
+
public let files: [ExpandedFile]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// What the macros generate for one source file, and what they report about it.
|
|
15
|
+
public struct ExpandedFile: Codable, Equatable, Sendable {
|
|
16
|
+
/// The path as it was passed on the command line, or the absolute path of a file found in a
|
|
17
|
+
/// directory.
|
|
18
|
+
public let file: String
|
|
19
|
+
|
|
20
|
+
/// Without `--symbol`, one expansion of the whole file. With it, one expansion per symbol found in
|
|
21
|
+
/// this file, in the order of the options. Empty with `--diagnostics-only`.
|
|
22
|
+
public let expansions: [Expansion]
|
|
23
|
+
|
|
24
|
+
/// The syntax errors in the file and the diagnostics of the macros, in source order. With
|
|
25
|
+
/// `--symbol`, only those inside the declarations of the symbols.
|
|
26
|
+
public let diagnostics: [ExpansionDiagnostic]
|
|
27
|
+
|
|
28
|
+
public init(file: String, expansions: [Expansion], diagnostics: [ExpansionDiagnostic]) {
|
|
29
|
+
self.file = file
|
|
30
|
+
self.expansions = expansions
|
|
31
|
+
self.diagnostics = diagnostics
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Expanded source code.
|
|
36
|
+
public struct Expansion: Codable, Equatable, Sendable {
|
|
37
|
+
/// The `--symbol` value this expansion is for, or `nil` for the whole file.
|
|
38
|
+
public let symbol: String?
|
|
39
|
+
|
|
40
|
+
public let source: String
|
|
41
|
+
|
|
42
|
+
public init(symbol: String?, source: String) {
|
|
43
|
+
self.symbol = symbol
|
|
44
|
+
self.source = source
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// A diagnostic, at its location in the original source file. A diagnostic that a macro attaches to
|
|
49
|
+
/// the code it generates has the location of the macro attribute.
|
|
50
|
+
public struct ExpansionDiagnostic: Codable, Equatable, Sendable {
|
|
51
|
+
public enum Severity: String, Codable, Sendable {
|
|
52
|
+
case error
|
|
53
|
+
case warning
|
|
54
|
+
case note
|
|
55
|
+
case remark
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public let severity: Severity
|
|
59
|
+
public let message: String
|
|
60
|
+
|
|
61
|
+
/// 1-based, as in the compiler's output. The column counts UTF-8 bytes.
|
|
62
|
+
public let line: Int
|
|
63
|
+
public let column: Int
|
|
64
|
+
|
|
65
|
+
public let notes: [ExpansionNote]
|
|
66
|
+
|
|
67
|
+
/// The messages of the Fix-Its, for example `replace 'var' with 'let'`.
|
|
68
|
+
public let fixIts: [String]
|
|
69
|
+
|
|
70
|
+
public init(
|
|
71
|
+
severity: Severity,
|
|
72
|
+
message: String,
|
|
73
|
+
line: Int,
|
|
74
|
+
column: Int,
|
|
75
|
+
notes: [ExpansionNote] = [],
|
|
76
|
+
fixIts: [String] = []
|
|
77
|
+
) {
|
|
78
|
+
self.severity = severity
|
|
79
|
+
self.message = message
|
|
80
|
+
self.line = line
|
|
81
|
+
self.column = column
|
|
82
|
+
self.notes = notes
|
|
83
|
+
self.fixIts = fixIts
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// A note attached to a diagnostic, at its own location.
|
|
88
|
+
public struct ExpansionNote: Codable, Equatable, Sendable {
|
|
89
|
+
public let message: String
|
|
90
|
+
public let line: Int
|
|
91
|
+
public let column: Int
|
|
92
|
+
|
|
93
|
+
public init(message: String, line: Int, column: Int) {
|
|
94
|
+
self.message = message
|
|
95
|
+
self.line = line
|
|
96
|
+
self.column = column
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Expands the macros in one source file. The macro implementations live in the plugin executable,
|
|
101
|
+
/// which this library can't depend on, so the executable passes this in (see `ScannerCLI`).
|
|
102
|
+
public struct MacroExpander {
|
|
103
|
+
/// The attribute names of the macros, such as `ExpoModule`. A file found in a directory is expanded
|
|
104
|
+
/// only when it contains one of them after an `@`.
|
|
105
|
+
let attributeNames: Set<String>
|
|
106
|
+
|
|
107
|
+
/// - Parameters:
|
|
108
|
+
/// - source: The contents of the file.
|
|
109
|
+
/// - file: The path of the file, for the diagnostics and the expansion context.
|
|
110
|
+
/// - symbols: The `--symbol` values. Empty to expand the whole file.
|
|
111
|
+
let expand: (_ source: String, _ file: String, _ symbols: [String]) -> ExpandedFile
|
|
112
|
+
|
|
113
|
+
public init(
|
|
114
|
+
attributeNames: Set<String>,
|
|
115
|
+
expand: @escaping (_ source: String, _ file: String, _ symbols: [String]) -> ExpandedFile
|
|
116
|
+
) {
|
|
117
|
+
self.attributeNames = attributeNames
|
|
118
|
+
self.expand = expand
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#if canImport(FoundationEssentials)
|
|
2
|
+
import FoundationEssentials
|
|
3
|
+
#else
|
|
4
|
+
import Foundation
|
|
5
|
+
#endif
|
|
6
|
+
|
|
7
|
+
/// The options of `expand`, parsed by `ScannerCLI`.
|
|
8
|
+
struct ExpandOptions: Equatable {
|
|
9
|
+
var paths: [String] = []
|
|
10
|
+
var symbols: [String] = []
|
|
11
|
+
var json = false
|
|
12
|
+
var diagnosticsOnly = false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
extension Scanner {
|
|
16
|
+
/// Runs `expand`: expands the macros in each file with `expander` and prints the result. A
|
|
17
|
+
/// directory stands for the `.swift` files in it that use one of the macros.
|
|
18
|
+
///
|
|
19
|
+
/// As text, the expanded source goes to stdout and the diagnostics go to stderr in the
|
|
20
|
+
/// `<file>:<line>:<column>: <severity>: <message>` format of the compiler. The exit code is `1`
|
|
21
|
+
/// when there is an error diagnostic. With `--json`, the report goes to stdout and the exit code
|
|
22
|
+
/// is `0` also with error diagnostics, because the report holds them; `1` then means that encoding
|
|
23
|
+
/// failed. An input that can't be expanded (a missing path, a file that isn't Swift, a symbol that
|
|
24
|
+
/// no file declares) is a usage error, `2`.
|
|
25
|
+
static func runExpand(options: ExpandOptions, expander: MacroExpander) -> Int32 {
|
|
26
|
+
let result: ExpandResult
|
|
27
|
+
switch expandFiles(options: options, expander: expander) {
|
|
28
|
+
case .success(let expanded):
|
|
29
|
+
result = expanded
|
|
30
|
+
case .failure(let error):
|
|
31
|
+
writeToStandardError("error: \(error.message)\n")
|
|
32
|
+
return 2
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if options.json {
|
|
36
|
+
do {
|
|
37
|
+
let encoder = JSONEncoder()
|
|
38
|
+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
39
|
+
let data = try encoder.encode(result)
|
|
40
|
+
writeToStandardOutput(String(decoding: data, as: UTF8.self) + "\n")
|
|
41
|
+
return 0
|
|
42
|
+
} catch {
|
|
43
|
+
writeToStandardError("error: failed to encode results: \(error)\n")
|
|
44
|
+
return 1
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
writeToStandardOutput(renderExpansions(result.files))
|
|
49
|
+
writeToStandardError(renderDiagnostics(result.files))
|
|
50
|
+
if options.diagnosticsOnly {
|
|
51
|
+
writeToStandardError(renderSummary(result.files))
|
|
52
|
+
}
|
|
53
|
+
let hasErrors = result.files.contains { $0.diagnostics.contains { $0.severity == .error } }
|
|
54
|
+
return hasErrors ? 1 : 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// The absolute path of `path` with symbolic links resolved, to tell whether two paths name the same
|
|
59
|
+
/// file. A path that the directory walk returns can differ from the one passed for the same file, for
|
|
60
|
+
/// example `/private/var` and `/var` on macOS.
|
|
61
|
+
private func fileIdentity(_ path: String) -> String {
|
|
62
|
+
URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// An input that `expand` can't take.
|
|
66
|
+
struct ExpandInputError: Error, Equatable {
|
|
67
|
+
let message: String
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Reads the files of `options`, expands each with `expander`, and returns the report that `expand`
|
|
71
|
+
/// prints. Separate from `runExpand` so tests can drive it without stdout.
|
|
72
|
+
///
|
|
73
|
+
/// A file keeps its path as it was passed and is always expanded. A directory is walked like the
|
|
74
|
+
/// scans walk it (`swiftFiles(in:)`, which skips dependency, build, test and example directories),
|
|
75
|
+
/// and only the files that use one of the macros are expanded, with their absolute paths. A file that
|
|
76
|
+
/// a directory also contains is expanded once.
|
|
77
|
+
func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<ExpandResult, ExpandInputError> {
|
|
78
|
+
let prefilter = MacroPrefilter(attributeNames: expander.attributeNames)
|
|
79
|
+
var sources: [(file: String, source: String)] = []
|
|
80
|
+
var seen: Set<String> = []
|
|
81
|
+
for path in options.paths {
|
|
82
|
+
switch isDirectory(atPath: path) {
|
|
83
|
+
case nil:
|
|
84
|
+
return .failure(ExpandInputError(message: "no such file or directory '\(path)'"))
|
|
85
|
+
case .some(true):
|
|
86
|
+
for file in swiftFiles(in: [path]) where !seen.contains(fileIdentity(file)) {
|
|
87
|
+
guard let source = try? String(contentsOfFile: file, encoding: .utf8) else {
|
|
88
|
+
return .failure(ExpandInputError(message: "could not read '\(file)'"))
|
|
89
|
+
}
|
|
90
|
+
seen.insert(fileIdentity(file))
|
|
91
|
+
if mightContainMacro(in: source, prefilter: prefilter) {
|
|
92
|
+
sources.append((file, source))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
case .some(false):
|
|
96
|
+
guard path.hasSuffix(".swift") else {
|
|
97
|
+
return .failure(ExpandInputError(message: "expand takes .swift files and directories, but got '\(path)'"))
|
|
98
|
+
}
|
|
99
|
+
guard !seen.contains(fileIdentity(path)) else {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
guard let source = try? String(contentsOfFile: path, encoding: .utf8) else {
|
|
103
|
+
return .failure(ExpandInputError(message: "could not read '\(path)'"))
|
|
104
|
+
}
|
|
105
|
+
seen.insert(fileIdentity(path))
|
|
106
|
+
sources.append((path, source))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let files = sources.map { expander.expand($0.source, $0.file, options.symbols) }
|
|
111
|
+
|
|
112
|
+
let found = Set(files.flatMap { $0.expansions.compactMap(\.symbol) })
|
|
113
|
+
if let missing = options.symbols.first(where: { !found.contains($0) }) {
|
|
114
|
+
return .failure(
|
|
115
|
+
ExpandInputError(
|
|
116
|
+
message: "no declaration of '\(missing)' in the given files; name a type ('Module'), a nested "
|
|
117
|
+
+ "type ('Module.Options') or a member ('Module.method')"
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return .success(
|
|
123
|
+
ExpandResult(
|
|
124
|
+
schemaVersion: expandSchemaVersion,
|
|
125
|
+
files: options.diagnosticsOnly
|
|
126
|
+
? files.map { ExpandedFile(file: $0.file, expansions: [], diagnostics: $0.diagnostics) }
|
|
127
|
+
: files
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// The expanded sources as text. A single expansion is printed as it is; more than one each get a
|
|
133
|
+
/// `// <file>` or `// <file>: <symbol>` header and are separated by an empty line.
|
|
134
|
+
func renderExpansions(_ files: [ExpandedFile]) -> String {
|
|
135
|
+
let expansions = files.flatMap { file in file.expansions.map { (file: file.file, expansion: $0) } }
|
|
136
|
+
if expansions.count == 1, let only = expansions.first {
|
|
137
|
+
return withTrailingNewline(only.expansion.source)
|
|
138
|
+
}
|
|
139
|
+
return expansions.map { file, expansion in
|
|
140
|
+
let header = expansion.symbol.map { "// \(file): \($0)" } ?? "// \(file)"
|
|
141
|
+
return "\(header)\n\n" + withTrailingNewline(expansion.source)
|
|
142
|
+
}
|
|
143
|
+
.joined(separator: "\n")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// The diagnostics in the compiler's format, one line each, followed by their notes and Fix-Its.
|
|
147
|
+
func renderDiagnostics(_ files: [ExpandedFile]) -> String {
|
|
148
|
+
var text = ""
|
|
149
|
+
for file in files {
|
|
150
|
+
for diagnostic in file.diagnostics {
|
|
151
|
+
text +=
|
|
152
|
+
"\(file.file):\(diagnostic.line):\(diagnostic.column): \(diagnostic.severity.rawValue): \(diagnostic.message)\n"
|
|
153
|
+
for note in diagnostic.notes {
|
|
154
|
+
text += "\(file.file):\(note.line):\(note.column): note: \(note.message)\n"
|
|
155
|
+
}
|
|
156
|
+
for fixIt in diagnostic.fixIts {
|
|
157
|
+
text += "\(file.file):\(diagnostic.line):\(diagnostic.column): note: fix-it: \(fixIt)\n"
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return text
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// The summary line of `--diagnostics-only`, for example `1 error and 2 warnings in 3 files`.
|
|
165
|
+
func renderSummary(_ files: [ExpandedFile]) -> String {
|
|
166
|
+
let diagnostics = files.flatMap(\.diagnostics)
|
|
167
|
+
let errors = diagnostics.filter { $0.severity == .error }.count
|
|
168
|
+
let warnings = diagnostics.filter { $0.severity == .warning }.count
|
|
169
|
+
let checked = count(files.count, "file")
|
|
170
|
+
if errors == 0 && warnings == 0 {
|
|
171
|
+
return "No macro errors or warnings in \(checked).\n"
|
|
172
|
+
}
|
|
173
|
+
let parts = [errors > 0 ? count(errors, "error") : nil, warnings > 0 ? count(warnings, "warning") : nil]
|
|
174
|
+
return "\(parts.compactMap { $0 }.joined(separator: " and ")) in \(checked).\n"
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private func count(_ n: Int, _ noun: String) -> String {
|
|
178
|
+
"\(n) \(noun)\(n == 1 ? "" : "s")"
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private func withTrailingNewline(_ text: String) -> String {
|
|
182
|
+
text.hasSuffix("\n") ? text : text + "\n"
|
|
183
|
+
}
|
|
@@ -102,7 +102,13 @@ struct MacroPrefilter {
|
|
|
102
102
|
private let bareNames: [[UInt8]]
|
|
103
103
|
|
|
104
104
|
init(macros: Set<DetectedMacro>, conformances: Set<String> = []) {
|
|
105
|
-
|
|
105
|
+
self.init(attributeNames: Set(macros.map(\.rawValue)), conformances: conformances)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// A pre-filter for attributes that `DetectedMacro` doesn't list, such as every macro that `expand`
|
|
109
|
+
/// runs.
|
|
110
|
+
init(attributeNames: Set<String>, conformances: Set<String> = []) {
|
|
111
|
+
attributes = attributeNames.map { Array($0.utf8) }
|
|
106
112
|
bareNames = conformances.map { Array($0.utf8) }
|
|
107
113
|
}
|
|
108
114
|
|
|
@@ -196,7 +202,7 @@ func swiftFiles(in paths: [String]) -> [String] {
|
|
|
196
202
|
}
|
|
197
203
|
|
|
198
204
|
/// Whether `path` is a directory (following symbolic links), or `nil` if nothing exists there.
|
|
199
|
-
|
|
205
|
+
func isDirectory(atPath path: String, fileManager: FileManager = .default) -> Bool? {
|
|
200
206
|
#if canImport(FoundationEssentials)
|
|
201
207
|
var isDirectory = false
|
|
202
208
|
#else
|