expo-modules-macros 0.19.1 → 0.20.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.
package/README.md CHANGED
@@ -81,11 +81,11 @@ Node consumers can call the scanner through this package instead of locating the
81
81
  ```ts
82
82
  import { scanModules, scanExports } from 'expo-modules-macros';
83
83
 
84
- const { modules, warnings } = await scanModules(['ios/'], { defines: ['DEBUG'] });
84
+ const { modules, warnings } = await scanModules(['ios/'], { defines: ['DEBUG'], timeout: 30_000 });
85
85
  const { exports } = await scanExports(['ios/']);
86
86
  ```
87
87
 
88
- The binary is a compiled executable, so each call still spawns a process. What the wrapper owns is the part consumers would otherwise duplicate: resolving the shipped binary, building the arguments, parsing the JSON, checking `schemaVersion`, and turning a non-zero exit into a `ScannerError`. The result types are hand-written mirrors of the Swift `Codable` types, which is what the version check guards against drifting.
88
+ The binary is a compiled executable, so each call still spawns a process. What the wrapper owns is the part consumers would otherwise duplicate: resolving the shipped binary, building the arguments, parsing the JSON, checking `schemaVersion`, and turning a non-zero exit or an exceeded `timeout` into a `ScannerError`. The result types are hand-written mirrors of the Swift `Codable` types, which is what the version check guards against drifting.
89
89
 
90
90
  # How the plugin reaches the compiler
91
91
 
Binary file
@@ -344,7 +344,7 @@ internal func containsFunctionType(_ type: TypeSyntax) -> Bool {
344
344
  }
345
345
 
346
346
  /// The first function type in `type` or anywhere inside it, in source order.
