expo-modules-macros 0.31.0 → 0.32.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.
@@ -1,4 +1,6 @@
1
- name: Swift
1
+ name: macOS
2
+
3
+ # Builds and tests the package on macOS, and checks the universal release tool there.
2
4
 
3
5
  on:
4
6
  push:
@@ -12,8 +14,8 @@ concurrency:
12
14
  cancel-in-progress: true
13
15
 
14
16
  jobs:
15
- build-and-test:
16
- name: Build & test
17
+ macos:
18
+ name: Build & test (macOS)
17
19
  # The package declares swift-tools-version 6.2, which requires Xcode 26+.
18
20
  runs-on: macos-26
19
21
  steps:
@@ -63,3 +65,10 @@ jobs:
63
65
  run: |
64
66
  pnpm install
65
67
  pnpm run typecheck
68
+
69
+ - name: Scan through the TypeScript wrapper
70
+ # The wrapper must find the release tool in `apple/`, and `scanExports` must report the
71
+ # surface in `scripts/check-scan-exports.json`, which the Windows and Linux workflows also check.
72
+ run: |
73
+ pnpm run build:ts
74
+ node scripts/check-scan.js .
package/AGENTS.md CHANGED
@@ -31,7 +31,7 @@ pnpm run swift:format # format the Swift sources in place
31
31
  pnpm run swift:lint # check the formatting, as CI does
