@expo/expo-modules-macros-plugin 0.8.0 → 0.10.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.
@@ -54,6 +54,9 @@ jobs:
54
54
  case "$archs" in *x86_64*) ;; *) echo "Missing x86_64 slice"; exit 1;; esac
55
55
  arch -arm64 ./ExpoModulesMacros-tool < /dev/null
56
56
  arch -x86_64 ./ExpoModulesMacros-tool < /dev/null
57
+ # With arguments the same binary runs the scanner CLI instead of the plugin server.
58
+ arch -arm64 ./ExpoModulesMacros-tool --help > /dev/null
59
+ arch -x86_64 ./ExpoModulesMacros-tool --help > /dev/null
57
60
 
58
61
  - name: Test
59
62
  run: swift test -v
Binary file
@@ -8,19 +8,19 @@ import PackageDescription
8
8
  let package = Package(
9
9
  name: "ExpoModulesMacros",
10
10
  platforms: [.macOS(.v13)],
11
- products: [
12
- // The scanner CLI. Named `ExpoModulesScanner` (the user-facing tool name) while its target is
13
- // `ExpoModulesScannerCLI`; the detection logic lives in the importable `ExpoModulesScanner`
14
- // library that both the CLI and the tests depend on.
15
- .executable(name: "ExpoModulesScanner", targets: ["ExpoModulesScannerCLI"]),
16
- ],
17
11
  dependencies: [
18
12
  .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest")
19
13
  ],
20
14
  targets: [
15
+ // The plugin executable doubles as the scanner CLI: the compiler launches it without arguments
16
+ // and speaks the plugin protocol over stdin, while an invocation with arguments dispatches into
17
+ // `ScannerCLI` (see the entry point in Plugin.swift). Sharing the binary keeps the package to a
18
+ // single shipped executable; the scanner adds little on top of the SwiftSyntax the macros
19
+ // already link.
21
20
  .macro(
22
21
  name: "ExpoModulesMacros",
23
22
  dependencies: [
23
+ "ExpoModulesScanner",
24
24
  .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
25
25
  .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
26
26
  ]
@@ -30,12 +30,9 @@ let package = Package(
30
30
  dependencies: [
31
31
  .product(name: "SwiftSyntax", package: "swift-syntax"),
32
32
  .product(name: "SwiftParser", package: "swift-syntax"),
33
+ .product(name: "SwiftIfConfig", package: "swift-syntax"),
33
34
  ]
34
35
  ),
35
- .executableTarget(
36
- name: "ExpoModulesScannerCLI",
37
- dependencies: ["ExpoModulesScanner"]
38
- ),
39
36
  ]
40
37
  )
41
38
 
@@ -166,8 +166,8 @@ internal struct JSFunction {
166
166
  /// primitive's `decode` is `@inlinable` and lowers to the same direct accessor a hand-rolled fast
167
167
  /// path would use.
168
168
  private func decodeStatement(at index: Int) -> String {
169
- let exprType = expressionType(parameters[index].type.trimmedDescription)
170
- return "let arg\(index) = try \(exprType).decode(arguments.unownedValue(at: \(index)), in: runtime)"
169
+ let type = parameters[index].type.trimmedDescription
170
+ return "let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))"
171
171
  }
172
172
 
173
173
  /// The `<callee>.<name>(...)` call for the given arity. Slots `0..<arity` are passed their decoded
@@ -332,8 +332,7 @@ internal struct JSProperty {
332
332
  // getter-only (a settable var with neither an annotation nor a literal default is rare and can't
333
333
  // be decoded).
334
334
  if isSettable, let valueType {
335
- let exprType = expressionType(valueType)
336
- let setDecode = "\(callee).\(swiftName) = try \(exprType).decode(arguments.unownedValue(at: 0), in: runtime)"
335
+ let setDecode = "\(callee).\(swiftName) = try \(decodeCall(valueType, from: "arguments.unownedValue(at: 0)"))"
337
336
  lines.append(
338
337
  accessorClosure(
339
338
  descriptorName, "set", receiver: receiver, body: "\(unwrap)\(setDecode)\nreturn .undefined"))
@@ -151,6 +151,13 @@ extension ExpoModuleMacro: MemberAttributeMacro {
151
151
  attributes.append("@JavaScriptActor")
152
152
  }
153
153
 
154
+ // `@JS(.concurrent)` is the inverse: instead of the JS-thread stamp the member gets
155
+ // `@concurrent`, so its body runs on the concurrent pool. `shouldStampJavaScriptActor` already
156
+ // skipped the stamp above, leaving the two mutually exclusive.
157
+ if isConcurrentJSMember(member) {
158
+ attributes.append("@concurrent")
159
+ }
160
+
154
161
  // Apply the result builder to `definition()` so the user doesn't have to. Skipped if
155
162
  // they already wrote `@ModuleDefinitionBuilder` themselves, which would otherwise be
156
163
  // a duplicate attribute.
@@ -0,0 +1,59 @@
1
+ /// Recognition of the free-form (`Any`-bearing) boundary types and the spellings the macros emit or
2
+ /// suggest for them. None of these types can conform to the JS codable protocols (`Any` can't conform
3
+ /// to a protocol, and the container conditional conformances require the element/value to conform), so
4
+ /// they can't cross the boundary through the usual `T.decode` / `T.encode` path. Instead they're
5
+ /// accepted **only** in decode position (function/constructor arguments), decoded through a dedicated
6
+ /// `JavaScriptValue.decodeAny…` entry point; a free-form return or getter is rejected, since there's
7
+ /// no free-form encode.
8
+ ///
9
+ /// Shared across the macro pipeline: `TypeConformanceAssertion` skips them, `MacroHelpers.decodeCall`
10
+ /// reroutes their decode, and `JSMacro` uses them for the steering diagnostics.
11
+
12
+ /// The free-form boundary types: an untyped value (`Any`), an untyped array (`[Any]`), and an untyped
13
+ /// string-keyed dictionary (`[String: Any]`). Matched by whitespace-normalized spelling so
14
+ /// `[String: Any]` and `[String : Any]` both count.
15
+ private let freeFormBoundaryTypes: Set<String> = ["Any", "[Any]", "[String:Any]"]
16
+
17
+ /// The `JavaScriptValue.decodeAny…` method the binding calls to decode a free-form argument, keyed by
18
+ /// the free-form type's normalized spelling. `nil` for any non-free-form type.
19
+ private let freeFormDecodeMethods: [String: String] = [
20
+ "Any": "decodeAny",
21
+ "[Any]": "decodeAnyArray",
22
+ "[String:Any]": "decodeAnyDictionary",
23
+ ]
24
+
25
+ /// The type-safe alternative a free-form type's diagnostic steers to: the same shape with the `Any`
26
+ /// element replaced by `JavaScriptValue`, which conforms to the codable protocols. Keyed by the
27
+ /// free-form type's normalized spelling; spelled with conventional spacing for the message.
28
+ private let typedFreeFormReplacements: [String: String] = [
29
+ "Any": "JavaScriptValue",
30
+ "[Any]": "[JavaScriptValue]",
31
+ "[String:Any]": "[String: JavaScriptValue]",
32
+ ]
33
+
34
+ /// True when a boundary type (as written) is one of the free-form spellings, ignoring internal
35
+ /// whitespace so `[String: Any]` and `[String :Any]` both match. Optionals are not free-form here: a
36
+ /// trailing `?` would route through `Optional.decode`, which free-form can't satisfy, so an optional
37
+ /// free-form type isn't recognized and stays a normal (failing) assertion.
38
+ internal func isFreeFormBoundaryType(_ type: String) -> Bool {
39
+ return freeFormBoundaryTypes.contains(normalizedTypeSpelling(type))
40
+ }
41
+
42
+ /// The `JavaScriptValue.decodeAny…` method name for a free-form boundary type, or `nil` when the type
43
+ /// isn't free-form. The binding emits `JavaScriptValue.<method>(arguments.unownedValue(at:), in:)` in
44
+ /// place of the type's own `.decode`.
45
+ internal func freeFormDecodeMethod(for type: String) -> String? {
46
+ return freeFormDecodeMethods[normalizedTypeSpelling(type)]
47
+ }
48
+
49
+ /// The type-safe alternative to suggest in place of a free-form type (its `Any` element replaced by
50
+ /// `JavaScriptValue`), or `nil` when the type isn't free-form.
51
+ internal func typedFreeFormReplacement(for type: String) -> String? {
52
+ return typedFreeFormReplacements[normalizedTypeSpelling(type)]
53
+ }
54
+
55
+ /// A type spelling with all whitespace removed, so spelling variations of the same type
56
+ /// (`[String: Any]` vs `[String :Any]`) compare equal.
57
+ private func normalizedTypeSpelling(_ type: String) -> String {
58
+ return type.filter { !$0.isWhitespace }
59
+ }
@@ -27,8 +27,8 @@ internal struct JSConstructor {
27
27
 
28
28
  var callArguments: [String] = []
29
29
  for (index, parameter) in parameters.enumerated() {
30
- let exprType = expressionType(parameter.type.trimmedDescription)
31
- lines.append("let arg\(index) = try \(exprType).decode(arguments.unownedValue(at: \(index)), in: runtime)")
30
+ let type = parameter.type.trimmedDescription
31
+ lines.append("let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))")
32
32
 
33
33
  let label = parameter.firstName.text
34
34
  callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
@@ -1,3 +1,4 @@
1
+ import SwiftDiagnostics
1
2
  import SwiftSyntax
2
3
  import SwiftSyntaxMacros
3
4
 
@@ -30,6 +31,9 @@ public struct JSMacro: PeerMacro {
30
31
  providingPeersOf declaration: some DeclSyntaxProtocol,
31
32
  in context: some MacroExpansionContext
32
33
  ) throws -> [DeclSyntax] {
34
+ diagnoseFreeFormTypes(in: declaration, in: context)
35
+ diagnoseConcurrentOption(of: node, on: declaration, in: context)
36
+
33
37
  guard let member = boundaryMember(of: declaration),
34
38
  let assertion = directionalConformanceAssertion(
35
39
  name: member.name,
@@ -43,6 +47,190 @@ public struct JSMacro: PeerMacro {
43
47
  }
44
48
  }
45
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
+
115
+ /// Emits the free-form (`Any` / `[Any]` / `[String: Any]`) diagnostics for a `@JS` declaration.
116
+ ///
117
+ /// A free-form type is only supported crossing the boundary as an **argument**, decoded through
118
+ /// `JavaScriptValue.decodeAny…`; there is no free-form encode, so any position that encodes is a hard
119
+ /// error. That means:
120
+ /// - a function/constructor **parameter** typed free-form gets a warning steering to
121
+ /// `[String: JavaScriptValue]` (the type-safe alternative), but compiles;
122
+ /// - a function **return** typed free-form is an error (it would need to encode);
123
+ /// - a **property** typed free-form is an error regardless of settability, because its getter always
124
+ /// encodes.
125
+ ///
126
+ /// Types are matched by their written spelling on the type node, so the diagnostic points at the
127
+ /// offending type in the user's source.
128
+ private func diagnoseFreeFormTypes(
129
+ in declaration: some DeclSyntaxProtocol,
130
+ in context: some MacroExpansionContext
131
+ ) {
132
+ if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
133
+ warnFreeFormArguments(funcDecl.signature.parameterClause.parameters, in: context)
134
+ if let returnType = funcDecl.signature.returnClause?.type,
135
+ isFreeFormBoundaryType(returnType.trimmedDescription) {
136
+ context.diagnose(
137
+ Diagnostic(node: returnType, message: freeFormReturnError(for: returnType.trimmedDescription)))
138
+ }
139
+ return
140
+ }
141
+
142
+ // A constructor decodes its arguments exactly like a function; it has no return value to encode, so
143
+ // only the argument warning applies.
144
+ if let initDecl = declaration.as(InitializerDeclSyntax.self) {
145
+ warnFreeFormArguments(initDecl.signature.parameterClause.parameters, in: context)
146
+ return
147
+ }
148
+
149
+ if let varDecl = declaration.as(VariableDeclSyntax.self),
150
+ let type = varDecl.bindings.first?.typeAnnotation?.type,
151
+ isFreeFormBoundaryType(type.trimmedDescription) {
152
+ context.diagnose(
153
+ Diagnostic(node: type, message: freeFormPropertyError(for: type.trimmedDescription)))
154
+ }
155
+ }
156
+
157
+ /// The tail of a free-form encode error: the reshaped `JavaScriptValue` alternative when the type is a
158
+ /// container (`[String: Any]` -> `[String: JavaScriptValue]`), otherwise the passthrough suggestion
159
+ /// for a bare `Any`. Both conform to the codable protocols, so either is a valid fix.
160
+ private func suggestedAlternative(for freeFormType: String) -> String {
161
+ if let reshaped = typedFreeFormReplacement(for: freeFormType), reshaped != "JavaScriptValue" {
162
+ return "Use '\(reshaped)', or 'JavaScriptValue' to pass a JS value through unchanged."
163
+ }
164
+ return "Use a concrete type, or 'JavaScriptValue' to pass a JS value through unchanged."
165
+ }
166
+
167
+ /// Emits the steering warning for each free-form parameter in a list. Shared by the function and
168
+ /// constructor cases, which both decode their arguments through the same path.
169
+ private func warnFreeFormArguments(
170
+ _ parameters: FunctionParameterListSyntax,
171
+ in context: some MacroExpansionContext
172
+ ) {
173
+ for parameter in parameters {
174
+ let type = parameter.type.trimmedDescription
175
+ guard let suggested = typedFreeFormReplacement(for: type) else {
176
+ continue
177
+ }
178
+ context.diagnose(
179
+ Diagnostic(
180
+ node: parameter.type,
181
+ message: freeFormArgumentWarning(for: type, suggesting: suggested)))
182
+ }
183
+ }
184
+
185
+ private func freeFormArgumentWarning(
186
+ for freeFormType: String,
187
+ suggesting suggestedType: String
188
+ ) -> JSDiagnosticMessage {
189
+ return JSDiagnosticMessage(
190
+ "Prefer '\(suggestedType)' over the free-form '\(freeFormType)' for a @JS argument. Free-form decoding boxes every value as 'Any' (slower, no static typing); the 'JavaScriptValue' element keeps each value inspectable without erasing it.",
191
+ id: "js-free-form-argument",
192
+ severity: .warning
193
+ )
194
+ }
195
+
196
+ private func freeFormReturnError(for freeFormType: String) -> JSDiagnosticMessage {
197
+ return JSDiagnosticMessage(
198
+ "A @JS function can't return the free-form '\(freeFormType)': there's no way to encode an untyped value back to JavaScript. \(suggestedAlternative(for: freeFormType))",
199
+ id: "js-free-form-return",
200
+ severity: .error
201
+ )
202
+ }
203
+
204
+ private func freeFormPropertyError(for freeFormType: String) -> JSDiagnosticMessage {
205
+ return JSDiagnosticMessage(
206
+ "A @JS property can't have the free-form '\(freeFormType)': its getter would have to encode an untyped value back to JavaScript, which isn't supported. \(suggestedAlternative(for: freeFormType))",
207
+ id: "js-free-form-property",
208
+ severity: .error
209
+ )
210
+ }
211
+
212
+ private struct JSDiagnosticMessage: DiagnosticMessage {
213
+ let message: String
214
+ let diagnosticID: MessageID
215
+ let severity: DiagnosticSeverity
216
+
217
+ init(_ message: String, id: String, severity: DiagnosticSeverity) {
218
+ self.message = message
219
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
220
+ self.severity = severity
221
+ }
222
+ }
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
+
46
234
  /// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer unique
47
235
  /// among siblings), the boundary types split by conversion direction, and whether the member is
48
236
  /// type-level. Arguments (and a settable property's incoming value) are decoded; return values (and a
@@ -31,6 +31,44 @@ internal func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool
31
31
  return nil
32
32
  }
33
33
 
34
+ /// True if the attribute lists the given `JSOptions` member, e.g. `@JS(.concurrent)` or
35
+ /// `@JS("name", [.concurrent])` -> true for "concurrent". Options are written as member-access
36
+ /// expressions (`.concurrent`), optionally inside an array literal when more than one is combined, so
37
+ /// both spellings are scanned. The leading string literal, when present, is the JS name and is skipped.
38
+ internal func hasJSOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
39
+ guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
40
+ return false
41
+ }
42
+ for arg in args {
43
+ if namesOption(arg.expression, option) {
44
+ return true
45
+ }
46
+ if let array = arg.expression.as(ArrayExprSyntax.self),
47
+ array.elements.contains(where: { namesOption($0.expression, option) }) {
48
+ return true
49
+ }
50
+ }
51
+ return false
52
+ }
53
+
54
+ /// True when the expression is the member access `.<option>` (or `JSOptions.<option>`).
55
+ private func namesOption(_ expression: ExprSyntax, _ option: String) -> Bool {
56
+ guard let member = expression.as(MemberAccessExprSyntax.self) else {
57
+ return false
58
+ }
59
+ return member.declName.baseName.text == option
60
+ }
61
+
62
+ /// True if the `@JS`-marked declaration opted into running off the JavaScript thread with
63
+ /// `@JS(.concurrent)`. Such a member is left unstamped and gets `@concurrent` instead, so its body
64
+ /// runs on the concurrent pool rather than inheriting the JS thread.
65
+ internal func isConcurrentJSMember(_ decl: DeclSyntaxProtocol) -> Bool {
66
+ guard let attribute = memberAttributes(of: decl).firstAttribute(named: "JS") else {
67
+ return false
68
+ }
69
+ return hasJSOption(attribute, named: "concurrent")
70
+ }
71
+
34
72
  /// True if the type is written as an optional: `T?`, `T!`, or the explicit `Optional<T>`. Used to
35
73
  /// decide argument requiredness (an optional parameter may be omitted) and record-field nullability.
36
74
  internal func isOptionalType(_ type: TypeSyntax) -> Bool {
@@ -156,6 +194,7 @@ internal func memberHasJSAttribute(_ decl: DeclSyntaxProtocol) -> Bool {
156
194
 
157
195
  /// Decides whether the macro should stamp `@JavaScriptActor` on a `@JS`-marked member.
158
196
  /// The macro defers to the user when they've already chosen an isolation:
197
+ /// - the member opted out with `@JS(.concurrent)`
159
198
  /// - the `nonisolated` modifier is present on the member
160
199
  /// - any attribute whose name matches a known global actor (`@MainActor`, `@JavaScriptActor`)
161
200
  /// or follows the `*Actor` naming convention is present on the member or its enclosing type
@@ -165,6 +204,10 @@ internal func shouldStampJavaScriptActor(
165
204
  on member: DeclSyntaxProtocol,
166
205
  enclosedBy enclosing: some DeclGroupSyntax
167
206
  ) -> Bool {
207
+ if isConcurrentJSMember(member) {
208
+ return false
209
+ }
210
+
168
211
  let modifiers = memberModifiers(of: member)
169
212
  if modifiers.contains(where: { $0.name.text == "nonisolated" }) {
170
213
  return false
@@ -261,6 +304,18 @@ internal func expressionType(_ type: String) -> String {
261
304
  return type.dropLast() + "?"
262
305
  }
263
306
 
307
+ /// The decode expression for a boundary type read from `valueExpression` (a `JavaScriptUnownedValue`),
308
+ /// as it appears after `try`. A free-form type (`Any`, `[Any]`, `[String: Any]`) can't conform to
309
+ /// `JavaScriptDecodable`, so it decodes through the dedicated `JavaScriptValue.decodeAny…` entry point
310
+ /// keyed to its shape; every other type decodes through its own static `decode`, spelled in expression
311
+ /// position (`T!` rewritten to `T?`).
312
+ internal func decodeCall(_ type: String, from valueExpression: String) -> String {
313
+ if let method = freeFormDecodeMethod(for: type) {
314
+ return "JavaScriptValue.\(method)(\(valueExpression), in: runtime)"
315
+ }
316
+ return "\(expressionType(type)).decode(\(valueExpression), in: runtime)"
317
+ }
318
+
264
319
  // MARK: - @JS property collection
265
320
 
266
321
  /// Collects the `@JS var` bindings of a declaration into `JSProperty` values for direct JSI binding.
@@ -314,3 +369,25 @@ internal func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
314
369
  return false
315
370
  }
316
371
  }
372
+
373
+ /// True if the type's inheritance clause already lists a protocol with the given name. Matches
374
+ /// either the bare identifier (`Record`) or a qualified member access ending in the name
375
+ /// (`ExpoModulesCore.Record`). Used by the extension macros to skip a conformance the author already
376
+ /// spelled out. Works for `struct`, `class`, and `enum` declarations; any other declaration kind has no
377
+ /// inheritance clause to read and reports `false`.
378
+ internal func inheritsProtocol(named name: String, in declaration: some DeclGroupSyntax) -> Bool {
379
+ let inheritanceClause: InheritanceClauseSyntax?
380
+ if let structDecl = declaration.as(StructDeclSyntax.self) {
381
+ inheritanceClause = structDecl.inheritanceClause
382
+ } else if let classDecl = declaration.as(ClassDeclSyntax.self) {
383
+ inheritanceClause = classDecl.inheritanceClause
384
+ } else if let enumDecl = declaration.as(EnumDeclSyntax.self) {
385
+ inheritanceClause = enumDecl.inheritanceClause
386
+ } else {
387
+ return false
388
+ }
389
+ guard let inherited = inheritanceClause?.inheritedTypes else {
390
+ return false
391
+ }
392
+ return inherited.contains { baseIdentifier(of: $0.type) == name }
393
+ }
@@ -1,7 +1,8 @@
1
+ import ExpoModulesScanner
2
+ import Foundation
1
3
  import SwiftCompilerPlugin
2
4
  import SwiftSyntaxMacros
3
5
 
4
- @main
5
6
  struct ExpoModulesMacrosPlugin: CompilerPlugin {
6
7
  let providingMacros: [Macro.Type] = [
7
8
  OptimizedFunctionAttachedMacro.self,
@@ -10,5 +11,22 @@ struct ExpoModulesMacrosPlugin: CompilerPlugin {
10
11
  ExpoModuleMacro.self,
11
12
  SharedObjectMacro.self,
12
13
  RecordMacro.self,
14
+ UnionMacro.self,
13
15
  ]
14
16
  }
17
+
18
+ /// The executable doubles as the scanner CLI. The compiler always launches a plugin executable
19
+ /// without arguments and speaks the plugin protocol over stdin, so any argument means a scanner
20
+ /// invocation (`ExpoModulesMacros-tool scan-modules <path>...`); with none, this starts the plugin
21
+ /// server exactly as `@main` on the `CompilerPlugin` type would.
22
+ @main
23
+ enum EntryPoint {
24
+ static func main() throws {
25
+ let arguments = Array(CommandLine.arguments.dropFirst())
26
+ if arguments.isEmpty {
27
+ try ExpoModulesMacrosPlugin.main()
28
+ } else {
29
+ exit(ScannerCLI.run(arguments: arguments))
30
+ }
31
+ }
32
+ }
@@ -478,37 +478,6 @@ private func isExcludedByModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
478
478
  return false
479
479
  }
480
480
 
481
- /**
482
- True if the type's inheritance clause already lists a protocol with the given name.
483
- Matches either the bare identifier (`Record`) or a qualified member access ending in
484
- the name (`ExpoModulesCore.Record`).
485
- */
486
- private func inheritsProtocol(named name: String, in declaration: some DeclGroupSyntax) -> Bool {
487
- let inheritanceClause: InheritanceClauseSyntax?
488
- if let structDecl = declaration.as(StructDeclSyntax.self) {
489
- inheritanceClause = structDecl.inheritanceClause
490
- } else if let classDecl = declaration.as(ClassDeclSyntax.self) {
491
- inheritanceClause = classDecl.inheritanceClause
492
- } else {
493
- return false
494
- }
495
- guard let inherited = inheritanceClause?.inheritedTypes else {
496
- return false
497
- }
498
- for entry in inherited {
499
- let typeSyntax = entry.type
500
- if let identifier = typeSyntax.as(IdentifierTypeSyntax.self),
501
- identifier.name.text == name {
502
- return true
503
- }
504
- if let member = typeSyntax.as(MemberTypeSyntax.self),
505
- member.name.text == name {
506
- return true
507
- }
508
- }
509
- return false
510
- }
511
-
512
481
  /**
513
482
  True if the class declaration has any inheritance clause. Used as a heuristic for
514
483
  whether the superclass also conforms to `Record` and provides the synthesized methods;
@@ -141,8 +141,14 @@ extension SharedObjectMacro: MemberAttributeMacro {
141
141
  // `@Event(sync: true)` members are stamped alongside `@JS` ones: a sync event dispatches
142
142
  // inline, so the isolation forces its call site onto the JS thread. Async events (the
143
143
  // default) stay unstamped — their `emit` schedules onto the JS thread itself.
144
- guard memberHasJSAttribute(member) || isSyncEventMember(member),
145
- shouldStampJavaScriptActor(on: member, enclosedBy: declaration) else {
144
+ guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
145
+ return []
146
+ }
147
+ // `@JS(.concurrent)` opts the member out of the JS-thread stamp and onto the concurrent pool.
148
+ if isConcurrentJSMember(member) {
149
+ return ["@concurrent"]
150
+ }
151
+ guard shouldStampJavaScriptActor(on: member, enclosedBy: declaration) else {
146
152
  return []
147
153
  }
148
154
  return ["@JavaScriptActor"]
@@ -21,6 +21,12 @@ internal let javaScriptEncodableProtocolName = "JavaScriptEncodable"
21
21
  /// support both directions, so the assertion requires the intersection.
22
22
  internal let recordFieldProtocolName = "AnyArgument & JavaScriptDecodable & JavaScriptEncodable"
23
23
 
24
+ /// The constraint a `@Union` case payload type must satisfy: the union decodes by trying each payload's
25
+ /// `decode` and encodes through the matching payload's `encode`, so every alternative must convert in
26
+ /// both directions. Unlike a `@Record` field, a union never crosses the native-`Any` dictionary path, so
27
+ /// `AnyArgument` is not required.
28
+ internal let unionPayloadProtocolName = "JavaScriptDecodable & JavaScriptEncodable"
29
+
24
30
  /// The protocol a type must conform to for `self.emit(event:…)` to resolve; core conforms
25
31
  /// `BaseModule` and `SharedObject` to it. Asserted by `@Event` so attaching it to a type that can't
26
32
  /// emit fails with a conformance diagnostic instead of an opaque "no member 'emit'" error.
@@ -116,11 +122,15 @@ internal func typeConformanceAssertions(
116
122
  """
117
123
  }
118
124
 
119
- /// The type to assert for a boundary type as written: trailing optional markers are unwrapped to the
120
- /// core type, and a known-conforming primitive returns `nil` (nothing to assert). Shared with
121
- /// `@Event`, which folds its single payload type into a combined assertion of its own shape rather
122
- /// than reusing the whole body fragment below.
125
+ /// The type to assert for a boundary type as written, or `nil` when nothing needs asserting: an
126
+ /// exactly-free-form type (handled by the binding's `decodeAny…` reroute) and a known-conforming
127
+ /// primitive both return `nil`; everything else is unwrapped of trailing optionals and asserted. An
128
+ /// *optional* free-form (`[String: Any]?`) isn't rerouted and doesn't conform, so it's kept and
129
+ /// asserted for a clean diagnostic. Shared with `@Event`.
123
130
  internal func assertableBoundaryType(_ type: String) -> String? {
131
+ guard !isFreeFormBoundaryType(type) else {
132
+ return nil
133
+ }
124
134
  let unwrapped = unwrappedOptional(type)
125
135
  return knownConformingPrimitives.contains(unwrapped) ? nil : unwrapped
126
136
  }
@@ -144,14 +154,12 @@ private func conformanceAssertionBody(
144
154
  return lines.joined(separator: "\n")
145
155
  }
146
156
 
147
- /// Normalizes a list of boundary types for assertion: unwraps each to its core type (trailing
148
- /// optionals stripped), drops the known-conforming primitives that never need asserting, and dedups
149
- /// while preserving first-seen order so a type is asserted once even when it appears more than once.
157
+ /// The distinct types to assert from a list, mapping each through `assertableBoundaryType` (which
158
+ /// drops what needs no assertion) and deduping in first-seen order.
150
159
  private func distinctAssertableTypes(_ types: [String]) -> [String] {
151
160
  var seen: Set<String> = []
152
161
  var distinct: [String] = []
153
- for type in types.map(unwrappedOptional)
154
- where !knownConformingPrimitives.contains(type) && seen.insert(type).inserted {
162
+ for type in types.compactMap(assertableBoundaryType) where seen.insert(type).inserted {
155
163
  distinct.append(type)
156
164
  }
157
165
  return distinct