@expo/expo-modules-macros-plugin 0.9.0 → 0.11.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 (29) hide show
  1. package/.github/resources/expo-modules-macros.svg +23 -0
  2. package/.github/workflows/publish.yml +4 -0
  3. package/.github/workflows/swift.yml +6 -0
  4. package/README.md +119 -0
  5. package/apple/ExpoModulesMacros-tool +0 -0
  6. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +3 -1
  7. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +17 -0
  8. package/apple/Sources/ExpoModulesMacros/ExpoViewMacro.swift +259 -0
  9. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +78 -2
  10. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +89 -2
  11. package/apple/Sources/ExpoModulesMacros/Plugin.swift +3 -0
  12. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +0 -47
  13. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +8 -2
  14. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +6 -0
  15. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +365 -0
  16. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +487 -0
  17. package/apple/Sources/ExpoModulesScanner/CLI.swift +8 -4
  18. package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +4 -4
  19. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +28 -7
  20. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +3 -9
  21. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +17 -7
  22. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +7 -0
  23. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +1 -0
  24. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +89 -22
  25. package/build/index.d.ts +51 -0
  26. package/build/index.js +153 -0
  27. package/build/types.d.ts +186 -0
  28. package/build/types.js +15 -0
  29. package/package.json +12 -2
@@ -0,0 +1,23 @@
1
+ <svg width="303" height="48" viewBox="0 0 303 48" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <g clip-path="url(#clip0_748_204)">
3
+ <rect width="48" height="48" rx="10" fill="#63607A"/>
4
+ <g opacity="0.4">
5
+ <rect x="-28" y="-28" width="64" height="64" rx="10" fill="url(#paint0_radial_748_204)"/>
6
+ </g>
7
+ <path opacity="0.55" d="M21.4 24H24.5M24.5 17V31M24.5 17H28.8M24.5 24H28.8M24.5 31H28.8" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
8
+ <circle cx="18" cy="24" r="3.4" stroke="white" stroke-width="2"/>
9
+ <circle cx="31" cy="17" r="2.2" stroke="white" stroke-width="2"/>
10
+ <circle cx="31" cy="24" r="2.2" stroke="white" stroke-width="2"/>
11
+ <circle cx="31" cy="31" r="2.2" stroke="white" stroke-width="2"/>
12
+ </g>
13
+ <path d="M69.5781 32.5H80.0469V30.0703H72.4766V25.7031H79.4453V23.2734H72.4766V18.9297H79.9844V16.5H69.5781ZM85.1328 20.5H82.1328L85.6953 26.5L82.0391 32.5H85.0391L87.5547 28.1797L90.0938 32.5H93.0703L89.3906 26.5L93.0078 20.5H90.0156L87.5547 24.9297ZM95.1953 37.0H98.0234V30.6094H98.1406C98.5859 31.4844 99.5156 32.7109 101.5781 32.7109C104.4062 32.7109 106.5234 30.4688 106.5234 26.5156C106.5234 22.5156 104.3438 20.3438 101.5703 20.3438C99.4531 20.3438 98.5703 21.6172 98.1406 22.4844H97.9766V20.5H95.1953ZM97.9688 26.5C97.9688 24.1719 98.9688 22.6641 100.7891 22.6641C102.6719 22.6641 103.6406 24.2656 103.6406 26.5C103.6406 28.75 102.6562 30.3906 100.7891 30.3906C98.9844 30.3906 97.9688 28.8281 97.9688 26.5ZM114.1719 32.7344C117.6875 32.7344 119.9219 30.2578 119.9219 26.5469C119.9219 22.8281 117.6875 20.3438 114.1719 20.3438C110.6562 20.3438 108.4219 22.8281 108.4219 26.5469C108.4219 30.2578 110.6562 32.7344 114.1719 32.7344ZM114.1875 30.4688C112.2422 30.4688 111.2891 28.7344 111.2891 26.5391C111.2891 24.3438 112.2422 22.5859 114.1875 22.5859C116.1016 22.5859 117.0547 24.3438 117.0547 26.5391C117.0547 28.7344 116.1016 30.4688 114.1875 30.4688ZM127.8984 16.5V32.5H130.6797V21.4844H130.8281L135.25 32.4531H137.3281L141.75 21.5078H141.8984V32.5H144.6797V16.5H141.1328L136.3828 28.0938H136.1953L131.4453 16.5ZM152.9453 32.7344C156.4609 32.7344 158.6953 30.2578 158.6953 26.5469C158.6953 22.8281 156.4609 20.3438 152.9453 20.3438C149.4297 20.3438 147.1953 22.8281 147.1953 26.5469C147.1953 30.2578 149.4297 32.7344 152.9453 32.7344ZM152.9609 30.4688C151.0156 30.4688 150.0625 28.7344 150.0625 26.5391C150.0625 24.3438 151.0156 22.5859 152.9609 22.5859C154.875 22.5859 155.8281 24.3438 155.8281 26.5391C155.8281 28.7344 154.875 30.4688 152.9609 30.4688ZM165.5391 32.7109C167.6016 32.7109 168.5312 31.4844 168.9766 30.6094H169.1484V32.5H171.9297V16.5H169.0938V22.4844H168.9766C168.5469 21.6172 167.6641 20.3438 165.5469 20.3438C162.7734 20.3438 160.5938 22.5156 160.5938 26.5156C160.5938 30.4688 162.7109 32.7109 165.5391 32.7109ZM166.3281 30.3906C164.4609 30.3906 163.4766 28.75 163.4766 26.5C163.4766 24.2656 164.4453 22.6641 166.3281 22.6641C168.1484 22.6641 169.1484 24.1719 169.1484 26.5C169.1484 28.8281 168.1328 30.3906 166.3281 30.3906ZM182.6016 27.4531C182.6016 29.2812 181.2969 30.1875 180.0469 30.1875C178.6875 30.1875 177.7812 29.2266 177.7812 27.7031V20.5H174.9531V28.1406C174.9531 31.0234 176.5938 32.6562 178.9531 32.6562C180.75 32.6562 182.0156 31.7109 182.5625 30.3672H182.6875V32.5H185.4297V20.5H182.6016ZM191.1641 16.5H188.3359V32.5H191.1641ZM199.3828 32.7344C202.1797 32.7344 204.1016 31.3672 204.6016 29.2812L201.9609 28.9844C201.5781 30.0 200.6406 30.5312 199.4219 30.5312C197.5938 30.5312 196.3828 29.3281 196.3594 27.2734H204.7188V26.4062C204.7188 22.1953 202.1875 20.3438 199.2344 20.3438C195.7969 20.3438 193.5547 22.8672 193.5547 26.5703C193.5547 30.3359 195.7656 32.7344 199.3828 32.7344ZM196.3672 25.3672C196.4531 23.8359 197.5859 22.5469 199.2734 22.5469C200.8984 22.5469 201.9922 23.7344 202.0078 25.3672ZM216.5938 23.6719C216.2031 21.6406 214.5781 20.3438 211.7656 20.3438C208.875 20.3438 206.9062 21.7656 206.9141 23.9844C206.9062 25.7344 207.9844 26.8906 210.2891 27.3672L212.3359 27.7969C213.4375 28.0391 213.9531 28.4844 213.9531 29.1641C213.9531 29.9844 213.0625 30.6016 211.7188 30.6016C210.4219 30.6016 209.5781 30.0391 209.3359 28.9609L206.5781 29.2266C206.9297 31.4297 208.7812 32.7344 211.7266 32.7344C214.7266 32.7344 216.8438 31.1797 216.8516 28.9062C216.8438 27.1953 215.7422 26.1484 213.4766 25.6562L211.4297 25.2188C210.2109 24.9453 209.7266 24.5234 209.7344 23.8281C209.7266 23.0156 210.625 22.4531 211.8047 22.4531C213.1094 22.4531 213.7969 23.1641 214.0156 23.9531ZM224.7734 16.5V32.5H227.5547V21.4844H227.7031L232.125 32.4531H234.2031L238.625 21.5078H238.7734V32.5H241.5547V16.5H238.0078L233.2578 28.0938H233.0703L228.3203 16.5ZM248.0391 32.7422C249.9219 32.7422 251.0469 31.8594 251.5625 30.8516H251.6562V32.5H254.375V24.4688C254.375 21.2969 251.7891 20.3438 249.5 20.3438C246.9766 20.3438 245.0391 21.4688 244.4141 23.6562L247.0547 24.0312C247.3359 23.2109 248.1328 22.5078 249.5156 22.5078C250.8281 22.5078 251.5469 23.1797 251.5469 24.3594V24.4062C251.5469 25.2188 250.6953 25.2578 248.5781 25.4844C246.25 25.7344 244.0234 26.4297 244.0234 29.1328C244.0234 31.4922 245.75 32.7422 248.0391 32.7422ZM248.7734 30.6641C247.5938 30.6641 246.75 30.125 246.75 29.0859C246.75 28.0 247.6953 27.5469 248.9609 27.3672C249.7031 27.2656 251.1875 27.0781 251.5547 26.7812V28.1953C251.5547 29.5312 250.4766 30.6641 248.7734 30.6641ZM262.4609 32.7344C265.4766 32.7344 267.3984 30.9453 267.6016 28.3984H264.8984C264.6562 29.6875 263.7266 30.4297 262.4844 30.4297C260.7188 30.4297 259.5781 28.9531 259.5781 26.5C259.5781 24.0781 260.7422 22.625 262.4844 22.625C263.8438 22.625 264.6797 23.5 264.8984 24.6562H267.6016C267.4062 22.0547 265.375 20.3438 262.4453 20.3438C258.9297 20.3438 256.7109 22.8828 256.7109 26.5469C256.7109 30.1797 258.875 32.7344 262.4609 32.7344ZM269.9297 32.5H272.7578V25.4453C272.7578 23.9219 273.9062 22.8438 275.4609 22.8438C275.9375 22.8438 276.5312 22.9297 276.7734 23.0078V20.4062C276.5156 20.3594 276.0703 20.3281 275.7578 20.3281C274.3828 20.3281 273.2344 21.1094 272.7969 22.5H272.6719V20.5H269.9297ZM283.8906 32.7344C287.4062 32.7344 289.6406 30.2578 289.6406 26.5469C289.6406 22.8281 287.4062 20.3438 283.8906 20.3438C280.375 20.3438 278.1406 22.8281 278.1406 26.5469C278.1406 30.2578 280.375 32.7344 283.8906 32.7344ZM283.9062 30.4688C281.9609 30.4688 281.0078 28.7344 281.0078 26.5391C281.0078 24.3438 281.9609 22.5859 283.9062 22.5859C285.8203 22.5859 286.7734 24.3438 286.7734 26.5391C286.7734 28.7344 285.8203 30.4688 283.9062 30.4688ZM301.5156 23.6719C301.125 21.6406 299.5 20.3438 296.6875 20.3438C293.7969 20.3438 291.8281 21.7656 291.8359 23.9844C291.8281 25.7344 292.9062 26.8906 295.2109 27.3672L297.2578 27.7969C298.3594 28.0391 298.875 28.4844 298.875 29.1641C298.875 29.9844 297.9844 30.6016 296.6406 30.6016C295.3438 30.6016 294.5 30.0391 294.2578 28.9609L291.5 29.2266C291.8516 31.4297 293.7031 32.7344 296.6484 32.7344C299.6484 32.7344 301.7656 31.1797 301.7734 28.9062C301.7656 27.1953 300.6641 26.1484 298.3984 25.6562L296.3516 25.2188C295.1328 24.9453 294.6484 24.5234 294.6562 23.8281C294.6484 23.0156 295.5469 22.4531 296.7266 22.4531C298.0312 22.4531 298.7188 23.1641 298.9375 23.9531Z" fill="#63607A"/>
14
+ <defs>
15
+ <radialGradient id="paint0_radial_748_204" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(4 4) rotate(90) scale(32)">
16
+ <stop stop-color="white"/>
17
+ <stop offset="1" stop-color="white" stop-opacity="0"/>
18
+ </radialGradient>
19
+ <clipPath id="clip0_748_204">
20
+ <rect width="48" height="48" rx="10" fill="white"/>
21
+ </clipPath>
22
+ </defs>
23
+ </svg>
@@ -81,6 +81,10 @@ jobs:
81
81
  run: swift package resolve
