expo-modules-macros 0.31.0 → 0.33.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 (28) hide show
  1. package/.github/workflows/{swift.yml → macos.yml} +12 -3
  2. package/AGENTS.md +4 -2
  3. package/Package.swift +10 -1
  4. package/README.md +6 -6
  5. package/apple/ExpoModulesMacros +0 -0
  6. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +124 -5
  7. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/InheritedMembers.swift +2 -1
  8. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ResolveRefs.swift +76 -17
  9. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ScanExports.swift +15 -8
  10. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +174 -20
  11. package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +14 -6
  12. package/apple/Sources/ExpoModulesCLI/Core/Detection.swift +6 -0
  13. package/apple/Sources/ExpoModulesCLI/Core/DetectionVisitor.swift +41 -2
  14. package/apple/Sources/ExpoModulesCLI/Core/SourceScan.swift +3 -2
  15. package/apple/Sources/ExpoModulesMacrosCore/Macros/EventMacro.swift +12 -2
  16. package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoModuleMacro.swift +12 -0
  17. package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoViewMacro.swift +64 -30
  18. package/apple/Sources/ExpoModulesMacrosCore/Macros/JSMacro.swift +124 -0
  19. package/apple/Sources/ExpoModulesMacrosCore/Macros/RecordMacro.swift +68 -0
  20. package/apple/Sources/ExpoModulesMacrosCore/Macros/SharedObjectMacro.swift +79 -1
  21. package/apple/Sources/ExpoModulesMacrosCore/Macros/UnionMacro.swift +101 -5
  22. package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +574 -23
  23. package/apple/Sources/ExpoModulesMacrosCore/ProvidedMacros.swift +1 -1
  24. package/apple/Sources/ExpoModulesMacrosCore/Support/JSNameCollisions.swift +111 -2
  25. package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +28 -2
  26. package/apple/Sources/ExpoModulesMacrosPlugin/ExpoModulesMacrosPlugin.swift +1 -0
  27. package/build/types.d.ts +58 -3
  28. package/package.json +5 -5
@@ -3,11 +3,11 @@ import SwiftSyntax
3
3
  import SwiftSyntaxBuilder
4
4
  import SwiftSyntaxMacros
5
5
 
6
- /// Member macro applied to an `ExpoView` subclass, binding it to its props type:
6
+ /// Member and extension macro applied to an `ExpoNativeView` subclass, binding it to its props type:
7
7
  ///
8
8
  /// @ExpoView<CardProps>
9
- /// final class CardView: ExpoView {
10
- /// override func didUpdateProps(_ diff: CardProps.Diff) {
9
+ /// final class CardView: ExpoNativeView {
10
+ /// func didUpdateProps(_ diff: CardProps.Diff) {
11
11
  /// if diff.changed(.color) {
12
12
  /// backgroundColor = props.color
13
13
  /// }
@@ -21,23 +21,25 @@ import SwiftSyntaxMacros
21
21
  /// compile error at the author's own line ("requires that 'X' conform to 'AnyViewProps'")
22
22
  /// rather than a macro diagnostic. This macro therefore validates nothing about the props type.
23
23
  ///
24
- /// The whole expansion is one line:
24
+ /// The expansion is a typealias and a conformance:
25
25
  ///
26
26
  /// public typealias Props = CardProps
27
+ /// extension CardView: AnyNativeView {}
27
28
  ///
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.
29
+ /// There is **no view definition**: no `ViewDefinition`, no `View(…) { }` builder, no `Props(_:)` or
30
+ /// `Events(_:)` element. Core reaches the view through the `AnyNativeView` conformance: it reads the
31
+ /// props type from the `Props` typealias, which satisfies the protocol's associated type, and the
32
+ /// event names from the props type's own `_eventNames` (synthesized by `@ViewProps`). Both are
33
+ /// static, so no builder has to be evaluated.
34
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.
35
+ /// Views also get no `_decorateView` hook: `_decorateModule` and `_decorateSharedObject` exist to bind
36
+ /// members into a JS object, and a view has no such object. Props reach a view through Fabric's
37
+ /// mounting layer, not a JS call, so a view's runtime entry point is the props update hook.
38
38
  ///
