expo-modules-macros 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.github/workflows/linux.yml +132 -0
  2. package/.github/workflows/publish.yml +160 -11
  3. package/.github/workflows/swift-format.yml +76 -0
  4. package/.github/workflows/windows.yml +3 -28
  5. package/AGENTS.md +19 -9
  6. package/README.md +27 -8
  7. package/apple/ExpoModulesMacros +0 -0
  8. package/apple/Package.resolved +3 -3
  9. package/apple/Package.swift +1 -2
  10. package/apple/Sources/ExpoModulesMacros/ClosureArguments.swift +39 -14
  11. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +117 -25
  12. package/apple/Sources/ExpoModulesMacros/EventMacro.swift +29 -15
  13. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +55 -45
  14. package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +2 -1
  15. package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +2 -61
  16. package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +6 -3
  17. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +180 -44
  18. package/apple/Sources/ExpoModulesMacros/JSNameCollisions.swift +84 -0
  19. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +165 -43
  20. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +358 -234
  21. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +48 -32
  22. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +57 -27
  23. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +23 -9
  24. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +10 -5
  25. package/apple/Sources/ExpoModulesScanner/CLI.swift +3 -1
  26. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +11 -7
  27. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +2 -1
  28. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +10 -7
  29. package/apple/Sources/ExpoModulesScanner/Exports/Availability.swift +6 -3
  30. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +3 -2
  31. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +15 -5
  32. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +8 -3
  33. package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +127 -45
  34. package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +2 -1
  35. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +8 -2
  36. package/build/index.d.ts +4 -3
  37. package/build/index.js +33 -18
  38. package/build/types.d.ts +7 -1
  39. package/package.json +7 -3
@@ -2,34 +2,32 @@ import SwiftSyntax
2
2
  import SwiftSyntaxBuilder
3
3
  import SwiftSyntaxMacros
4
4
 