82
82
  working-directory: apple
83
83
 
84
+ - name: Install dependencies
85
+ # The wrapper's `prepublishOnly` build needs `tsc`; the tool binary needs none.
86
+ run: npm install
87
+
84
88
  - name: Build release tool
85
89
  # Runs apple/build.js: builds the release plugin binary,
86
90
  # copies it to apple/ExpoModulesMacros-tool, and strips it.
@@ -60,3 +60,9 @@ jobs:
60
60
 
61
61
  - name: Test
62
62
  run: swift test -v
63
+
64
+ - name: Typecheck the TypeScript wrapper
65
+ working-directory: ${{ github.workspace }}
66
+ run: |
67
+ npm install
68
+ npm run typecheck
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ <p>
2
+ <a href="https://docs.expo.dev/modules/">
3
+ <img
4
+ src=".github/resources/expo-modules-macros.svg"
5
+ alt="expo-modules-macros-plugin"
6
+ height="64" />
7
+ </a>
8
+ </p>
9
+
10
+ `@expo/expo-modules-macros-plugin` is the Swift compiler plugin behind the Expo Modules API. It implements the macros that [`expo-modules-core`](https://github.com/expo/expo/tree/main/packages/expo-modules-core) declares, so a module author writes plain Swift declarations and the plugin synthesizes the code that binds them to JavaScript.
11
+
12
+ The same executable doubles as a source scanner CLI.
13
+
14
+ # Installation
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-tool`, so consumers never build the plugin themselves.
17
+
18
+ # Macros
19
+
20
+ - **`@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.
21
+ - **`@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. 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.
22
+ - **`@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.
23
+ - **`@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:)`.
24
+ - **`@Record()`** on a record type. 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.
25
+ - **`@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.
26
+
27
+ How that looks in a module:
28
+
29
+ ```swift
30
+ import ExpoModulesCore
31
+
32
+ @ExpoModule(classes: [Cache.self])
33
+ public final class MyModule {
34
+ @JS
35
+ func greet(name: String) -> String {
36
+ "Hi, \(name)"
37
+ }
38
+
39
+ @JS("doWork")
40
+ func performWork() async throws { ... }
41
+
42
+ @Event
43
+ var onProgress: (ProgressEvent) -> Void
44
+ }
45
+
46
+ @SharedObject
47
+ final class Cache: SharedObject {
48
+ @JS
49
+ init(name: String) { ... }
50
+
51
+ @JS
52
+ func get(_ key: String) -> String? { ... }
53
+ }
54
+ ```
55
+
56
+ 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.
57
+
58
+ # Scanner
59
+
60
+ 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:
61
+
62
+ ```
63
+ ExpoModulesMacros-tool <subcommand> [options] <path> [<path> ...]
64
+
65
+ subcommands:
66
+ scan-modules fast scan for top-level @ExpoModule types (autolinking)
67
+ scan-exports deep scan of the full JS-exported surface (type generation)
68
+
69
+ options (scan-modules only):
70
+ --platform <os> evaluate '#if os(...)' against this platform
71
+ --define <flag> treat a conditional compilation flag as set; repeatable
72
+ ```
73
+
74
+ Each path is a `.swift` file or a directory, scanned recursively for `.swift` files. Both subcommands print a JSON report to stdout, each carrying its own `schemaVersion` so a consumer can check it understands the shape before trusting it. The two versions are independent: the commands serve different consumers and change for different reasons.
75
+
76
+ # TypeScript wrapper
77
+
78
+ Node consumers can call the scanner through this package instead of locating the binary and shelling out themselves:
79
+
80
+ ```ts
81
+ import { scanModules, scanExports } from '@expo/expo-modules-macros-plugin';
82
+
83
+ const { modules, warnings } = await scanModules(['ios/'], { defines: ['DEBUG'] });
84
+ const { exports } = await scanExports(['ios/']);
85
+ ```
86
+
87
+ The binary is a compiled executable, so each call still spawns a process. What the wrapper owns is the part consumers would otherwise duplicate: resolving the shipped binary, building the arguments, parsing the JSON, checking `schemaVersion`, and turning a non-zero exit into a `ScannerError`. The result types are hand-written mirrors of the Swift `Codable` types, which is what the version check guards against drifting.
88
+
89
+ # How the plugin reaches the compiler
90
+
91
+ `expo-modules-core` declares the macro signatures with `#externalMacro(module: "ExpoModulesMacros", type: …)`. During `pod install`, `expo-modules-autolinking` resolves this package from the core package and appends
92
+
93
+ ```
94
+ -Xfrontend -load-plugin-executable -Xfrontend <plugin>/apple/ExpoModulesMacros-tool#ExpoModulesMacros
95
+ ```
96
+
97
+ 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.
98
+
99
+ The module and type names in `#externalMacro` must stay in sync with `apple/Sources/ExpoModulesMacros/Plugin.swift`.
100
+
101
+ # Development
102
+
103
+ Requires macOS 13 or newer and a toolchain with Swift 6.2, which means Xcode 26 or newer.
104
+
105
+ ```sh
106
+ cd apple
107
+ swift build
108
+ swift test
109
+ ```
110
+
111
+ `npm run build` runs `apple/build.js`, which builds the release binary for arm64 and x86_64, merges the slices into `apple/ExpoModulesMacros-tool` 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. The resulting binary is committed to the repository.
112
+
113
+ # Releasing
114
+
115
+ The **Publish** workflow is manual (`workflow_dispatch`) and takes a release type. It bumps the version, builds the universal binary, and publishes to npm through OIDC trusted publishing. The commit, tag and GitHub release are created only after the publish succeeds, so a failed build leaves the branch untouched.
116
+
117
+ # Contributing
118
+
119
+ Contributions are very welcome! Please refer to the guidelines described in the [contributing guide](https://github.com/expo/expo#contributing).
Binary file
@@ -192,7 +192,9 @@ internal struct JSFunction {
192
192
  }
193
193
  callArguments.append(label == "_" ? value : "\(label): \(value)")
194
194
  }
195
- let tryKeyword = (isThrowing || isAsync) ? "try " : ""
195
+ // `try` only for a throwing function. An `async` call that doesn't throw must not get one, or the
196
+ // expansion warns that no throwing call occurs within the `try` expression.
197
+ let tryKeyword = isThrowing ? "try " : ""
196
198
  let awaitKeyword = isAsync ? "await " : ""
197
199
  return "\(tryKeyword)\(awaitKeyword)\(receiver.callee).\(swiftName)(\(callArguments.joined(separator: ", ")))"
198
200
  }
