expo-modules-macros 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/swift-format.yml +76 -0
- package/AGENTS.md +9 -4
- package/README.md +15 -2
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Package.resolved +3 -3
- package/apple/Package.swift +1 -2
- package/apple/Sources/ExpoModulesMacros/ClosureArguments.swift +39 -14
- package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +113 -26
- package/apple/Sources/ExpoModulesMacros/EventMacro.swift +29 -15
- package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +53 -45
- package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +2 -1
- package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +2 -61
- package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +6 -3
- package/apple/Sources/ExpoModulesMacros/JSMacro.swift +180 -44
- package/apple/Sources/ExpoModulesMacros/JSNameCollisions.swift +26 -6
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +163 -42
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +285 -221
- package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +45 -32
- package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +57 -27
- package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +23 -9
- package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +10 -5
- package/apple/Sources/ExpoModulesScanner/CLI.swift +3 -1
- package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +11 -7
- package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +2 -1
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +10 -7
- package/apple/Sources/ExpoModulesScanner/Exports/Availability.swift +6 -3
- package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +3 -2
- package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +15 -5
- package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +8 -3
- package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +127 -45
- package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +2 -1
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +8 -2
- package/build/types.d.ts +7 -1
- package/package.json +7 -5
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
name: Swift Format
|
|
2
|
+
|
|
3
|
+
# Checks that the Swift sources are formatted with the pinned swift-format version.
|
|
4
|
+
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
pull_request:
|
|
9
|
+
branches: [main]
|
|
10
|
+
workflow_dispatch: {}
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
defaults:
|
|
17
|
+
run:
|
|
18
|
+
shell: bash
|
|
19
|
+
|
|
20
|
+
env:
|
|
21
|
+
# Keep in sync with the version in the Development section of README.md.
|
|
22
|
+
SWIFT_FORMAT_VERSION: '604.0.0'
|
|
23
|
+
|
|
24
|
+
jobs:
|
|
25
|
+
lint:
|
|
26
|
+
name: Lint
|
|
27
|
+
runs-on: ubuntu-24.04
|
|
28
|
+
# The same image as the Linux workflow. swift-format links the Swift runtime from it.
|
|
29
|
+
container: swift:6.4.0-rhel-ubi9@sha256:802a003f1eacbca9c20664a8ba79621d13f4538c3d329e745b7f8d53c70604a3
|
|
30
|
+
timeout-minutes: 30
|
|
31
|
+
steps:
|
|
32
|
+
- name: Checkout
|
|
33
|
+
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
34
|
+
|
|
35
|
+
- name: Setup Node
|
|
36
|
+
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
|
37
|
+
with:
|
|
38
|
+
node-version: 24
|
|
39
|
+
|
|
40
|
+
- name: Restore swift-format
|
|
41
|
+
id: swift-format-cache
|
|
42
|
+
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
43
|
+
with:
|
|
44
|
+
path: ~/.local/bin/swift-format
|
|
45
|
+
# The binary links the Swift runtime of the container image, so the key changes with this
|
|
46
|
+
# file, which sets the image.
|
|
47
|
+
key: swift-format-${{ runner.os }}-${{ env.SWIFT_FORMAT_VERSION }}-${{ hashFiles('.github/workflows/swift-format.yml') }}
|
|
48
|
+
|
|
49
|
+
- name: Build swift-format
|
|
50
|
+
if: steps.swift-format-cache.outputs.cache-hit != 'true'
|
|
51
|
+
run: |
|
|
52
|
+
set -euo pipefail
|
|
53
|
+
src="$(mktemp -d)"
|
|
54
|
+
git clone --depth 1 --branch "$SWIFT_FORMAT_VERSION" https://github.com/swiftlang/swift-format.git "$src"
|
|
55
|
+
cd "$src"
|
|
56
|
+
swift build -c release --product swift-format
|
|
57
|
+
mkdir -p ~/.local/bin
|
|
58
|
+
cp "$(swift build -c release --show-bin-path)/swift-format" ~/.local/bin/swift-format
|
|
59
|
+
|
|
60
|
+
- name: Save swift-format
|
|
61
|
+
# Saves the binary before the lint, so that a run with findings doesn't build it again.
|
|
62
|
+
if: steps.swift-format-cache.outputs.cache-hit != 'true'
|
|
63
|
+
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
64
|
+
with:
|
|
65
|
+
path: ~/.local/bin/swift-format
|
|
66
|
+
key: ${{ steps.swift-format-cache.outputs.cache-primary-key }}
|
|
67
|
+
|
|
68
|
+
- name: Add swift-format to PATH
|
|
69
|
+
run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
70
|
+
|
|
71
|
+
- name: Lint
|
|
72
|
+
run: |
|
|
73
|
+
# The script lists the files with `git ls-files`. The checkout belongs to another user than
|
|
74
|
+
# the container's root user, so Git needs it marked as safe.
|
|
75
|
+
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
|
76
|
+
npm run swift:lint
|
package/AGENTS.md
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
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.
|
|
15
15
|
- `scripts/build.js` (`npm run build`): builds the release binaries: the universal macOS `apple/ExpoModulesMacros` (committed and published), and on Windows and Linux `platforms/win32-<arch>/ExpoModulesMacros.exe` or `platforms/linux-<arch>/ExpoModulesMacros` for the host architecture (not committed; published in that package).
|
|
16
|
+
- `scripts/swift-format.js` (`npm run swift:format`, `npm run swift:lint`): runs swift-format on the `.swift` files that Git tracks or doesn't ignore, with the configuration in `.swift-format`.
|
|
16
17
|
|
|
17
18
|
## Commands
|
|
18
19
|
|
|
@@ -23,9 +24,11 @@ cd apple && swift build # debug build
|
|
|
23
24
|
cd apple && swift test # all Swift tests
|
|
24
25
|
npm run typecheck # TypeScript wrapper
|
|
25
26
|
npm run build # release binary (slow; on macOS uses Rosetta for x86_64)
|
|
27
|
+
npm run swift:format # format the Swift sources in place
|
|
28
|
+
npm run swift:lint # check the formatting, as CI does
|
|
26
29
|
```
|
|
27
30
|
|
|
28
|
-
CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.github/workflows/windows.yml`), and on Linux x64 and arm64 (`.github/workflows/linux.yml`). On macOS it runs the release build, checks both binary slices, then runs `swift test` and `npm run typecheck`. On Windows it builds and tests the package, builds the release binary, checks that it starts without the Swift runtime, packs it into its `platforms/` package (checking the files and the `os`/`cpu` fields), installs that package, and scans through the TypeScript wrapper with `scripts/check-scan.js`, which must find the executable there. On Linux it does the same in the `swift:6.4.0-rhel-ubi9` image, and also checks that no Swift library is linked dynamically, the newest glibc symbol version that the binary needs, and that the package keeps the executable bit. Code in `Sources` and `Tests` must not assume Apple platforms or POSIX paths.
|
|
31
|
+
CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.github/workflows/windows.yml`), and on Linux x64 and arm64 (`.github/workflows/linux.yml`). On macOS it runs the release build, checks both binary slices, then runs `swift test` and `npm run typecheck`. On Windows it builds and tests the package, builds the release binary, checks that it starts without the Swift runtime, packs it into its `platforms/` package (checking the files and the `os`/`cpu` fields), installs that package, and scans through the TypeScript wrapper with `scripts/check-scan.js`, which must find the executable there. On Linux it does the same in the `swift:6.4.0-rhel-ubi9` image, and also checks that no Swift library is linked dynamically, the newest glibc symbol version that the binary needs, and that the package keeps the executable bit. The Swift Format workflow (`.github/workflows/swift-format.yml`) builds the pinned swift-format version and runs `npm run swift:lint`. Code in `Sources` and `Tests` must not assume Apple platforms or POSIX paths.
|
|
29
32
|
|
|
30
33
|
## Windows
|
|
31
34
|
|
|
@@ -39,9 +42,11 @@ CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.gi
|
|
|
39
42
|
|
|
40
43
|
## Conventions
|
|
41
44
|
|
|
42
|
-
- Swift
|
|
45
|
+
- swift-format formats the Swift sources. After you change Swift code, run `npm run swift:format` and then `npm run swift:lint`, which also reports the rules that the formatter doesn't fix. `.swift-format` is a copy of the configuration in `expo/expo`, so keep the two the same. Each repository pins its own swift-format version.
|
|
46
|
+
- The scripts run the swift-format on `PATH`, or on macOS the one in Xcode when there is none. CI builds the version in `SWIFT_FORMAT_VERSION` (`.github/workflows/swift-format.yml`, also named in the README), which can format differently from the one in Xcode. When CI reports a finding that the local lint doesn't, fix it as CI reports it.
|
|
47
|
+
- Write new doc comments as `///` line comments, not `/** */` blocks.
|
|
43
48
|
- Tests use Swift Testing (`import Testing`, `@Test`) with backtick-quoted descriptive names, for example ``func `Prunes .build, Pods, .git, and node_modules directories`()``.
|
|
44
|
-
- Macro tests compare full expansions with `assertMacroExpansion` from `SwiftSyntaxMacrosGenericTestSupport`, using `indentationWidth: .spaces(2)`. Each test file wraps it in a local `assertExpansion` helper.
|
|
49
|
+
- 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.
|
|
45
50
|
- Scanner tests reach internal API with `@testable import ExpoModulesScanner`. Do not make scanner types `public` only for tests.
|
|
46
51
|
- `@JS` applies to functions, properties and initializers. Do not write code or comments that assume it marks only functions.
|
|
47
52
|
- Code comments describe the code as it is. Do not refer to open PRs, branches or planned work.
|
|
@@ -49,7 +54,7 @@ CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.gi
|
|
|
49
54
|
## Changing macros
|
|
50
55
|
|
|
51
56
|
- 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.
|
|
52
|
-
- The scanner only parses files that
|
|
57
|
+
- 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.
|
|
53
58
|
- 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`.
|
|
54
59
|
|
|
55
60
|
## Contracts with other packages
|
package/README.md
CHANGED
|
@@ -25,10 +25,10 @@ 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. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. 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, so a bad type is reported on the author's own declaration rather than on the enclosing type. 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 `SharedObject` subclass. Collects the class's `@JS` members into a class definition that a module exposes through `@ExpoModule(classes:)`.
|
|
31
|
-
- **`@Record()`** on a
|
|
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
34
|
How that looks in a module:
|
|
@@ -116,6 +116,19 @@ swift build
|
|
|
116
116
|
swift test
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
The Swift sources are formatted with [swift-format](https://github.com/swiftlang/swift-format), using the same configuration (`.swift-format`) as the `expo/expo` repository. `npm run swift:format` formats the `.swift` files that Git tracks or doesn't ignore in place, and `npm run swift:lint` checks them without changes, as CI does.
|
|
120
|
+
|
|
121
|
+
The scripts run the `swift-format` on `PATH`, or on macOS the one in the selected Xcode when there is none on `PATH`. CI uses swift-format `604.0.0`, which can format differently from the one in Xcode. To get the version that CI uses, build it from source:
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
git clone --depth 1 --branch 604.0.0 https://github.com/swiftlang/swift-format.git /tmp/swift-format
|
|
125
|
+
(cd /tmp/swift-format && swift build -c release --product swift-format)
|
|
126
|
+
mkdir -p ~/.local/bin
|
|
127
|
+
cp "$(cd /tmp/swift-format && swift build -c release --show-bin-path)/swift-format" ~/.local/bin/
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Then add `~/.local/bin` to `PATH`.
|
|
131
|
+
|
|
119
132
|
`npm 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).
|
|
120
133
|
|
|
121
134
|
- 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.
|
package/apple/ExpoModulesMacros
CHANGED
|
Binary file
|
package/apple/Package.resolved
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"originHash" : "
|
|
2
|
+
"originHash" : "a90a0c6d6b4023098b6a673ded4f314ec06c219ded1559e5d5595f23c7dc323b",
|
|
3
3
|
"pins" : [
|
|
4
4
|
{
|
|
5
5
|
"identity" : "swift-syntax",
|
|
6
6
|
"kind" : "remoteSourceControl",
|
|
7
7
|
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
|
8
8
|
"state" : {
|
|
9
|
-
"revision" : "
|
|
10
|
-
"version" : "
|
|
9
|
+
"revision" : "050f1a346fbbac0ca2cfb15a95274f7bd1cf0ccf",
|
|
10
|
+
"version" : "604.0.0"
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
],
|
package/apple/Package.swift
CHANGED
|
@@ -9,7 +9,7 @@ let package = Package(
|
|
|
9
9
|
name: "ExpoModulesMacros",
|
|
10
10
|
platforms: [.macOS(.v13)],
|
|
11
11
|
dependencies: [
|
|
12
|
-
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "
|
|
12
|
+
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "604.0.0-latest")
|
|
13
13
|
],
|
|
14
14
|
targets: [
|
|
15
15
|
// The plugin executable doubles as the scanner CLI: the compiler launches it without arguments
|
|
@@ -44,7 +44,6 @@ if FileManager.default.fileExists(atPath: Context.packageDirectory + "/Tests") {
|
|
|
44
44
|
name: "ExpoModulesMacrosTests",
|
|
45
45
|
dependencies: [
|
|
46
46
|
"ExpoModulesMacros",
|
|
47
|
-
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
|
|
48
47
|
.product(name: "SwiftSyntaxMacrosGenericTestSupport", package: "swift-syntax"),
|
|
49
48
|
]
|
|
50
49
|
)
|
|
@@ -189,7 +189,9 @@ internal func diagnoseClosureTypes(
|
|
|
189
189
|
message: ClosureDiagnosticMessage(
|
|
190
190
|
"A @JS function can't return a closure: a native closure can't be encoded to a JavaScript function.",
|
|
191
191
|
id: "js-closure-return"
|
|
192
|
-
)
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
)
|
|
193
195
|
}
|
|
194
196
|
return
|
|
195
197
|
}
|
|
@@ -199,14 +201,17 @@ internal func diagnoseClosureTypes(
|
|
|
199
201
|
}
|
|
200
202
|
if let varDecl = declaration.as(VariableDeclSyntax.self),
|
|
201
203
|
let type = varDecl.bindings.first?.typeAnnotation?.type,
|
|
202
|
-
let nested = firstFunctionType(in: type)
|
|
204
|
+
let nested = firstFunctionType(in: type)
|
|
205
|
+
{
|
|
203
206
|
context.diagnose(
|
|
204
207
|
Diagnostic(
|
|
205
208
|
node: nested,
|
|
206
209
|
message: ClosureDiagnosticMessage(
|
|
207
210
|
"A @JS property can't have a closure type: a native closure can't be encoded to a JavaScript function.",
|
|
208
211
|
id: "js-closure-property"
|
|
209
|
-
)
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
)
|
|
210
215
|
}
|
|
211
216
|
}
|
|
212
217
|
|
|
@@ -223,7 +228,9 @@ private func diagnoseClosureParameters(
|
|
|
223
228
|
message: ClosureDiagnosticMessage(
|
|
224
229
|
"A closure in a @JS argument must be the argument's own type, optionally wrapped in an optional. Closures inside other types, such as arrays or dictionaries, aren't supported.",
|
|
225
230
|
id: "js-closure-nested-in-type"
|
|
226
|
-
)
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
)
|
|
227
234
|
}
|
|
228
235
|
continue
|
|
229
236
|
}
|
|
@@ -245,7 +252,9 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
|
|
|
245
252
|
message: ClosureDiagnosticMessage(
|
|
246
253
|
"'@\(name)' isn't supported on a @JS closure argument. The closure calls JavaScript through its own thread hop, so only '@escaping' and '@Sendable' are allowed.",
|
|
247
254
|
id: "js-closure-attribute"
|
|
248
|
-
)
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
)
|
|
249
258
|
}
|
|
250
259
|
|
|
251
260
|
if let throwsClause = functionType.effectSpecifiers?.throwsClause, throwsClause.type != nil {
|
|
@@ -255,7 +264,9 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
|
|
|
255
264
|
message: ClosureDiagnosticMessage(
|
|
256
265
|
"A @JS closure argument can't use typed throws: the error from JavaScript can be any error. Write 'throws' without a type.",
|
|
257
266
|
id: "js-closure-typed-throws"
|
|
258
|
-
)
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
)
|
|
259
270
|
}
|
|
260
271
|
|
|
261
272
|
for parameter in closure.parameters {
|
|
@@ -266,7 +277,9 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
|
|
|
266
277
|
message: ClosureDiagnosticMessage(
|
|
267
278
|
"A @JS closure argument can't take an '\(specifier)' parameter: its value is encoded and sent to JavaScript, which can't write it back or share its isolation.",
|
|
268
279
|
id: "js-closure-parameter-specifier"
|
|
269
|
-
)
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
)
|
|
270
283
|
}
|
|
271
284
|
if parameter.isVariadic {
|
|
272
285
|
context.diagnose(
|
|
@@ -275,7 +288,9 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
|
|
|
275
288
|
message: ClosureDiagnosticMessage(
|
|
276
289
|
"A @JS closure argument can't take a variadic parameter. Use an array instead.",
|
|
277
290
|
id: "js-closure-variadic-parameter"
|
|
278
|
-
)
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
)
|
|
279
294
|
}
|
|
280
295
|
}
|
|
281
296
|
|
|
@@ -290,7 +305,9 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
|
|
|
290
305
|
message: ClosureDiagnosticMessage(
|
|
291
306
|
"A @JS closure argument can't take or return another closure.",
|
|
292
307
|
id: "js-closure-nested"
|
|
293
|
-
)
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
)
|
|
294
311
|
} else if mentionsFreeFormAny(type) {
|
|
295
312
|
context.diagnose(
|
|
296
313
|
Diagnostic(
|
|
@@ -298,7 +315,9 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
|
|
|
298
315
|
message: ClosureDiagnosticMessage(
|
|
299
316
|
"A @JS closure argument can't use the free-form '\(type.trimmedDescription)'. Use a concrete type, or 'JavaScriptValue' to pass a JS value through unchanged.",
|
|
300
317
|
id: "js-closure-free-form"
|
|
301
|
-
)
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
)
|
|
302
321
|
}
|
|
303
322
|
}
|
|
304
323
|
|
|
@@ -310,7 +329,9 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
|
|
|
310
329
|
"A @JS closure argument that returns a value must be 'throws': when the JavaScript function throws, the closure has no value to return.",
|
|
311
330
|
id: "js-closure-requires-throws"
|
|
312
331
|
),
|
|
313
|
-
fixIts: [insertThrowsFixIt(for: functionType)]
|
|
332
|
+
fixIts: [insertThrowsFixIt(for: functionType)]
|
|
333
|
+
)
|
|
334
|
+
)
|
|
314
335
|
}
|
|
315
336
|
}
|
|
316
337
|
|
|
@@ -321,13 +342,16 @@ private func insertThrowsFixIt(for functionType: FunctionTypeSyntax) -> FixIt {
|
|
|
321
342
|
var newType = functionType
|
|
322
343
|
if var effectSpecifiers = functionType.effectSpecifiers, let asyncSpecifier = effectSpecifiers.asyncSpecifier {
|
|
323
344
|
effectSpecifiers.throwsClause = ThrowsClauseSyntax(
|
|
324
|
-
throwsSpecifier: .keyword(.throws, trailingTrivia: asyncSpecifier.trailingTrivia)
|
|
345
|
+
throwsSpecifier: .keyword(.throws, trailingTrivia: asyncSpecifier.trailingTrivia)
|
|
346
|
+
)
|
|
325
347
|
effectSpecifiers.asyncSpecifier = asyncSpecifier.with(\.trailingTrivia, .space)
|
|
326
348
|
newType.effectSpecifiers = effectSpecifiers
|
|
327
349
|
} else {
|
|
328
350
|
newType.effectSpecifiers = TypeEffectSpecifiersSyntax(
|
|
329
351
|
throwsClause: ThrowsClauseSyntax(
|
|
330
|
-
throwsSpecifier: .keyword(.throws, trailingTrivia: functionType.rightParen.trailingTrivia)
|
|
352
|
+
throwsSpecifier: .keyword(.throws, trailingTrivia: functionType.rightParen.trailingTrivia)
|
|
353
|
+
)
|
|
354
|
+
)
|
|
331
355
|
newType.rightParen = functionType.rightParen.with(\.trailingTrivia, .space)
|
|
332
356
|
}
|
|
333
357
|
return FixIt(
|
|
@@ -360,7 +384,8 @@ internal func firstFunctionType(in type: some SyntaxProtocol) -> FunctionTypeSyn
|
|
|
360
384
|
/// same type as `(Int) -> Void`.
|
|
361
385
|
private func unparenthesized(_ type: TypeSyntax) -> TypeSyntax {
|
|
362
386
|
if let tuple = type.as(TupleTypeSyntax.self), tuple.elements.count == 1,
|
|
363
|
-
let element = tuple.elements.first, element.firstName == nil
|
|
387
|
+
let element = tuple.elements.first, element.firstName == nil
|
|
388
|
+
{
|
|
364
389
|
return unparenthesized(element.type)
|
|
365
390
|
}
|
|
366
391
|
return type
|
|
@@ -13,18 +13,27 @@ import SwiftSyntax
|
|
|
13
13
|
/// unwrap, arity guard, argument decode, all while `this` and the arguments are still valid)
|
|
14
14
|
/// and returns the async body (call and result encode), so nothing JSI-owned crosses the asynchronous
|
|
15
15
|
/// boundary and JS gets a promise.
|
|
16
|
+
///
|
|
17
|
+
/// A synchronous function marked `@JS(.withAsync("name"))` also binds a promise-returning variant
|
|
18
|
+
/// under that name (see `asyncVariant`). It decodes and encodes like an `async` function, and calls
|
|
19
|
+
/// the function on the concurrent pool.
|
|
16
20
|
internal struct JSFunction {
|
|
17
21
|
let swiftName: String
|
|
18
|
-
|
|
22
|
+
private(set) var jsName: String
|
|
19
23
|
let parameters: [FunctionParameterSyntax]
|
|
20
24
|
/// The declared return type as written, or `nil` when the function returns `Void`/nothing.
|
|
21
25
|
let returnType: String?
|
|
22
26
|
/// How the return value encodes when its type mentions `Any`, or `nil` for any other type.
|
|
23
27
|
let returnFreeForm: FreeFormConversion?
|
|
24
28
|
let isThrowing: Bool
|
|
25
|
-
|
|
29
|
+
private(set) var isAsync: Bool
|
|
26
30
|
/// The function's name in the source, where a JavaScript name collision is reported.
|
|
27
|
-
|
|
31
|
+
private(set) var nameToken: TokenSyntax
|
|
32
|
+
/// The name from `.withAsync("name")` and its token, or `nil` when the attribute doesn't list it.
|
|
33
|
+
private var asyncVariantName: (name: String, token: TokenSyntax)?
|
|
34
|
+
/// True for the binding `asyncVariant` returns: it calls the synchronous function on the concurrent
|
|
35
|
+
/// pool instead of on the JavaScript thread.
|
|
36
|
+
private(set) var isAsyncVariant = false
|
|
28
37
|
|
|
29
38
|
init(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) {
|
|
30
39
|
self.swiftName = funcDecl.name.text
|
|
@@ -39,6 +48,23 @@ internal struct JSFunction {
|
|
|
39
48
|
let effectSpecifiers = funcDecl.signature.effectSpecifiers
|
|
40
49
|
self.isThrowing = effectSpecifiers?.throwsClause?.throwsSpecifier != nil
|
|
41
50
|
self.isAsync = effectSpecifiers?.asyncSpecifier != nil
|
|
51
|
+
self.asyncVariantName = asyncVariantOption(of: attribute).flatMap(asyncVariantName(of:))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// The promise-returning binding that `.withAsync("name")` adds next to a synchronous function, or
|
|
55
|
+
/// `nil` when the attribute doesn't list it. An `async` function gets none: `@JS` reports the option
|
|
56
|
+
/// there.
|
|
57
|
+
var asyncVariant: JSFunction? {
|
|
58
|
+
guard let asyncVariantName, !isAsync else {
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
var variant = self
|
|
62
|
+
variant.jsName = asyncVariantName.name
|
|
63
|
+
variant.nameToken = asyncVariantName.token
|
|
64
|
+
variant.isAsync = true
|
|
65
|
+
variant.isAsyncVariant = true
|
|
66
|
+
variant.asyncVariantName = nil
|
|
67
|
+
return variant
|
|
42
68
|
}
|
|
43
69
|
|
|
44
70
|
/// The number of leading parameters that must always be supplied: the total minus the maximal
|
|
@@ -79,14 +105,16 @@ internal struct JSFunction {
|
|
|
79
105
|
guard arguments.count == \(maximum) else {
|
|
80
106
|
throw Exceptions.ArgumentsRangeMismatch((functionName: "\(jsName)", received: arguments.count, required: \(required), maximum: \(maximum)))
|
|
81
107
|
}
|
|
82
|
-
"""
|
|
108
|
+
"""
|
|
109
|
+
)
|
|
83
110
|
} else {
|
|
84
111
|
lines.append(
|
|
85
112
|
"""
|
|
86
113
|
guard arguments.count >= \(required) && arguments.count <= \(maximum) else {
|
|
87
114
|
throw Exceptions.ArgumentsRangeMismatch((functionName: "\(jsName)", received: arguments.count, required: \(required), maximum: \(maximum)))
|
|
88
115
|
}
|
|
89
|
-
"""
|
|
116
|
+
"""
|
|
117
|
+
)
|
|
90
118
|
}
|
|
91
119
|
|
|
92
120
|
// Decode the required prefix once — these slots are present in every accepted arity, so the
|
|
@@ -142,7 +170,8 @@ internal struct JSFunction {
|
|
|
142
170
|
lines.append(contentsOf: encodeResultLines())
|
|
143
171
|
}
|
|
144
172
|
|
|
145
|
-
return
|
|
173
|
+
return
|
|
174
|
+
lines
|
|
146
175
|
.flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
|
|
147
176
|
.map { indent + $0 }
|
|
148
177
|
.joined(separator: "\n")
|
|
@@ -152,14 +181,22 @@ internal struct JSFunction {
|
|
|
152
181
|
/// the function's async body (`AsyncFunctionBody`), awaiting the call and encoding the result. It
|
|
153
182
|
/// captures only what the call needs — the decoded `arg<i>` locals, the receiver, and `runtime`
|
|
154
183
|
/// for the encode — never `this` or the arguments buffer, which are only valid for the duration
|
|
155
|
-
/// of the host call.
|
|
184
|
+
/// of the host call. The `asyncVariant` binding awaits its call on the concurrent pool instead.
|
|
156
185
|
private func asyncBodyLines(receiver: Receiver, arity: Int) -> [String] {
|
|
157
186
|
var lines: [String] = ["return {"]
|
|
187
|
+
let call =
|
|
188
|
+
isAsyncVariant
|
|
189
|
+
? concurrentCallExpression(receiver: receiver, arity: arity)
|
|
190
|
+
: callExpression(receiver: receiver, arity: arity)
|
|
191
|
+
// The concurrent call spans several lines, so each of its lines is indented into the body.
|
|
192
|
+
let indented = { (statement: String) in
|
|
193
|
+
statement.split(separator: "\n", omittingEmptySubsequences: false).map { " " + $0 }
|
|
194
|
+
}
|
|
158
195
|
if returnType != nil {
|
|
159
|
-
lines.append("
|
|
196
|
+
lines.append(contentsOf: indented("let result = \(call)"))
|
|
160
197
|
lines.append(contentsOf: encodeResultLines().map { " " + $0 })
|
|
161
198
|
} else {
|
|
162
|
-
lines.append(
|
|
199
|
+
lines.append(contentsOf: indented(call))
|
|
163
200
|
lines.append(" return .undefined()")
|
|
164
201
|
}
|
|
165
202
|
lines.append("}")
|
|
@@ -179,10 +216,36 @@ internal struct JSFunction {
|
|
|
179
216
|
)
|
|
180
217
|
}
|
|
181
218
|
|
|
182
|
-
/// The `<callee>.<name>(...)` call for the given arity
|
|
219
|
+
/// The `<callee>.<name>(...)` call for the given arity, with the arguments from `callArguments`.
|
|
220
|
+
private func callExpression(receiver: Receiver, arity: Int) -> String {
|
|
221
|
+
// `try` only for a throwing function. An `async` call that doesn't throw must not get one, or the
|
|
222
|
+
// expansion warns that no throwing call occurs within the `try` expression.
|
|
223
|
+
let tryKeyword = isThrowing ? "try " : ""
|
|
224
|
+
let awaitKeyword = isAsync ? "await " : ""
|
|
225
|
+
return "\(tryKeyword)\(awaitKeyword)\(receiver.callee).\(swiftName)(\(callArguments(arity: arity)))"
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// The call of the `asyncVariant` binding for the given arity: the synchronous function runs in a
|
|
229
|
+
/// `@concurrent` closure, so it leaves the JavaScript thread, and the body continues once it returns.
|
|
230
|
+
private func concurrentCallExpression(receiver: Receiver, arity: Int) -> String {
|
|
231
|
+
// The closure captures the receiver and the decoded arguments, so Swift 6 requires them to be
|
|
232
|
+
// `Sendable`, as it does for the receiver and arguments of a `.concurrent` call. The signature is
|
|
233
|
+
// spelled out because `@concurrent` needs an `async` closure, and a body without `await` would
|
|
234
|
+
// infer a synchronous one.
|
|
235
|
+
let tryKeyword = isThrowing ? "try " : ""
|
|
236
|
+
let effects = isThrowing ? "async throws" : "async"
|
|
237
|
+
let returnClause = returnType.map { " -> \(expressionType($0))" } ?? ""
|
|
238
|
+
return """
|
|
239
|
+
\(tryKeyword)await { @concurrent () \(effects)\(returnClause) in
|
|
240
|
+
\(tryKeyword)\(receiver.callee).\(swiftName)(\(callArguments(arity: arity)))
|
|
241
|
+
}()
|
|
242
|
+
"""
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/// The argument list of a call for the given arity. Slots `0..<arity` are passed their decoded
|
|
183
246
|
/// `arg<i>`; a trailing optional-without-default slot that this arity omits is passed `nil`; a
|
|
184
247
|
/// trailing defaulted slot that this arity omits is dropped entirely so Swift applies its default.
|
|
185
|
-
private func
|
|
248
|
+
private func callArguments(arity: Int) -> String {
|
|
186
249
|
var callArguments: [String] = []
|
|
187
250
|
for (index, parameter) in parameters.enumerated() {
|
|
188
251
|
let label = parameter.firstName.text
|
|
@@ -201,11 +264,7 @@ internal struct JSFunction {
|
|
|
201
264
|
}
|
|
202
265
|
callArguments.append(label == "_" ? value : "\(label): \(value)")
|
|
203
266
|
}
|
|
204
|
-
|
|
205
|
-
// expansion warns that no throwing call occurs within the `try` expression.
|
|
206
|
-
let tryKeyword = isThrowing ? "try " : ""
|
|
207
|
-
let awaitKeyword = isAsync ? "await " : ""
|
|
208
|
-
return "\(tryKeyword)\(awaitKeyword)\(receiver.callee).\(swiftName)(\(callArguments.joined(separator: ", ")))"
|
|
267
|
+
return callArguments.joined(separator: ", ")
|
|
209
268
|
}
|
|
210
269
|
|
|
211
270
|
/// The flat (single-arity) call-and-encode lines used when no trailing parameter is omittable:
|
|
@@ -347,7 +406,8 @@ internal struct JSProperty {
|
|
|
347
406
|
getEncode = "return \(callee).\(swiftName).toJavaScriptValue(in: runtime)"
|
|
348
407
|
}
|
|
349
408
|
lines.append(
|
|
350
|
-
accessorClosure(descriptorName, "get", receiver: receiver, body: "\(unwrap)\(getEncode)")
|
|
409
|
+
accessorClosure(descriptorName, "get", receiver: receiver, body: "\(unwrap)\(getEncode)")
|
|
410
|
+
)
|
|
351
411
|
|
|
352
412
|
// Setter: decode argument 0 by the static type through `decode` and write `<callee>.<name>`. A
|
|
353
413
|
// typed setter needs a known value type; when the type couldn't be inferred the property is bound
|
|
@@ -358,12 +418,19 @@ internal struct JSProperty {
|
|
|
358
418
|
let setDecode = "\(callee).\(swiftName) = try \(decode)"
|
|
359
419
|
lines.append(
|
|
360
420
|
accessorClosure(
|
|
361
|
-
descriptorName,
|
|
421
|
+
descriptorName,
|
|
422
|
+
"set",
|
|
423
|
+
receiver: receiver,
|
|
424
|
+
returnsVoid: true,
|
|
425
|
+
body: "\(unwrap)\(setDecode)"
|
|
426
|
+
)
|
|
427
|
+
)
|
|
362
428
|
}
|
|
363
429
|
|
|
364
430
|
lines.append("\(object).defineProperty(\"\(jsName)\", descriptor: \(descriptorName))")
|
|
365
431
|
|
|
366
|
-
return
|
|
432
|
+
return
|
|
433
|
+
lines
|
|
367
434
|
.flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
|
|
368
435
|
.map { " " + $0 }
|
|
369
436
|
.joined(separator: "\n")
|
|
@@ -374,12 +441,17 @@ internal struct JSProperty {
|
|
|
374
441
|
/// A setter passes `returnsVoid`, so its closure declares `-> Void` and binds through the `Void`
|
|
375
442
|
/// overload, like a function that returns nothing.
|
|
376
443
|
private func accessorClosure(
|
|
377
|
-
_ descriptorName: String,
|
|
444
|
+
_ descriptorName: String,
|
|
445
|
+
_ key: String,
|
|
446
|
+
receiver: Receiver,
|
|
447
|
+
returnsVoid: Bool = false,
|
|
448
|
+
body: String
|
|
378
449
|
) -> String {
|
|
379
450
|
let captures = receiver.captureClause
|
|
380
451
|
// Indent each line of a (possibly multi-line) body to sit one level inside the closure; a bare
|
|
381
452
|
// `\(body)` interpolation would only indent the first line.
|
|
382
|
-
let indentedBody =
|
|
453
|
+
let indentedBody =
|
|
454
|
+
body
|
|
383
455
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
|
384
456
|
.map { " \($0)" }
|
|
385
457
|
.joined(separator: "\n")
|
|
@@ -404,7 +476,10 @@ internal struct JSProperty {
|
|
|
404
476
|
/// how each binding reaches its Swift value (module `self`, shared-object instance `_self`, or the
|
|
405
477
|
/// metatype for a static member).
|
|
406
478
|
private func decorateBody(
|
|
407
|
-
functions: [JSFunction],
|
|
479
|
+
functions: [JSFunction],
|
|
480
|
+
properties: [JSProperty],
|
|
481
|
+
object: String,
|
|
482
|
+
receiver: Receiver
|
|
408
483
|
) -> String {
|
|
409
484
|
let functionBody = functions.map { $0.decorateStatements(object: object, receiver: receiver) }
|
|
410
485
|
let propertyBody = properties.map { $0.decorateStatements(object: object, receiver: receiver) }
|
|
@@ -421,7 +496,11 @@ private func decorateBody(
|
|
|
421
496
|
/// receiver; the shared-object counterpart is `buildDecorateSharedObjectPhase`.
|
|
422
497
|
internal func buildDecorateJavaScriptObject(functions: [JSFunction], properties: [JSProperty]) -> DeclSyntax {
|
|
423
498
|
let body = decorateBody(
|
|
424
|
-
functions: functions,
|
|
499
|
+
functions: functions,
|
|
500
|
+
properties: properties,
|
|
501
|
+
object: Phase.object.rawValue,
|
|
502
|
+
receiver: .module
|
|
503
|
+
)
|
|
425
504
|
return """
|
|
426
505
|
@JavaScriptActor
|
|
427
506
|
public func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime) throws {
|
|
@@ -442,13 +521,21 @@ internal func buildDecorateJavaScriptObject(functions: [JSFunction], properties:
|
|
|
442
521
|
/// which builds an instance and is emitted separately (see `JSConstructor.buildConstructor`). Each phase
|
|
443
522
|
/// is emitted only when the type has a member for it.
|
|
444
523
|
internal func buildDecorateSharedObjectPhase(
|
|
445
|
-
phase: Phase,
|
|
524
|
+
phase: Phase,
|
|
525
|
+
functions: [JSFunction],
|
|
526
|
+
properties: [JSProperty],
|
|
527
|
+
typeName: String
|
|
446
528
|
) -> DeclSyntax {
|
|
447
|
-
let receiver: Receiver =
|
|
529
|
+
let receiver: Receiver =
|
|
530
|
+
phase == .constructor
|
|
448
531
|
? .staticMember(typeName: typeName)
|
|
449
532
|
: .sharedObject(typeName: typeName)
|
|
450
533
|
let body = decorateBody(
|
|
451
|
-
functions: functions,
|
|
534
|
+
functions: functions,
|
|
535
|
+
properties: properties,
|
|
536
|
+
object: phase.rawValue,
|
|
537
|
+
receiver: receiver
|
|
538
|
+
)
|
|
452
539
|
return """
|
|
453
540
|
@JavaScriptActor
|
|
454
541
|
public override class func _decorateSharedObject(\(raw: phase.rawValue): borrowing JavaScriptObject, in runtime: JavaScriptRuntime) throws {
|