32
32
  ```
33
33
 
34
- 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 `pnpm 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 `pnpm run swift:lint`. Code in `Sources` and `Tests` must not assume Apple platforms or POSIX paths.
34
+ CI runs on macOS (`.github/workflows/macos.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, runs `swift test` and `pnpm run typecheck`, then scans through the TypeScript wrapper with `scripts/check-scan.js`. 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. On every platform, `check-scan.js` also requires `scanExports` to report the surface in `scripts/check-scan-exports.json` for its fixture, so the generated types are the same on each OS. The Swift Format workflow (`.github/workflows/swift-format.yml`) builds the pinned swift-format version and runs `pnpm run swift:lint`. Code in `Sources` and `Tests` must not assume Apple platforms or POSIX paths.
35
35
 
36
36
  ## Windows
37
37
 
@@ -57,6 +57,7 @@ CI runs on macOS (`.github/workflows/swift.yml`), on Windows x64 and arm64 (`.gi
57
57
  ## Changing macros
58
58
 
59
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.
60
+ - When the `scan-exports` output changes on purpose, update `scripts/check-scan-exports.json`: run `swift build`, then `node scripts/check-scan.js . --update .build/debug/ExpoModulesMacrosTool`, and review the diff. CI compares it with the output on macOS, Windows and Linux.
60
61
  - 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.
61
62
  - 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`.
62
63
 
package/README.md CHANGED
@@ -25,11 +25,11 @@ 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. 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. Because of that, a `.concurrent` function and a function with `.withAsync` can't take a JavaScript value such as `JavaScriptValue` or `JavaScriptObject`, which is valid only on the JavaScript thread; the macro reports such a parameter. 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
- - **`@Event(_ name: String? = nil, _ options: EventOptions...)`** 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. By default the event is scheduled on the JavaScript thread. The `.inline` option runs the listeners before the call returns when it is made on the JavaScript thread, and schedules the event otherwise; it never blocks. The `.blocking` option runs the listeners before the call returns on any thread, so a call from another thread blocks until the JavaScript thread runs them. An event property is callable from any thread with each option.
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
- - **`@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.
28
+ - **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. The class macro reads only the class body, so `@JS` on a member of an extension, or of a type that is neither a module nor a shared object, is an error. So is `@JS` on an initializer or a `static` member of a module, since JavaScript doesn't construct a module and gets only its instance. `@JS` on any other declaration, such as a subscript, is an error too. A `@JS` function or initializer can't be generic, since the binding converts each value by its static type. `@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. A name that JavaScript already gives a meaning on the object is an error too: `__proto__` on any object, `constructor` on a shared object's prototype, and `prototype` on its constructor. The macro also checks that every type crossing the boundary is convertible in the direction it travels. For a top-level declaration, 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. Because of that, a `.concurrent` function and a function with `.withAsync` can't take a JavaScript value such as `JavaScriptValue` or `JavaScriptObject`, which is valid only on the JavaScript thread; the macro reports such a parameter. 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
+ - **`@Event(_ name: String? = nil, _ options: EventOptions...)`** 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. `@ExpoModule` and `@SharedObject` warn when two events in the class body have the same JS name. By default the event is scheduled on the JavaScript thread. The `.inline` option runs the listeners before the call returns when it is made on the JavaScript thread, and schedules the event otherwise; it never blocks. The `.blocking` option runs the listeners before the call returns on any thread, so a call from another thread blocks until the JavaScript thread runs them. An event property is callable from any thread with each option.
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 class without an inheritance clause is an error, with a Fix-It that adds `: SharedObject`. 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. The memberwise initializer sets every field, so a field that is a `let` with a default value is an error. It doesn't set a `private` or `fileprivate` stored property, so such a property needs a default value, unless it's an optional `var` or the type declares its own memberwise initializer.
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. The macro warns about a case that an earlier case always decodes first: any case after a `JavaScriptValue` case, and a case with a payload that decodes only numbers, such as `Float` or `Int32`, after a `Double`, `Float` or `CGFloat` case.
33
33
 
34
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
35
 
Binary file
@@ -29,8 +29,8 @@ import SwiftSyntaxMacros
29
29
  /// The synthesized property is deliberately **not** isolated to `@JavaScriptActor`, unlike `@JS`
30
30
  /// members: `emit` is itself non-isolated and schedules the dispatch onto the JS thread internally,
31
31
  /// so the event is callable from any thread or isolation with no actor hop at the call site. It is
32
- /// also self-contained: `@ExpoModule`/`@SharedObject` neither collect `@Event` members nor register
33
- /// their names anywhere.
32
+ /// also self-contained: `@ExpoModule`/`@SharedObject` don't register `@Event` members anywhere. They
33
+ /// read the event names only to warn when two events share one (see `diagnoseEventNameCollisions`).
34
34
  ///
35
35
  /// Two options change how the event is dispatched, and each one calls its own `EventEmitter` method,
36
36
  /// which is callable from any thread as well:
@@ -293,6 +293,16 @@ private func isDispatchOption(_ expression: ExprSyntax) -> Bool {
293
293
  return name == "inline" || name == "blocking"
294
294
  }
295
295
 
296
+ /// The JS name of an `@Event` property: the `@Event("…")` override, or the default name derived from
297
+ /// the property name. `nil` when the declaration isn't a single named property, which the macro reports.
298
+ internal func eventJSName(of varDecl: VariableDeclSyntax, attribute: AttributeSyntax) -> String? {
299
+ guard varDecl.bindings.count == 1, let identifier = varDecl.bindings.first?.pattern.as(IdentifierPatternSyntax.self)
300
+ else {
301
+ return nil
302
+ }
303
+ return jsNameArgument(of: attribute) ?? defaultEventName(for: withoutBackticks(identifier.identifier.text))
304
+ }
305
+
296
306
  // MARK: - Default event name
297
307
 
298
308
  /// The default JS event name for a property: the Swift name with the conventional `on` prefix
@@ -63,6 +63,16 @@ public struct ExpoModuleMacro: MemberMacro {
63
63
  for member in classDecl.memberBlock.members {
64
64
  // The types of a `@JS` member are read with the class's tuple aliases resolved.
65
65
  let decl = aliases.resolving(member.decl)
66
+ // A generic member is reported by `@JS` and left out, so the build has no errors in its binding.
67
+ if memberHasJSAttribute(decl) && jsGenericParameterClause(of: decl) != nil {
68
+ continue
69
+ }
70
+ // So is a `static` or `class` member: the module object binds only instance members.
71
+ if memberHasJSAttribute(decl),
72
+ let modifiers = decl.asProtocol(WithModifiersSyntax.self)?.modifiers, isTypeLevel(modifiers)
73
+ {
74
+ continue
75
+ }
66
76
  if memberHasJSAttribute(decl), let assertion = jsConformanceAssertionBody(for: decl) {
67
77
  assertions.append(assertion)
68
78
  }
@@ -86,6 +96,8 @@ public struct ExpoModuleMacro: MemberMacro {
86
96
  }
87
97
 
88
98
  diagnoseJSNameCollisions(functions: functions, properties: properties, in: context)
99
+ diagnoseReservedJSNames(functions: functions, properties: properties, on: .module, in: context)
100
+ diagnoseEventNameCollisions(in: classDecl.memberBlock, in: context)
89
101
 
90
102
  var emitted: [DeclSyntax] = []
91
103
 
@@ -31,6 +31,20 @@ public struct JSMacro: PeerMacro {
31
31
  providingPeersOf declaration: some DeclSyntaxProtocol,
32
32
  in context: some MacroExpansionContext
33
33
  ) throws -> [DeclSyntax] {
34
+ diagnosePlacement(of: node, on: declaration, in: context)
35
+ if let genericParameterClause = jsGenericParameterClause(of: declaration) {
36
+ context.diagnose(
37
+ Diagnostic(
38
+ node: genericParameterClause,
39
+ message: JSDiagnosticMessage(
40
+ "A @JS function or initializer can't be generic: the binding decodes each argument and encodes the result by its static type, which a generic parameter doesn't have. Use a concrete type, a @Union of the types, or 'JavaScriptValue'.",
41
+ id: "js-generic-member",
42
+ severity: .error
43
+ )
44
+ )
45
+ )
46
+ return []
47
+ }
34
48
  diagnoseParameterSpecifiers(in: declaration, in: context)
35
49
  diagnoseFreeFormTypes(in: declaration, in: context)
36
50
  diagnoseClosureTypes(in: declaration, in: context)
@@ -75,6 +89,116 @@ private func isInBindingClass(_ context: some MacroExpansionContext) -> Bool {
75
89
  || classDecl.attributes.firstAttribute(named: "SharedObject") != nil
76
90
  }
77
91
 
92
+ /// Reports a `@JS` attribute that no macro binds, so the member would be missing in JavaScript without
93
+ /// an error:
94
+ /// - on a declaration other than a function, property or initializer, such as a subscript;
95
+ /// - on a member of an extension, since `@ExpoModule` and `@SharedObject` read only the class body;
96
+ /// - on a member of a type that isn't an `@ExpoModule` or `@SharedObject` class;
97
+ /// - on a `static` or `class` member of an `@ExpoModule` class, since JavaScript gets only the instance;
98
+ /// - on an initializer of an `@ExpoModule` class, since JavaScript doesn't construct a module.
99
+ private func diagnosePlacement(
100
+ of node: AttributeSyntax,
101
+ on declaration: some DeclSyntaxProtocol,
102
+ in context: some MacroExpansionContext
103
+ ) {
104
+ guard
105
+ declaration.is(FunctionDeclSyntax.self) || declaration.is(VariableDeclSyntax.self)
106
+ || declaration.is(InitializerDeclSyntax.self)
107
+ else {
108
+ let kind = declaration.is(SubscriptDeclSyntax.self) ? "a subscript" : "this declaration"
109
+ context.diagnose(
110
+ Diagnostic(
111
+ node: node,
112
+ message: JSDiagnosticMessage(
113
+ "@JS applies to a function, property or initializer, so \(kind) isn't bound to JavaScript. Expose a @JS function instead.",
114
+ id: "js-unsupported-declaration",
115
+ severity: .error
116
+ )
117
+ )
118
+ )
119
+ return
120
+ }
121
+ if let extensionDecl = context.lexicalContext.first?.as(ExtensionDeclSyntax.self) {
122
+ let typeName = extensionDecl.extendedType.trimmedDescription
123
+ context.diagnose(
124
+ Diagnostic(
125
+ node: node,
126
+ message: JSDiagnosticMessage(
127
+ "A @JS member of an extension isn't bound to JavaScript: @ExpoModule and @SharedObject bind only the @JS members declared in the class body. Move the member into the declaration of '\(typeName)'.",
128
+ id: "js-in-extension",
129
+ severity: .error
130
+ )
131
+ )
132
+ )
133
+ return
134
+ }
135
+ if let enclosingType = context.lexicalContext.first, enclosingType.isProtocol(DeclGroupSyntax.self),
136
+ !isInBindingClass(context)
137
+ {
138
+ let typeName = enclosingType.asProtocol(NamedDeclSyntax.self).map { "'\($0.name.text)'" } ?? "this type"
139
+ context.diagnose(
140
+ Diagnostic(
141
+ node: node,
142
+ message: JSDiagnosticMessage(
143
+ "A @JS member of \(typeName) isn't bound to JavaScript: only an @ExpoModule or @SharedObject class binds its @JS members. Add one of these macros to \(typeName), or move the member into such a class.",
144
+ id: "js-outside-binding-class",
145
+ severity: .error
146
+ )
147
+ )
148
+ )
149
+ return
150
+ }
151
+ if let modifiers = declaration.asProtocol(WithModifiersSyntax.self)?.modifiers, isTypeLevel(modifiers),
152
+ let classDecl = context.lexicalContext.first?.as(ClassDeclSyntax.self),
153
+ classDecl.attributes.firstAttribute(named: "ExpoModule") != nil
154
+ {
155
+ context.diagnose(
156
+ Diagnostic(
157
+ node: node,
158
+ message: JSDiagnosticMessage(
159
+ "A @JS member of an @ExpoModule class can't be 'static' or 'class': JavaScript gets one module object, and @ExpoModule binds the members of the instance to it. Make the member an instance member.",
160
+ id: "js-module-static-member",
161
+ severity: .error
162
+ )
163
+ )
164
+ )
165
+ return
166
+ }
167
+ if let initDecl = declaration.as(InitializerDeclSyntax.self),
168
+ let classDecl = context.lexicalContext.first?.as(ClassDeclSyntax.self),
169
+ classDecl.attributes.firstAttribute(named: "ExpoModule") != nil
170
+ {
171
+ context.diagnose(
172
+ Diagnostic(
173
+ node: node,
174
+ message: JSDiagnosticMessage(
175
+ "@JS on an initializer of an @ExpoModule class isn't bound to JavaScript: JavaScript doesn't construct a module, it gets the instance that the app context creates. Remove @JS, or construct a @SharedObject class from JavaScript instead.",
176
+ id: "js-module-initializer",
177
+ severity: .error
178
+ ),
179
+ fixIts: removeAttributeFixIts(node, before: initDecl.initKeyword)
180
+ )
181
+ )
182
+ }
183
+ }
184
+
185
+ /// The fix-it that removes a `@JS` attribute written on its own line, with the line break and
186
+ /// indentation before it. The macro gets the attribute without the trivia after it, so when the
187
+ /// declaration starts on the line of the attribute (`@JS init()`), the fix-it can't tell how much
188
+ /// space to remove, and there is none. It edits only the text of the attribute, since the declaration
189
+ /// that a peer macro gets can carry attributes that other macros added.
190
+ private func removeAttributeFixIts(_ node: AttributeSyntax, before keyword: TokenSyntax) -> [FixIt] {
191
+ guard keyword.leadingTrivia.contains(where: \.isNewline) else {
192
+ return []
193
+ }
194
+ return [
195
+ FixIt(
196
+ message: JSFixItMessage("Remove '@JS'", id: "js-remove-attribute"),
197
+ changes: [.replaceText(range: node.position..<node.endPosition, with: "", in: Syntax(node))]
198
+ )
199
+ ]
200
+ }
201
+
78
202
  /// Emits the diagnostic for `@JS(.concurrent)` on a member that can't take it. The option maps to
79
203
  /// Swift's `@concurrent`, which requires an `async` function: a synchronous member has nowhere to
80
204
  /// suspend, and a property or initializer can't be async at all. Diagnosing here points at the
@@ -81,6 +81,12 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
81
81
  // the author's version stand — the factories only need *an* `init(<properties>)` and `Record` only
82
82
  // needs *an* `init()`, regardless of who wrote them.
83
83
  let existingInitLabels = initializerParameterLabels(of: declaration)
84
+ if !existingInitLabels.contains(properties.map { $0.key }) {
85
+ let diagnostics = unsetPrivatePropertyDiagnostics(of: declaration)
86
+ guard diagnostics.isEmpty else {
87
+ throw DiagnosticsError(diagnostics: diagnostics)
88
+ }
89
+ }
84
90
 
85
91
  var members: [DeclSyntax] = []
86
92
 
@@ -227,6 +233,10 @@ private func recordProperties(
227
233
  guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
228
234
  continue
229
235
  }
236
+ // The memberwise init sets every property, and a `let` with a default value can't be set again.
237
+ if varDecl.bindingSpecifier.tokenKind == .keyword(.let), binding.initializer != nil {
238
+ throw DiagnosticsError(diagnostics: [letWithDefaultDiagnostic(for: varDecl, name: ident.identifier.text)])
239
+ }
230
240
  // Prefer the explicit annotation. When it's omitted, recover the type from a literal default
231
241
  // (Swift's own default-literal type) — covers the common `var name = "foo"` case. Anything
232
242
  // whose type a syntactic macro can't determine (calls, collections, member access) still needs
@@ -637,6 +647,64 @@ private func unsupportedClassDiagnostics(of declaration: some DeclGroupSyntax) -
637
647
  return diagnostics
638
648
  }
639
649
 
650
+ /// The errors for the `private` and `fileprivate` stored properties that the synthesized memberwise
651
+ /// initializer would leave without a value. Such a property isn't a record property, so the initializer
652
+ /// doesn't set it, and it needs a default value. An optional `var` starts as `nil`, so it needs none.
653
+ private func unsetPrivatePropertyDiagnostics(of declaration: some DeclGroupSyntax) -> [Diagnostic] {
654
+ var diagnostics: [Diagnostic] = []
655
+ for member in declaration.memberBlock.members {
656
+ guard let varDecl = member.decl.as(VariableDeclSyntax.self),
657
+ let access = varDecl.modifiers.first(where: {
658
+ $0.name.tokenKind == .keyword(.private) || $0.name.tokenKind == .keyword(.fileprivate)
659
+ }),
660
+ !varDecl.modifiers.contains(where: {
661
+ $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class)
662
+ || $0.name.tokenKind == .keyword(.lazy)
663
+ })
664
+ else {
665
+ continue
666
+ }
667
+ let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
668
+ for binding in varDecl.bindings where binding.accessorBlock == nil && binding.initializer == nil {
669
+ guard let ident = binding.pattern.as(IdentifierPatternSyntax.self),
670
+ !(isVar && binding.typeAnnotation.map { isOptionalType($0.type) } == true)
671
+ else {
672
+ continue
673
+ }
674
+ diagnostics.append(
675
+ Diagnostic(
676
+ node: ident,
677
+ message: RecordDiagnosticMessage(
678
+ "@Record leaves out the \(access.name.text) property '\(ident.identifier.text)', so the synthesized initializer doesn't set it. Give it a default value, or remove '\(access.name.text)' to make it a record property",
679
+ id: "record-unset-private-property"
680
+ )
681
+ )
682
+ )
683
+ }
684
+ }
685
+ return diagnostics
686
+ }
687
+
688
+ /// The error for a `let` property with a default value, attached to the `let` keyword and carrying a
689
+ /// fix-it that replaces it with `var`. The default still applies when JavaScript omits the property.
690
+ private func letWithDefaultDiagnostic(for varDecl: VariableDeclSyntax, name: String) -> Diagnostic {
691
+ let specifier = varDecl.bindingSpecifier
692
+ let fixIt = FixIt(
693
+ message: RecordFixItMessage("Replace 'let' with 'var'", id: "record-let-to-var"),
694
+ changes: [
695
+ .replace(oldNode: Syntax(specifier), newNode: Syntax(specifier.with(\.tokenKind, .keyword(.var))))
696
+ ]
697
+ )
698
+ return Diagnostic(
699
+ node: specifier,
700
+ message: RecordDiagnosticMessage(
701
+ "@Record property '\(name)' is a 'let' with a default value, so the synthesized initializer can't set it from JavaScript. Make it a 'var', and the default applies when JavaScript omits the property, or make it 'static' or 'private' to leave it out of the record",
702
+ id: "record-let-with-default"
703
+ ),
704
+ fixIts: [fixIt]
705
+ )
706
+ }
707
+
640
708
  /// The fix-it for a record class that isn't `final`: add the modifier right before `class`.
641
709
  private func markFinalFixIt(for classDecl: ClassDeclSyntax) -> FixIt {
642
710
  // The new modifier takes over the leading trivia of `class` (the line break after the attribute
@@ -1,3 +1,4 @@
1
+ import SwiftDiagnostics
1
2
  import SwiftSyntax
2
3
  import SwiftSyntaxBuilder
3
4
  import SwiftSyntaxMacros
@@ -42,9 +43,13 @@ public struct SharedObjectMacro: MemberMacro {
42
43
  in: context
43
44
  )
44
45
 
45
- // The superclass isn't checked here: the macro sees only this declaration, so it can't tell an
46
+ // A class without an inheritance clause has no superclass, so it can't be a `SharedObject`. Any
47
+ // other superclass isn't checked: the macro sees only this declaration, so it can't tell an
46
48
  // indirect subclass (`class Thumbnail: ImageRef`) from an unrelated class. The compiler reports a
47
49
  // class that isn't a `SharedObject` subclass on the `_jsName` override, which is always emitted.
50
+ if classDecl.inheritanceClause == nil {
51
+ throw DiagnosticsError(diagnostics: [missingBaseClassDiagnostic(for: classDecl)])
52
+ }
48
53
  let typeName = classDecl.name.text
49
54
  let jsName = jsNameArgument(of: node) ?? withoutBackticks(typeName)
50
55
 
@@ -71,6 +76,10 @@ public struct SharedObjectMacro: MemberMacro {
71
76
  for member in classDecl.memberBlock.members {
72
77
  // The types of a `@JS` member are read with the class's tuple aliases resolved.
73
78
  let decl = aliases.resolving(member.decl)
79
+ // A generic member is reported by `@JS` and left out, so the build has no errors in its binding.
80
+ if memberHasJSAttribute(decl) && jsGenericParameterClause(of: decl) != nil {
81
+ continue
82
+ }
74
83
  if memberHasJSAttribute(decl), let assertion = jsConformanceAssertionBody(for: decl) {
75
84
  assertions.append(assertion)
76
85
  }
@@ -114,6 +123,19 @@ public struct SharedObjectMacro: MemberMacro {
114
123
 
115
124
  diagnoseJSNameCollisions(functions: instanceFunctions, properties: instanceProperties, in: context)
116
125
  diagnoseJSNameCollisions(functions: staticFunctions, properties: staticProperties, in: context)
126
+ diagnoseReservedJSNames(
127
+ functions: instanceFunctions,
128
+ properties: instanceProperties,
129
+ on: .prototype,
130
+ in: context
131
+ )
132
+ diagnoseReservedJSNames(
133
+ functions: staticFunctions,
134
+ properties: staticProperties,
135
+ on: .constructor,
136
+ in: context
137
+ )
138
+ diagnoseEventNameCollisions(in: classDecl.memberBlock, in: context)
117
139
 
118
140
  // The resolved JS class name, read by core when it builds the class. An `override` of the
119
141
  // `SharedObject` class property, so core reads it through the concrete type's metatype.
@@ -199,3 +221,59 @@ extension SharedObjectMacro: ExtensionMacro {
199
221
  return [sendable]
200
222
  }
201
223
  }
224
+
225
+ /// The error for a `@SharedObject` class without an inheritance clause, with a fix-it that makes it
226
+ /// inherit from `SharedObject`: `class Cache {` becomes `class Cache: SharedObject {`.
227
+ private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagnostic {
228
+ // The name carries the space before `{` as trailing trivia, so move it onto the clause to keep the
229
+ // spacing.
230
+ let name = classDecl.name
231
+ let clause = InheritanceClauseSyntax(
232
+ colon: .colonToken(trailingTrivia: .space),
233
+ inheritedTypes: InheritedTypeListSyntax([
234
+ InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("SharedObject"))))
235
+ ])
236
+ )
237
+ let fixIt = FixIt(
238
+ message: SharedObjectFixItMessage("Inherit from 'SharedObject'", id: "sharedobject-add-base-class"),
239
+ changes: [
240
+ .replace(
241
+ oldNode: Syntax(classDecl),
242
+ newNode: Syntax(
243
+ classDecl
244
+ .with(\.name, name.with(\.trailingTrivia, []))
245
+ .with(\.inheritanceClause, clause.with(\.trailingTrivia, name.trailingTrivia))
246
+ )
247
+ )
248
+ ]
249
+ )
250
+ return Diagnostic(
251
+ node: name,
252
+ message: SharedObjectDiagnosticMessage(
253
+ "@SharedObject class '\(name.text)' must inherit from 'SharedObject', directly or through another class such as 'SharedRef'. Add ': SharedObject' to the class declaration.",
254
+ id: "sharedobject-missing-base-class"
255
+ ),
256
+ fixIts: [fixIt]
257
+ )
258
+ }
259
+
260
+ private struct SharedObjectDiagnosticMessage: DiagnosticMessage {
261
+ let message: String
262
+ let diagnosticID: MessageID
263
+ let severity: DiagnosticSeverity = .error
264
+
265
+ init(_ message: String, id: String) {
266
+ self.message = message
267
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
268
+ }
269
+ }
270
+
271
+ private struct SharedObjectFixItMessage: FixItMessage {
272
+ let message: String
273
+ let fixItID: MessageID
274
+
275
+ init(_ message: String, id: String) {
276
+ self.message = message
277
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
278
+ }
279
+ }
@@ -35,7 +35,9 @@ import SwiftSyntaxMacros
35
35
  /// Discrimination is structural and order-dependent: the first case whose payload decodes wins. When
36
36
  /// two payload shapes overlap (two records with compatible fields, `Int` and `Double`), the earlier
37
37
  /// case matches; the author orders the more specific case first. A case may not repeat another case's
38
- /// payload type, since it could never be chosen.
38
+ /// payload type, since it could never be chosen. A case after a `JavaScriptValue` case, or with a
39
+ /// number-only payload after a `Double`, `Float` or `CGFloat` case, can't be chosen either, so the macro
40
+ /// warns about it.
39
41
  ///
40
42
  /// The named-union counterpart of core's `Either`: any number of named cases instead of two anonymous
41
43
  /// slots, no `Any?` box, and an exhaustive `switch` as the primary way to read it (`as(_:)` covers the
@@ -48,6 +50,7 @@ public struct UnionMacro: MemberMacro, ExtensionMacro {
48
50
  in context: some MacroExpansionContext
49
51
  ) throws -> [DeclSyntax] {
50
52
  let union = try validatedUnion(of: declaration)
53
+ diagnoseUnreachableCases(of: declaration, in: context)
51
54
 
52
55
  var members: [DeclSyntax] = []
53
56
 
@@ -223,8 +226,8 @@ private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> Unio
223
226
  let payloadType = parameter.type.trimmedDescription
224
227
  // Alternatives decode in declaration order and the first success wins, so a case whose payload
225
228
  // type repeats an earlier case's is unreachable: an exact-spelling duplicate is an error. (An
226
- // overlap between *different* types, like `Int` and `Double`, is the author's ordering call and
227
- // isn't checked here.)
229
+ // overlap between *different* types, like `Int` and `Double`, is the author's ordering call;
230
+ // `diagnoseUnreachableCases` warns about the overlaps that make a case unreachable.)
228
231
  let normalizedType = payloadType.filter { !$0.isWhitespace }
229
232
  if let earlierCase = seenPayloadTypes[normalizedType] {
230
233
  diagnostics.append(
@@ -256,6 +259,88 @@ private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> Unio
256
259
  return UnionType(name: enumDecl.name.text, cases: cases)
257
260
  }
258
261
 
262
+ /// The payload types whose decode accepts every JavaScript number: they convert the number with
263
+ /// `asDouble()`, so they never fail on one.
264
+ private let anyNumberPayloadTypes: Set<String> = ["Double", "Float", "CGFloat"]
265
+
266
+ /// The payload types that decode only a JavaScript number, so a case before them with a type of
267
+ /// `anyNumberPayloadTypes` decodes every value they could. `Int`, `Int64`, `UInt` and `UInt64` aren't
268
+ /// listed, since they also decode a `bigint`.
269
+ private let numberOnlyPayloadTypes: Set<String> = [
270
+ "Double", "Float", "CGFloat", "Int8", "Int16", "Int32", "UInt8", "UInt16", "UInt32",
271
+ ]
272
+
273
+ /// Warns about each case that an earlier case always decodes first, so it is never chosen. Cases are
274
+ /// tried in declaration order and the first that decodes wins. The macro knows that only for some
275
+ /// payload types: a `JavaScriptValue` payload decodes every value, and a floating-point payload every
276
+ /// number, so a later case that decodes only numbers can't be reached. Types with the same spelling
277
+ /// are reported as an error by `validatedUnion`, which runs first.
278
+ private func diagnoseUnreachableCases(of declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) {
279
+ var catchAllCase: (name: TokenSyntax, type: String)?
280
+ var anyNumberCase: (name: TokenSyntax, type: String)?
281
+
282
+ for member in declaration.memberBlock.members {
283
+ guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
284
+ continue
285
+ }
286
+ for element in caseDecl.elements {
287
+ guard let parameter = element.parameterClause?.parameters.first else {
288
+ continue
289
+ }
290
+ let type = unqualifiedPayloadTypeName(parameter.type)
291
+ let reason: String?
292
+ let earlier: (name: TokenSyntax, type: String)?
293
+ if let catchAllCase {
294
+ earlier = catchAllCase
295
+ reason = "'\(catchAllCase.type)' decodes every JavaScript value"
296
+ } else if let anyNumberCase, numberOnlyPayloadTypes.contains(type) {
297
+ earlier = anyNumberCase
298
+ reason = "'\(anyNumberCase.type)' decodes every JavaScript number, and '\(type)' decodes only numbers"
299
+ } else {
300
+ earlier = nil
301
+ reason = nil
302
+ }
303
+ if let earlier, let reason {
304
+ let name = element.name.text
305
+ context.diagnose(
306
+ Diagnostic(
307
+ node: element,
308
+ message: UnionDiagnosticMessage(
309
+ "@Union case '\(name)' is never decoded: case '\(earlier.name.text)' is tried first, and \(reason). Move case '\(name)' before case '\(earlier.name.text)'.",
310
+ id: "union-unreachable-case",
311
+ severity: .warning
312
+ ),
313
+ notes: [
314
+ Note(
315
+ node: Syntax(earlier.name),
316
+ message: UnionNoteMessage(
317
+ "case '\(earlier.name.text)' is declared here",
318
+ id: "union-unreachable-case-earlier"
319
+ )
320
+ )
321
+ ]
322
+ )
323
+ )
324
+ continue
325
+ }
326
+ if type == "JavaScriptValue" {
327
+ catchAllCase = (element.name, type)
328
+ } else if anyNumberCase == nil, anyNumberPayloadTypes.contains(type) {
329
+ anyNumberCase = (element.name, type)
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ /// The name of a payload type without the module that it may be qualified with, such as `Swift.Double`.
336
+ private func unqualifiedPayloadTypeName(_ type: TypeSyntax) -> String {
337
+ let modules = ["Swift", "CoreGraphics", "CoreFoundation", "Foundation", "ExpoModulesCore", "ExpoModulesJSI"]
338
+ if let memberType = type.as(MemberTypeSyntax.self), modules.contains(memberType.baseType.trimmedDescription) {
339
+ return memberType.name.text
340
+ }
341
+ return type.trimmedDescription
342
+ }
343
+
259
344
  // MARK: - Synthesized members
260
345
 
261
346
  /// `decode(_:in:)`: tries each case's payload converter in declaration order and returns the first
@@ -429,11 +514,22 @@ private func defaultValueDiagnostic(
429
514
  private struct UnionDiagnosticMessage: DiagnosticMessage {
430
515
  let message: String
431
516
  let diagnosticID: MessageID
432
- let severity: DiagnosticSeverity = .error
517
+ let severity: DiagnosticSeverity
433
518
 
434
- init(_ message: String, id: String) {
519
+ init(_ message: String, id: String, severity: DiagnosticSeverity = .error) {
435
520
  self.message = message
436
521
  self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
522
+ self.severity = severity
523
+ }
524
+ }
525
+
526
+ private struct UnionNoteMessage: NoteMessage {
527
+ let message: String
528
+ let noteID: MessageID
529
+
530
+ init(_ message: String, id: String) {
531
+ self.message = message
532
+ self.noteID = MessageID(domain: "ExpoModulesMacros", id: id)
437
533
  }
438
534
  }
439
535