expo-modules-macros 0.27.1 → 0.29.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 (44) hide show
  1. package/.github/workflows/linux.yml +3 -7
  2. package/.github/workflows/publish.yml +2 -3
  3. package/.github/workflows/swift.yml +7 -13
  4. package/.github/workflows/windows.yml +3 -10
  5. package/AGENTS.md +10 -7
  6. package/Package.swift +110 -0
  7. package/README.md +31 -8
  8. package/apple/ExpoModulesMacros +0 -0
  9. package/apple/Sources/ExpoModulesCLI/CLI.swift +1 -1
  10. package/apple/Sources/{ExpoModulesMacros → ExpoModulesCLI/Commands}/Expand/MacroExpander.swift +1 -1
  11. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +21 -4
  12. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/InheritedMembers.swift +96 -0
  13. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ScanExports.swift +3 -2
  14. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +42 -11
  15. package/apple/Sources/ExpoModulesMacros/main.swift +6 -0
  16. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoModuleMacro.swift +12 -6
  17. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/RecordMacro.swift +29 -71
  18. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/SharedObjectMacro.swift +30 -3
  19. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/UnionMacro.swift +6 -4
  20. package/apple/Sources/ExpoModulesMacrosCore/PluginServer.swift +40 -0
  21. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/ProvidedMacros.swift +7 -7
  22. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/DecorateModuleBuilder.swift +44 -9
  23. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/JSConstructor.swift +15 -2
  24. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/MacroHelpers.swift +2 -3
  25. package/apple/Sources/ExpoModulesMacrosCore/Support/SendableConformance.swift +36 -0
  26. package/apple/Sources/ExpoModulesMacrosTool/main.swift +27 -0
  27. package/build/types.d.ts +11 -3
  28. package/package.json +5 -5
  29. package/apple/Package.swift +0 -66
  30. package/apple/Sources/ExpoModulesMacros/Plugin.swift +0 -36
  31. /package/{apple/Package.resolved → Package.resolved} +0 -0
  32. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/EventMacro.swift +0 -0
  33. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoModulesOptimizedMacro.swift +0 -0
  34. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoViewMacro.swift +0 -0
  35. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/JSMacro.swift +0 -0
  36. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ViewPropsMacro.swift +0 -0
  37. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/ClosureArguments.swift +0 -0
  38. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/FreeFormTypes.swift +0 -0
  39. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/JSNameCollisions.swift +0 -0
  40. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/OptimizedFunctionHelpers.swift +0 -0
  41. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/Receiver.swift +0 -0
  42. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TupleTypes.swift +0 -0
  43. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TypeAliases.swift +0 -0
  44. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TypeConformanceAssertion.swift +0 -0
@@ -15,7 +15,6 @@ concurrency:
15
15
 
16
16
  defaults:
17
17
  run:
18
- working-directory: apple
19
18
  shell: bash
20
19
 
21
20
  jobs:
@@ -56,8 +55,8 @@ jobs:
56
55
  - name: Cache SwiftPM build
57
56
  uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
58
57
  with:
59
- path: apple/.build
60
- key: spm-${{ github.repository }}-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('apple/Package.resolved') }}
58
+ path: .build
59
+ key: spm-${{ github.repository }}-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('Package.resolved') }}
61
60
  restore-keys: |
62
61
  spm-${{ github.repository }}-${{ runner.os }}-${{ matrix.arch }}-
63
62
 
@@ -70,14 +69,13 @@ jobs:
70
69
  - name: Build release tool
71
70
  # Runs `pnpm run build` (scripts/build.js): builds the release plugin binary for this
72
71
  # architecture as platforms/linux-<arch>/ExpoModulesMacros.
73
- working-directory: ${{ github.workspace }}
74
72
  run: pnpm run build
75
73
 
76
74
  - name: Verify the release tool
77
75
  # With no arguments it is the plugin server, which exits when stdin closes; with arguments it
78
76
  # is the scanner CLI.
79
77
  run: |
80
- tool="../platforms/linux-${{ matrix.arch }}/ExpoModulesMacros"
78
+ tool="platforms/linux-${{ matrix.arch }}/ExpoModulesMacros"
81
79
  # The Swift runtime must be linked in statically, so no Swift library may be a dependency.
82
80
  needed="$(readelf --dynamic "$tool" | sed -n 's/.*(NEEDED).*\[\(.*\)\]$/\1/p')"
83
81
  echo "Needed libraries:" $needed
@@ -98,7 +96,6 @@ jobs:
98
96
  # Checks what `npm pack` would publish from `platforms/linux-<arch>`, where the release build
99
97
  # put the tool: the executable with its executable bit, the README, the license and the
100
98
  # manifest, with the `os`, `cpu` and `libc` fields for this machine.
101
- working-directory: ${{ github.workspace }}
102
99
  run: |
103
100
  # The `./` matters: npm reads `platforms/linux-<arch>` as a GitHub repository.
104
101
  npm pack "./platforms/linux-${{ matrix.arch }}" --pack-destination "$RUNNER_TEMP" --json > "$RUNNER_TEMP/pack.json"
@@ -126,7 +123,6 @@ jobs:
126
123
  - name: Scan through the TypeScript wrapper
127
124
  # Installs the packed platform package. The wrapper must find the executable in
128
125
  # `@expo/modules-macros-linux-<arch>`, as it would in a project, not the local build.
129
- working-directory: ${{ github.workspace }}
130
126
  run: |
131
127
  pnpm install
