expo-modules-macros 0.25.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.
Files changed (48) hide show
  1. package/AGENTS.md +8 -8
  2. package/README.md +40 -3
  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/Exports → ExpoModulesCLI/Commands/ScanExports}/ExportedSurface.swift +3 -2
  9. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ResolveRefs.swift +9 -0
  10. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/SurfaceVisitor.swift +10 -3
  11. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/TupleTypeAliases.swift +126 -0
  12. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/TypeNode.swift +47 -4
  13. package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/SourceScan.swift +8 -2
  14. package/apple/Sources/ExpoModulesMacros/Expand/MacroExpander.swift +484 -0
  15. package/apple/Sources/ExpoModulesMacros/{EventMacro.swift → Macros/EventMacro.swift} +23 -10
  16. package/apple/Sources/ExpoModulesMacros/{ExpoModuleMacro.swift → Macros/ExpoModuleMacro.swift} +13 -1
  17. package/apple/Sources/ExpoModulesMacros/{JSMacro.swift → Macros/JSMacro.swift} +71 -54
  18. package/apple/Sources/ExpoModulesMacros/{RecordMacro.swift → Macros/RecordMacro.swift} +18 -5
  19. package/apple/Sources/ExpoModulesMacros/{SharedObjectMacro.swift → Macros/SharedObjectMacro.swift} +13 -1
  20. package/apple/Sources/ExpoModulesMacros/{UnionMacro.swift → Macros/UnionMacro.swift} +16 -5
  21. package/apple/Sources/ExpoModulesMacros/Plugin.swift +9 -14
  22. package/apple/Sources/ExpoModulesMacros/ProvidedMacros.swift +31 -0
  23. package/apple/Sources/ExpoModulesMacros/{ClosureArguments.swift → Support/ClosureArguments.swift} +30 -4
  24. package/apple/Sources/ExpoModulesMacros/{DecorateModuleBuilder.swift → Support/DecorateModuleBuilder.swift} +5 -1
  25. package/apple/Sources/ExpoModulesMacros/{MacroHelpers.swift → Support/MacroHelpers.swift} +11 -4
  26. package/apple/Sources/ExpoModulesMacros/Support/TupleTypes.swift +272 -0
  27. package/apple/Sources/ExpoModulesMacros/Support/TypeAliases.swift +75 -0
  28. package/apple/Sources/ExpoModulesMacros/{TypeConformanceAssertion.swift → Support/TypeConformanceAssertion.swift} +104 -41
  29. package/build/index.d.ts +31 -1
  30. package/build/index.js +34 -0
  31. package/build/types.d.ts +77 -2
  32. package/build/types.js +4 -3
  33. package/package.json +5 -5
  34. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/Availability.swift +0 -0
  35. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ScanExports.swift +0 -0
  36. /package/apple/Sources/{ExpoModulesScanner/Modules → ExpoModulesCLI/Commands/ScanModules}/ScanModules.swift +0 -0
  37. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/Detection.swift +0 -0
  38. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/DetectionVisitor.swift +0 -0
  39. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/ScanBuildConfiguration.swift +0 -0
  40. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/StandardStreams.swift +0 -0
  41. /package/apple/Sources/ExpoModulesMacros/{ExpoModulesOptimizedMacro.swift → Macros/ExpoModulesOptimizedMacro.swift} +0 -0
  42. /package/apple/Sources/ExpoModulesMacros/{ExpoViewMacro.swift → Macros/ExpoViewMacro.swift} +0 -0
  43. /package/apple/Sources/ExpoModulesMacros/{ViewPropsMacro.swift → Macros/ViewPropsMacro.swift} +0 -0
  44. /package/apple/Sources/ExpoModulesMacros/{FreeFormTypes.swift → Support/FreeFormTypes.swift} +0 -0
  45. /package/apple/Sources/ExpoModulesMacros/{JSConstructor.swift → Support/JSConstructor.swift} +0 -0
  46. /package/apple/Sources/ExpoModulesMacros/{JSNameCollisions.swift → Support/JSNameCollisions.swift} +0 -0
  47. /package/apple/Sources/ExpoModulesMacros/{OptimizedFunctionHelpers.swift → Support/OptimizedFunctionHelpers.swift} +0 -0
  48. /package/apple/Sources/ExpoModulesMacros/{Receiver.swift → Support/Receiver.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
@@ -25,12 +25,14 @@ 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.
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, so a bad type is reported on the author's own declaration rather than on the enclosing type. A `lazy var` is read-only from JavaScript. On the first read, its getter stores the value as a data property on the JavaScript object, so later reads don't call into native code. On a frozen object the value isn't stored, and each read calls the getter. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. The `.withAsync("name")` option binds a synchronous function a second time under that name, as a function that returns a promise and runs the body on the concurrent thread pool. That function isn't isolated to the JavaScript thread, so its body must be safe to run on any thread. The macro reports a function isolated to a global actor through its own attribute or its type's. When the isolation comes from elsewhere, such as a protocol or the module's default isolation, 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.
28
+ - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. Two `@JS` members with the same JS name on one JavaScript object are a compile-time error, since JavaScript could reach only one of them. A shared object's static and instance members bind onto different objects, so they can share a name. The macro also checks that every type crossing the boundary is convertible in the direction it travels. Outside a module or shared object, a bad type is reported on the author's own declaration. In an `@ExpoModule` or `@SharedObject` class, the class macro makes the check, since only it sees the type aliases in the class body, so the error is reported on the class and names the member and its argument labels, such as `local function 'move(_:by:returning:)' requires that 'Point' conform to 'JavaScriptEncodable'`. A `lazy var` is read-only from JavaScript. On the first read, its getter stores the value as a data property on the JavaScript object, so later reads don't call into native code. On a frozen object the value isn't stored, and each read calls the getter. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. The `.withAsync("name")` option binds a synchronous function a second time under that name, as a function that returns a promise and runs the body on the concurrent thread pool. That function isn't isolated to the JavaScript thread, so its body must be safe to run on any thread. The macro reports a function isolated to a global actor through its own attribute or its type's. When the isolation comes from elsewhere, such as a protocol or the module's default isolation, 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
29
  - **`@Event(_ name: String? = nil, sync: Bool = false)`** 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. `sync: true` dispatches inline instead of asynchronously.
30
30
  - **`@SharedObject(_ name: String? = nil)`** on a subclass of `SharedObject`, direct or not, such as a `SharedRef<T>` subclass. Binds the class's `@JS` members through the `SharedObject` hooks that core calls when it builds the JavaScript class, and emits the JS class name, which defaults to the class name. A module exposes the class through `@ExpoModule(classes:)`, which only accepts `SharedObject` subclasses.
31
31
  - **`@Record()`** on a struct, or on a final class with no inheritance clause (declare a class's protocol conformances in an extension). Treats every non-static, non-private, non-computed stored property as a field, with no per-field wrapper, and synthesizes the memberwise initializer, the conversions in both directions, and the `Record` conformance. Requiredness is inferred: a default value makes a field optional, an optional type makes it nullable and optional.
32
32
  - **`@Union()`** on an enum whose cases each carry one associated value. Models a TypeScript union. Synthesizes the conversions in both directions, a typed accessor per payload type, and the `JavaScriptDecodable` and `JavaScriptEncodable` conformances. Decoding is ordered: the first case whose payload decodes wins, so the more specific case goes first.
33
33
 
34
+ A tuple can cross the boundary as a `@JS` argument, result or property, a `@Record` property, a `@Union` payload, an `@Event` payload, or a parameter or result of a closure argument that isn't `async`. An unlabeled tuple such as `(Int, String)` maps to an array with one value per element, and a labeled tuple such as `(x: Double, y: Double)` to an object with one property per label. A tuple must have two or more elements, all labeled or none, and each element must be convertible. Tuples nest in other tuples, optionals, arrays and `String`-keyed dictionaries. `@ExpoModule` and `@SharedObject` also resolve a type alias of a tuple, such as `typealias Span = (start: Int, end: Int)`, when the class body declares it. A macro sees only the declaration it is attached to, so an alias declared anywhere else doesn't resolve.
35
+
34
36
  How that looks in a module:
35
37
 
36
38
  ```swift
@@ -72,22 +74,57 @@ ExpoModulesMacros <subcommand> [options] <path> [<path> ...]
72
74
  subcommands:
73
75
  scan-modules fast scan for top-level @ExpoModule types (autolinking)
74
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
75
78
 
76
79
  options (scan-modules only):
77
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/
78
98
  ```
79
99
 
80
- 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.
81
117
 
82
118
  # TypeScript wrapper
83
119
 
84
120
  Node consumers can call the scanner through this package instead of locating the binary and shelling out themselves:
85
121
 
86
122
  ```ts
87
- import { scanModules, scanExports } from 'expo-modules-macros';
123
+ import { expandMacros, scanModules, scanExports } from 'expo-modules-macros';
88
124
 
89
125
  const { modules, warnings } = await scanModules(['ios/'], { defines: ['DEBUG'], timeout: 30_000 });
90
126
  const { exports } = await scanExports(['ios/']);
127
+ const { files } = await expandMacros(['ios/HapticsModule.swift'], { symbols: ['HapticsModule.impactAsync'] });
91
128
  ```
92
129
 
93
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
+ }
@@ -463,8 +463,9 @@ struct ExportedSurface: Encodable, Equatable {
463
463
  /// qualified names and gives a resolved ref the name of its declaration; version 8 reports
464
464
  /// declarations in `#if` blocks, adds `platforms` (with deprecations), `accessLevel`, and
465
465
  /// `defaultValue`, and replaces `constructorParameters` with `initializer`; version 9 adds the
466
- /// `sharedRef` type kind and the `sharedRef` of a shared object.
467
- let scanExportsSchemaVersion = 9
466
+ /// `sharedRef` type kind and the `sharedRef` of a shared object; version 10 adds the `tuple` and `object` type kinds (named after a tuple alias)
467
+ /// and resolves the tuple type aliases declared in `@ExpoModule` and `@SharedObject` classes.
468
+ let scanExportsSchemaVersion = 10
468
469
 
469
470
  /// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from
470
471
  /// `ScanModulesResult` (different consumer: TS generation vs. autolinking).
@@ -136,6 +136,15 @@ extension TypeNode {
136
136
  )
137
137
  case .promise(let value):
138
138
  return .promise(value: value.resolvingRefs(using: index, in: scope))
139
+ case .tuple(let elements, let name):
140
+ return .tuple(elements: elements.map { $0.resolvingRefs(using: index, in: scope) }, name: name)
141
+ case .object(let properties, let name):
142
+ return .object(
143
+ properties: properties.map {
144
+ TypeNodeProperty(name: $0.name, type: $0.type.resolvingRefs(using: index, in: scope))
145
+ },
146
+ name: name
147
+ )
139
148
  case .function(let parameters, let returns, let isAsync, let isThrowing):
140
149
  return .function(
141
150
  parameters: parameters.map { $0.resolvingRefs(using: index, in: scope) },
@@ -142,13 +142,14 @@ final class SurfaceVisitor: SyntaxVisitor {
142
142
  guard scope.isEmpty else {
143
143
  return
144
144
  }
145
+ let aliases = TupleTypeAliases(in: members, scope: qualified)
145
146
  let (functions, properties, events, _) = collectJSMembers(members)
146
147
  modules.append(
147
148
  ExportedModule(
148
149
  name: qualified,
149
150
  jsName: stringArgument(of: attribute) ?? name,
150
- functions: functions,
151
- properties: properties,
151
+ functions: functions.map(aliases.resolving),
152
+ properties: properties.map(aliases.resolving),
152
153
  events: events,
153
154
  // `jsName` is filled in by `ExportedSurface.resolvingRefs()`, since the `@SharedObject` can
154
155
  // be declared in another file.
@@ -164,7 +165,13 @@ final class SurfaceVisitor: SyntaxVisitor {
164
165
  }
165
166
 
166
167
  if let attribute = attributes.firstAttribute(named: DetectedMacro.sharedObject.rawValue) {
167
- let (functions, properties, events, initializer) = collectJSMembers(members)
168
+ let aliases = TupleTypeAliases(in: members, scope: qualified)
169
+ var (functions, properties, events, initializer) = collectJSMembers(members)
170
+ functions = functions.map(aliases.resolving)
171
+ properties = properties.map(aliases.resolving)
172
+ if let parameters = initializer?.parameters {
173
+ initializer?.parameters = parameters.map(aliases.resolving)
174
+ }
168
175
  sharedObjects.append(
169
176
  ExportedSharedObject(
170
177
  name: qualified,