5
- /**
6
- Macro applied to a module class. It plays three roles, each implemented in its own
7
- extension below:
8
-
9
- - `MemberMacro`: binds `@JS`-marked members directly into the module's JS object via the
10
- synthesized `_decorateModule`, and emits the resolved module name as a non-optional
11
- `_jsName` static (no `Name(…)` DSL element). It also synthesizes a
12
- framework-internal `_synthesizedDefinition()` returning `[AnyDefinition]` (now carrying
13
- only nested `classes` entries) that `expo-modules-core` merges into the module's
14
- definition. When the class doesn't already inherit `Module`/`BaseModule`, it also
15
- synthesizes the `appContext` storage and `init(appContext:)` those base classes provide.
16
- - `MemberAttributeMacro`: stamps `@JavaScriptActor` on `@JS` sync members and
17
- `@ModuleDefinitionBuilder` on a `definition()` method.
18
- - `ExtensionMacro`: adds the `AnyModule` conformance when the class doesn't inherit it.
19
-
20
- Inheriting from `Module` is therefore optional — a class can carry any superclass (or
21
- none) and still be a module, because everything inheritance used to provide is synthesized.
22
-
23
- @ExpoModule
24
- public final class MyModule {
25
- public func definition() -> ModuleDefinition {
26
- Name("MyModule")
27
- }
28
-
29
- @JS
30
- func greet(name: String) -> String { "Hi, \(name)" }
31
- }
32
- */
5
+ /// Macro applied to a module class. It plays three roles, each implemented in its own
6
+ /// extension below:
7
+ ///
8
+ /// - `MemberMacro`: binds `@JS`-marked members directly into the module's JS object via the
9
+ /// synthesized `_decorateModule`, and emits the resolved module name as a non-optional
10
+ /// `_jsName` static (no `Name(…)` DSL element). It also synthesizes a
11
+ /// framework-internal `_synthesizedDefinition()` returning `[AnyDefinition]` (now carrying
12
+ /// only nested `classes` entries) that `expo-modules-core` merges into the module's
13
+ /// definition. When the class doesn't already inherit `Module`/`BaseModule`, it also
14
+ /// synthesizes the `appContext` storage and `init(appContext:)` those base classes provide.
15
+ /// - `MemberAttributeMacro`: stamps `@JavaScriptActor` on `@JS` sync members and
16
+ /// `@ModuleDefinitionBuilder` on a `definition()` method.
17
+ /// - `ExtensionMacro`: adds the `AnyModule` conformance when the class doesn't inherit it.
18
+ ///
19
+ /// Inheriting from `Module` is therefore optional — a class can carry any superclass (or
20
+ /// none) and still be a module, because everything inheritance used to provide is synthesized.
21
+ ///
22
+ /// @ExpoModule
23
+ /// public final class MyModule {
24
+ /// public func definition() -> ModuleDefinition {
25
+ /// Name("MyModule")
26
+ /// }
27
+ ///
28
+ /// @JS
29
+ /// func greet(name: String) -> String { "Hi, \(name)" }
30
+ /// }
33
31
  public struct ExpoModuleMacro: MemberMacro {
34
32
  public static func expansion(
35
33
  of node: AttributeSyntax,
@@ -62,17 +60,25 @@ public struct ExpoModuleMacro: MemberMacro {
62
60
  let decl = member.decl
63
61
 
64
62
  if let funcDecl = decl.as(FunctionDeclSyntax.self),
65
- let attribute = funcDecl.attributes.firstAttribute(named: "JS") {
66
- functions.append(JSFunction(funcDecl: funcDecl, attribute: attribute))
63
+ let attribute = funcDecl.attributes.firstAttribute(named: "JS")
64
+ {
65
+ let function = JSFunction(funcDecl: funcDecl, attribute: attribute)
66
+ functions.append(function)
67
+ if let variant = function.asyncVariant {
68
+ functions.append(variant)
69
+ }
67
70
  continue
68
71
  }
69
72
 
70
73
  if let varDecl = decl.as(VariableDeclSyntax.self),
71
- let attribute = varDecl.attributes.firstAttribute(named: "JS") {
74
+ let attribute = varDecl.attributes.firstAttribute(named: "JS")
75
+ {
72
76
  properties.append(contentsOf: collectProperties(varDecl: varDecl, attribute: attribute))
73
77
  }
74
78
  }
75
79
 
80
+ diagnoseJSNameCollisions(functions: functions, properties: properties, in: context)
81
+
76
82
  let body: String
77
83
  if entries.isEmpty {
78
84
  body = " return []"
@@ -152,12 +158,14 @@ extension ExpoModuleMacro: MemberAttributeMacro {
152
158
 
153
159
  // `@JS` sync members run on the JS thread; stamp `@JavaScriptActor` so isolation is
154
160
  // checked at compile time. Skipped when the member already chose an isolation
155
- // (`async`, `nonisolated`, or another global actor) — see `shouldStampJavaScriptActor`.
161
+ // (`nonisolated` or another global actor), or when `.concurrent` or `.withAsync("name")` runs
162
+ // it on the concurrent pool. See `shouldStampJavaScriptActor`.
156
163
  // `@Event(sync: true)` members get the stamp too: a sync event dispatches inline, so the
157
164
  // isolation forces its call site onto the JS thread. Async events (the default) are
158
165
  // deliberately left unstamped — their `emit` schedules onto the JS thread itself.
159
166
  if memberHasJSAttribute(member) || isSyncEventMember(member),
160
- shouldStampJavaScriptActor(on: member, enclosedBy: declaration) {
167
+ shouldStampJavaScriptActor(on: member, enclosedBy: declaration)
168
+ {
161
169
  attributes.append("@JavaScriptActor")
162
170
  }
163
171
 
@@ -172,7 +180,8 @@ extension ExpoModuleMacro: MemberAttributeMacro {
172
180
  // they already wrote `@ModuleDefinitionBuilder` themselves, which would otherwise be
173
181
  // a duplicate attribute.
174
182
  if isModuleDefinitionFunction(member),
175
- !memberAttributes(of: member).contains(where: { hasAttributeName($0, "ModuleDefinitionBuilder") }) {
183
+ !memberAttributes(of: member).contains(where: { hasAttributeName($0, "ModuleDefinitionBuilder") })
184
+ {
176
185
  attributes.append("@ModuleDefinitionBuilder")
177
186
  }
178
187
 
@@ -184,7 +193,8 @@ private func isModuleDefinitionFunction(_ member: some DeclSyntaxProtocol) -> Bo
184
193
  guard let funcDecl = member.as(FunctionDeclSyntax.self),
185
194
  funcDecl.name.text == "definition",
186
195
  funcDecl.signature.parameterClause.parameters.isEmpty,
187
- let returnType = funcDecl.signature.returnClause?.type else {
196
+ let returnType = funcDecl.signature.returnClause?.type
197
+ else {
188
198
  return false
189
199
  }
190
200
  return baseIdentifier(of: returnType) == "ModuleDefinition"
@@ -213,7 +223,8 @@ extension ExpoModuleMacro: ExtensionMacro {
213
223
  // These base types already supply `AnyModule`; emitting a second conformance here
214
224
  // would be redundant and error.
215
225
  if let classDecl = declaration.as(ClassDeclSyntax.self),
216
- inheritsFromAny(classDecl, names: ["Module", "BaseModule", "AnyModule"]) {
226
+ inheritsFromAny(classDecl, names: ["Module", "BaseModule", "AnyModule"])
227
+ {
217
228
  return []
218
229
  }
219
230
  let conformance: DeclSyntax = "extension \(type.trimmed): AnyModule {}"
@@ -230,7 +241,8 @@ private func hasStoredProperty(_ classDecl: ClassDeclSyntax, named name: String)
230
241
  }
231
242
  for binding in varDecl.bindings {
232
243
  if let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
233
- identifier.identifier.text == name {
244
+ identifier.identifier.text == name
245
+ {
234
246
  return true
235
247
  }
236
248
  }
@@ -238,14 +250,12 @@ private func hasStoredProperty(_ classDecl: ClassDeclSyntax, named name: String)
238
250
  return false
239
251
  }
240
252
 
241
- /**
242
- True if the class declares an initializer that satisfies the `init(appContext:)` protocol
243
- requirement: a single parameter labeled `appContext` whose type is written as `AppContext`
244
- (or `…​.AppContext`). Both must match — the label is part of the requirement, so
245
- `init(c: AppContext)` does *not* satisfy it (we still synthesize ours), and the type guards
246
- against a same-labeled init of an unrelated type. A macro can't resolve types, so this is a
247
- syntactic match on the spelled name.
248
- */
253
+ /// True if the class declares an initializer that satisfies the `init(appContext:)` protocol
254
+ /// requirement: a single parameter labeled `appContext` whose type is written as `AppContext`
255
+ /// (or `…​.AppContext`). Both must match — the label is part of the requirement, so
256
+ /// `init(c: AppContext)` does *not* satisfy it (we still synthesize ours), and the type guards
257
+ /// against a same-labeled init of an unrelated type. A macro can't resolve types, so this is a
258
+ /// syntactic match on the spelled name.
249
259
  private func hasAppContextInitializer(_ classDecl: ClassDeclSyntax) -> Bool {
250
260
  for member in classDecl.memberBlock.members {
251
261
  guard let initDecl = member.decl.as(InitializerDeclSyntax.self) else {
@@ -34,7 +34,8 @@ public struct OptimizedFunctionAttachedMacro: PeerMacro {
34
34
  ) throws -> [DeclSyntax] {
35
35
  guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
36
36
  throw MacroExpansionErrorMessage(
37
- "@OptimizedFunction can only be attached to function declarations")
37
+ "@OptimizedFunction can only be attached to function declarations"
38
+ )
38
39
  }
39
40
 
40
41
  let functionName = funcDecl.name.text
@@ -178,72 +178,13 @@ internal func isAnyType(_ type: some SyntaxProtocol) -> Bool {
178
178
  if let member = type.as(MemberTypeSyntax.self),
179
179
  member.name.text == "Any",
180
180
  member.genericArgumentClause == nil,
181
- let base = member.baseType.as(IdentifierTypeSyntax.self) {
181
+ let base = member.baseType.as(IdentifierTypeSyntax.self)
182
+ {
182
183
  return base.name.text == "Swift"
183
184
  }
184
185
  return false
185
186
  }
186
187
 
187
- /// The element type of `[T]` or `Array<T>`, or `nil` for any other type.
188
- private func arrayElementType(_ type: TypeSyntax) -> TypeSyntax? {
189
- if let array = type.as(ArrayTypeSyntax.self) {
190
- return array.element
191
- }
192
- guard let arguments = genericArguments(of: type, named: "Array"), arguments.count == 1 else {
193
- return nil
194
- }
195
- return arguments[0]
196
- }
197
-
198
- /// The value type of `[String: V]` or `Dictionary<String, V>`, or `nil` for any other type, including a
199
- /// dictionary with a different key type.
200
- private func stringKeyedDictionaryValueType(_ type: TypeSyntax) -> TypeSyntax? {
201
- if let dictionary = type.as(DictionaryTypeSyntax.self) {
202
- return isStringType(dictionary.key) ? dictionary.value : nil
203
- }
204
- guard let arguments = genericArguments(of: type, named: "Dictionary"), arguments.count == 2 else {
205
- return nil
206
- }
207
- return isStringType(arguments[0]) ? arguments[1] : nil
208
- }
209
-
210
- /// The type arguments of a generic type spelled `<name><…>` (or `Swift.<name><…>`), or `nil` when the
211
- /// type has another name or no type arguments.
212
- private func genericArguments(of type: TypeSyntax, named name: String) -> [TypeSyntax]? {
213
- let clause: GenericArgumentClauseSyntax?
214
- if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == name {
215
- clause = identifier.genericArgumentClause
216
- } else if let member = type.as(MemberTypeSyntax.self),
217
- member.name.text == name,
218
- member.baseType.as(IdentifierTypeSyntax.self)?.name.text == "Swift" {
219
- clause = member.genericArgumentClause
220
- } else {
221
- return nil
222
- }
223
- guard let clause else {
224
- return nil
225
- }
226
- var types: [TypeSyntax] = []
227
- for argument in clause.arguments {
228
- guard case .type(let type) = argument.argument else {
229
- return nil
230
- }
231
- types.append(type)
232
- }
233
- return types
234
- }
235
-
236
- /// True when the type is `String` or `Swift.String`.
237
- private func isStringType(_ type: TypeSyntax) -> Bool {
238
- if let identifier = type.as(IdentifierTypeSyntax.self) {
239
- return identifier.name.text == "String" && identifier.genericArgumentClause == nil
240
- }
241
- if let member = type.as(MemberTypeSyntax.self), member.name.text == "String" {
242
- return member.baseType.as(IdentifierTypeSyntax.self)?.name.text == "Swift"
243
- }
244
- return false
245
- }
246
-
247
188
  /// Walks a type looking for `Any` (or `Swift.Any`) anywhere inside it.
248
189
  private final class AnyTypeFinder: SyntaxVisitor {
249
190
  var found = false
@@ -23,7 +23,8 @@ internal struct JSConstructor {
23
23
  guard arguments.count == \(parameters.count) else {
24
24
  throw Exceptions.ArgumentsRangeMismatch((functionName: "\(typeName)", received: arguments.count, required: \(parameters.count), maximum: \(parameters.count)))
25
25
  }
26
- """)
26
+ """
27
+ )
27
28
 
28
29
  var callArguments: [String] = []
29
30
  for (index, parameter) in parameters.enumerated() {
@@ -32,7 +33,8 @@ internal struct JSConstructor {
32
33
  type: parameter.type,
33
34
  into: "arg\(index)",
34
35
  from: "arguments.unownedValue(at: \(index))"
35
- ))
36
+ )
37
+ )
36
38
 
37
39
  let label = parameter.firstName.text
38
40
  callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
@@ -40,7 +42,8 @@ internal struct JSConstructor {
40
42
 
41
43
  lines.append("return \(typeName)(\(callArguments.joined(separator: ", ")))")
42
44
 
43
- return lines
45
+ return
46
+ lines
44
47
  .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
45
48
  .map { indent + $0 }
46
49
  .joined(separator: "\n")
@@ -35,6 +35,7 @@ public struct JSMacro: PeerMacro {
35
35
  diagnoseFreeFormTypes(in: declaration, in: context)
36
36
  diagnoseClosureTypes(in: declaration, in: context)
37
37
  diagnoseConcurrentOption(of: node, on: declaration, in: context)
38
+ diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
38
39
 
39
40
  guard let member = boundaryMember(of: declaration),
40
41
  let assertion = directionalConformanceAssertion(
@@ -43,7 +44,8 @@ public struct JSMacro: PeerMacro {
43
44
  encodableType: member.encodableType,
44
45
  callbackArgumentTypes: member.callbackArgumentTypes,
45
46
  isStatic: member.isStatic
46
- ) else {
47
+ )
48
+ else {
47
49
  return []
48
50
  }
49
51
  return [assertion]
@@ -74,7 +76,9 @@ private func diagnoseConcurrentOption(
74
76
  id: "js-concurrent-requires-async",
75
77
  severity: .error
76
78
  ),
77
- fixIts: [insertAsyncFixIt(for: funcDecl)]))
79
+ fixIts: [insertAsyncFixIt(for: funcDecl)]
80
+ )
81
+ )
78
82
  return
79
83
  }
80
84
  context.diagnose(
@@ -84,7 +88,108 @@ private func diagnoseConcurrentOption(
84
88
  "'.concurrent' applies only to an 'async' @JS function, not to a property or initializer.",
85
89
  id: "js-concurrent-requires-function",
86
90
  severity: .error
87
- )))
91
+ )
92
+ )
93
+ )
94
+ }
95
+
96
+ /// Emits the diagnostics for `@JS(.withAsync("name"))` on a member that can't take it. The option
97
+ /// adds a promise-returning variant that calls the function on the concurrent pool, so it needs a
98
+ /// synchronous function without a global actor, and the variant's JavaScript name as a string literal.
99
+ private func diagnoseAsyncVariantOption(
100
+ of node: AttributeSyntax,
101
+ on declaration: some DeclSyntaxProtocol,
102
+ in context: some MacroExpansionContext
103
+ ) {
104
+ guard let option = asyncVariantOption(of: node) else {
105
+ return
106
+ }
107
+ guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
108
+ context.diagnose(
109
+ Diagnostic(
110
+ node: node,
111
+ message: JSDiagnosticMessage(
112
+ "'.withAsync' applies only to a synchronous @JS function, not to a property or initializer.",
113
+ id: "js-async-variant-requires-function",
114
+ severity: .error
115
+ )
116
+ )
117
+ )
118
+ return
119
+ }
120
+ if funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil {
121
+ context.diagnose(
122
+ Diagnostic(
123
+ node: node,
124
+ message: JSDiagnosticMessage(
125
+ "'.withAsync' adds a promise-returning variant to a synchronous function, and this function is already 'async'. Remove '.withAsync', and use '.concurrent' to run the function off the JavaScript thread.",
126
+ id: "js-async-variant-on-async-function",
127
+ severity: .error
128
+ )
129
+ )
130
+ )
131
+ return
132
+ }
133
+ for duplicate in asyncVariantOptions(of: node).dropFirst() {
134
+ context.diagnose(
135
+ Diagnostic(
136
+ node: duplicate,
137
+ message: JSDiagnosticMessage(
138
+ "'.withAsync' can be listed only once: a function gets one promise-returning variant.",
139
+ id: "js-async-variant-duplicate",
140
+ severity: .error
141
+ )
142
+ )
143
+ )
144
+ }
145
+ if asyncVariantName(of: option) == nil {
146
+ context.diagnose(
147
+ Diagnostic(
148
+ node: option,
149
+ message: JSDiagnosticMessage(
150
+ "'.withAsync' takes the JavaScript name of the variant as a string literal, for example '.withAsync(\"loadAsync\")'.",
151
+ id: "js-async-variant-name",
152
+ severity: .error
153
+ )
154
+ )
155
+ )
156
+ }
157
+ if let isolation = globalActorIsolation(of: funcDecl, in: context) {
158
+ context.diagnose(
159
+ Diagnostic(
160
+ node: node,
161
+ message: JSDiagnosticMessage(
162
+ isolation.enclosingTypeName.map {
163
+ "'.withAsync' calls the function on the concurrent thread pool, so the function can't inherit '\(isolation.attribute)' from '\($0)'. Mark the function 'nonisolated'."
164
+ }
165
+ ?? "'.withAsync' calls the function on the concurrent thread pool, so the function can't be isolated to '\(isolation.attribute)'.",
166
+ id: "js-async-variant-isolated",
167
+ severity: .error
168
+ )
169
+ )
170
+ )
171
+ }
172
+ }
173
+
174
+ /// The global actor a function is isolated to, written on the function itself or inherited from its
175
+ /// enclosing type, or `nil` when it has none or is `nonisolated`. `enclosingTypeName` is set when the
176
+ /// isolation is inherited.
177
+ private func globalActorIsolation(
178
+ of funcDecl: FunctionDeclSyntax,
179
+ in context: some MacroExpansionContext
180
+ ) -> (attribute: String, enclosingTypeName: String?)? {
181
+ if let attribute = funcDecl.attributes.first(where: hasGlobalActorShape) {
182
+ return (attribute.trimmedDescription, nil)
183
+ }
184
+ if funcDecl.modifiers.contains(where: { $0.name.text == "nonisolated" }) {
185
+ return nil
186
+ }
187
+ guard let enclosing = context.lexicalContext.first,
188
+ let attribute = enclosing.asProtocol(WithAttributesSyntax.self)?.attributes.first(where: hasGlobalActorShape)
189
+ else {
190
+ return nil
191
+ }
192
+ return (attribute.trimmedDescription, enclosing.asProtocol(NamedDeclSyntax.self)?.name.text)
88
193
  }
89
194
 
90
195
  /// The fix-it offered alongside the synchronous-function diagnostic: insert `async` into the
@@ -144,7 +249,9 @@ private func diagnoseParameterSpecifiers(
144
249
  "A @JS parameter can't be '\(specifier)': its argument is a value decoded from JavaScript, which can't be written back or carry an isolation.",
145
250
  id: "js-parameter-specifier",
146
251
  severity: .error
147
- )))
252
+ )
253
+ )
254
+ )
148
255
  }
