expo-modules-macros 0.18.0 → 0.19.1

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.
Binary file
@@ -93,7 +93,8 @@ internal func argumentDecodeStatements(
93
93
  // A specifier such as `borrowing` belongs to the parameter, not to the type that decodes.
94
94
  let type = SpecifiedType(type).type
95
95
  guard let closure = JSClosureType(type) else {
96
- return ["let \(name) = try \(decodeCall(type.trimmedDescription, from: valueExpression))"]
96
+ let decode = decodeCall(type.trimmedDescription, freeForm: freeFormConversion(of: type), from: valueExpression)
97
+ return ["let \(name) = try \(decode)"]
97
98
  }
98
99
  return closureArgumentStatements(closure, into: name, from: valueExpression)
99
100
  }
@@ -279,7 +280,7 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
279
280
  }
280
281
 
281
282
  // The closure's parameters are encoded and its result is decoded, so neither can be another
282
- // closure or a free-form type.
283
+ // closure or a type that mentions `Any`, in any shape.
283
284
  let returnType = functionType.returnClause.type
284
285
  for type in closure.parameters.map(\.type) + [returnType] {
285
286
  if let nested = firstFunctionType(in: type) {
@@ -290,7 +291,7 @@ private func diagnoseClosure(_ closure: JSClosureType, in context: some MacroExp
290
291
  "A @JS closure argument can't take or return another closure.",
291
292
  id: "js-closure-nested"
292
293
  )))