132
128
  pnpm add --save-optional "$PLATFORM_PACKAGE_TARBALL"
@@ -284,14 +284,13 @@ jobs:
284
284
  - name: Cache SwiftPM build
285
285
  uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
286
286
  with:
287
- path: apple/.build
288
- key: spm-${{ github.repository }}-${{ runner.os }}-${{ hashFiles('apple/Package.resolved') }}
287
+ path: .build
288
+ key: spm-${{ github.repository }}-${{ runner.os }}-${{ hashFiles('Package.resolved') }}
289
289
  restore-keys: |
290
290
  spm-${{ github.repository }}-${{ runner.os }}-
291
291
 
292
292
  - name: Resolve dependencies
293
293
  run: swift package resolve
294
- working-directory: apple
295
294
 
296
295
  - name: Install dependencies
297
296
  # The wrapper's `prepublishOnly` build needs `tsc`; the tool binary needs none.
@@ -11,10 +11,6 @@ concurrency:
11
11
  group: ci-${{ github.workflow }}-${{ github.ref }}
12
12
  cancel-in-progress: true
13
13
 
14
- defaults:
15
- run:
16
- working-directory: apple
17
-
18
14
  jobs:
19
15
  build-and-test:
20
16
  name: Build & test
@@ -34,8 +30,8 @@ jobs:
34
30
  - name: Cache SwiftPM build
35
31
  uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
36
32
  with:
37
- path: apple/.build
38
- key: spm-${{ github.repository }}-${{ runner.os }}-${{ hashFiles('apple/Package.resolved') }}
33
+ path: .build
34
+ key: spm-${{ github.repository }}-${{ runner.os }}-${{ hashFiles('Package.resolved') }}
39
35
  restore-keys: |
40
36
  spm-${{ github.repository }}-${{ runner.os }}-
41
37
 
@@ -46,26 +42,24 @@ jobs:
46
42
  # Runs `pnpm run build` (scripts/build.js): builds the universal
47
43
  # (arm64 + x86_64) release plugin binary, copies it to
48
44
  # apple/ExpoModulesMacros, and strips it.
49
- working-directory: ${{ github.workspace }}
50
45
  run: pnpm run build
51
46
 
52
47
  - name: Verify universal binary
53
48
  run: |
54
- archs="$(lipo -archs ExpoModulesMacros)"
49
+ archs="$(lipo -archs apple/ExpoModulesMacros)"
55
50
  echo "Architectures: $archs"
56
51
  case "$archs" in *arm64*) ;; *) echo "Missing arm64 slice"; exit 1;; esac
57
52
  case "$archs" in *x86_64*) ;; *) echo "Missing x86_64 slice"; exit 1;; esac
58
- arch -arm64 ./ExpoModulesMacros < /dev/null
59
- arch -x86_64 ./ExpoModulesMacros < /dev/null
53
+ arch -arm64 apple/ExpoModulesMacros < /dev/null
54
+ arch -x86_64 apple/ExpoModulesMacros < /dev/null
60
55
  # With arguments the same binary runs the scanner CLI instead of the plugin server.
61
- arch -arm64 ./ExpoModulesMacros --help > /dev/null
62
- arch -x86_64 ./ExpoModulesMacros --help > /dev/null
56
+ arch -arm64 apple/ExpoModulesMacros --help > /dev/null
57
+ arch -x86_64 apple/ExpoModulesMacros --help > /dev/null
63
58
 
64
59
  - name: Test
65
60
  run: swift test -v
66
61
 
67
62
  - name: Typecheck the TypeScript wrapper
68
- working-directory: ${{ github.workspace }}
69
63
  run: |
70
64
  pnpm install
71
65
  pnpm run typecheck
@@ -13,10 +13,6 @@ concurrency:
13
13
  group: ci-${{ github.workflow }}-${{ github.ref }}
14
14
  cancel-in-progress: true
15
15
 
16
- defaults:
17
- run:
18
- working-directory: apple
19
-
20
16
  jobs:
21
17
  windows:
22
18
  name: Build & test (Windows ${{ matrix.arch }})
@@ -56,8 +52,8 @@ jobs:
56
52
  - name: Cache SwiftPM build
57
53
  uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
58
54
  with:
59
- path: apple/.build
60
- key: spm-${{ github.repository }}-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('apple/Package.resolved') }}
55
+ path: .build
56
+ key: spm-${{ github.repository }}-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('Package.resolved') }}
61
57
  restore-keys: |
62
58
  spm-${{ github.repository }}-${{ runner.os }}-${{ matrix.arch }}-
63
59
 
@@ -70,7 +66,6 @@ jobs:
70
66
  - name: Build release tool
71
67
  # Runs `pnpm run build` (scripts/build.js): builds the release plugin binary for this
72
68
  # architecture as platforms/win32-<arch>/ExpoModulesMacros.exe.
73
- working-directory: ${{ github.workspace }}
74
69
  run: pnpm run build
75
70
 
76
71
  - name: Verify the release tool
@@ -79,7 +74,7 @@ jobs:
79
74
  # is the scanner CLI.
80
75
  shell: pwsh
81
76
  run: |
82
- $exe = (Resolve-Path "../platforms/win32-${{ matrix.arch }}/ExpoModulesMacros.exe").Path
77
+ $exe = (Resolve-Path "platforms/win32-${{ matrix.arch }}/ExpoModulesMacros.exe").Path
83
78
  $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