@@ -88,6 +88,16 @@ public struct ExpoModuleMacro: MemberMacro {
88
88
  // JS object name, so they can't diverge.
89
89
  emitted.append("public static let _jsName = \"\(raw: moduleName)\"")
90
90
 
91
+ // View classes are registered by type, not spliced as definitions: an `@ExpoView` emits no
92
+ // definition function, so there is nothing to call on each entry. Core reads a view's props
93
+ // type from its `Props` typealias and its event names from that type's `_eventNames`, both
94
+ // static. Emitted only when the argument is present, so a module without views gains nothing.
95
+ let viewTypes = classListArgument(of: node, label: "views")
96
+ if !viewTypes.isEmpty {
97
+ let entries = viewTypes.map { "\($0).self" }.joined(separator: ", ")
98
+ emitted.append("public static let _viewTypes: [Any.Type] = [\(raw: entries)]")
99
+ }
100
+
91
101
  // `Module`/`BaseModule` already provide `appContext` storage and the
92
102
  // `init(appContext:)` requirement, so we only synthesize them for classes that
93
103
  // inherit from neither. Each is skipped individually if the user wrote their own,
@@ -151,6 +161,13 @@ extension ExpoModuleMacro: MemberAttributeMacro {
151
161
  attributes.append("@JavaScriptActor")
152
162
  }
153
163
 
164
+ // `@JS(.concurrent)` is the inverse: instead of the JS-thread stamp the member gets
165
+ // `@concurrent`, so its body runs on the concurrent pool. `shouldStampJavaScriptActor` already
166
+ // skipped the stamp above, leaving the two mutually exclusive.
167
+ if isConcurrentJSMember(member) {
168
+ attributes.append("@concurrent")
169
+ }
170
+
154
171
  // Apply the result builder to `definition()` so the user doesn't have to. Skipped if
155
172
  // they already wrote `@ModuleDefinitionBuilder` themselves, which would otherwise be
156
173
  // a duplicate attribute.
@@ -0,0 +1,259 @@
1
+ import SwiftDiagnostics
2
+ import SwiftSyntax
3
+ import SwiftSyntaxBuilder
4
+ import SwiftSyntaxMacros
5
+
6
+ /// Member macro applied to an `ExpoView` subclass, binding it to its props type:
7
+ ///
8
+ /// @ExpoView<CardProps>
9
+ /// final class CardView: ExpoView {
10
+ /// override func didUpdateProps(_ diff: CardProps.Diff) {
11
+ /// if diff.changed(.color) {
12
+ /// backgroundColor = props.color
13
+ /// }
14
+ /// }
15
+ /// }
16
+ ///
17
+ /// The props type is the attribute's **generic argument**, so it sits in type position: no
18
+ /// `.self` metatype to write, and the macro reads it straight off the attribute's
19
+ /// `genericArgumentClause`. Core's declaration constrains the parameter
20
+ /// (`macro ExpoView<Props: AnyViewProps>()`), so passing a type that isn't a props type is a
21
+ /// compile error at the author's own line ("requires that 'X' conform to 'AnyViewProps'")
22
+ /// rather than a macro diagnostic. This macro therefore validates nothing about the props type.
23
+ ///
24
+ /// The whole expansion is one line:
25
+ ///
26
+ /// public typealias Props = CardProps
27
+ ///
28
+ /// That is deliberately all of it. There is **no view definition**: no `ViewDefinition`, no
29
+ /// `View(…) { }` builder, no `Props(_:)` or `Events(_:)` element. `_synthesizedViewDefinition()`
30
+ /// existed only to hand core a DSL value the old registration path could consume, and modules and
31
+ /// shared objects already moved to direct hooks. Core reads the props type from this typealias and
32
+ /// the event names from the props type's own `_eventNames` (synthesized by `@ViewProps`), both
33
+ /// static, neither needing a builder to be evaluated.
34
+ ///
35
+ /// Views also get no `_decorateView` hook in its place: `_decorateModule` and `_decorateSharedObject`
36
+ /// exist to bind members into a JS object, and a view has no such object. Props reach a view through
37
+ /// Fabric's mounting layer, not a JS call, so a view's runtime entry point is the props update hook.
38
+ ///
39
+ /// `didUpdateProps` is a core-called override, not synthesized here.
40
+ public struct ExpoViewMacro: MemberMacro {
41
+ public static func expansion(
42
+ of node: AttributeSyntax,
43
+ providingMembersOf declaration: some DeclGroupSyntax,
44
+ conformingTo protocols: [TypeSyntax],
45
+ in context: some MacroExpansionContext
46
+ ) throws -> [DeclSyntax] {
47
+ guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
48
+ throw MacroExpansionErrorMessage("@ExpoView can only be applied to a class")
49
+ }
50
+
51
+ guard inheritsFromAny(classDecl, names: expoViewBaseClassNames) else {
52
+ throw DiagnosticsError(diagnostics: [missingBaseClassDiagnostic(for: classDecl)])
53
+ }
54
+
55
+ // A generic view can't be registered: `@ExpoModule(views: [CardView.self])` on a generic type
56
+ // fails with "generic parameter 'T' could not be inferred", and the props type ignores the
57
+ // parameter anyway. Reject it here, at the author's line, rather than at the registration site.
58
+ // `@ViewProps` rejects generic props types for the same reason.
59
+ if classDecl.genericParameterClause != nil {
60
+ throw MacroExpansionErrorMessage(
61
+ "@ExpoView does not support generic classes — a view registered with @ExpoModule(views:) must be concrete"
62
+ )
63
+ }
64
+
65
+ // The expansion is a `Props` typealias, so a member of that name already on the class collides.
66
+ // Left alone, the compiler reports "invalid redeclaration of 'Props'" positioned inside the
67
+ // macro expansion, which is code the author never wrote. Name it here instead.
68
+ if let existing = existingPropsMemberKind(in: classDecl) {
69
+ throw MacroExpansionErrorMessage(
70
+ "@ExpoView synthesizes a `Props` typealias, but this class already declares a \(existing) named 'Props'. Rename it"
71
+ )
72
+ }
73
+
74
+ // The props type is the attribute's generic argument. Core's declaration is generic, so a bare
75
+ // `@ExpoView` never reaches expansion: it fails type-checking first with "generic parameter
76
+ // 'Props' could not be inferred". This diagnostic covers the case where the macro is declared
77
+ // differently than core declares it, so the author still gets a message naming the attribute.
78
+ guard let propsType = genericArgument(of: node) else {
79
+ throw MacroExpansionErrorMessage(
80
+ "@ExpoView requires its props type as a generic argument, as in `@ExpoView<MyViewProps>`"
81
+ )
82
+ }
83
+
84
+ return [
85
+ """
86
+ public typealias Props = \(raw: propsType)
87
+ """
88
+ ]
89
+ }
90
+ }
91
+
92
+ // MARK: - Diagnostics
93
+
94
+ /// The error for a class that doesn't name `ExpoView` in its inheritance clause, carrying a fix-it
95
+ /// that adds it.
96
+ ///
97
+ /// The fix-it only applies where the edit is unambiguous. With no inheritance clause at all it
98
+ /// inserts `: ExpoView`; with an existing clause it prepends `ExpoView` as the first entry, since
99
+ /// Swift requires the superclass to come before any protocol. A class that already names some other
100
+ /// superclass gets the diagnostic without a fix-it: replacing that superclass is a decision the macro
101
+ /// can't make (see the indirect-inheritance note on `expoViewBaseClassNames`).
102
+ private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagnostic {
103
+ let message = ExpoViewDiagnosticMessage(
104
+ "@ExpoView class must inherit from ExpoView. Add `: ExpoView` to the class declaration.",
105
+ id: "expoview-missing-base-class"
106
+ )
107
+ var fixIts: [FixIt] = []
108
+
109
+ if classDecl.inheritanceClause == nil {
110
+ // `class CardView {` → `class CardView: ExpoView {`. The name carries the space before `{` as
111
+ // trailing trivia, so move it onto the clause to keep the spacing.
112
+ let name = classDecl.name
113
+ let clause = InheritanceClauseSyntax(
114
+ colon: .colonToken(trailingTrivia: .space),
115
+ inheritedTypes: InheritedTypeListSyntax([
116
+ InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoView"))))
117
+ ])
118
+ )
119
+ fixIts.append(
120
+ FixIt(
121
+ message: ExpoViewFixItMessage("Inherit from 'ExpoView'", id: "expoview-add-base-class"),
122
+ changes: [
123
+ .replace(
124
+ oldNode: Syntax(classDecl),
125
+ newNode: Syntax(
126
+ classDecl
127
+ .with(\.name, name.with(\.trailingTrivia, []))
128
+ .with(\.inheritanceClause, clause.with(\.trailingTrivia, name.trailingTrivia))
129
+ )
130
+ )
131
+ ]
132
+ )
133
+ )
134
+ } else if let clause = classDecl.inheritanceClause, inheritsOnlyProtocolsByConvention(clause) {
135
+ // `class CardView: Identifiable {` → `class CardView: ExpoView, Identifiable {`.
136
+ var inherited = clause.inheritedTypes
137
+ if var first = inherited.first {
138
+ first.trailingComma = .commaToken(trailingTrivia: .space)
139
+ first.type = TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoView")))
140
+ inherited.insert(first, at: inherited.startIndex)
141
+ }
142
+ fixIts.append(
143
+ FixIt(
144
+ message: ExpoViewFixItMessage("Inherit from 'ExpoView'", id: "expoview-add-base-class"),
145
+ changes: [
146
+ .replace(
147
+ oldNode: Syntax(clause),
148
+ newNode: Syntax(clause.with(\.inheritedTypes, inherited))
149
+ )
150
+ ]
151
+ )
152
+ )
153
+ }
154
+
155
+ return Diagnostic(node: classDecl.name, message: message, fixIts: fixIts)
156
+ }
157
+
158
+ /// Whether every entry in the clause looks like a protocol rather than a superclass, by the
159
+ /// capitalization-free heuristic that a Swift superclass must come first: if the first entry is one
160
+ /// the macro would be replacing, prepending `ExpoView` would produce two superclasses. A macro can't
161
+ /// resolve names, so this only reports true when the clause is empty of anything that could be a
162
+ /// base class the author chose deliberately. Conservative by design: a wrong guess here would emit a
163
+ /// fix-it that doesn't compile.
164
+ private func inheritsOnlyProtocolsByConvention(_ clause: InheritanceClauseSyntax) -> Bool {
165
+ // Any entry at all could be a superclass, so only offer the prepend when the author wrote
166
+ // something that is definitely not one: a known protocol-shaped name from the standard library.
167
+ let knownProtocols: Set<String> = [
168
+ "Identifiable", "Equatable", "Hashable", "Codable", "Encodable", "Decodable",
169
+ "Sendable", "CustomStringConvertible", "ObservableObject",
170
+ ]
171
+ return clause.inheritedTypes.allSatisfy { entry in
172
+ guard let name = baseIdentifier(of: entry.type) else {
173
+ return false
174
+ }
175
+ return knownProtocols.contains(name)
176
+ }
177
+ }
178
+
179
+ private struct ExpoViewDiagnosticMessage: DiagnosticMessage {
180
+ let message: String
181
+ let diagnosticID: MessageID
182
+ let severity: DiagnosticSeverity = .error
183
+
184
+ init(_ message: String, id: String) {
185
+ self.message = message
186
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
187
+ }
188
+ }
189
+
190
+ private struct ExpoViewFixItMessage: FixItMessage {
191
+ let message: String
192
+ let fixItID: MessageID
193
+
194
+ init(_ message: String, id: String) {
195
+ self.message = message
196
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
197
+ }
198
+ }
199
+
200
+ /// Describes an existing `Props` member on the class, for the collision diagnostic, or `nil` when the
201
+ /// name is free. Covers the shapes that would clash with the synthesized typealias: another typealias,
202
+ /// and a nested type of any kind.
203
+ private func existingPropsMemberKind(in classDecl: ClassDeclSyntax) -> String? {
204
+ for member in classDecl.memberBlock.members {
205
+ let decl = member.decl
206
+ if let typealiasDecl = decl.as(TypeAliasDeclSyntax.self), typealiasDecl.name.text == "Props" {
207
+ return "typealias"
208
+ }
209
+ if let structDecl = decl.as(StructDeclSyntax.self), structDecl.name.text == "Props" {
210
+ return "struct"
211
+ }
212
+ if let nestedClass = decl.as(ClassDeclSyntax.self), nestedClass.name.text == "Props" {
213
+ return "class"
214
+ }
215
+ if let enumDecl = decl.as(EnumDeclSyntax.self), enumDecl.name.text == "Props" {
216
+ return "enum"
217
+ }
218
+ if let actorDecl = decl.as(ActorDeclSyntax.self), actorDecl.name.text == "Props" {
219
+ return "actor"
220
+ }
221
+ }
222
+ return nil
223
+ }
224
+
225
+ /// Base classes an `@ExpoView` may inherit from. The check is syntactic, matching the name in the
226
+ /// class's own inheritance clause: both `: ExpoView` and a qualified `: ExpoModulesCore.ExpoView`
227
+ /// pass, since `baseIdentifier` reads the trailing name.
228
+ ///
229
+ /// Indirect inheritance does not: a view whose superclass is itself an `ExpoView` subclass
230
+ /// (`class Base: ExpoView` then `class CardView: Base`) is rejected, because a macro can't resolve
231
+ /// the superclass to check. `@SharedObject` has the same limitation, through the same helper. The
232
+ /// workaround is to apply `@ExpoView` to the class that names `ExpoView` directly.
233
+ private let expoViewBaseClassNames: Set<String> = ["ExpoView"]
234
+
235
+ /// The attribute's single generic argument, verbatim (`@ExpoView<CardProps>` → `"CardProps"`), or
236
+ /// `nil` when the attribute carries no generic argument clause.
237
+ ///
238
+ /// The type is available syntactically, with no type resolution. Two spellings carry it, mirroring
239
+ /// `baseIdentifier`'s handling of the inheritance clause: a bare `@ExpoView<CardProps>` parses as an
240
+ /// `IdentifierTypeSyntax`, and a qualified `@ExpoModulesCore.ExpoView<CardProps>` as a
241
+ /// `MemberTypeSyntax`. Both have a `genericArgumentClause`, and both are legal at the use site, so
242
+ /// reading only the bare form would reject valid code while claiming the author omitted the argument.
243
+ ///
244
+ /// Only the first argument is read: core's declaration has exactly one parameter, so a second is
245
+ /// already a compile error at the use site ("specialized with too many type parameters").
246
+ private func genericArgument(of attribute: AttributeSyntax) -> String? {
247
+ let arguments: GenericArgumentListSyntax?
248
+ if let identifier = attribute.attributeName.as(IdentifierTypeSyntax.self) {
249
+ arguments = identifier.genericArgumentClause?.arguments
250
+ } else if let member = attribute.attributeName.as(MemberTypeSyntax.self) {
251
+ arguments = member.genericArgumentClause?.arguments
252
+ } else {
253
+ arguments = nil
254
+ }
255
+ guard let first = arguments?.first, case .type(let type) = first.argument else {
256
+ return nil
257
+ }
258
+ return type.trimmedDescription
259
+ }
@@ -3,8 +3,8 @@ import SwiftSyntax
3
3
  import SwiftSyntaxMacros