293
- } else if isFreeFormBoundaryType(type.trimmedDescription) {
294
+ } else if mentionsFreeFormAny(type) {
294
295
  context.diagnose(
295
296
  Diagnostic(
296
297
  node: type,
@@ -19,6 +19,8 @@ internal struct JSFunction {
19
19
  let parameters: [FunctionParameterSyntax]
20
20
  /// The declared return type as written, or `nil` when the function returns `Void`/nothing.
21
21
  let returnType: String?
22
+ /// How the return value encodes when its type mentions `Any`, or `nil` for any other type.
23
+ let returnFreeForm: FreeFormConversion?
22
24
  let isThrowing: Bool
23
25
  let isAsync: Bool
24
26
 
@@ -29,6 +31,7 @@ internal struct JSFunction {
29
31
 
30
32
  let declaredReturnType = funcDecl.signature.returnClause?.type
31
33
  self.returnType = isVoidType(declaredReturnType) ? nil : declaredReturnType?.trimmedDescription
34
+ self.returnFreeForm = declaredReturnType.flatMap(freeFormConversion(of:))
32
35
 
33
36
  let effectSpecifiers = funcDecl.signature.effectSpecifiers
34
37
  self.isThrowing = effectSpecifiers?.throwsClause?.throwsSpecifier != nil
@@ -220,9 +223,10 @@ internal struct JSFunction {
220
223
  return lines
221
224
  }
222
225
 
223
- /// Encode the `result` local back to JS and return it through `JavaScriptEncodable.encode`, the same
224
- /// for every type. Unlike the decode side there's no primitive fast path: `encode` produces the same
225
- /// value as the primitive's `toJavaScriptValue(in:)` except for `Int`/`UInt`, where it range-checks
226
+ /// Encode the `result` local back to JS and return it through `JavaScriptEncodable.encode` (or, for a
227
+ /// type that mentions `Any`, the matching `JavaScriptValue` free-form method). Unlike the decode side
228
+ /// there's no primitive fast path: `encode` produces the same value as the primitive's
229
+ /// `toJavaScriptValue(in:)` except for `Int`/`UInt`, where it range-checks
226
230
  /// and throws instead of silently encoding an out-of-safe-range value as a lossy number — the
227
231
  /// catchable error is the right behavior, and matches how non-primitive integers already encode. A
228
232
  /// no-return function returns `.undefined()` instead.
@@ -237,7 +241,7 @@ internal struct JSFunction {
237
241
  guard let returnType else {
238
242
  return ["return .undefined()"]
239
243
  }
240
- let encode = "try \(expressionType(returnType)).encode(result, in: runtime)"
244
+ let encode = "try \(encodeCall(returnType, freeForm: returnFreeForm, of: "result"))"
241
245
  if isAsync {
242
246
  return [
243
247
  "return try await runtime.execute {",
@@ -290,7 +294,8 @@ internal struct JSFunction {
290
294
  /// The receiver (see `Receiver`) is the module's `self` for a module binding, or the per-call `_self`
291
295
  /// unwrapped from the JS `this` for a shared object. The getter reads `<callee>.<name>` and the setter
292
296
  /// writes `<callee>.<name> = …`. Decode/encode of the value go through `JavaScriptDecodable.decode` /
293
- /// `JavaScriptEncodable.encode`, the same uniform path as functions.
297
+ /// `JavaScriptEncodable.encode` (or the free-form methods for a type that mentions `Any`), the same
298
+ /// path as functions.
294
299
  internal struct JSProperty {
295
300
  let swiftName: String
296
301
  let jsName: String
@@ -298,6 +303,8 @@ internal struct JSProperty {
298
303
  /// no literal default). When `nil` the getter still works (the encode infers from `self.<name>`)
299
304
  /// but the setter uses an untyped closure parameter.
300
305
  let valueType: String?
306
+ /// How the value converts when its type mentions `Any`, or `nil` for any other type.
307
+ let freeForm: FreeFormConversion?
301
308
  /// Whether the property is settable from JS: `true` for a stored `var` or a computed `var` with an
302
309
  /// explicit `set` accessor; `false` for a getter-only computed `var` or a `let`.
303
310
  let isSettable: Bool
@@ -325,7 +332,7 @@ internal struct JSProperty {
325
332
  // no static type to call `encode` on, so the value's own `toJavaScriptValue(in:)` is the fallback.
326
333
  let getEncode: String
327
334
  if let valueType {
328
- getEncode = "return try \(expressionType(valueType)).encode(\(callee).\(swiftName), in: runtime)"
335
+ getEncode = "return try \(encodeCall(valueType, freeForm: freeForm, of: "\(callee).\(swiftName)"))"
329
336
  } else {
330
337
  getEncode = "return \(callee).\(swiftName).toJavaScriptValue(in: runtime)"
331
338
  }
@@ -337,7 +344,8 @@ internal struct JSProperty {
337
344
  // getter-only (a settable var with neither an annotation nor a literal default is rare and can't
338
345
  // be decoded).
339
346
  if isSettable, let valueType {
340
- let setDecode = "\(callee).\(swiftName) = try \(decodeCall(valueType, from: "arguments.unownedValue(at: 0)"))"
347
+ let decode = decodeCall(valueType, freeForm: freeForm, from: "arguments.unownedValue(at: 0)")
348
+ let setDecode = "\(callee).\(swiftName) = try \(decode)"
341
349
  lines.append(
342
350
  accessorClosure(
343
351
  descriptorName, "set", receiver: receiver, body: "\(unwrap)\(setDecode)\nreturn .undefined()"))
@@ -1,74 +1,123 @@
1
1
  import SwiftSyntax
2
2
 
3
- /// Recognition of the free-form (`Any`-bearing) boundary types and the spellings the macros emit or
4
- /// suggest for them. None of these types can conform to the JS codable protocols (`Any` can't conform
5
- /// to a protocol, and the container conditional conformances require the element/value to conform), so
6
- /// they can't cross the boundary through the usual `T.decode` / `T.encode` path. Instead they're
7
- /// accepted **only** in decode position (function/constructor arguments), decoded through a dedicated
8
- /// `JavaScriptValue.decodeAny…` entry point; a free-form return or getter is rejected, since there's
9
- /// no free-form encode.
3
+ /// Recognition of the free-form (`Any`-bearing) boundary types and the conversions the macros emit for
4
+ /// them. None of these types can conform to the JS codable protocols (`Any` can't conform to a
5
+ /// protocol, and the container conditional conformances require the element/value to conform), so
6
+ /// they can't cross the boundary through the usual `T.decode` / `T.encode` path. They convert through
7
+ /// the `JavaScriptValue` free-form methods instead: the three free-form shapes (`Any`, `[Any]` and
8
+ /// `[String: Any]`), optionally wrapped in one optional, use the dedicated `decodeAny…` and
9
+ /// `encodeAny…` methods, and every other type built from `Any`, arrays, `String`-keyed dictionaries and
10
+ /// optionals uses the generic `decodeAny(_:as:in:)` and `encodeAny(_:in:)`.
10
11
  ///
11
- /// Shared across the macro pipeline: `TypeConformanceAssertion` skips them, `MacroHelpers.decodeCall`
12
- /// reroutes their decode, and `JSMacro` uses them for the steering diagnostics.
13
- ///
14
- /// `@Record` accepts a property whose type mentions `Any` anywhere (`Any?`, `[String: Any]?`,
15
- /// `[String: [Any]]`, …) and converts it on the JS-value paths without `decode`/`encode`: the three
16
- /// free-form shapes, optionally wrapped in one optional, use the dedicated `JavaScriptValue.decodeAny…`
17
- /// and `encodeAny…` methods, and every other type uses the generic `decodeAny(_:as:in:)` and
18
- /// `encodeAny(_:in:)`.
12
+ /// Shared across the macro pipeline: `@Record` properties and `@JS` arguments, return values and
13
+ /// properties convert through `FreeFormConversion` (the `@JS` bindings by way of
14
+ /// `MacroHelpers.decodeCall` and `encodeCall`), `TypeConformanceAssertion` skips the free-form shapes,
15
+ /// and `JSMacro` uses them for its diagnostics.
19
16
 
20
17
  /// The free-form boundary types: an untyped value (`Any`), an untyped array (`[Any]`), and an untyped
21
18
  /// string-keyed dictionary (`[String: Any]`). Matched by whitespace-normalized spelling so
22
19
  /// `[String: Any]` and `[String : Any]` both count.
23
20
  private let freeFormBoundaryTypes: Set<String> = ["Any", "[Any]", "[String:Any]"]
24
21
 
25
- /// The `JavaScriptValue.decodeAny…` method the binding calls to decode a free-form argument, keyed by
26
- /// the free-form type's normalized spelling. `nil` for any non-free-form type.
27
- private let freeFormDecodeMethods: [String: String] = [
28
- "Any": "decodeAny",
29
- "[Any]": "decodeAnyArray",
30
- "[String:Any]": "decodeAnyDictionary",
31
- ]
32
-
33
- /// The type-safe alternative a free-form type's diagnostic steers to: the JS wrapper of the same shape
34
- /// (`JavaScriptArray` for an array, `JavaScriptObject` for a dictionary, `JavaScriptValue` for an
35
- /// untyped value). The wrappers conform to the codable protocols and keep the value unconverted; a
36
- /// non-copyable `JavaScriptValue` cannot be an element of a Swift array or dictionary, so the container
37
- /// shapes map to a wrapper rather than to a container of values. Keyed by the free-form type's
38
- /// normalized spelling.
39
- private let typedFreeFormReplacements: [String: String] = [
40
- "Any": "JavaScriptValue",
41
- "[Any]": "JavaScriptArray",
42
- "[String:Any]": "JavaScriptObject",
43
- ]
44
-
45
- /// True when a boundary type (as written) is one of the free-form spellings, ignoring internal
46
- /// whitespace so `[String: Any]` and `[String :Any]` both match. Optionals are not free-form here: a
47
- /// trailing `?` would route through `Optional.decode`, which free-form can't satisfy, so an optional
48
- /// free-form type isn't recognized and stays a normal (failing) assertion.
22
+ /// True when a boundary type (as written) is exactly one of the free-form shapes, ignoring internal
23
+ /// whitespace so `[String: Any]` and `[String :Any]` both match. An optional or nested free-form type
24
+ /// doesn't match; `freeFormConversion(of:)` recognizes those.
49
25
  internal func isFreeFormBoundaryType(_ type: String) -> Bool {
50
26
  return freeFormBoundaryTypes.contains(normalizedTypeSpelling(type))
51
27
  }
52
28
 
53
- /// The `JavaScriptValue.decodeAny…` method name for a free-form boundary type, or `nil` when the type
54
- /// isn't free-form. The binding emits `JavaScriptValue.<method>(arguments.unownedValue(at:), in:)` in
55
- /// place of the type's own `.decode`.
56
- internal func freeFormDecodeMethod(for type: String) -> String? {
57
- return freeFormDecodeMethods[normalizedTypeSpelling(type)]
58
- }
59
-
60
- /// The type-safe alternative to suggest in place of a free-form type (the JS wrapper of the same shape),
61
- /// or `nil` when the type isn't free-form.
62
- internal func typedFreeFormReplacement(for type: String) -> String? {
63
- return typedFreeFormReplacements[normalizedTypeSpelling(type)]
64
- }
65
-
66
29
  /// A type spelling with all whitespace removed, so spelling variations of the same type
67
30
  /// (`[String: Any]` vs `[String :Any]`) compare equal.
68
31
  private func normalizedTypeSpelling(_ type: String) -> String {
69
32
  return type.filter { !$0.isWhitespace }
70
33
  }
71
34
 
35
+ /// How a type that mentions `Any` converts on the JS-value paths. Such a type can't conform to the JS
36
+ /// codable protocols, so it converts through the `JavaScriptValue` free-form methods.
37
+ internal enum FreeFormConversion {
38
+ /// `Any`, `[Any]` or `[String: Any]`, wrapped in at most one optional: the dedicated methods for
39
+ /// that shape, named by `decodeMethod` and `encodeMethod`. The optional is handled around them.
40
+ case shape(decodeMethod: String, encodeMethod: String, isOptional: Bool, isBareAny: Bool)
41
+ /// Any other type that mentions `Any`: the generic `decodeAny(_:as:in:)` and `encodeAny(_:in:)`.
42
+ case generic
43
+
44
+ /// Classifies a type that mentions `Any`; the caller checks `isConvertibleFreeFormType` first.
45
+ init(type: TypeSyntax) {
46
+ let wrapped = optionalWrappedType(type)
47
+ switch freeFormShape(of: wrapped ?? type) {
48
+ case .any:
49
+ self = .shape(
50
+ decodeMethod: "decodeAny",
51
+ encodeMethod: "encodeAny",
52
+ isOptional: wrapped != nil,
53
+ isBareAny: true
54
+ )
55
+ case .array:
56
+ self = .shape(
57
+ decodeMethod: "decodeAnyArray",
58
+ encodeMethod: "encodeAnyArray",
59
+ isOptional: wrapped != nil,
60
+ isBareAny: false
61
+ )
62
+ case .dictionary:
63
+ self = .shape(
64
+ decodeMethod: "decodeAnyDictionary",
65
+ encodeMethod: "encodeAnyDictionary",
66
+ isOptional: wrapped != nil,
67
+ isBareAny: false
68
+ )
69
+ case nil:
70
+ self = .generic
71
+ }
72
+ }
73
+
74
+ /// True for `Any` itself, optional or not.
75
+ var isBareAny: Bool {
76
+ guard case .shape(_, _, _, let isBareAny) = self else {
77
+ return false
78
+ }
79
+ return isBareAny
80
+ }
81
+
82
+ /// The decode expression, as it appears after `try`, for a `@JS` value read from `valueExpression`
83
+ /// (a `JavaScriptUnownedValue`) into `type` (spelled in expression position). For an optional
84
+ /// free-form shape, `undefined` and `null` map to `nil` around the dedicated method, just like
85
+ /// `Optional.decode`. The generic method takes an owning value, so the borrowed one is copied first.
86
+ func decodeExpression(of valueExpression: String, as type: String) -> String {
87
+ switch self {
88
+ case .shape(let decodeMethod, _, let isOptional, _):
89
+ let call = "JavaScriptValue.\(decodeMethod)(\(valueExpression), in: runtime)"
90
+ return isOptional ? "\(valueExpression).isUndefined() || \(valueExpression).isNull() ? nil : \(call)" : call
91
+ case .generic:
92
+ return "JavaScriptValue.decodeAny(\(valueExpression).copied(in: runtime), as: \(type).self, in: runtime)"
93
+ }
94
+ }
95
+
96
+ /// The encode expression, as it appears after `try`, for the `@JS` value `valueExpression`. An
97
+ /// optional free-form shape encodes through the generic `encodeAny(_:in:)`, which encodes `nil` as
98
+ /// `null` and unwraps any other value, so the expression (which may call a computed getter) is
99
+ /// evaluated only once.
100
+ func encodeExpression(of valueExpression: String) -> String {
101
+ switch self {
102
+ case .shape(_, let encodeMethod, false, _):
103
+ return "JavaScriptValue.\(encodeMethod)(\(valueExpression), in: runtime)"
104
+ case .shape(_, _, true, _), .generic:
105
+ // The explicit `as Any` keeps an optional value from warning about its implicit coercion.
106
+ return "JavaScriptValue.encodeAny(\(valueExpression) as Any, in: runtime)"
107
+ }
108
+ }
109
+ }
110
+
111
+ /// The conversion for a boundary type that mentions `Any`, or `nil` when the type doesn't mention
112
+ /// `Any`, or holds it in a shape the free-form methods can't convert (see `isConvertibleFreeFormType`),
113
+ /// which the caller diagnoses.
114
+ internal func freeFormConversion(of type: TypeSyntax) -> FreeFormConversion? {
115
+ guard mentionsFreeFormAny(type), isConvertibleFreeFormType(type) else {
116
+ return nil
117
+ }
118
+ return FreeFormConversion(type: type)
119
+ }
120
+
72
121
  /// True when a type mentions `Any` anywhere in its spelling, at any nesting depth: `Any`, `[Any]?`,
73
122
  /// `[String: Any]`, `Dictionary<String, [Any]>`, `[Int: Any]`, and so on. Such a type can't conform to
74
123
  /// the JS codable protocols.
@@ -148,99 +148,46 @@ private func diagnoseParameterSpecifiers(
148
148
  }
149
149
  }
150
150
 
151
- /// Emits the free-form (`Any` / `[Any]` / `[String: Any]`) diagnostics for a `@JS` declaration.
151
+ /// Emits the free-form (`Any`-bearing) diagnostics for a `@JS` declaration.
152
152
  ///
153
- /// A free-form type is only supported crossing the boundary as an **argument**, decoded through
154
- /// `JavaScriptValue.decodeAny…`; there is no free-form encode, so any position that encodes is a hard
155
- /// error. That means:
156
- /// - a function/constructor **parameter** typed free-form gets a warning steering to
157
- /// `JavaScriptValue` (the type-safe alternative), but compiles;
158
- /// - a function **return** typed free-form is an error (it would need to encode);
159
- /// - a **property** typed free-form is an error regardless of settability, because its getter always
160
- /// encodes.
153
+ /// A type built from `Any`, arrays, `String`-keyed dictionaries and optionals converts through the
154
+ /// `JavaScriptValue` free-form methods in every position (see `FreeFormConversion`). That means:
155
+ /// - a free-form argument, return value or property needs no diagnostic: it decodes through
156
+ /// `decodeAny…` and encodes through `encodeAny…`;
157
+ /// - a type that holds `Any` in any other shape (`[Int: Any]`, `Set<[String: Any]>`) is an error in
158
+ /// every position, since the free-form methods never produce or accept it.
161
159
  ///
162
- /// Types are matched by their written spelling on the type node, so the diagnostic points at the
163
- /// offending type in the user's source.
160
+ /// Types are matched on the type node, so the diagnostic points at the offending type in the user's
161
+ /// source. A closure type is left to `diagnoseClosureTypes`.
164
162
  private func diagnoseFreeFormTypes(
165
163
  in declaration: some DeclSyntaxProtocol,
166
164
  in context: some MacroExpansionContext
167
165
  ) {
166
+ var types: [TypeSyntax] = []
168
167
  if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
169
- warnFreeFormArguments(funcDecl.signature.parameterClause.parameters, in: context)
170
- if let returnType = funcDecl.signature.returnClause?.type,
171
- isFreeFormBoundaryType(returnType.trimmedDescription) {
172
- context.diagnose(
173
- Diagnostic(node: returnType, message: freeFormReturnError(for: returnType.trimmedDescription)))
168
+ types = funcDecl.signature.parameterClause.parameters.map { SpecifiedType($0.type).type }
169
+ if let returnType = funcDecl.signature.returnClause?.type {
170
+ types.append(returnType)
174
171
  }
175
- return
176
- }
177
-
178
- // A constructor decodes its arguments exactly like a function; it has no return value to encode, so
179
- // only the argument warning applies.
180
- if let initDecl = declaration.as(InitializerDeclSyntax.self) {
181
- warnFreeFormArguments(initDecl.signature.parameterClause.parameters, in: context)
182
- return
183
- }
184
-
185
- if let varDecl = declaration.as(VariableDeclSyntax.self),
186
- let type = varDecl.bindings.first?.typeAnnotation?.type,
187
- isFreeFormBoundaryType(type.trimmedDescription) {
188
- context.diagnose(
189
- Diagnostic(node: type, message: freeFormPropertyError(for: type.trimmedDescription)))
172
+ } else if let initDecl = declaration.as(InitializerDeclSyntax.self) {
173
+ // A constructor decodes its arguments exactly like a function and has no return value.
174
+ types = initDecl.signature.parameterClause.parameters.map { SpecifiedType($0.type).type }
175
+ } else if let varDecl = declaration.as(VariableDeclSyntax.self),
176
+ let type = varDecl.bindings.first?.typeAnnotation?.type {
177
+ types = [type]
190
178
  }
191
- }
192
179
 
193
- /// The tail of a free-form encode error: the typed wrapper of the same shape (`JavaScriptArray`,
194
- /// `JavaScriptObject`) when there is one, otherwise the plain `JavaScriptValue` passthrough suggestion.
195
- private func suggestedAlternative(for freeFormType: String) -> String {
196
- if let reshaped = typedFreeFormReplacement(for: freeFormType), reshaped != "JavaScriptValue" {
197
- return "Use '\(reshaped)', or 'JavaScriptValue' to pass a JS value through unchanged."
198
- }
199
- return "Use a concrete type, or 'JavaScriptValue' to pass a JS value through unchanged."
200
- }
201
-
202
- /// Emits the steering warning for each free-form parameter in a list. Shared by the function and
203
- /// constructor cases, which both decode their arguments through the same path.
204
- private func warnFreeFormArguments(
205
- _ parameters: FunctionParameterListSyntax,
206
- in context: some MacroExpansionContext
207
- ) {
208
- for parameter in parameters {
209
- // Matched on the bare type: `borrowing [String: Any]` decodes as free-form too.
210
- let type = SpecifiedType(parameter.type).type.trimmedDescription
211
- guard let suggested = typedFreeFormReplacement(for: type) else {
212
- continue
213
- }
180
+ for type in types
181
+ where mentionsFreeFormAny(type) && !isConvertibleFreeFormType(type) && !containsFunctionType(type) {
214
182
  context.diagnose(
215
- Diagnostic(
216
- node: parameter.type,
217
- message: freeFormArgumentWarning(for: type, suggesting: suggested)))
183
+ Diagnostic(node: type, message: unconvertibleFreeFormError(for: type.trimmedDescription)))
218
184
  }
219
185
  }
220
186
 
221
- private func freeFormArgumentWarning(
222
- for freeFormType: String,
223
- suggesting suggestedType: String
224
- ) -> JSDiagnosticMessage {
187
+ private func unconvertibleFreeFormError(for type: String) -> JSDiagnosticMessage {
225
188
  return JSDiagnosticMessage(
226
- "Prefer '\(suggestedType)' over the free-form '\(freeFormType)' for a @JS argument. Free-form decoding boxes every value as 'Any' (slower, no static typing); '\(suggestedType)' keeps the JS value inspectable without converting it.",
227
- id: "js-free-form-argument",
228
- severity: .warning
229
- )
230
- }
231
-
232
- private func freeFormReturnError(for freeFormType: String) -> JSDiagnosticMessage {
233
- return JSDiagnosticMessage(
234
- "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))",
235
- id: "js-free-form-return",
236
- severity: .error
237
- )
238
- }
239
-
240
- private func freeFormPropertyError(for freeFormType: String) -> JSDiagnosticMessage {
241
- return JSDiagnosticMessage(
242
- "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))",
243
- id: "js-free-form-property",
189
+ "A @JS member can't use the type '\(type)': a type holding 'Any' converts to and from JavaScript only when it's built from 'Any', arrays, String-keyed dictionaries and optionals. Use one of those shapes, such as '[String: Any]', or 'JavaScriptValue' to keep the JavaScript value unconverted.",
190
+ id: "js-free-form-unsupported",
244
191
  severity: .error
245
192
  )
246
193
  }
@@ -289,9 +236,10 @@ private struct BoundaryMember {
289
236
  /// function contributes its parameter types (decodable) and its return type when non-Void (encodable);
290
237
  /// a property contributes its value type as encodable (the getter) and also as decodable when settable
291
238
  /// (the setter). Composed types (`[Int]`, `String?`, …) are kept verbatim, their conditional
292
- /// conformances transitively constraining the elements. Returns `nil` for declaration kinds `@JS`
293
- /// doesn't read types from, or a property whose type isn't spelled out (a syntactic macro can't
294
- /// recover it).
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`.
295
243
  private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> BoundaryMember? {
296
244
  if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
297
245
  // A closure argument moves data the other way: JS receives its parameters and returns its result.
@@ -307,21 +255,25 @@ private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> Boundary
307
255
  continue
308
256
  }
309
257
  guard let closure = JSClosureType(specified.type) else {
310
- if !containsFunctionType(specified.type) {
258
+ if !containsFunctionType(specified.type) && !mentionsFreeFormAny(specified.type) {
311
259
  decodableTypes.append(specified.type.trimmedDescription)
312
260
  }
313
261
  continue
314
262
  }
315
- for parameter in closure.parameters where !containsFunctionType(parameter.type) {
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) {
316
266
  callbackArgumentTypes.append(parameter.type.trimmedDescription)
317
267
  }
318
- if let returnType = closure.returnType, !containsFunctionType(closure.functionType.returnClause.type) {
268
+ let closureReturnType = closure.functionType.returnClause.type
269
+ if let returnType = closure.returnType,
270
+ !containsFunctionType(closureReturnType) && !mentionsFreeFormAny(closureReturnType) {
319
271
  decodableTypes.append(returnType)
320
272
  }
321
273
  }
322
274
  let returnType = funcDecl.signature.returnClause?.type
323
275
  let encodableType = returnType.flatMap {
324
- isVoidType($0) || containsFunctionType($0) ? nil : $0.trimmedDescription
276
+ isVoidType($0) || containsFunctionType($0) || mentionsFreeFormAny($0) ? nil : $0.trimmedDescription
325
277
  }
326
278
  return BoundaryMember(
327
279
  name: funcDecl.name.text,
@@ -338,7 +290,11 @@ private func boundaryMember(of declaration: some DeclSyntaxProtocol) -> Boundary
338
290
  let type = binding.typeAnnotation?.type,
339
291
  !containsFunctionType(type) {
340
292
  // A `let`, or a `var` with no setter, is read-only (encodable only). A settable `var` is also
341
- // decoded on write, so its value type is asserted in both directions.
293
+ // decoded on write, so its value type is asserted in both directions. A type that mentions `Any`
294
+ // converts through the free-form methods instead, so it isn't asserted at all.
295
+ guard !mentionsFreeFormAny(type) else {
296
+ return nil
297
+ }
342
298
  let typeText = type.trimmedDescription
343
299
  let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
344
300
  let isSettable = isVar && bindingIsSettable(binding)
@@ -366,17 +366,27 @@ internal func expressionType(_ type: String) -> String {
366
366
  }
367
367
 
368
368
  /// The decode expression for a boundary type read from `valueExpression` (a `JavaScriptUnownedValue`),
369
- /// as it appears after `try`. A free-form type (`Any`, `[Any]`, `[String: Any]`) can't conform to
370
- /// `JavaScriptDecodable`, so it decodes through the dedicated `JavaScriptValue.decodeAny…` entry point
371
- /// keyed to its shape; every other type decodes through its own static `decode`, spelled in expression
372
- /// position (`T!` rewritten to `T?`).
373
- internal func decodeCall(_ type: String, from valueExpression: String) -> String {
374
- if let method = freeFormDecodeMethod(for: type) {
375
- return "JavaScriptValue.\(method)(\(valueExpression), in: runtime)"
369
+ /// as it appears after `try`. A type that mentions `Any` can't conform to `JavaScriptDecodable`, so it
370
+ /// decodes through the `JavaScriptValue` free-form methods its `freeForm` conversion names; every other
371
+ /// type decodes through its own static `decode`, spelled in expression position (`T!` rewritten to
372
+ /// `T?`).
373
+ internal func decodeCall(_ type: String, freeForm: FreeFormConversion?, from valueExpression: String) -> String {
374
+ if let freeForm {
375
+ return freeForm.decodeExpression(of: valueExpression, as: expressionType(type))
376
376
  }
377
377
  return "\(expressionType(type)).decode(\(valueExpression), in: runtime)"
378
378
  }
379
379
 
380
+ /// The encode expression for the native value `valueExpression` of a boundary type, as it appears
381
+ /// after `try`. The counterpart of `decodeCall`: a type that mentions `Any` encodes through the
382
+ /// `JavaScriptValue` free-form methods, and every other type through its own static `encode`.
383
+ internal func encodeCall(_ type: String, freeForm: FreeFormConversion?, of valueExpression: String) -> String {
384
+ if let freeForm {
385
+ return freeForm.encodeExpression(of: valueExpression)
386
+ }
387
+ return "\(expressionType(type)).encode(\(valueExpression), in: runtime)"
388
+ }
389
+
380
390
  // MARK: - Escaped names
381
391
 
382
392
  /// `name` without the backticks that escape it: `` `default` `` becomes `default`. Swift needs the
@@ -429,6 +439,8 @@ internal func collectProperties(
429
439
  swiftName: swiftName,
430
440
  jsName: jsNameOverride ?? withoutBackticks(swiftName),
431
441
  valueType: valueType,
442
+ // A literal-inferred type never mentions `Any`, so only an explicit annotation can be free-form.
443
+ freeForm: binding.typeAnnotation.flatMap { freeFormConversion(of: $0.type) },
432
444
  isSettable: isVar && bindingIsSettable(binding)
433
445
  )
434
446
  }
@@ -143,56 +143,6 @@ public struct RecordMacro: MemberMacro, ExtensionMacro {
143
143
 
144
144
  // MARK: - Property model
145
145
 
146
- /**
147
- How a property whose type mentions `Any` converts on the JS-value paths. Such a type can't conform to
148
- the JS codable protocols, so the property converts through the `JavaScriptValue` free-form methods.
149
- */
150
- private enum FreeFormConversion {
151
- /// `Any`, `[Any]` or `[String: Any]`, wrapped in at most one optional: the dedicated methods for
152
- /// that shape, named by `decodeMethod` and `encodeMethod`. The optional is handled around them.
153
- case shape(decodeMethod: String, encodeMethod: String, isOptional: Bool, isBareAny: Bool)
154
- /// Any other type that mentions `Any`: the generic `decodeAny(_:as:in:)` and `encodeAny(_:in:)`.
155
- case generic
156
-
157
- /// Classifies a type that mentions `Any`; the caller checks `isConvertibleFreeFormType` first.
158
- init(type: TypeSyntax) {
159
- let wrapped = optionalWrappedType(type)
160
- switch freeFormShape(of: wrapped ?? type) {
161
- case .any:
162
- self = .shape(
163
- decodeMethod: "decodeAny",
164
- encodeMethod: "encodeAny",
165
- isOptional: wrapped != nil,
166
- isBareAny: true
167
- )
168
- case .array:
169
- self = .shape(
170
- decodeMethod: "decodeAnyArray",
171
- encodeMethod: "encodeAnyArray",
172
- isOptional: wrapped != nil,
173
- isBareAny: false
174
- )
175
- case .dictionary:
176
- self = .shape(
177
- decodeMethod: "decodeAnyDictionary",
178
- encodeMethod: "encodeAnyDictionary",
179
- isOptional: wrapped != nil,
180
- isBareAny: false
181
- )
182
- case nil:
183
- self = .generic
184
- }
185
- }
186
-
187
- /// True for `Any` itself, optional or not.
188
- var isBareAny: Bool {
189
- guard case .shape(_, _, _, let isBareAny) = self else {
190
- return false
191
- }
192
- return isBareAny
193
- }
194
- }
195
-
196
146
  /**
197
147
  A single record property discovered on the type, paired with everything the synthesized
198
148
  conversions need: its property name, its written type, and how the source may omit it.
@@ -88,22 +88,23 @@ internal func directionalConformanceAssertion(
88
88
  // Build the generic parameter list and the matching call arguments in lockstep: one `A0…` slot
89
89
  // constrained `JavaScriptDecodable` per argument, one `E0…` slot constrained `JavaScriptEncodable`
90
90
  // per closure-argument parameter, and a single `Return` slot constrained `JavaScriptEncodable` for
91
- // the return/getter value (there's only ever one, so it isn't indexed).
91
+ // the return/getter value (there's only ever one, so it isn't indexed). Every slot suppresses
92
+ // `Copyable`, so a non-copyable boundary type such as `JavaScriptValue` can fill it.
92
93
  var parameters: [String] = []
93
94
  var typeParameters: [String] = []
94
95
  var arguments: [String] = []
95
96
  for (index, type) in decodables.enumerated() {
96
- parameters.append("A\(index): \(javaScriptDecodableProtocolName)")
97
+ parameters.append("A\(index): \(javaScriptDecodableProtocolName) & ~Copyable")
97
98
  typeParameters.append("_: A\(index).Type")
98
99
  arguments.append("\(type).self")
99
100
  }
100
101
  for (index, type) in callbackArguments.enumerated() {
101
- parameters.append("E\(index): \(javaScriptEncodableProtocolName)")
102
+ parameters.append("E\(index): \(javaScriptEncodableProtocolName) & ~Copyable")
102
103
  typeParameters.append("_: E\(index).Type")
103
104
  arguments.append("\(type).self")
104
105
  }
105
106
  if let encodable {
106
- parameters.append("Return: \(javaScriptEncodableProtocolName)")
107
+ parameters.append("Return: \(javaScriptEncodableProtocolName) & ~Copyable")
107
108
  typeParameters.append("_: Return.Type")
108
109
  arguments.append("\(encodable).self")
109
110
  }
@@ -143,10 +144,11 @@ internal func typeConformanceAssertions(
143
144
  }
144
145
 
145
146
  /// The type to assert for a boundary type as written, or `nil` when nothing needs asserting: an
146
- /// exactly-free-form type (handled by the binding's `decodeAny…` reroute) and a known-conforming
147
- /// primitive both return `nil`; everything else is unwrapped of trailing optionals and asserted. An
148
- /// *optional* free-form (`[String: Any]?`) isn't rerouted and doesn't conform, so it's kept and
149
- /// asserted for a clean diagnostic. Shared with `@Event`.
147
+ /// exactly-free-form type (converted through the `JavaScriptValue` free-form methods) and a
148
+ /// known-conforming primitive both return `nil`; everything else is unwrapped of trailing optionals
149
+ /// and asserted. Shared with `@Event`, where an *optional* free-form type (`[String: Any]?`) is kept
150
+ /// and asserted for a clean diagnostic. `@JS` leaves out every type that mentions `Any` before it gets
151
+ /// here.
150
152
  internal func assertableBoundaryType(_ type: String) -> String? {
151
153
  guard !isFreeFormBoundaryType(type) else {
152
154
  return nil