84
79
  cmd /c "`"$exe`" < NUL"
85
80
  if ($LASTEXITCODE -ne 0) { throw "The plugin server exited with $LASTEXITCODE" }
@@ -90,7 +85,6 @@ jobs:
90
85
  # Checks what `npm pack` would publish from `platforms/win32-<arch>`, where the release build
91
86
  # put the tool: the executable, the README, the license and the manifest, with the `os` and
92
87
  # `cpu` fields for this machine.
93
- working-directory: ${{ github.workspace }}
94
88
  shell: pwsh
95
89
  run: |
96
90
  # The `./` matters: npm reads `platforms/win32-<arch>` as a GitHub repository.
@@ -109,7 +103,6 @@ jobs:
109
103
  - name: Scan through the TypeScript wrapper
110
104
  # Installs the packed platform package. The wrapper must find the executable in
111
105
  # `@expo/modules-macros-win32-<arch>`, as it would in a project, not the local build.
112
- working-directory: ${{ github.workspace }}
113
106
  shell: pwsh
114
107
  run: |
115
108
  pnpm install
package/AGENTS.md CHANGED
@@ -4,9 +4,11 @@
4
4
 
5
5
  ## Layout
6
6
 
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 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.
7
+ - `Package.swift`: the SwiftPM package, at the root so that its identity is `expo-modules-macros` (an `apple/` manifest would have the identity `apple`, which conflicts with other packages that keep theirs in `apple/`). The targets name their paths under `apple/`. The `ExpoModulesMacrosPlugin` library product vends the macro target to Swift packages, and its name must differ from the target's. The test targets are declared only when `apple/Tests` exists, because the published npm package leaves it out.
8
+ - `apple/Sources/ExpoModulesMacrosCore`: the macro implementations, in a library target so that both executables below can link them. `Macros/` holds one file per macro, and `Support/` the code that several macros share. `ProvidedMacros.swift` lists every macro with its attribute name and conformances in `ProvidedMacro.all`. `PluginServer.swift` runs the compiler plugin protocol with a provider that resolves the macros of `ProvidedMacro.all` by type name, because the default `CompilerPlugin` lookup expects them in a module named `ExpoModulesMacros`. It uses the `@_spi(PluginMessage)` API of swift-syntax.
9
+ - `apple/Sources/ExpoModulesMacros`: the `.macro` target that the `ExpoModulesMacrosPlugin` product vends to Swift packages. It only runs the plugin server, so Swift packages don't compile the CLI.
10
+ - `apple/Sources/ExpoModulesMacrosTool`: the executable that ships as `apple/ExpoModulesMacros` and in the Windows and Linux packages. `main.swift` runs the plugin server without arguments and sends any invocation with arguments to the CLI.
11
+ - `apple/Sources/ExpoModulesCLI`: the library behind the command-line tool. `CLI.swift` parses the arguments, `Commands/` holds one directory per subcommand (`ScanModules/` for autolinking, `ScanExports/` for type generation, `Expand/` for `expand`, with `MacroExpander.swift`, which runs the macros in process), and `Core/` holds the shared parsing, `#if` evaluation and output.
10
12
  - `apple/Sources/ExpoModulesOptimized`: declarations for the `@OptimizedFunction` macro.
11
13
  - `apple/Tests`: `ExpoModulesMacrosTests` (expansion tests) and `ExpoModulesCLITests`.
12
14
  - `platforms/`: the Windows packages `@expo/modules-macros-win32-x64` and `-arm64`, and the Linux packages `@expo/modules-macros-linux-x64` and `-arm64` (a `package.json` with `os` and `cpu`, plus `libc` on Linux, a README and a copy of `LICENSE`). The Publish workflow runs `scripts/set-platform-versions.js` to give them the version of this package. Their `ExpoModulesMacros.exe` (Windows) or `ExpoModulesMacros` (Linux) is written there by `pnpm run build` on that platform and isn't committed.
@@ -20,8 +22,8 @@
20
22
  Swift 6.2 is required (Xcode 26 or newer on macOS). The package manager is pnpm, at the version in `packageManager` in `package.json`.
21
23
 
22
24
  ```sh
23
- cd apple && swift build # debug build
24
- cd apple && swift test # all Swift tests
25
+ swift build # debug build
26
+ swift test # all Swift tests
25
27
  pnpm install # TypeScript dependencies
26
28
  pnpm run typecheck # TypeScript wrapper
27
29
  pnpm run build # release binary (slow; on macOS uses Rosetta for x86_64)
@@ -56,7 +58,7 @@ CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.gi
56
58
 
57
59
  - The scanner re-implements the macros' naming and member rules on the syntax tree; it does not call the macro code. When a macro changes which members it binds or how it names them (the `@JS` name override, the `on` prefix `@Event` strips, which properties `@Record` treats as fields), change `scan-exports` to match, or generated TypeScript types drift from the runtime.
58
60
  - 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`.
61
+ - A new macro needs: the implementation in `apple/Sources/ExpoModulesMacrosCore/Macros`, an entry in `ProvidedMacro.all` in `ProvidedMacros.swift` (with the attribute name and the extension conformances of its declaration), an expansion test file, the `#externalMacro` declaration in `expo-modules-core`, and an entry in the macro list in `README.md`.
60
62
 
61
63
  ## Contracts with other packages
62
64
 
@@ -64,6 +66,7 @@ CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.gi
64
66
  - **Generated code calls `expo-modules-core` API.** An expansion that uses a new core symbol only works with a core version that has it.