4
4
 
5
5
  /// Marker macro applied to module / shared-object members that should be exposed to JavaScript.
6
- /// `@ExpoModule` and `@SharedObject` discover declarations carrying this attribute and generate the
7
- /// corresponding `Function` / `AsyncFunction` / `Property` / `Constructor` registrations; that part
6
+ /// `@ExpoModule` and `@SharedObject` discover declarations carrying this attribute and bind each one
7
+ /// directly into the JS object, with the decode-call-encode body inlined into the binding; that part
8
8
  /// of the expansion lives in those macros.
9
9
  ///
10
10
  /// On its own, `@JS` emits one thing: a never-called peer that asserts each type crossing the JS
@@ -32,6 +32,7 @@ public struct JSMacro: PeerMacro {
32
32
  in context: some MacroExpansionContext
33
33
  ) throws -> [DeclSyntax] {
34
34
  diagnoseFreeFormTypes(in: declaration, in: context)
35
+ diagnoseConcurrentOption(of: node, on: declaration, in: context)
35
36
 
36
37
  guard let member = boundaryMember(of: declaration),
37
38
  let assertion = directionalConformanceAssertion(
@@ -46,6 +47,71 @@ public struct JSMacro: PeerMacro {
46
47
  }
47
48
  }
48
49
 
50
+ /// Emits the diagnostic for `@JS(.concurrent)` on a member that can't take it. The option maps to
51
+ /// Swift's `@concurrent`, which requires an `async` function: a synchronous member has nowhere to
52
+ /// suspend, and a property or initializer can't be async at all. Diagnosing here points at the
53
+ /// user's own `@JS` attribute rather than at the `@concurrent` the module macro would attach.
54
+ private func diagnoseConcurrentOption(
55
+ of node: AttributeSyntax,
56
+ on declaration: some DeclSyntaxProtocol,
57
+ in context: some MacroExpansionContext
58
+ ) {
59
+ guard hasJSOption(node, named: "concurrent") else {
60
+ return
61
+ }
62
+ if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
63
+ guard funcDecl.signature.effectSpecifiers?.asyncSpecifier == nil else {
64
+ return
65
+ }
66
+ context.diagnose(
67
+ Diagnostic(
68
+ node: node,
69
+ message: JSDiagnosticMessage(
70
+ "'.concurrent' needs an 'async' function: a synchronous @JS member runs on the JavaScript thread by definition. Mark the function 'async' to run its body off that thread.",
71
+ id: "js-concurrent-requires-async",
72
+ severity: .error
73
+ ),
74
+ fixIts: [insertAsyncFixIt(for: funcDecl)]))
75
+ return
76
+ }
77
+ context.diagnose(
78
+ Diagnostic(
79
+ node: node,
80
+ message: JSDiagnosticMessage(
81
+ "'.concurrent' applies only to an 'async' @JS function, not to a property or initializer.",
82
+ id: "js-concurrent-requires-function",
83
+ severity: .error
84
+ )))
85
+ }
86
+
87
+ /// The fix-it offered alongside the synchronous-function diagnostic: insert `async` into the
88
+ /// signature so `@JS(.concurrent)` becomes valid. The macro can't add the keyword itself (no macro
89
+ /// role rewrites the declaration it's attached to), but Xcode can apply this in one click.
90
+ ///
91
+ /// `async` goes at the front of the effect specifiers, ahead of any `throws`, which is the only
92
+ /// order Swift accepts. When the signature has no effect specifiers yet, the new clause inherits
93
+ /// what the parameter clause had trailing it and the parameter clause is left with a single space,
94
+ /// so `() -> Int` becomes `() async -> Int` rather than `() async-> Int`.
95
+ private func insertAsyncFixIt(for funcDecl: FunctionDeclSyntax) -> FixIt {
96
+ let signature = funcDecl.signature
97
+ var newSignature = signature
98
+
99
+ if var effectSpecifiers = signature.effectSpecifiers {
100
+ effectSpecifiers.asyncSpecifier = .keyword(.async, trailingTrivia: .space)
101
+ newSignature.effectSpecifiers = effectSpecifiers
102
+ } else {
103
+ newSignature.effectSpecifiers = FunctionEffectSpecifiersSyntax(
104
+ asyncSpecifier: .keyword(.async, trailingTrivia: signature.parameterClause.trailingTrivia)
105
+ )
106
+ newSignature.parameterClause.trailingTrivia = .space
107
+ }
108
+
109
+ return FixIt(
110
+ message: JSFixItMessage("Mark the function 'async'", id: "js-concurrent-insert-async"),
111
+ changes: [.replace(oldNode: Syntax(signature), newNode: Syntax(newSignature))]
112
+ )
113
+ }
114
+
49
115
  /// Emits the free-form (`Any` / `[Any]` / `[String: Any]`) diagnostics for a `@JS` declaration.
50
116
  ///
51
117
  /// A free-form type is only supported crossing the boundary as an **argument**, decoded through
@@ -155,6 +221,16 @@ private struct JSDiagnosticMessage: DiagnosticMessage {
155
221
  }
156
222
  }
157
223
 
224
+ private struct JSFixItMessage: FixItMessage {
225
+ let message: String
226
+ let fixItID: MessageID
227
+
228
+ init(_ message: String, id: String) {
229
+ self.message = message
230
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
231
+ }
232
+ }
233
+
158
234
  /// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer unique
159
235
  /// among siblings), the boundary types split by conversion direction, and whether the member is
160
236
  /// type-level. Arguments (and a settable property's incoming value) are decoded; return values (and a