39
- /// `didUpdateProps` is a core-called override, not synthesized here.
40
- public struct ExpoViewMacro: MemberMacro {
39
+ /// `didUpdateProps` is an `AnyNativeView` requirement with a default in core, not synthesized here.
40
+ /// It isn't an `override`: `ExpoNativeView` has no such method, because its parameter type depends on
41
+ /// the props type.
42
+ public struct ExpoViewMacro: MemberMacro, ExtensionMacro {
41
43
  public static func expansion(
42
44
  of node: AttributeSyntax,
43
45
  providingMembersOf declaration: some DeclGroupSyntax,
@@ -87,38 +89,66 @@ public struct ExpoViewMacro: MemberMacro {
87
89
  """
88
90
  ]
89
91
  }
92
+
93
+ public static func expansion(
94
+ of node: AttributeSyntax,
95
+ attachedTo declaration: some DeclGroupSyntax,
96
+ providingExtensionsOf type: some TypeSyntaxProtocol,
97
+ conformingTo protocols: [TypeSyntax],
98
+ in context: some MacroExpansionContext
99
+ ) throws -> [ExtensionDeclSyntax] {
100
+ // A class that the member macro rejects gets no conformance either: the member macro has already
101
+ // reported the error, and a conformance without the `Props` typealias would only add a second
102
+ // error about the associated type.
103
+ guard let classDecl = declaration.as(ClassDeclSyntax.self),
104
+ inheritsFromAny(classDecl, names: expoViewBaseClassNames),
105
+ classDecl.genericParameterClause == nil,
106
+ existingPropsMemberKind(in: classDecl) == nil,
107
+ genericArgument(of: node) != nil,
108
+ !inheritsProtocol(named: nativeViewProtocolName, in: declaration)
109
+ else {
110
+ return []
111
+ }
112
+ let ext: DeclSyntax = """
113
+ extension \(type.trimmed): \(raw: nativeViewProtocolName) {}
114
+ """
115
+ guard let extDecl = ext.as(ExtensionDeclSyntax.self) else {
116
+ return []
117
+ }
118
+ return [extDecl]
119
+ }
90
120
  }
91
121
 
92
122
  // MARK: - Diagnostics
93
123
 
94
- /// The error for a class that doesn't name `ExpoView` in its inheritance clause, carrying a fix-it
124
+ /// The error for a class that doesn't name `ExpoNativeView` in its inheritance clause, carrying a fix-it
95
125
  /// that adds it.
96
126
  ///
97
127
  /// 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
128
+ /// inserts `: ExpoNativeView`; with an existing clause it prepends `ExpoNativeView` as the first entry, since
99
129
  /// Swift requires the superclass to come before any protocol. A class that already names some other
100
130
  /// superclass gets the diagnostic without a fix-it: replacing that superclass is a decision the macro
101
131
  /// can't make (see the indirect-inheritance note on `expoViewBaseClassNames`).
102
132
  private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagnostic {
103
133
  let message = ExpoViewDiagnosticMessage(
104
- "@ExpoView class must inherit from ExpoView. Add `: ExpoView` to the class declaration.",
134
+ "@ExpoView class must inherit from ExpoNativeView. Add `: ExpoNativeView` to the class declaration.",
105
135
  id: "expoview-missing-base-class"
106
136
  )
107
137
  var fixIts: [FixIt] = []
108
138
 
109
139
  if classDecl.inheritanceClause == nil {
110
- // `class CardView {` → `class CardView: ExpoView {`. The name carries the space before `{` as
140
+ // `class CardView {` → `class CardView: ExpoNativeView {`. The name carries the space before `{` as
111
141
  // trailing trivia, so move it onto the clause to keep the spacing.
112
142
  let name = classDecl.name
113
143
  let clause = InheritanceClauseSyntax(
114
144
  colon: .colonToken(trailingTrivia: .space),
115
145
  inheritedTypes: InheritedTypeListSyntax([
116
- InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoView"))))
146
+ InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoNativeView"))))
117
147
  ])
118
148
  )
119
149
  fixIts.append(
120
150
  FixIt(
121
- message: ExpoViewFixItMessage("Inherit from 'ExpoView'", id: "expoview-add-base-class"),
151
+ message: ExpoViewFixItMessage("Inherit from 'ExpoNativeView'", id: "expoview-add-base-class"),
122
152
  changes: [
123
153
  .replace(
124
154
  oldNode: Syntax(classDecl),
@@ -132,16 +162,16 @@ private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagn
132
162
  )
133
163
  )
134
164
  } else if let clause = classDecl.inheritanceClause, inheritsOnlyProtocolsByConvention(clause) {
135
- // `class CardView: Identifiable {` → `class CardView: ExpoView, Identifiable {`.
165
+ // `class CardView: Identifiable {` → `class CardView: ExpoNativeView, Identifiable {`.
136
166
  var inherited = clause.inheritedTypes
137
167
  if var first = inherited.first {
138
168
  first.trailingComma = .commaToken(trailingTrivia: .space)
139
- first.type = TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoView")))
169
+ first.type = TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoNativeView")))
140
170
  inherited.insert(first, at: inherited.startIndex)
141
171
  }
142
172
  fixIts.append(
143
173
  FixIt(
144
- message: ExpoViewFixItMessage("Inherit from 'ExpoView'", id: "expoview-add-base-class"),
174
+ message: ExpoViewFixItMessage("Inherit from 'ExpoNativeView'", id: "expoview-add-base-class"),
145
175
  changes: [
146
176
  .replace(
147
177
  oldNode: Syntax(clause),
@@ -157,7 +187,7 @@ private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagn
157
187
 
158
188
  /// Whether every entry in the clause looks like a protocol rather than a superclass, by the
159
189
  /// 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
190
+ /// the macro would be replacing, prepending `ExpoNativeView` would produce two superclasses. A macro can't
161
191
  /// resolve names, so this only reports true when the clause is empty of anything that could be a
162
192
  /// base class the author chose deliberately. Conservative by design: a wrong guess here would emit a
163
193
  /// fix-it that doesn't compile.
@@ -223,14 +253,14 @@ private func existingPropsMemberKind(in classDecl: ClassDeclSyntax) -> String? {
223
253
  }
224
254
 
225
255
  /// 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`
256
+ /// class's own inheritance clause: both `: ExpoNativeView` and a qualified `: ExpoModulesCore.ExpoNativeView`
227
257
  /// pass, since `baseIdentifier` reads the trailing name.
228
258
  ///
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
259
+ /// Indirect inheritance does not: a view whose superclass is itself an `ExpoNativeView` subclass
260
+ /// (`class Base: ExpoNativeView` then `class CardView: Base`) is rejected, because a macro can't resolve
231
261
  /// 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"]
262
+ /// workaround is to apply `@ExpoView` to the class that names `ExpoNativeView` directly.
263
+ private let expoViewBaseClassNames: Set<String> = ["ExpoNativeView"]
234
264
 
235
265
  /// The attribute's single generic argument, verbatim (`@ExpoView<CardProps>` → `"CardProps"`), or
236
266
  /// `nil` when the attribute carries no generic argument clause.
@@ -257,3 +287,7 @@ private func genericArgument(of attribute: AttributeSyntax) -> String? {
257
287
  }
258
288
  return type.trimmedDescription
259
289
  }
290
+
291
+ /// The core protocol that `@ExpoView` conforms the class to. Core registers the view and decodes its
292
+ /// props through it.
293
+ private let nativeViewProtocolName = "AnyNativeView"
@@ -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