65
67
  - **Scanner output is versioned.** `scanModulesSchemaVersion` (`Commands/ScanModules/ScanModules.swift`), `scanExportsSchemaVersion` (`Commands/ScanExports/ExportedSurface.swift`) and `expandSchemaVersion` (`Commands/Expand/ExpandReport.swift`) change independently. When the output shape of a command changes, bump its version and update the matching `SUPPORTED_*_SCHEMA_VERSION` constant and mirror types in `src/types.ts`. `expo-modules-autolinking` also checks the `scan-modules` version.
66
68
  - **The binary path is part of the contract.** `expo-modules-autolinking` resolves this package from `expo-modules-core` and passes `-load-plugin-executable <package>/apple/<binary>#ExpoModulesMacros` to the compiler. The scanner wrapper in autolinking uses the same path, and `expo-modules-cli` uses `getScannerBinaryPath()`. On Windows the binary is `ExpoModulesMacros.exe` in `@expo/modules-macros-win32-<arch>`, and on Linux `ExpoModulesMacros` in `@expo/modules-macros-linux-<arch>`. Renaming or moving a binary needs a matching change in `expo/expo`.
69
+ - **Swift packages depend on the product.** A Swift package that declares or uses the macros depends on the `ExpoModulesMacrosPlugin` product and builds the plugin from source, without the binary. Renaming the product or the macro target, or moving `Package.swift`, needs a matching change in `expo/expo`.
67
70
 
68
71
  ## Releases
69
72
 
@@ -74,4 +77,4 @@ CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.gi
74
77
 
75
78
  ## Installed copies
76
79
 
77
- In `node_modules`, the compiler runs the prebuilt binary `apple/ExpoModulesMacros` (on Windows and Linux, the binary from `@expo/modules-macros-<platform>-<arch>`); editing the Swift sources there has no effect. Macro and scanner fixes belong in this repository and ship in a new release. The TypeScript wrapper ships compiled in `build/`, since `src/` is not published.
80
+ In `node_modules`, a CocoaPods build runs the prebuilt binary `apple/ExpoModulesMacros` (on Windows and Linux, the binary from `@expo/modules-macros-<platform>-<arch>`); editing the Swift sources there has no effect. Macro and scanner fixes belong in this repository and ship in a new release. The TypeScript wrapper ships compiled in `build/`, since `src/` is not published.
package/Package.swift ADDED
@@ -0,0 +1,110 @@
1
+ // swift-tools-version: 6.2
2
+ // The swift-tools-version declares the minimum version of Swift required to build this package.
3
+
4
+ import CompilerPluginSupport
5
+ import Foundation
6
+ import PackageDescription
7
+
8
+ let package = Package(
9
+ name: "ExpoModulesMacros",
10
+ platforms: [.macOS(.v13)],
11
+ products: [
12
+ // Lets a SwiftPM package depend on the macros, so the compiler builds and loads the plugin from
13
+ // source, with no `-load-plugin-executable` flag. A target that declares the macros with
14
+ // `#externalMacro(module: "ExpoModulesMacros", ...)` depends on this product, and the targets that
15
+ // depend on it get the plugin too. CocoaPods builds keep loading the prebuilt binary. The name
16
+ // differs from the macro target's, because SwiftPM already has a product of that name for it.
17
+ .library(name: "ExpoModulesMacrosPlugin", targets: ["ExpoModulesMacros"]),
18
+ .executable(name: "ExpoModulesMacrosTool", targets: ["ExpoModulesMacrosTool"]),
19
+ ],
20
+ dependencies: [
21
+ .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "604.0.0-latest")
22
+ ],
23
+ targets: [
24
+ // The macro implementations, shared by the plugin and the tool. The macro target can't be a
25
+ // dependency of an executable, so they live in a library target.
26
+ .target(
27
+ name: "ExpoModulesMacrosCore",
28
+ dependencies: [
29
+ .product(name: "SwiftSyntax", package: "swift-syntax"),
30
+ .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
31
+ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
32
+ .product(name: "SwiftDiagnostics", package: "swift-syntax"),
33
+ // For the plugin server, which resolves the macros by type name (see PluginServer.swift).
34
+ .product(name: "_SwiftCompilerPluginMessageHandling", package: "swift-syntax"),
35
+ ],
36
+ path: "apple/Sources/ExpoModulesMacrosCore"
37
+ ),
38
+ // The plugin that Swift packages build from source. It serves only the macros, so it doesn't
39
+ // compile the CLI. It lists the swift-syntax products of the implementation again: when SwiftPM
40
+ // uses its prebuilt swift-syntax, it links the prebuilt library only for the products that the
41
+ // macro target itself depends on, and the link fails without them.
42
+ .macro(
43
+ name: "ExpoModulesMacros",
44
+ dependencies: [
45
+ "ExpoModulesMacrosCore",
46
+ .product(name: "SwiftSyntax", package: "swift-syntax"),
47
+ .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
48
+ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
49
+ .product(name: "SwiftDiagnostics", package: "swift-syntax"),
50
+ .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
51
+ ],
52
+ path: "apple/Sources/ExpoModulesMacros"
53
+ ),
54
+ // The shipped binary, `apple/ExpoModulesMacros` and the Windows and Linux binaries. It is both the
55
+ // compiler plugin and the scanner CLI: the compiler launches it without arguments and speaks the
56
+ // plugin protocol over stdin, while an invocation with arguments dispatches into `ScannerCLI`
57
+ // (see main.swift). CocoaPods builds, `expo-modules-autolinking` and `expo-modules-cli` all
58
+ // use this one executable.
59
+ .executableTarget(
60
+ name: "ExpoModulesMacrosTool",
61
+ dependencies: [
62
+ "ExpoModulesCLI",
63
+ "ExpoModulesMacrosCore",
64
+ ],
65
+ path: "apple/Sources/ExpoModulesMacrosTool"
66
+ ),
67
+ .target(
68
+ name: "ExpoModulesCLI",
69
+ dependencies: [
70
+ "ExpoModulesMacrosCore",
71
+ .product(name: "SwiftSyntax", package: "swift-syntax"),
72
+ .product(name: "SwiftParser", package: "swift-syntax"),
73
+ .product(name: "SwiftIfConfig", package: "swift-syntax"),
74
+ // For `expand`, which runs the macros in process.
75
+ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
76
+ .product(name: "SwiftSyntaxMacroExpansion", package: "swift-syntax"),
77
+ .product(name: "SwiftParserDiagnostics", package: "swift-syntax"),
78
+ .product(name: "SwiftBasicFormat", package: "swift-syntax"),
79
+ .product(name: "SwiftDiagnostics", package: "swift-syntax"),
80
+ ],
81
+ path: "apple/Sources/ExpoModulesCLI"
82
+ ),
83
+ ]
84
+ )
85
+
86
+ // The Tests directory is excluded from the published npm package,
87
+ // so only declare the test target when building from the repository.
88
+ if FileManager.default.fileExists(atPath: Context.packageDirectory + "/apple/Tests") {
89
+ package.targets.append(
90
+ .testTarget(
91
+ name: "ExpoModulesMacrosTests",
92
+ dependencies: [
93
+ "ExpoModulesMacrosCore",
94
+ "ExpoModulesCLI",
95
+ .product(name: "SwiftSyntaxMacrosGenericTestSupport", package: "swift-syntax"),
96
+ ],
97
+ path: "apple/Tests/ExpoModulesMacrosTests"
98
+ )
99
+ )
100
+ package.targets.append(
101
+ .testTarget(
102
+ name: "ExpoModulesCLITests",
103
+ dependencies: [
104
+ "ExpoModulesCLI",
105
+ .product(name: "SwiftParser", package: "swift-syntax"),
106
+ ],
107
+ path: "apple/Tests/ExpoModulesCLITests"
108
+ )
109
+ )
110
+ }
package/README.md CHANGED
@@ -13,7 +13,7 @@ The same executable doubles as a source scanner CLI.
13
13
 
