expo-modules-macros 0.30.0 → 0.32.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.
@@ -35,7 +35,9 @@ import SwiftSyntaxMacros
35
35
  /// Discrimination is structural and order-dependent: the first case whose payload decodes wins. When
36
36
  /// two payload shapes overlap (two records with compatible fields, `Int` and `Double`), the earlier
37
37
  /// case matches; the author orders the more specific case first. A case may not repeat another case's
38
- /// payload type, since it could never be chosen.
38
+ /// payload type, since it could never be chosen. A case after a `JavaScriptValue` case, or with a
39
+ /// number-only payload after a `Double`, `Float` or `CGFloat` case, can't be chosen either, so the macro
40
+ /// warns about it.
39
41
  ///
40
42
  /// The named-union counterpart of core's `Either`: any number of named cases instead of two anonymous
41
43
  /// slots, no `Any?` box, and an exhaustive `switch` as the primary way to read it (`as(_:)` covers the
@@ -48,6 +50,7 @@ public struct UnionMacro: MemberMacro, ExtensionMacro {
48
50
  in context: some MacroExpansionContext
49
51
  ) throws -> [DeclSyntax] {
50
52
  let union = try validatedUnion(of: declaration)
53
+ diagnoseUnreachableCases(of: declaration, in: context)
51
54
 
52
55
  var members: [DeclSyntax] = []
53
56
 
@@ -223,8 +226,8 @@ private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> Unio
223
226
  let payloadType = parameter.type.trimmedDescription
224
227
  // Alternatives decode in declaration order and the first success wins, so a case whose payload
225
228
  // type repeats an earlier case's is unreachable: an exact-spelling duplicate is an error. (An
226
- // overlap between *different* types, like `Int` and `Double`, is the author's ordering call and
227
- // isn't checked here.)
229
+ // overlap between *different* types, like `Int` and `Double`, is the author's ordering call;
230
+ // `diagnoseUnreachableCases` warns about the overlaps that make a case unreachable.)
228
231
  let normalizedType = payloadType.filter { !$0.isWhitespace }
229
232
  if let earlierCase = seenPayloadTypes[normalizedType] {
230
233
  diagnostics.append(
@@ -256,6 +259,88 @@ private func validatedUnion(of declaration: some DeclGroupSyntax) throws -> Unio
256
259
  return UnionType(name: enumDecl.name.text, cases: cases)
257
260
  }
258
261
 
262
+ /// The payload types whose decode accepts every JavaScript number: they convert the number with
263
+ /// `asDouble()`, so they never fail on one.
264
+ private let anyNumberPayloadTypes: Set<String> = ["Double", "Float", "CGFloat"]
265
+
266
+ /// The payload types that decode only a JavaScript number, so a case before them with a type of
267
+ /// `anyNumberPayloadTypes` decodes every value they could. `Int`, `Int64`, `UInt` and `UInt64` aren't
268
+ /// listed, since they also decode a `bigint`.
269
+ private let numberOnlyPayloadTypes: Set<String> = [
270
+ "Double", "Float", "CGFloat", "Int8", "Int16", "Int32", "UInt8", "UInt16", "UInt32",
271
+ ]
272
+
273
+ /// Warns about each case that an earlier case always decodes first, so it is never chosen. Cases are
274
+ /// tried in declaration order and the first that decodes wins. The macro knows that only for some
275
+ /// payload types: a `JavaScriptValue` payload decodes every value, and a floating-point payload every
276
+ /// number, so a later case that decodes only numbers can't be reached. Types with the same spelling
277
+ /// are reported as an error by `validatedUnion`, which runs first.
278
+ private func diagnoseUnreachableCases(of declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) {
279
+ var catchAllCase: (name: TokenSyntax, type: String)?
280
+ var anyNumberCase: (name: TokenSyntax, type: String)?
281
+
282
+ for member in declaration.memberBlock.members {
283
+ guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
284
+ continue
285
+ }
286
+ for element in caseDecl.elements {
287
+ guard let parameter = element.parameterClause?.parameters.first else {
288
+ continue
289
+ }
290
+ let type = unqualifiedPayloadTypeName(parameter.type)
291
+ let reason: String?
292
+ let earlier: (name: TokenSyntax, type: String)?
293
+ if let catchAllCase {
294
+ earlier = catchAllCase
295
+ reason = "'\(catchAllCase.type)' decodes every JavaScript value"
296
+ } else if let anyNumberCase, numberOnlyPayloadTypes.contains(type) {
297
+ earlier = anyNumberCase
298
+ reason = "'\(anyNumberCase.type)' decodes every JavaScript number, and '\(type)' decodes only numbers"
299
+ } else {
300
+ earlier = nil
301
+ reason = nil
302
+ }
303
+ if let earlier, let reason {
304
+ let name = element.name.text
305
+ context.diagnose(
306
+ Diagnostic(
307
+ node: element,
308
+ message: UnionDiagnosticMessage(
309
+ "@Union case '\(name)' is never decoded: case '\(earlier.name.text)' is tried first, and \(reason). Move case '\(name)' before case '\(earlier.name.text)'.",
310
+ id: "union-unreachable-case",
311
+ severity: .warning
312
+ ),
313
+ notes: [
314
+ Note(
315
+ node: Syntax(earlier.name),
316
+ message: UnionNoteMessage(
317
+ "case '\(earlier.name.text)' is declared here",
318
+ id: "union-unreachable-case-earlier"
319
+ )
320
+ )
321
+ ]
322
+ )
323
+ )
324
+ continue
325
+ }
326
+ if type == "JavaScriptValue" {
327
+ catchAllCase = (element.name, type)
328
+ } else if anyNumberCase == nil, anyNumberPayloadTypes.contains(type) {
329
+ anyNumberCase = (element.name, type)
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ /// The name of a payload type without the module that it may be qualified with, such as `Swift.Double`.
336
+ private func unqualifiedPayloadTypeName(_ type: TypeSyntax) -> String {
337
+ let modules = ["Swift", "CoreGraphics", "CoreFoundation", "Foundation", "ExpoModulesCore", "ExpoModulesJSI"]
338
+ if let memberType = type.as(MemberTypeSyntax.self), modules.contains(memberType.baseType.trimmedDescription) {
339
+ return memberType.name.text
340
+ }
341
+ return type.trimmedDescription
342
+ }
343
+
259
344
  // MARK: - Synthesized members
260
345
 
261
346
  /// `decode(_:in:)`: tries each case's payload converter in declaration order and returns the first
@@ -429,11 +514,22 @@ private func defaultValueDiagnostic(
429
514
  private struct UnionDiagnosticMessage: DiagnosticMessage {
430
515
  let message: String
431
516
  let diagnosticID: MessageID
432
- let severity: DiagnosticSeverity = .error
517
+ let severity: DiagnosticSeverity
433
518
 
434
- init(_ message: String, id: String) {
519
+ init(_ message: String, id: String, severity: DiagnosticSeverity = .error) {
435
520
  self.message = message
436
521
  self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
522
+ self.severity = severity
523
+ }
524
+ }
525
+
526
+ private struct UnionNoteMessage: NoteMessage {
527
+ let message: String
528
+ let noteID: MessageID
529
+
530
+ init(_ message: String, id: String) {
531
+ self.message = message
532
+ self.noteID = MessageID(domain: "ExpoModulesMacros", id: id)
437
533
  }
438
534
  }
439
535