347
- private func firstFunctionType(in type: some SyntaxProtocol) -> FunctionTypeSyntax? {
347
+ internal func firstFunctionType(in type: some SyntaxProtocol) -> FunctionTypeSyntax? {
348
348
  if let functionType = type.as(FunctionTypeSyntax.self) {
349
349
  return functionType
350
350
  }
@@ -206,8 +206,8 @@ internal struct JSFunction {
206
206
  }
207
207
 
208
208
  /// The flat (single-arity) call-and-encode lines used when no trailing parameter is omittable:
209
- /// `let result = <callee>.f(...)` then the return encode (or the no-return `<callee>.f(...)` +
210
- /// `.undefined()`).
209
+ /// `let result = <callee>.f(...)` then the return encode (or just `<callee>.f(...)` for a function
210
+ /// that returns nothing).
211
211
  private func callAndEncodeLines(receiver: Receiver, arity: Int, decodingFrom: Int) -> [String] {
212
212
  var lines: [String] = []
213
213
  for index in decodingFrom..<arity {
@@ -218,7 +218,6 @@ internal struct JSFunction {
218
218
  lines.append(contentsOf: encodeResultLines())
219
219
  } else {
220
220
  lines.append(callExpression(receiver: receiver, arity: arity))
221
- lines.append("return .undefined()")
222
221
  }
223
222
  return lines
224
223
  }
@@ -229,7 +228,8 @@ internal struct JSFunction {
229
228
  /// `toJavaScriptValue(in:)` except for `Int`/`UInt`, where it range-checks
230
229
  /// and throws instead of silently encoding an out-of-safe-range value as a lossy number — the
231
230
  /// catchable error is the right behavior, and matches how non-primitive integers already encode. A
232
- /// no-return function returns `.undefined()` instead.
231
+ /// no-return function has nothing to encode: its binding closure returns `Void` (see
232
+ /// `decorateStatements(object:receiver:)`).
233
233
  ///
234
234
  /// For an `async` function the encode must run on the JS thread. An `async` body may suspend and
235
235
  /// resume on an arbitrary cooperative-pool thread, and encoding a heap-allocated JS value (a string,
@@ -239,7 +239,7 @@ internal struct JSFunction {
239
239
  /// which always run on the JS thread — keep encoding directly without the wrapper.
240
240
  private func encodeResultLines() -> [String] {
241
241
  guard let returnType else {
242
- return ["return .undefined()"]
242
+ return []
243
243
  }
244
244
  let encode = "try \(encodeCall(returnType, freeForm: returnFreeForm, of: "result"))"
245
245
  if isAsync {
@@ -272,11 +272,16 @@ internal struct JSFunction {
272
272
  // a shorthand `{ [capture] name, name in }` parameter; for a sync binding the explicit
273
273
  // `JavaScriptUnownedValue` also selects the (otherwise `@_disfavoredOverload`) unowned-`this`
274
274
  // overload.
275
+ // A sync function that returns nothing binds through the `Void` overload, which leaves JS's
276
+ // result as `undefined` without building and writing a value. The closure declares `-> Void`
277
+ // because a body of several statements without a `return` is otherwise ambiguous between the
278
+ // two unowned-`this` overloads.
275
279
  let captures = receiver.captureClause
276
280
  let parameters = "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
281
+ let returnClause = returnType == nil && !isAsync ? " -> Void" : ""
277
282
 
278
283
  return """
279
- \(object).setProperty("\(jsName)") { \(captures)\(parameters) in
284
+ \(object).setProperty("\(jsName)") { \(captures)\(parameters)\(returnClause) in
280
285
  \(bodyStatements(receiver: receiver, indent: " "))
281
286
  }
282
287
  """
@@ -348,7 +353,7 @@ internal struct JSProperty {
348
353
  let setDecode = "\(callee).\(swiftName) = try \(decode)"
349
354
  lines.append(
350
355
  accessorClosure(
351
- descriptorName, "set", receiver: receiver, body: "\(unwrap)\(setDecode)\nreturn .undefined()"))
356
+ descriptorName, "set", receiver: receiver, returnsVoid: true, body: "\(unwrap)\(setDecode)"))
352
357
  }
353
358
 
354
359
  lines.append("\(object).defineProperty(\"\(jsName)\", descriptor: \(descriptorName))")
@@ -361,8 +366,10 @@ internal struct JSProperty {
361
366
 
362
367
  /// One `descriptor.setProperty("get"/"set") { … }` accessor entry. The capture list follows the
363
368
  /// receiver: a module captures `self` strong; a shared object captures nothing of the instance.
369
+ /// A setter passes `returnsVoid`, so its closure declares `-> Void` and binds through the `Void`
370
+ /// overload, like a function that returns nothing.
364
371
  private func accessorClosure(
365
- _ descriptorName: String, _ key: String, receiver: Receiver, body: String
372
+ _ descriptorName: String, _ key: String, receiver: Receiver, returnsVoid: Bool = false, body: String
366
373
  ) -> String {
367
374
  let captures = receiver.captureClause
368
375
  // Indent each line of a (possibly multi-line) body to sit one level inside the closure; a bare
@@ -377,8 +384,9 @@ internal struct JSProperty {
377
384
  // `borrowing JavaScriptUnownedValue` selects the unowned-`this` overload. A module ignores `this`;
378
385
  // a shared object unwraps it in the body.
379
386
  let parameters = "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
387
+ let returnClause = returnsVoid ? " -> Void" : ""
380
388
  return """
381
- \(descriptorName).setProperty("\(key)") { \(captures)\(parameters) in
389
+ \(descriptorName).setProperty("\(key)") { \(captures)\(parameters)\(returnClause) in
382
390
  \(indentedBody)
383
391
  }
384
392
  """
@@ -445,7 +453,7 @@ internal func buildDecorateSharedObjectPhase(
445
453
  }
446
454
 
447
455
  /// True when a return clause is absent or written as `Void` / `()` — i.e. the function returns
448
- /// nothing JS-visible, so the binding returns `.undefined()`.
456
+ /// nothing JS-visible, so the binding closure returns `Void`.
449
457
  private func isVoidType(_ type: TypeSyntax?) -> Bool {
450
458
  guard let type else {
451
459
  return true
@@ -122,7 +122,7 @@ private struct EventMember {
122
122
  /// Validates the declaration `@Event` is attached to and reads the event out of it. The checks
123
123
  /// mirror what the expansion relies on: a single-binding instance `var` (the macro synthesizes a
124
124
  /// computed getter, so `let`, accessors, and initializers are all incompatible) whose type is a
125
- /// function type returning `Void` with at most one payload parameter.
125
+ /// function type returning `Void` with at most one payload parameter, which holds no closure.
126
126
  private func validatedEvent(
127
127
  of node: AttributeSyntax,
128
128
  on declaration: some DeclSyntaxProtocol
@@ -169,6 +169,17 @@ private func validatedEvent(
169
169
  throw MacroExpansionErrorMessage(
170
170
  "@Event function type takes at most one payload parameter; combine multiple values into a single record")
171
171
  }
172
+ // The payload is encoded to JavaScript, and a native closure can't be encoded.
173
+ if let payloadClosure = functionType.parameters.first.flatMap({ firstFunctionType(in: $0.type) }) {
174
+ throw DiagnosticsError(diagnostics: [
175
+ Diagnostic(
176
+ node: payloadClosure,
177
+ message: EventDiagnosticMessage(
178
+ "@Event payload can't hold a closure: the payload is encoded to JavaScript, and a native closure can't be encoded.",
179
+ id: "event-closure-payload"
180
+ ))
181
+ ])
182
+ }
172
183
 
173
184
  let swiftName = identifier.identifier.text
174
185
  return EventMember(
@@ -1,3 +1,4 @@
1
+ import SwiftDiagnostics
1
2
  import SwiftSyntax
2
3
  import SwiftSyntaxBuilder
3
4
  import SwiftSyntaxMacros
@@ -226,8 +227,21 @@ private func recordProperties(
226
227
  "@Record properties must declare an explicit type — '\(ident.identifier.text)' has none"
227
228
  )
228
229
  }
229
- // A literal-inferred type never mentions `Any`, so only an explicit annotation can be free-form.
230
+ // A literal-inferred type never mentions `Any` or a closure, so only an explicit annotation can.
230
231
  let annotatedType = binding.typeAnnotation?.type
232
+ // A closure has no `decode`, `encode` or dynamic type, and the record converts every property both
233
+ // ways. A JavaScript function decodes into a native closure only as a `@JS` argument; a native
234
+ // closure can't be encoded.
235
+ if let functionType = annotatedType.flatMap(firstFunctionType(in:)) {
236
+ throw DiagnosticsError(diagnostics: [
237
+ Diagnostic(
238
+ node: functionType,
239
+ message: RecordDiagnosticMessage(
240
+ "@Record property '\(ident.identifier.text)' can't hold a closure: a record property is also encoded to JavaScript, and a native closure can't be encoded",
241
+ id: "record-closure-property"
242
+ ))
243
+ ])
244
+ }
231
245
  let freeForm = try annotatedType.flatMap { annotatedType -> FreeFormConversion? in
232
246
  guard mentionsFreeFormAny(annotatedType) else {
233
247
  return nil
@@ -545,3 +559,14 @@ private func classHasInheritance(_ declaration: some DeclGroupSyntax) -> Bool {
545
559
  }
546
560
  return true
547
561
  }
562
+
563
+ private struct RecordDiagnosticMessage: DiagnosticMessage {
564
+ let message: String
565
+ let diagnosticID: MessageID
566
+ let severity: DiagnosticSeverity = .error
567
+
568
+ init(_ message: String, id: String) {
569
+ self.message = message
570
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
571
+ }
572
+ }
@@ -141,7 +141,8 @@ private struct UnionCase {
141
141
 
142
142
  /// Reads and validates the union off the attached declaration. Every check is a compile error located
143
143
  /// on the offending node: the macro must be on a non-generic `enum` with at least one case, and every
144
- /// case must carry exactly one associated value, its payload type distinct from every earlier case's.
144
+ /// case must carry exactly one associated value that holds no closure, its payload type distinct from
145
+ /// every earlier case's.
145
146
  private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> UnionType {
146
147
  guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
147
148
  throw MacroExpansionErrorMessage("@Union can only be applied to an enum")
@@ -197,6 +198,18 @@ private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> Unio
197
198
  continue
198
199
  }
199
200
 
201
+ // A payload is decoded from and encoded to JavaScript, and a native closure can't be encoded.
202
+ if let payloadClosure = firstFunctionType(in: parameter.type) {
203
+ diagnostics.append(
204
+ Diagnostic(
205
+ node: payloadClosure,
206
+ message: UnionDiagnosticMessage(
207
+ "@Union case '\(name)' can't carry a closure: the payload is also encoded to JavaScript, and a native closure can't be encoded.",
208
+ id: "union-closure-payload"
209
+ )))
210
+ continue
211
+ }
212
+
200
213
  let payloadType = parameter.type.trimmedDescription
201
214
  // Alternatives decode in declaration order and the first success wins, so a case whose payload
202
215
  // type repeats an earlier case's is unreachable: an exact-spelling duplicate is an error. (An
@@ -0,0 +1,259 @@
1
+ import SwiftIfConfig
2
+ import SwiftSyntax
3
+
4
+ /// The Apple OSes `scan-exports` attributes declarations to, in the order they are reported. Spelled
5
+ /// as `os(...)` spells them.
6
+ let applePlatforms = ["iOS", "macOS", "tvOS", "watchOS", "visionOS"]
7
+
8
+ /// The platform names `@available` accepts for each reported OS, including the legacy `OSX`. Other
9
+ /// names (`iOSApplicationExtension`, `macCatalyst`, `swift`, …) do not change where a declaration is
10
+ /// built for an app, so they are ignored.
11
+ private let availabilityPlatformNames: [String: String] = [
12
+ "iOS": "iOS",
13
+ "macOS": "macOS",
14
+ "OSX": "macOS",
15
+ "tvOS": "tvOS",
16
+ "watchOS": "watchOS",
17
+ "visionOS": "visionOS",
18
+ ]
19
+
20
+ /// Answers which platforms build a declaration in one file: those whose `#if` evaluation keeps it,
21
+ /// less those an `@available` attribute on it or on an enclosing type makes unavailable. The `#if`
22
+ /// regions are evaluated once per platform for the whole file. Each platform also carries the
23
+ /// deprecation the declaration's own `@available` attributes give it there.
24
+ ///
25
+ /// A condition the scan cannot answer (`#if DEBUG`, `targetEnvironment`, a non-SDK `canImport`)
26
+ /// makes its region inactive on every platform, so a declaration under one is built nowhere.
27
+ struct PlatformResolver {
28
+ private let regions: [(platform: String, regions: ConfiguredRegions)]
29
+
30
+ init(tree: SourceFileSyntax) {
31
+ regions = applePlatforms.map { platform in
32
+ (platform, tree.configuredRegions(in: ScanBuildConfiguration(platform: platform, defines: [])))
33
+ }
34
+ }
35
+
36
+ /// The platforms that build `node`, in `applePlatforms` order. Empty when none does.
37
+ func platforms(of node: some SyntaxProtocol) -> [ExportedPlatform] {
38
+ let availability = Availability(of: node)
39
+ return regions.compactMap { platform, regions in
40
+ guard regions.isActive(node) == .active, !availability.isUnavailable(on: platform) else {
41
+ return nil
42
+ }
43
+ return ExportedPlatform(
44
+ name: platform,
45
+ introduced: availability.introduced[platform],
46
+ deprecated: availability.deprecation(on: platform)
47
+ )
48
+ }
49
+ }
50
+ }
51
+
52
+ /// What the `@available` attributes on a declaration and its enclosing declarations say. A member
53
+ /// cannot be more available than the type it is declared in, so the attributes combine: a platform
54
+ /// any of them makes unavailable is unavailable, and the latest introduced version wins.
55
+ /// Deprecations are read from the declaration's own attributes only.
56
+ private struct Availability {
57
+ private var unavailable: Set<String> = []
58
+ private var isUnavailableEverywhere = false
59
+ private(set) var introduced: [String: String] = [:]
60
+ /// The deprecation from `@available(*, deprecated, ...)`, which applies on every platform.
61
+ private var deprecatedEverywhere: ExportedDeprecation?
62
+ /// The deprecations from attributes that name a platform.
63
+ private var deprecated: [String: ExportedDeprecation] = [:]
64
+
65
+ init(of node: some SyntaxProtocol) {
66
+ var current: Syntax? = Syntax(node)
67
+ var isOwn = true
68
+ while let decl = current {
69
+ if let attributes = decl.asProtocol(WithAttributesSyntax.self)?.attributes {
70
+ for element in attributes {
71
+ if let attribute = element.as(AttributeSyntax.self),
72
+ attribute.attributeName.trimmedDescription == "available",
73
+ case .availability(let arguments) = attribute.arguments {
74
+ read(arguments, readsDeprecation: isOwn)
75
+ }
76
+ }
77
+ }
78
+ isOwn = false
79
+ current = decl.parent
80
+ }
81
+ }
82
+
83
+ func isUnavailable(on platform: String) -> Bool {
84
+ return isUnavailableEverywhere || unavailable.contains(platform)
85
+ }
86
+
87
+ /// The deprecation on `platform`: the platform's own, with any field it leaves out taken from the
88
+ /// `*` one.
89
+ func deprecation(on platform: String) -> ExportedDeprecation? {
90
+ switch (deprecated[platform], deprecatedEverywhere) {
91
+ case (nil, let everywhere):
92
+ return everywhere
93
+ case (let own?, nil):
94
+ return own
95
+ case (let own?, let everywhere?):
96
+ return own.filling(from: everywhere)
97
+ }
98
+ }
99
+
100
+ /// Reads one attribute. The long form names one platform (or `*`) first and then what applies to
101
+ /// it: `@available(iOS, introduced: 16)`, `@available(tvOS, unavailable)`,
102
+ /// `@available(*, deprecated, renamed: "other")`. The shorthand lists platforms with versions:
103
+ /// `@available(iOS 17, macOS 14, *)`.
104
+ ///
105
+ /// A long-form attribute deprecates its platform when it writes `deprecated` (bare or with a
106
+ /// version) or `obsoleted:`. `message:` and `renamed:` describe that deprecation, and are ignored
107
+ /// when the attribute deprecates nothing.
108
+ private mutating func read(_ arguments: AvailabilityArgumentListSyntax, readsDeprecation: Bool) {
109
+ guard case .token(let first) = arguments.first?.argument else {
110
+ for argument in arguments {
111
+ if case .availabilityVersionRestriction(let restriction) = argument.argument,
112
+ let platform = availabilityPlatformNames[restriction.platform.text],
113
+ let version = restriction.version {
114
+ raiseIntroduced(on: platform, to: version.trimmedDescription)
115
+ }
116
+ }
117
+ return
118
+ }
119
+ let isWildcard = first.tokenKind == .binaryOperator("*")
120
+ let platform = availabilityPlatformNames[first.text]
121
+ guard isWildcard || platform != nil else {
122
+ return
123
+ }
124
+ var deprecation = ExportedDeprecation()
125
+ var isDeprecated = false
126
+ for argument in arguments.dropFirst() {
127
+ switch argument.argument {
128
+ case .token(let token) where token.text == "unavailable":
129
+ if let platform {
130
+ unavailable.insert(platform)
131
+ } else {
132
+ isUnavailableEverywhere = true
133
+ }
134
+ case .token(let token) where token.text == "deprecated":
135
+ isDeprecated = true
136
+ case .availabilityLabeledArgument(let labeled):
137
+ switch (labeled.label.text, labeled.value) {
138
+ case ("introduced", .version(let version)):
139
+ if let platform {
140
+ raiseIntroduced(on: platform, to: version.trimmedDescription)
141
+ }
142
+ case ("deprecated", .version(let version)):
143
+ isDeprecated = true
144
+ deprecation.since = version.trimmedDescription
145
+ case ("obsoleted", .version(let version)):
146
+ isDeprecated = true
147
+ deprecation.obsoleted = version.trimmedDescription
148
+ case ("message", .string(let literal)):
149
+ deprecation.message = decodedText(of: literal)
150
+ case ("renamed", .string(let literal)):
151
+ deprecation.renamed = decodedText(of: literal)
152
+ default:
153
+ continue
154
+ }
155
+ default:
156
+ continue
157
+ }
158
+ }
159
+ guard readsDeprecation, isDeprecated else {
160
+ return
161
+ }
162
+ if let platform {
163
+ deprecated[platform] = deprecated[platform].map { deprecation.filling(from: $0) } ?? deprecation
164
+ } else {
165
+ deprecatedEverywhere = deprecatedEverywhere.map { deprecation.filling(from: $0) } ?? deprecation
166
+ }
167
+ }
168
+
169
+ private mutating func raiseIntroduced(on platform: String, to version: String) {
170
+ if let current = introduced[platform], !isVersion(version, laterThan: current) {
171
+ return
172
+ }
173
+ introduced[platform] = version
174
+ }
175
+ }
176
+
177
+ extension ExportedDeprecation {
178
+ /// This deprecation with each field it leaves out taken from `other`.
179
+ fileprivate func filling(from other: ExportedDeprecation) -> ExportedDeprecation {
180
+ return ExportedDeprecation(
181
+ since: since ?? other.since,
182
+ obsoleted: obsoleted ?? other.obsoleted,
183
+ message: message ?? other.message,
184
+ renamed: renamed ?? other.renamed
185
+ )
186
+ }
187
+ }
188
+
189
+ /// The value of a string literal in an attribute, with its escape sequences decoded: `\"` reads as
190
+ /// `"`, `\n` as a line break, and `\u{2192}` as `→`. An escaped line break in a multi-line literal
191
+ /// joins the lines, as in Swift. An escape that isn't valid is kept as written.
192
+ private func decodedText(of literal: SimpleStringLiteralExprSyntax) -> String {
193
+ let text = literal.segments.map(\.content.text).joined()
194
+ var result = ""
195
+ var index = text.startIndex
196
+ while index < text.endIndex {
197
+ let character = text[index]
198
+ index = text.index(after: index)
199
+ guard character == "\\", index < text.endIndex else {
200
+ result.append(character)
201
+ continue
202
+ }
203
+ let escaped = text[index]
204
+ index = text.index(after: index)
205
+ switch escaped {
206
+ case "0":
207
+ result.append("\0")
208
+ case "n":
209
+ result.append("\n")
210
+ case "r":
211
+ result.append("\r")
212
+ case "t":
213
+ result.append("\t")
214
+ case "\"", "'", "\\":
215
+ result.append(escaped)
216
+ case "\n", "\r\n":
217
+ continue
218
+ case "u":
219
+ if text[index...].hasPrefix("{"), let close = text[index...].firstIndex(of: "}"),
220
+ let scalarValue = UInt32(text[text.index(after: index)..<close], radix: 16),
221
+ let scalar = Unicode.Scalar(scalarValue) {
222
+ result.unicodeScalars.append(scalar)
223
+ index = text.index(after: close)
224
+ } else {
225
+ result.append("\\u")
226
+ }
227
+ default:
228
+ result.append("\\")
229
+ result.append(escaped)
230
+ }
231
+ }
232
+ return result
233
+ }
234
+
235
+ /// Compares two dotted version spellings component by component, treating a missing component as
236
+ /// `0`, so `17` and `17.0` are equal.
237
+ private func isVersion(_ lhs: String, laterThan rhs: String) -> Bool {
238
+ let left = lhs.split(separator: ".").map { Int($0) ?? 0 }
239
+ let right = rhs.split(separator: ".").map { Int($0) ?? 0 }
240
+ for index in 0..<max(left.count, right.count) {
241
+ let l = index < left.count ? left[index] : 0
242
+ let r = index < right.count ? right[index] : 0
243
+ if l != r {
244
+ return l > r
245
+ }
246
+ }
247
+ return false
248
+ }
249
+
250
+ /// The spelled access modifier of a declaration (`open`, `public`, `package`, `internal`,
251
+ /// `fileprivate`, `private`), or `nil` when none is written. A modifier with a detail, like the
252
+ /// `private` in `private(set)`, limits only the setter, so it is not the declaration's.
253
+ func spelledAccessLevel(of modifiers: DeclModifierListSyntax) -> String? {
254
+ return modifiers.first { modifier in
255
+ modifier.detail == nil && accessModifierNames.contains(modifier.name.text)
256
+ }?.name.text
257
+ }
258
+
259
+ private let accessModifierNames: Set<String> = ["open", "public", "package", "internal", "fileprivate", "private"]