149
256
  }
150
257
 
@@ -173,14 +280,16 @@ private func diagnoseFreeFormTypes(
173
280
  // A constructor decodes its arguments exactly like a function and has no return value.
174
281
  types = initDecl.signature.parameterClause.parameters.map { SpecifiedType($0.type).type }
175
282
  } else if let varDecl = declaration.as(VariableDeclSyntax.self),
176
- let type = varDecl.bindings.first?.typeAnnotation?.type {
283
+ let type = varDecl.bindings.first?.typeAnnotation?.type
284
+ {
177
285
  types = [type]
178
286
  }
179
287
 
180
288
  for type in types
181
289
  where mentionsFreeFormAny(type) && !isConvertibleFreeFormType(type) && !containsFunctionType(type) {
182
290
  context.diagnose(
183
- Diagnostic(node: type, message: unconvertibleFreeFormError(for: type.trimmedDescription)))
291
+ Diagnostic(node: type, message: unconvertibleFreeFormError(for: type.trimmedDescription))
292
+ )
184
293
  }
185
294
  }
186
295
 
@@ -234,61 +343,49 @@ private struct BoundaryMember {
234
343
 
235
344
  /// Reads the boundary member off a `@JS` declaration, splitting its types by conversion direction. A
236
345
  /// function contributes its parameter types (decodable) and its return type when non-Void (encodable);
237
- /// a property contributes its value type as encodable (the getter) and also as decodable when settable
238
- /// (the setter). Composed types (`[Int]`, `String?`, …) are kept verbatim, their conditional
239
- /// conformances transitively constraining the elements. A type that mentions `Any` is left out: it
240
- /// converts through the free-form methods, or is diagnosed by `diagnoseFreeFormTypes`. Returns `nil`
241
- /// for declaration kinds `@JS` doesn't read types from, a property whose type isn't spelled out (a
242
- /// syntactic macro can't recover it), or one whose type mentions `Any`.
346
+ /// an initializer contributes its parameter types; a property contributes its value type as encodable
347
+ /// (the getter) and also as decodable when settable (the setter). Composed types (`[Int]`, `String?`,
348
+ /// …) are kept verbatim, their conditional conformances transitively constraining the elements. A type
349
+ /// that mentions `Any` is left out: it converts through the free-form methods, or is diagnosed by
350
+ /// `diagnoseFreeFormTypes`. Returns `nil` for declaration kinds `@JS` doesn't read types from, a
351
+ /// property whose type isn't spelled out (a syntactic macro can't recover it), or one whose type
352
+ /// mentions `Any`.
243
353
  private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> BoundaryMember? {
244
354
  if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
245
- // A closure argument moves data the other way: JS receives its parameters and returns its result.
246
- // A closure in any other position is diagnosed by `diagnoseClosureTypes` and left out here, since
247
- // its metatype isn't a valid expression in the assertion.
248
- var decodableTypes: [String] = []
249
- var callbackArgumentTypes: [String] = []
250
- for parameter in funcDecl.signature.parameterClause.parameters {
251
- // The specifier isn't part of the asserted type, and a parameter with one the binding can't honor
252
- // is reported by `diagnoseParameterSpecifiers` and not asserted.
253
- let specified = SpecifiedType(parameter.type)
254
- guard specified.unsupportedSpecifier == nil else {
255
- continue
256
- }
257
- guard let closure = JSClosureType(specified.type) else {
258
- if !containsFunctionType(specified.type) && !mentionsFreeFormAny(specified.type) {
259
- decodableTypes.append(specified.type.trimmedDescription)
260
- }
261
- continue
262
- }
263
- // A closure type that mentions `Any` is reported by `diagnoseClosureTypes` instead.
264
- for parameter in closure.parameters
265
- where !containsFunctionType(parameter.type) && !mentionsFreeFormAny(parameter.type) {
266
- callbackArgumentTypes.append(parameter.type.trimmedDescription)
267
- }
268
- let closureReturnType = closure.functionType.returnClause.type
269
- if let returnType = closure.returnType,
270
- !containsFunctionType(closureReturnType) && !mentionsFreeFormAny(closureReturnType) {
271
- decodableTypes.append(returnType)
272
- }
273
- }
355
+ let argumentTypes = argumentBoundaryTypes(of: funcDecl.signature.parameterClause.parameters)
274
356
  let returnType = funcDecl.signature.returnClause?.type
275
357
  let encodableType = returnType.flatMap {
276
358
  isVoidType($0) || containsFunctionType($0) || mentionsFreeFormAny($0) ? nil : $0.trimmedDescription
277
359
  }
278
360
  return BoundaryMember(
279
361
  name: funcDecl.name.text,
280
- decodableTypes: decodableTypes,
362
+ decodableTypes: argumentTypes.decodable,
281
363
  encodableType: encodableType,
282
- callbackArgumentTypes: callbackArgumentTypes,
364
+ callbackArgumentTypes: argumentTypes.callbackArguments,
283
365
  isStatic: isTypeLevel(funcDecl.modifiers)
284
366
  )
285
367
  }
286
368
 
369
+ if let initDecl = declaration.as(InitializerDeclSyntax.self) {
370
+ // A constructor decodes its arguments like a function does, and the instance it returns isn't
371
+ // encoded. The name is escaped because it also names the assertion's nested helper, and `init` is
372
+ // a keyword.
373
+ let argumentTypes = argumentBoundaryTypes(of: initDecl.signature.parameterClause.parameters)
374
+ return BoundaryMember(
375
+ name: "`init`",
376
+ decodableTypes: argumentTypes.decodable,
377
+ encodableType: nil,
378
+ callbackArgumentTypes: argumentTypes.callbackArguments,
379
+ isStatic: false
380
+ )
381
+ }
382
+
287
383
  if let varDecl = declaration.as(VariableDeclSyntax.self),
288
384
  let binding = varDecl.bindings.first,
289
385
  let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
290
386
  let type = binding.typeAnnotation?.type,
291
- !containsFunctionType(type) {
387
+ !containsFunctionType(type)
388
+ {
292
389
  // A `let`, or a `var` with no setter, is read-only (encodable only). A settable `var` is also
293
390
  // decoded on write, so its value type is asserted in both directions. A type that mentions `Any`
294
391
  // converts through the free-form methods instead, so it isn't asserted at all.
@@ -309,6 +406,45 @@ private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> Boundary
309
406
  return nil
310
407
  }
311
408
 
409
+ /// The boundary types of a function's or constructor's arguments: an argument is decoded from JS, and
410
+ /// a closure argument instead contributes its parameter types (encoded when native code calls it) and
411
+ /// its return type (decoded).
412
+ private func argumentBoundaryTypes(
413
+ of parameters: FunctionParameterListSyntax
414
+ ) -> (decodable: [String], callbackArguments: [String]) {
415
+ // A closure argument moves data the other way: JS receives its parameters and returns its result.
416
+ // A closure in any other position is diagnosed by `diagnoseClosureTypes` and left out here, since
417
+ // its metatype isn't a valid expression in the assertion.
418
+ var decodableTypes: [String] = []
419
+ var callbackArgumentTypes: [String] = []
420
+ for parameter in parameters {
421
+ // The specifier isn't part of the asserted type, and a parameter with one the binding can't honor
422
+ // is reported by `diagnoseParameterSpecifiers` and not asserted.
423
+ let specified = SpecifiedType(parameter.type)
424
+ guard specified.unsupportedSpecifier == nil else {
425
+ continue
426
+ }
427
+ guard let closure = JSClosureType(specified.type) else {
428
+ if !containsFunctionType(specified.type) && !mentionsFreeFormAny(specified.type) {
429
+ decodableTypes.append(specified.type.trimmedDescription)
430
+ }
431
+ continue
432
+ }
433
+ // A closure type that mentions `Any` is reported by `diagnoseClosureTypes` instead.
434
+ for parameter in closure.parameters
435
+ where !containsFunctionType(parameter.type) && !mentionsFreeFormAny(parameter.type) {
436
+ callbackArgumentTypes.append(parameter.type.trimmedDescription)
437
+ }
438
+ let closureReturnType = closure.functionType.returnClause.type
439
+ if let returnType = closure.returnType,
440
+ !containsFunctionType(closureReturnType) && !mentionsFreeFormAny(closureReturnType)
441
+ {
442
+ decodableTypes.append(returnType)
443
+ }
444
+ }
445
+ return (decodableTypes, callbackArgumentTypes)
446
+ }
447
+
312
448
  /// True when a return clause is written as `Void` / `()` — nothing crosses the boundary, so it needs
313
449
  /// no conformance assertion. (A missing return clause never reaches here: `returnClause` is `nil`.)
314
450
  private func isVoidType(_ type: TypeSyntax) -> Bool {