14
14
  # Installation
15
15
 
16
- This package is not meant to be installed directly. It is a dependency of `expo-modules-core`, so every Expo project already has it. The published package contains a prebuilt universal (arm64 + x86_64) macOS binary at `apple/ExpoModulesMacros`, so consumers never build the plugin themselves.
16
+ This package is not meant to be installed directly. It is a dependency of `expo-modules-core`, so every Expo project already has it. The published package contains a prebuilt universal (arm64 + x86_64) macOS binary at `apple/ExpoModulesMacros`, so CocoaPods builds never build the plugin themselves. A Swift package can build it from source instead (see [Swift packages](#swift-packages)).
17
17
 
18
18
  The Windows and Linux binaries ship in their own packages, one per platform and architecture:
19
19
 
@@ -24,13 +24,15 @@ This package lists them as optional dependencies, and their `os`, `cpu` and (on
24
24
 
25
25
  # Macros
26
26
 
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. 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.
27
+ - **`@ExpoModule(_ name: String? = nil, classes: [Any.Type] = [])`** on a class. Turns the class into a module: binds its `@JS` members into the module's JavaScript object and resolves the module name from the argument, falling back to the class name. It also synthesizes everything inheriting from `Module` used to provide, so a module class can carry any superclass, or none. It adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet (see [Sendable conformance](#sendable-conformance)).
28
+ - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. Two `@JS` members with the same JS name on one JavaScript object are a compile-time error, since JavaScript could reach only one of them. A shared object's static and instance members bind onto different objects, so they can share a name. The macro also checks that every type crossing the boundary is convertible in the direction it travels. Outside a module or shared object, a bad type is reported on the author's own declaration. In an `@ExpoModule` or `@SharedObject` class, the class macro makes the check, since only it sees the type aliases in the class body, so the error is reported on the class and names the member and its argument labels, such as `local function 'move(_:by:returning:)' requires that 'Point' conform to 'JavaScriptEncodable'`. A `lazy var` is read-only from JavaScript. On the first read, its getter stores the value as a data property on the JavaScript object, so later reads don't call into native code. On a frozen object the value isn't stored, and each read calls the getter. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. The `.withAsync("name")` option binds a synchronous function a second time under that name, as a function that returns a promise and runs the body on the concurrent thread pool. That function isn't isolated to the JavaScript thread, so its body must be safe to run on any thread. The macro reports a function isolated to a global actor through its own attribute or its type's. When the isolation comes from elsewhere, such as a protocol, the error points into the generated code, so mark the function `nonisolated`. A function or initializer can take a closure argument, such as `(Point) throws -> Size` or `(String) async throws -> Data`. JavaScript passes a function for it, and native code can store the closure and call it from any thread. A closure that throws or returns a value blocks the caller until JavaScript returns, and an `async` closure suspends instead and awaits a promise that the function returns. A closure that returns a value must be `throws`, so that an error from JavaScript can reach the caller. A non-throwing `Void` closure returns before JavaScript reads its arguments, so don't change a class instance after passing it to one.
29
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
- - **`@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
- - **`@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.
30
+ - **`@SharedObject(_ name: String? = nil)`** on a subclass of `SharedObject`, direct or not, such as a `SharedRef<T>` subclass. Binds the class's `@JS` members through the `SharedObject` hooks that core calls when it builds the JavaScript class, and emits the JS class name, which defaults to the class name. A module exposes the class through `@ExpoModule(classes:)`, which only accepts `SharedObject` subclasses. A subclass of another `@SharedObject` class also gets the `@JS` members of that class, and its own members replace inherited members with the same JS name. A subclass that isn't `final` can't be constructed from JavaScript through the `@JS init` of its superclass, so give each subclass that JavaScript constructs its own `@JS init`. Like `@ExpoModule`, it adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet.
31
+ - **`@Record()`** on a struct, or on a final class with no inheritance clause (declare a class's protocol conformances in an extension). Treats every non-static, non-private, non-computed stored property as a field, with no per-field wrapper, and synthesizes the memberwise initializer, the conversions in both directions, and the `Record` conformance. A record converts only from and to JavaScript values: its `from(dictionary:appContext:)` throws `RecordDictionaryConversionException`, so a record can't be created from a native `[String: Any]`. A `@Union` can be a record property. Requiredness is inferred: a default value makes a field optional, an optional type makes it nullable and optional.
32
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
+ The members and conformances that `@Record` and `@Union` generate are `nonisolated`, so they also work in a module that builds with a default isolation, such as `-default-isolation MainActor`. The stored properties of a record still take that isolation. A class record, or a struct record with a property whose type isn't `Sendable`, then needs `nonisolated` on its declaration, for example `@Record nonisolated final class Options`.
35
+
34
36
  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
37
 
36
38
  How that looks in a module:
@@ -64,6 +66,10 @@ final class Cache: SharedObject {
64
66
 
65
67
  The author-facing documentation for each macro lives next to its declaration in `expo-modules-core`, in `ios/Core/ExpoModulesMacros.swift`. This repository holds the implementations.
66
68
 
69
+ ## Sendable conformance
70
+
71
+ Native code calls a module or shared object from more than one thread: a `.withAsync` variant runs on the concurrent thread pool, and events and closure arguments can be called from any thread. So the generated code needs the class to be `Sendable`. A subclass of `Module` or `SharedObject` can't be checked `Sendable`, because those base classes aren't, so `@ExpoModule` and `@SharedObject` add `extension MyModule: @unchecked Sendable {}`. The compiler doesn't check the class's mutable state then: keep state that more than one thread uses behind a lock or an actor. The macros add nothing when the class already declares `Sendable` or `@unchecked Sendable`, or inherits it. Swift warns on a subclass of a module or shared object until the subclass restates `@unchecked Sendable`.
72
+
67
73
  # Scanner
68
74
 
69
75
  The Swift compiler launches a plugin executable with no arguments and speaks the plugin protocol over stdin, so the binary treats any argument as a scanner invocation instead:
@@ -139,16 +145,33 @@ The binary is a compiled executable, so each call still spawns a process. What t
139
145
 
140
146
  to `OTHER_SWIFT_FLAGS` for `ExpoModulesCore`, every pod that depends on it, and their test specs. Expo's SPM prebuilds pass the same flag when they generate `Package.swift`, so both build systems load the same binary.
141
147
 
148
+ ## Swift packages
149
+
150
+ The package manifest is `Package.swift` at the root of this package, so a Swift package can depend on it by its path in `node_modules` or by the URL of this repository. Its identity is then `expo-modules-macros`. The `ExpoModulesMacrosPlugin` library product holds the `ExpoModulesMacros` macro target:
151
+
152
+ ```swift
153
+ dependencies: [
154
+ .package(path: "../node_modules/expo-modules-macros")
155
+ ],
156
+ targets: [
157
+ .target(
158
+ name: "ExpoModulesCore",
159
+ dependencies: [.product(name: "ExpoModulesMacrosPlugin", package: "expo-modules-macros")]
160
+ )
161
+ ]
162
+ ```
163
+
164
+ SwiftPM then builds the plugin from source for the host (the macros, without the scanner CLI) and loads it into every target that depends on that target, with no `-load-plugin-executable` flag and no unsafe flags. When the resolved swift-syntax version is a release, SwiftPM and Xcode can use their prebuilt swift-syntax for it, so only the sources of this package compile.
165
+
142
166
  On Windows and Linux, the compiler loads `ExpoModulesMacros.exe` from `@expo/modules-macros-win32-<arch>` or `ExpoModulesMacros` from `@expo/modules-macros-linux-<arch>` with the same flag. `getScannerBinaryPath()` in the TypeScript wrapper returns the binary for the current platform and architecture. On Windows and Linux it falls back to the local `pnpm run build` output in `platforms/<platform>-<arch>` when the platform package isn't installed, for example in this repository.
143
167
 
144
- The module and type names in `#externalMacro` must stay in sync with `apple/Sources/ExpoModulesMacros/Plugin.swift`.
168
+ The module and type names in `#externalMacro` must stay in sync with `ProvidedMacro.all` in `apple/Sources/ExpoModulesMacrosCore/ProvidedMacros.swift`.
145
169
 
146
170
  # Development
147
171
 
148
172
  Requires a toolchain with Swift 6.2 or newer: on macOS 13 or newer that means Xcode 26 or newer, and on Windows and Linux the Swift toolchain from swift.org (CI uses 6.4).
149
173
 
150
174
  ```sh
151
- cd apple
152
175
  swift build
153
176
  swift test
154
177
  ```
@@ -168,7 +191,7 @@ cp "$(cd /tmp/swift-format && swift build -c release --show-bin-path)/swift-form
168
191
 
169
192
  Then add `~/.local/bin` to `PATH`.
170
193
 
171
- `pnpm run build` runs `scripts/build.js`, which builds the release binary with SwiftPM's native build system (Swift Build, the default since Swift 6.4, doesn't build a macro tool that no target in the package uses).
194
+ `pnpm run build` runs `scripts/build.js`, which builds the release binary, the `ExpoModulesMacrosTool` executable, with SwiftPM's native build system.
172
195
 
173
196
  - On macOS, it builds for arm64 and x86_64, merges the slices into `apple/ExpoModulesMacros` with `lipo`, strips it, and verifies both slices are present. SwiftPM only builds macro tools for the host architecture, so the x86_64 slice is produced by running the toolchain under Rosetta; the script installs Rosetta if it is missing.
174
197
  - On Windows, it builds for the host architecture only, with the Swift runtime linked in statically and without debug info, and writes `platforms/win32-<arch>/ExpoModulesMacros.exe` (`x64` or `arm64`, as Node's `process.arch` names them), the file that the package for that architecture publishes. It strips the executable with `llvm-strip` from the Swift toolchain and checks the architecture in its header.
Binary file
@@ -110,7 +110,7 @@ public enum ScannerCLI {
110
110
  }
111
111
 
112
112
  /// The invoked executable's basename, so the usage text matches however the tool was launched
113
- /// (the `ExpoModulesMacros` shipped in the package, or a locally built `ExpoModulesMacros-tool`).
113
+ /// (the `ExpoModulesMacros` shipped in the package, or a locally built `ExpoModulesMacrosTool`).
114
114
  private var toolName: String {
115
115
  guard let path = CommandLine.arguments.first else {
116
116
  return "ExpoModulesMacros"
@@ -1,4 +1,4 @@
1
- import ExpoModulesCLI
1
+ import ExpoModulesMacrosCore
2
2
  import SwiftBasicFormat
3
3
  import SwiftDiagnostics
4
4
  import SwiftParser
@@ -293,9 +293,12 @@ struct ExportedSharedObject: Encodable, Equatable {
293
293
  /// if a malformed source has more. Not named `constructor`, which every JS object inherits.
294
294
  var initializer: ExportedInitializer?
295
295
 
296
- /// What the class inherits from `SharedRef<T>`, or `nil` when its superclass isn't `SharedRef`.
297
- let sharedRef: ExportedSharedRef?
296
+ /// What the class inherits from `SharedRef<T>`, directly or through another scanned `@SharedObject`
297
+ /// class, or `nil` when it doesn't inherit `SharedRef`.
298
+ var sharedRef: ExportedSharedRef?
298
299
 
300
+ /// The class's `@JS` members, followed by the ones it inherits from scanned `@SharedObject`
301
+ /// superclasses (see `ExportedSurface.mergingInheritedMembers()`).
299
302
  var functions: [ExportedFunction]
300
303
  var properties: [ExportedProperty]
301
304
  var events: [ExportedEvent]
@@ -306,10 +309,24 @@ struct ExportedSharedObject: Encodable, Equatable {
306
309
  let platforms: [ExportedPlatform]
307
310
 
308
311
  let file: String
312
+
313
+ /// The superclass as written, without generic arguments, or `nil` when there is none or it is
314
+ /// `SharedObject` or `SharedRef`. Not encoded: it is only read to find the inherited members.
315
+ var superclass: String? = nil
316
+
317
+ /// The value of the class's own `nativeRefType` override (see `ExportedSharedRef.nativeRefType`), or
318
+ /// `.none` when it declares none. Not encoded: a subclass of a `SharedRef` subclass reports its own
319
+ /// override in the inherited `sharedRef`.
320
+ var ownNativeRefType: String?? = .none
321
+
322
+ private enum CodingKeys: String, CodingKey {
323
+ case name, jsName, initializer, sharedRef, functions, properties, events, accessLevel, platforms, file
324
+ }
309
325
  }
310
326
 
311
- /// The `SharedRef<T>` superclass of a shared object. Only the superclass written on the class is read,
312
- /// so a class that inherits `SharedRef` through another subclass has none.
327
+ /// The `SharedRef<T>` superclass of a shared object. A class that inherits `SharedRef` through another
328
+ /// `@SharedObject` class reports the `T` of that class. A class that inherits it through a class the
329
+ /// scan doesn't report has none.
313
330
  struct ExportedSharedRef: Encodable, Equatable {
314
331
  /// The `T` in `SharedRef<T>`: the native type the object holds, which JS never sees.
315
332
  let native: TypeNode
@@ -0,0 +1,96 @@
1
+ /// Adds to each `@SharedObject` class the `@JS` members it inherits from scanned `@SharedObject`
2
+ /// superclasses.
3
+ ///
4
+ /// The `@SharedObject` macro binds the members of the superclass on the JavaScript objects of the
5
+ /// subclass too, since core builds each JavaScript class on the base `SharedObject` class and not on the
6
+ /// JavaScript class of the superclass. So the reported surface of a subclass lists the inherited members
7
+ /// as its own, and a consumer generates the same type as for any other class.
8
+
9
+ extension ExportedSurface {
10
+ /// This surface with the inherited members added to each shared object. Runs after
11
+ /// `resolvingRefs()`, so the refs in an inherited member are resolved from the superclass that
12
+ /// declares it, as Swift resolves them.
13
+ ///
14
+ /// The superclass is looked up from the scope that encloses the class, the way `RefIndex` looks up a
15
+ /// ref. A superclass the scan doesn't report as a shared object (a protocol, a class without
16
+ /// `@SharedObject`, or a class from another module) ends the chain, so nothing is inherited through it.
17
+ ///
18
+ /// A superclass declared more than once, in `#if` branches, contributes the members of each
19
+ /// declaration whose platforms overlap the platforms of the class. Each member keeps its own
20
+ /// `platforms`.
21
+ ///
22
+ /// The members merge the way the macro binds them: the inherited ones first, then the class's own. An
23
+ /// own member replaces an inherited function or property with the same JS name, separately for static
24
+ /// and instance members. Events are kept from both: an event is a stored property that emits under its
25
+ /// JS name, so a subclass can't replace an inherited one, and both are sent. The `@JS init` isn't
26
+ /// inherited: core doesn't construct a subclass through the initializer of its superclass.
27
+ ///
28
+ /// A class that inherits `SharedRef` through its superclass gets the `sharedRef` of the superclass,
29
+ /// with the class's own `nativeRefType` override when it has one.
30
+ func mergingInheritedMembers() -> ExportedSurface {
31
+ let index = RefIndex(surface: self)
32
+ let declarations = Dictionary(grouping: sharedObjects, by: \.name)
33
+
34
+ // `chain` holds the classes being merged, so a malformed cycle in the inheritance ends instead of
35
+ // recursing forever.
36
+ func merged(_ sharedObject: ExportedSharedObject, chain: Set<String>) -> ExportedSharedObject {
37
+ let scope = sharedObject.name.split(separator: ".").dropLast().joined(separator: ".")
38
+ guard let superclass = sharedObject.superclass,
39
+ let entry = index.lookup(superclass, in: scope),
40
+ entry.kind == .sharedObject,
41
+ !chain.contains(entry.name)
42
+ else {
43
+ return sharedObject
44
+ }
45
+ let platforms = Set(sharedObject.platforms.map(\.name))
46
+ let parents = (declarations[entry.name] ?? [])
47
+ .filter { declaration in declaration.platforms.contains { platforms.contains($0.name) } }
48
+ .map { merged($0, chain: chain.union([sharedObject.name])) }
49
+ guard !parents.isEmpty else {
50
+ return sharedObject
51
+ }
52
+ var result = sharedObject
53
+
54
+ let ownMembers = Set(
55
+ sharedObject.functions.map { MemberKey(jsName: $0.jsName, isStatic: $0.isStatic) }
56
+ + sharedObject.properties.map { MemberKey(jsName: $0.jsName, isStatic: $0.isStatic) }
57
+ )
58
+ result.functions =
59
+ parents.flatMap(\.functions).filter {
60
+ !ownMembers.contains(MemberKey(jsName: $0.jsName, isStatic: $0.isStatic))
61
+ }
62
+ + sharedObject.functions
63
+ result.properties =
64
+ parents.flatMap(\.properties).filter {
65
+ !ownMembers.contains(MemberKey(jsName: $0.jsName, isStatic: $0.isStatic))
66
+ }
67
+ + sharedObject.properties
68
+ result.events = parents.flatMap(\.events) + sharedObject.events
69
+
70
+ // Every declaration of a superclass names the same superclass in practice, so the first one that
71
+ // inherits `SharedRef` stands for all of them.
72
+ if result.sharedRef == nil, let inherited = parents.lazy.compactMap(\.sharedRef).first {
73
+ result.sharedRef = ExportedSharedRef(
74
+ native: inherited.native,
75
+ nativeRefType: sharedObject.ownNativeRefType ?? inherited.nativeRefType
76
+ )
77
+ }
78
+ return result
79
+ }
80
+
81
+ return ExportedSurface(
82
+ modules: modules,
83
+ sharedObjects: sharedObjects.map { merged($0, chain: []) },
84
+ records: records,
85
+ enums: enums,
86
+ unions: unions
87
+ )
88
+ }
89
+ }
90
+
91
+ /// A member's slot on the JavaScript side: static members live on the constructor and instance members
92
+ /// on the prototype, so the two don't replace each other.
93
+ private struct MemberKey: Hashable {
94
+ let jsName: String
95
+ let isStatic: Bool
96
+ }
@@ -57,14 +57,15 @@ func scanExports(paths: [String]) -> ScanExportsResult {
57
57
  }
58
58
 
59
59
  // Refs resolve only once every file has been walked: a type parsed in one file may name a type
60
- // declared in another, so the full set of declarations has to exist before any lookup is valid.
60
+ // declared in another, so the full set of declarations has to exist before any lookup is valid. The
61
+ // same holds for a superclass, so the inherited members are merged after that.
61
62
  let surface = ExportedSurface(
62
63
  modules: modules,
63
64
  sharedObjects: sharedObjects,
64
65
  records: records,
65
66
  enums: enums,
66
67
  unions: unions
67
- ).resolvingRefs()
68
+ ).resolvingRefs().mergingInheritedMembers()
68
69
 
69
70
  return ScanExportsResult(
70
71
  schemaVersion: scanExportsSchemaVersion,