expo-modules-macros 0.26.0 → 0.27.1

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.
Files changed (48) hide show
  1. package/AGENTS.md +8 -8
  2. package/README.md +37 -2
  3. package/apple/ExpoModulesMacros +0 -0
  4. package/apple/Package.swift +10 -4
  5. package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/CLI.swift +45 -2
  6. package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +120 -0
  7. package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +183 -0
  8. package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/SourceScan.swift +8 -2
  9. package/apple/Sources/ExpoModulesMacros/Expand/MacroExpander.swift +484 -0
  10. package/apple/Sources/ExpoModulesMacros/{EventMacro.swift → Macros/EventMacro.swift} +1 -1
  11. package/apple/Sources/ExpoModulesMacros/{RecordMacro.swift → Macros/RecordMacro.swift} +4 -2
  12. package/apple/Sources/ExpoModulesMacros/{UnionMacro.swift → Macros/UnionMacro.swift} +2 -2
  13. package/apple/Sources/ExpoModulesMacros/Plugin.swift +9 -14
  14. package/apple/Sources/ExpoModulesMacros/ProvidedMacros.swift +31 -0
  15. package/apple/Sources/ExpoModulesMacros/{ClosureArguments.swift → Support/ClosureArguments.swift} +4 -2
  16. package/apple/Sources/ExpoModulesMacros/{DecorateModuleBuilder.swift → Support/DecorateModuleBuilder.swift} +14 -6
  17. package/apple/Sources/ExpoModulesMacros/{TupleTypes.swift → Support/TupleTypes.swift} +50 -12
  18. package/build/index.d.ts +31 -1
  19. package/build/index.js +34 -0
  20. package/build/types.d.ts +45 -1
  21. package/build/types.js +3 -2
  22. package/package.json +5 -5
  23. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/Availability.swift +0 -0
  24. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ExportedSurface.swift +0 -0
  25. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ResolveRefs.swift +0 -0
  26. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ScanExports.swift +0 -0
  27. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/SurfaceVisitor.swift +0 -0
  28. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/TupleTypeAliases.swift +0 -0
  29. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/TypeNode.swift +0 -0
  30. /package/apple/Sources/{ExpoModulesScanner/Modules → ExpoModulesCLI/Commands/ScanModules}/ScanModules.swift +0 -0
  31. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/Detection.swift +0 -0
  32. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/DetectionVisitor.swift +0 -0
  33. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/ScanBuildConfiguration.swift +0 -0
  34. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/StandardStreams.swift +0 -0
  35. /package/apple/Sources/ExpoModulesMacros/{ExpoModuleMacro.swift → Macros/ExpoModuleMacro.swift} +0 -0
  36. /package/apple/Sources/ExpoModulesMacros/{ExpoModulesOptimizedMacro.swift → Macros/ExpoModulesOptimizedMacro.swift} +0 -0
  37. /package/apple/Sources/ExpoModulesMacros/{ExpoViewMacro.swift → Macros/ExpoViewMacro.swift} +0 -0
  38. /package/apple/Sources/ExpoModulesMacros/{JSMacro.swift → Macros/JSMacro.swift} +0 -0
  39. /package/apple/Sources/ExpoModulesMacros/{SharedObjectMacro.swift → Macros/SharedObjectMacro.swift} +0 -0
  40. /package/apple/Sources/ExpoModulesMacros/{ViewPropsMacro.swift → Macros/ViewPropsMacro.swift} +0 -0
  41. /package/apple/Sources/ExpoModulesMacros/{FreeFormTypes.swift → Support/FreeFormTypes.swift} +0 -0
  42. /package/apple/Sources/ExpoModulesMacros/{JSConstructor.swift → Support/JSConstructor.swift} +0 -0
  43. /package/apple/Sources/ExpoModulesMacros/{JSNameCollisions.swift → Support/JSNameCollisions.swift} +0 -0
  44. /package/apple/Sources/ExpoModulesMacros/{MacroHelpers.swift → Support/MacroHelpers.swift} +0 -0
  45. /package/apple/Sources/ExpoModulesMacros/{OptimizedFunctionHelpers.swift → Support/OptimizedFunctionHelpers.swift} +0 -0
  46. /package/apple/Sources/ExpoModulesMacros/{Receiver.swift → Support/Receiver.swift} +0 -0
  47. /package/apple/Sources/ExpoModulesMacros/{TypeAliases.swift → Support/TypeAliases.swift} +0 -0
  48. /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 macro implementations. `Plugin.swift` registers every macro in `providingMacros` and holds the entry point that sends any invocation with arguments to the scanner CLI.
9
- - `apple/Sources/ExpoModulesScanner`: the scanner library. `Modules/` implements `scan-modules` (autolinking), `Exports/` implements `scan-exports` (type generation), `Core/` holds the shared parsing and `#if` evaluation, and `CLI.swift` is the command-line front end.
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 `ExpoModulesScannerTests`.
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
- - Scanner tests reach internal API with `@testable import ExpoModulesScanner`. Do not make scanner types `public` only for tests.
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 `providingMacros` in `Plugin.swift`, an expansion test file, the `#externalMacro` declaration in `expo-modules-core`, and an entry in the macro list in `README.md`.
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 `providingMacros` in `Plugin.swift`.
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` (`Modules/ScanModules.swift`) and `scanExportsSchemaVersion` (`Exports/ExportedSurface.swift`) change independently. When the output shape of a command changes, bump its version and update the matching `SUPPORTED_SCAN_*_SCHEMA_VERSION` constant and mirror types in `src/types.ts`. `expo-modules-autolinking` also checks the `scan-modules` version.
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
- Each path is a `.swift` file or a directory, scanned recursively for `.swift` files. Both subcommands 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.
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.
Binary file
@@ -20,13 +20,18 @@ let package = Package(
20
20
  .macro(
21
21
  name: "ExpoModulesMacros",
22
22
  dependencies: [
23
- "ExpoModulesScanner",
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: "ExpoModulesScanner",
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: "ExpoModulesScannerTests",
59
+ name: "ExpoModulesCLITests",
54
60
  dependencies: [
55
- "ExpoModulesScanner",
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
- public static func run(arguments: [String]) -> Int32 {
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 "ExpoModulesScanner"
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
- attributes = macros.map { Array($0.rawValue.utf8) }
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
- private func isDirectory(atPath path: String, fileManager: FileManager) -> Bool? {
205
+ func isDirectory(atPath path: String, fileManager: FileManager = .default) -> Bool? {
200
206
  #if canImport(FoundationEssentials)
201
207
  var isDirectory = false
202
208
  #else