expo-modules-macros 0.25.0 → 0.27.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 (48) hide show
  1. package/AGENTS.md +8 -8
  2. package/README.md +40 -3
  3. package/apple/ExpoModulesMacros +0 -0
  4. package/apple/Package.swift +10 -4
  5. package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/CLI.swift +45 -2
  6. package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +120 -0
  7. package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +183 -0
  8. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ExportedSurface.swift +3 -2
  9. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ResolveRefs.swift +9 -0
  10. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/SurfaceVisitor.swift +10 -3
  11. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/TupleTypeAliases.swift +126 -0
  12. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/TypeNode.swift +47 -4
  13. package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/SourceScan.swift +8 -2
  14. package/apple/Sources/ExpoModulesMacros/Expand/MacroExpander.swift +484 -0
  15. package/apple/Sources/ExpoModulesMacros/{EventMacro.swift → Macros/EventMacro.swift} +23 -10
  16. package/apple/Sources/ExpoModulesMacros/{ExpoModuleMacro.swift → Macros/ExpoModuleMacro.swift} +13 -1
  17. package/apple/Sources/ExpoModulesMacros/{JSMacro.swift → Macros/JSMacro.swift} +71 -54
  18. package/apple/Sources/ExpoModulesMacros/{RecordMacro.swift → Macros/RecordMacro.swift} +18 -5
  19. package/apple/Sources/ExpoModulesMacros/{SharedObjectMacro.swift → Macros/SharedObjectMacro.swift} +13 -1
  20. package/apple/Sources/ExpoModulesMacros/{UnionMacro.swift → Macros/UnionMacro.swift} +16 -5
  21. package/apple/Sources/ExpoModulesMacros/Plugin.swift +9 -14
  22. package/apple/Sources/ExpoModulesMacros/ProvidedMacros.swift +31 -0
  23. package/apple/Sources/ExpoModulesMacros/{ClosureArguments.swift → Support/ClosureArguments.swift} +30 -4
  24. package/apple/Sources/ExpoModulesMacros/{DecorateModuleBuilder.swift → Support/DecorateModuleBuilder.swift} +5 -1
  25. package/apple/Sources/ExpoModulesMacros/{MacroHelpers.swift → Support/MacroHelpers.swift} +11 -4
  26. package/apple/Sources/ExpoModulesMacros/Support/TupleTypes.swift +272 -0
  27. package/apple/Sources/ExpoModulesMacros/Support/TypeAliases.swift +75 -0
  28. package/apple/Sources/ExpoModulesMacros/{TypeConformanceAssertion.swift → Support/TypeConformanceAssertion.swift} +104 -41
  29. package/build/index.d.ts +31 -1
  30. package/build/index.js +34 -0
  31. package/build/types.d.ts +77 -2
  32. package/build/types.js +4 -3
  33. package/package.json +5 -5
  34. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/Availability.swift +0 -0
  35. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ScanExports.swift +0 -0
  36. /package/apple/Sources/{ExpoModulesScanner/Modules → ExpoModulesCLI/Commands/ScanModules}/ScanModules.swift +0 -0
  37. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/Detection.swift +0 -0
  38. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/DetectionVisitor.swift +0 -0
  39. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/ScanBuildConfiguration.swift +0 -0
  40. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/StandardStreams.swift +0 -0
  41. /package/apple/Sources/ExpoModulesMacros/{ExpoModulesOptimizedMacro.swift → Macros/ExpoModulesOptimizedMacro.swift} +0 -0
  42. /package/apple/Sources/ExpoModulesMacros/{ExpoViewMacro.swift → Macros/ExpoViewMacro.swift} +0 -0
  43. /package/apple/Sources/ExpoModulesMacros/{ViewPropsMacro.swift → Macros/ViewPropsMacro.swift} +0 -0
  44. /package/apple/Sources/ExpoModulesMacros/{FreeFormTypes.swift → Support/FreeFormTypes.swift} +0 -0
  45. /package/apple/Sources/ExpoModulesMacros/{JSConstructor.swift → Support/JSConstructor.swift} +0 -0
  46. /package/apple/Sources/ExpoModulesMacros/{JSNameCollisions.swift → Support/JSNameCollisions.swift} +0 -0
  47. /package/apple/Sources/ExpoModulesMacros/{OptimizedFunctionHelpers.swift → Support/OptimizedFunctionHelpers.swift} +0 -0
  48. /package/apple/Sources/ExpoModulesMacros/{Receiver.swift → Support/Receiver.swift} +0 -0
@@ -0,0 +1,484 @@
1
+ import ExpoModulesCLI
2
+ import SwiftBasicFormat
3
+ import SwiftDiagnostics
4
+ import SwiftParser
5
+ import SwiftParserDiagnostics
6
+ import SwiftSyntax
7
+ import SwiftSyntaxMacroExpansion
8
+ import SwiftSyntaxMacros
9
+
10
+ /// Expands the macros of the plugin in one source file in process, with the same swift-syntax
11
+ /// machinery as `assertMacroExpansion`. The macros only read the syntax tree, so the generated code
12
+ /// is what the compiler gets, except for the names from `makeUniqueName`. The compiler also skips the
13
+ /// extension conformances that a type already has; the macros check the inheritance clause for that
14
+ /// too, so the result differs only when a conformance is inherited from somewhere else.
15
+ ///
16
+ /// Without `symbols`, it returns one expansion of the whole file. Otherwise it returns one expansion
17
+ /// for each symbol declared in the file, and only the diagnostics inside those declarations:
18
+ ///
19
+ /// - A type (`MyModule`, `MyModule.Options`): the expanded type and the extensions that its macros
20
+ /// add.
21
+ /// - A member (`MyModule.greet`, `MyModule.init`): the expanded type that declares it, with only the
22
+ /// declarations of that name (all overloads). The member macros of the type then generate only the
23
+ /// code for that member. A macro that reads other members (for example to find a name collision)
24
+ /// can generate a little differently than for the whole type.
25
+ ///
26
+ /// A symbol that the file doesn't declare is left out of the expansions.
27
+ public func expandMacros(source: String, file: String, symbols: [String]) -> ExpandedFile {
28
+ let tree = Parser.parse(source: source)
29
+ let converter = SourceLocationConverter(fileName: file, tree: tree)
30
+ let (expanded, context) = expand(tree, file: file)
31
+
32
+ var diagnostics = ParseDiagnosticsGenerator.diagnostics(for: tree).map { diagnostic in
33
+ expansionDiagnostic(diagnostic) { position, _ in converter.location(for: position) }
34
+ }
35
+ let originalDeclarations = OriginalDeclarations(tree)
36
+ diagnostics += context.diagnostics.map { diagnostic in
37
+ expansionDiagnostic(diagnostic) { position, node in
38
+ let location = context.location(for: position, anchoredAt: node, fileName: file)
39
+ if let original = originalDeclarations.position(of: position, in: node, shiftedOffset: location.offset) {
40
+ return converter.location(for: original)
41
+ }
42
+ return location
43
+ }
44
+ }
45
+ diagnostics.sort { ($0.line, $0.column) < ($1.line, $1.column) }
46
+
47
+ guard !symbols.isEmpty else {
48
+ return ExpandedFile(
49
+ file: file,
50
+ expansions: [Expansion(symbol: nil, source: expanded.description)],
51
+ diagnostics: diagnostics
52
+ )
53
+ }
54
+
55
+ let declarations = SymbolIndex(tree).declarations
56
+ var expansions: [Expansion] = []
57
+ var lineRanges: [ClosedRange<Int>] = []
58
+ for symbol in symbols {
59
+ let types = declarations.filter { $0.path == symbol && $0.kind == .type }
60
+ let members = declarations.filter { $0.path == symbol && $0.kind == .member }
61
+
62
+ if !types.isEmpty {
63
+ let source = render(typePath: symbol, in: expanded, original: tree) { index in
64
+ index.filter { $0.path == symbol && $0.kind == .type }.map(\.node)
65
+ }
66
+ expansions.append(Expansion(symbol: symbol, source: source))
67
+ lineRanges += types.map { lineRange(of: $0.node, converter: converter) }
68
+ } else if !members.isEmpty {
69
+ let pruned = MemberPruner(
70
+ parents: Set(members.compactMap(\.parent?.id)),
71
+ kept: Set(members.map(\.node.id))
72
+ )
73
+ .rewrite(tree)
74
+ guard let prunedTree = pruned.as(SourceFileSyntax.self) else {
75
+ continue
76
+ }
77
+ let parentPath = String(symbol[..<(symbol.lastIndex(of: ".") ?? symbol.startIndex)])
78
+ let (prunedExpansion, _) = expand(prunedTree, file: file)
79
+ // Only the types or extensions that declare the member: another extension of the same type
80
+ // isn't pruned.
81
+ let source = render(typePath: parentPath, in: prunedExpansion, original: prunedTree) { index in
82
+ let parents = Set(index.filter { $0.path == symbol && $0.kind == .member }.compactMap(\.parent?.id))
83
+ return index.filter { parents.contains($0.node.id) }.map(\.node)
84
+ }
85
+ expansions.append(Expansion(symbol: symbol, source: source))
86
+ lineRanges += members.map { lineRange(of: $0.node, converter: converter) }
87
+ }
88
+ }
89
+
90
+ return ExpandedFile(
91
+ file: file,
92
+ expansions: expansions,
93
+ diagnostics: diagnostics.filter { diagnostic in
94
+ lineRanges.contains { $0.contains(diagnostic.line) }
95
+ }
96
+ )
97
+ }
98
+
99
+ /// Expands every macro of the plugin in `tree`.
100
+ private func expand(
101
+ _ tree: SourceFileSyntax,
102
+ file: String
103
+ ) -> (expanded: Syntax, context: BasicMacroExpansionContext) {
104
+ let specs = Dictionary(
105
+ uniqueKeysWithValues: ProvidedMacro.all.map {
106
+ ($0.name, MacroSpec(type: $0.type, conformances: $0.conformances))
107
+ }
108
+ )
109
+ let context = BasicMacroExpansionContext(
110
+ sourceFiles: [tree: .init(moduleName: "ExpandedModule", fullFilePath: file)]
111
+ )
112
+ let expanded = tree.expand(
113
+ macroSpecs: specs,
114
+ contextGenerator: { node in
115
+ BasicMacroExpansionContext(sharingWith: context, lexicalContext: node.allMacroLexicalContexts())
116
+ },
117
+ // The generated code follows the indentation of the file.
118
+ indentationWidth: BasicFormat.inferIndentation(of: tree) ?? .spaces(2)
119
+ )
120
+ return (expanded, context)
121
+ }
122
+
123
+ /// The expanded declarations that `select` picks from the symbols of `expanded`, each without the
124
+ /// indentation of its nesting, followed by the extensions that the extension macros add for the type
125
+ /// at `typePath`.
126
+ private func render(
127
+ typePath: String,
128
+ in expanded: Syntax,
129
+ original: SourceFileSyntax,
130
+ select: ([SymbolDeclaration]) -> [Syntax]
131
+ ) -> String {
132
+ let declarations = select(SymbolIndex(expanded).declarations)
133
+ // The macro system adds the extensions after the expanded statements of the file, with the type
134
+ // name as the macro got it (qualified for a nested type). So they are the last statements, after
135
+ // the extensions that already end the file.
136
+ let statements = expanded.as(SourceFileSyntax.self)?.statements ?? []
137
+ let addedCount =
138
+ trailingExtensions(of: statements).count - trailingExtensions(of: original.statements).count
139
+ let addedExtensions = trailingExtensions(of: statements).suffix(max(addedCount, 0)).filter {
140
+ $0.extendedType.trimmedDescription == typePath
141
+ }
142
+ let nodes = declarations + addedExtensions.map(Syntax.init)
143
+ return nodes.map(withoutNestingIndentation).joined(separator: "\n\n") + "\n"
144
+ }
145
+
146
+ /// The extensions at the end of `statements`, up to the last statement that isn't one.
147
+ private func trailingExtensions(of statements: CodeBlockItemListSyntax) -> [ExtensionDeclSyntax] {
148
+ var extensions: [ExtensionDeclSyntax] = []
149
+ for item in statements.reversed() {
150
+ guard let extensionDecl = item.item.as(ExtensionDeclSyntax.self) else {
151
+ break
152
+ }
153
+ extensions.insert(extensionDecl, at: 0)
154
+ }
155
+ return extensions
156
+ }
157
+
158
+ /// The source of `node` without its leading trivia, and with the indentation of its first line
159
+ /// removed from all the lines.
160
+ private func withoutNestingIndentation(_ node: Syntax) -> String {
161
+ var indentation = ""
162
+ for piece in node.leadingTrivia.pieces {
163
+ switch piece {
164
+ case .newlines, .carriageReturns, .carriageReturnLineFeeds:
165
+ indentation = ""
166
+ case .spaces(let count):
167
+ indentation += String(repeating: " ", count: count)
168
+ case .tabs(let count):
169
+ indentation += String(repeating: "\t", count: count)
170
+ default:
171
+ break
172
+ }
173
+ }
174
+ let lines = node.trimmedDescription.split(separator: "\n", omittingEmptySubsequences: false)
175
+ return lines.enumerated().map { index, line in
176
+ index > 0 && line.hasPrefix(indentation) ? String(line.dropFirst(indentation.count)) : String(line)
177
+ }
178
+ .joined(separator: "\n")
179
+ }
180
+
181
+ /// The lines of `node` in the original file, from its attributes to its closing brace.
182
+ private func lineRange(of node: Syntax, converter: SourceLocationConverter) -> ClosedRange<Int> {
183
+ let start = converter.location(for: node.positionAfterSkippingLeadingTrivia).line
184
+ let end = converter.location(for: node.endPositionBeforeTrailingTrivia).line
185
+ return start...end
186
+ }
187
+
188
+ private func expansionDiagnostic(
189
+ _ diagnostic: Diagnostic,
190
+ location: (AbsolutePosition, Syntax) -> SourceLocation
191
+ ) -> ExpansionDiagnostic {
192
+ let start = location(diagnostic.position, diagnostic.node)
193
+ return ExpansionDiagnostic(
194
+ severity: severity(diagnostic.diagMessage.severity),
195
+ message: diagnostic.message,
196
+ line: start.line,
197
+ column: start.column,
198
+ notes: diagnostic.notes.map { note in
199
+ let noteStart = location(note.position, note.node)
200
+ return ExpansionNote(message: note.message, line: noteStart.line, column: noteStart.column)
201
+ },
202
+ fixIts: diagnostic.fixIts.map(\.message.message)
203
+ )
204
+ }
205
+
206
+ private func severity(_ severity: DiagnosticSeverity) -> ExpansionDiagnostic.Severity {
207
+ switch severity {
208
+ case .error: .error
209
+ case .warning: .warning
210
+ case .note: .note
211
+ case .remark: .remark
212
+ }
213
+ }
214
+
215
+ // MARK: - Diagnostic locations
216
+
217
+ /// Maps a position in a declaration that a macro got to the same position in the original file.
218
+ ///
219
+ /// The macro system detaches the declaration that it passes to an attached macro and maps positions
220
+ /// in it back to the declaration it detached it from. But a peer macro gets the declaration with the
221
+ /// attributes that the member attribute macros of its type added, for example the `@JavaScriptActor`
222
+ /// that `@ExpoModule` adds to a `@JS` function, and that declaration isn't in the original file. So
223
+ /// every position after the added attributes is off by their length. A diagnostic in a parameter
224
+ /// type of a `@JS` function would be on the next line.
225
+ ///
226
+ /// The text from the keyword of the declaration (`func`, `var`, `init`) to its end is the same in
227
+ /// both, so this finds the original declaration by that text and moves the position by the offset of
228
+ /// the keyword. A position before the keyword, in the attributes or the modifiers, isn't mapped.
229
+ private struct OriginalDeclarations {
230
+ /// The positions of the keywords of the declarations in the original file, in source order, by the
231
+ /// text from the keyword to the end of the declaration.
232
+ private let keywordPositions: [String: [AbsolutePosition]]
233
+
234
+ init(_ tree: SourceFileSyntax) {
235
+ let collector = DeclarationCollector(viewMode: .sourceAccurate)
236
+ collector.walk(tree)
237
+ var keywordPositions: [String: [AbsolutePosition]] = [:]
238
+ for declaration in collector.declarations {
239
+ guard let keyword = keywordToken(of: declaration) else {
240
+ continue
241
+ }
242
+ keywordPositions[textFromKeyword(keyword, of: declaration), default: []].append(
243
+ keyword.positionAfterSkippingLeadingTrivia
244
+ )
245
+ }
246
+ self.keywordPositions = keywordPositions
247
+ }
248
+
249
+ /// The position in the original file of `position` in `node`, or `nil` when `node` is in the
250
+ /// original file, isn't in a detached declaration, or is before its keyword.
251
+ ///
252
+ /// `shiftedOffset` is the offset that the macro system maps `position` to. The added attributes
253
+ /// only move it forward, so when declarations with the same text are in the file, the one it is
254
+ /// in is the last one that starts at or before that offset.
255
+ func position(of position: AbsolutePosition, in node: Syntax, shiftedOffset: Int) -> AbsolutePosition? {
256
+ guard !node.root.is(SourceFileSyntax.self),
257
+ let declaration = node.root.as(DeclSyntax.self),
258
+ let keyword = keywordToken(of: declaration),
259
+ position >= keyword.positionAfterSkippingLeadingTrivia,
260
+ let candidates = keywordPositions[textFromKeyword(keyword, of: declaration)]
261
+ else {
262
+ return nil
263
+ }
264
+ let offset = position.utf8Offset - keyword.positionAfterSkippingLeadingTrivia.utf8Offset
265
+ let shiftedKeyword = shiftedOffset - offset
266
+ guard let originalKeyword = candidates.last(where: { $0.utf8Offset <= shiftedKeyword }) ?? candidates.first
267
+ else {
268
+ return nil
269
+ }
270
+ return AbsolutePosition(utf8Offset: originalKeyword.utf8Offset + offset)
271
+ }
272
+ }
273
+
274
+ /// The first token of `declaration` after its attributes and modifiers, such as `func` or `var`.
275
+ private func keywordToken(of declaration: DeclSyntax) -> TokenSyntax? {
276
+ for child in declaration.children(viewMode: .sourceAccurate)
277
+ where !child.is(AttributeListSyntax.self) && !child.is(DeclModifierListSyntax.self) {
278
+ if let token = child.firstToken(viewMode: .sourceAccurate) {
279
+ return token
280
+ }
281
+ }
282
+ return nil
283
+ }
284
+
285
+ /// The source text of `declaration` from `keyword` to its end, without the trailing trivia.
286
+ private func textFromKeyword(_ keyword: TokenSyntax, of declaration: DeclSyntax) -> String {
287
+ let text = Array(declaration.description.utf8)
288
+ let start = keyword.positionAfterSkippingLeadingTrivia.utf8Offset - declaration.position.utf8Offset
289
+ let end = declaration.endPositionBeforeTrailingTrivia.utf8Offset - declaration.position.utf8Offset
290
+ return String(decoding: text[start..<end], as: UTF8.self)
291
+ }
292
+
293
+ /// Collects every declaration in a tree.
294
+ private final class DeclarationCollector: SyntaxAnyVisitor {
295
+ private(set) var declarations: [DeclSyntax] = []
296
+
297
+ override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind {
298
+ if let declaration = node.as(DeclSyntax.self) {
299
+ declarations.append(declaration)
300
+ }
301
+ return .visitChildren
302
+ }
303
+ }
304
+
305
+ // MARK: - Symbols
306
+
307
+ /// A declaration that `--symbol` can name.
308
+ private struct SymbolDeclaration {
309
+ enum Kind {
310
+ case type
311
+ case `extension`
312
+ case member
313
+ }
314
+
315
+ /// The names from the outermost type to the declaration, joined with dots: `MyModule.greet`. An
316
+ /// extension has the path of the type it extends.
317
+ let path: String
318
+ let kind: Kind
319
+ let node: Syntax
320
+
321
+ /// For a member, the type or extension that declares it.
322
+ let parent: Syntax?
323
+ }
324
+
325
+ /// Collects the types, extensions and members in a tree, also those inside `#if` blocks. Function
326
+ /// bodies are skipped, so a local type isn't a symbol.
327
+ private final class SymbolIndex: SyntaxVisitor {
328
+ private(set) var declarations: [SymbolDeclaration] = []
329
+ private var scopes: [(path: String, node: Syntax)] = []
330
+
331
+ init(_ tree: some SyntaxProtocol) {
332
+ super.init(viewMode: .sourceAccurate)
333
+ walk(tree)
334
+ }
335
+
336
+ override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
337
+ enterType(node, name: node.name)
338
+ }
339
+
340
+ override func visitPost(_ node: ClassDeclSyntax) {
341
+ scopes.removeLast()
342
+ }
343
+
344
+ override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
345
+ enterType(node, name: node.name)
346
+ }
347
+
348
+ override func visitPost(_ node: StructDeclSyntax) {
349
+ scopes.removeLast()
350
+ }
351
+
352
+ override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
353
+ enterType(node, name: node.name)
354
+ }
355
+
356
+ override func visitPost(_ node: EnumDeclSyntax) {
357
+ scopes.removeLast()
358
+ }
359
+
360
+ override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind {
361
+ enterType(node, name: node.name)
362
+ }
363
+
364
+ override func visitPost(_ node: ActorDeclSyntax) {
365
+ scopes.removeLast()
366
+ }
367
+
368
+ override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind {
369
+ enterType(node, name: node.name)
370
+ }
371
+
372
+ override func visitPost(_ node: ProtocolDeclSyntax) {
373
+ scopes.removeLast()
374
+ }
375
+
376
+ override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
377
+ let path = qualified(node.extendedType.trimmedDescription)
378
+ declarations.append(SymbolDeclaration(path: path, kind: .extension, node: Syntax(node), parent: nil))
379
+ scopes.append((path, Syntax(node)))
380
+ return .visitChildren
381
+ }
382
+
383
+ override func visitPost(_ node: ExtensionDeclSyntax) {
384
+ scopes.removeLast()
385
+ }
386
+
387
+ override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind {
388
+ addMember(node, names: [node.name.text])
389
+ }
390
+
391
+ override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind {
392
+ addMember(
393
+ node,
394
+ names: node.bindings.compactMap { $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.text }
395
+ )
396
+ }
397
+
398
+ override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind {
399
+ addMember(node, names: ["init"])
400
+ }
401
+
402
+ override func visit(_ node: SubscriptDeclSyntax) -> SyntaxVisitorContinueKind {
403
+ addMember(node, names: ["subscript"])
404
+ }
405
+
406
+ override func visit(_ node: EnumCaseDeclSyntax) -> SyntaxVisitorContinueKind {
407
+ addMember(node, names: node.elements.map(\.name.text))
408
+ }
409
+
410
+ private func enterType(_ node: some DeclSyntaxProtocol, name: TokenSyntax) -> SyntaxVisitorContinueKind {
411
+ let path = qualified(name.text)
412
+ declarations.append(SymbolDeclaration(path: path, kind: .type, node: Syntax(node), parent: nil))
413
+ scopes.append((path, Syntax(node)))
414
+ return .visitChildren
415
+ }
416
+
417
+ /// Records a member of the innermost type. Doesn't visit the children: a declaration in a body or
418
+ /// an accessor isn't a member.
419
+ private func addMember(_ node: some DeclSyntaxProtocol, names: [String]) -> SyntaxVisitorContinueKind {
420
+ if let scope = scopes.last {
421
+ for name in names.map(withoutBackticks) {
422
+ declarations.append(
423
+ SymbolDeclaration(path: "\(scope.path).\(name)", kind: .member, node: Syntax(node), parent: scope.node)
424
+ )
425
+ }
426
+ }
427
+ return .skipChildren
428
+ }
429
+
430
+ /// `name` inside the innermost type, without backticks: `` `default` `` is `default`.
431
+ private func qualified(_ name: String) -> String {
432
+ let name = withoutBackticks(name)
433
+ return scopes.last.map { "\($0.path).\(name)" } ?? name
434
+ }
435
+ }
436
+
437
+ /// Removes from the member blocks of `parents` every member that isn't in `kept`, except the type
438
+ /// aliases. An `#if` block stays with the kept members inside it.
439
+ private final class MemberPruner: SyntaxRewriter {
440
+ private let parents: Set<SyntaxIdentifier>
441
+ private let kept: Set<SyntaxIdentifier>
442
+
443
+ init(parents: Set<SyntaxIdentifier>, kept: Set<SyntaxIdentifier>) {
444
+ self.parents = parents
445
+ self.kept = kept
446
+ super.init(viewMode: .sourceAccurate)
447
+ }
448
+
449
+ override func visit(_ node: MemberBlockSyntax) -> MemberBlockSyntax {
450
+ guard let parent = node.parent, parents.contains(parent.id) else {
451
+ return super.visit(node)
452
+ }
453
+ return node.with(\.members, prune(node.members))
454
+ }
455
+
456
+ private func prune(_ members: MemberBlockItemListSyntax) -> MemberBlockItemListSyntax {
457
+ MemberBlockItemListSyntax(
458
+ members.compactMap { item -> MemberBlockItemSyntax? in
459
+ // The macros resolve the type aliases of the type in the types of its members.
460
+ if kept.contains(item.decl.id) || item.decl.is(TypeAliasDeclSyntax.self) {
461
+ return item
462
+ }
463
+ guard let ifConfig = item.decl.as(IfConfigDeclSyntax.self) else {
464
+ return nil
465
+ }
466
+ let clauses = ifConfig.clauses.map { clause -> IfConfigClauseSyntax in
467
+ guard case .decls(let decls) = clause.elements else {
468
+ return clause
469
+ }
470
+ return clause.with(\.elements, .decls(prune(decls)))
471
+ }
472
+ let keepsMember = clauses.contains { clause in
473
+ guard case .decls(let decls) = clause.elements else {
474
+ return false
475
+ }
476
+ // A clause with only type aliases left keeps no member.
477
+ return decls.contains { !$0.decl.is(TypeAliasDeclSyntax.self) }
478
+ }
479
+ return keepsMember
480
+ ? item.with(\.decl, DeclSyntax(ifConfig.with(\.clauses, IfConfigClauseListSyntax(clauses)))) : nil
481
+ }
482
+ )
483
+ }
484
+ }
@@ -53,11 +53,19 @@ public struct EventMacro: AccessorMacro {
53
53
  // The closure's parameter and return types are inferred from the property's declared type
54
54
  // through the getter, so the body never has to spell the payload type. A sync event calls
55
55
  // `emitSync` (inline dispatch, JS thread only); the default calls the scheduling `emit`.
56
+ // A payload that holds a tuple can't conform to the payload protocols, so it's passed with a
57
+ // closure that encodes it.
56
58
  let emitMethod = event.isSync ? "emitSync" : "emit"
57
- let closure =
58
- event.hasPayload
59
- ? "{ [weak self] payload in self?.\(emitMethod)(event: \"\(event.jsName)\", payload: payload) }"
60
- : "{ [weak self] in self?.\(emitMethod)(event: \"\(event.jsName)\") }"
59
+ let closure: String
60
+ if let tuple = event.payloadType.flatMap({ tupleConversion(of: $0) }) {
61
+ let encode = encoderClosure(encoding: tuple.encodeCall(of: "value", into: "slot"))
62
+ closure =
63
+ "{ [weak self] payload in self?.\(emitMethod)(event: \"\(event.jsName)\", payload: payload, encode: \(encode)) }"
64
+ } else if event.hasPayload {
65
+ closure = "{ [weak self] payload in self?.\(emitMethod)(event: \"\(event.jsName)\", payload: payload) }"
66
+ } else {
67
+ closure = "{ [weak self] in self?.\(emitMethod)(event: \"\(event.jsName)\") }"
68
+ }
61
69
  return [
62
70
  """
63
71
  get {
@@ -85,15 +93,20 @@ extension EventMacro: PeerMacro {
85
93
  // through `self.emit`). Named after the member so the member shows up in either diagnostic,
86
94
  // and asserting the enclosing type by its spelled name so the conformance error names the
87
95
  // user's type rather than 'Self'.
96
+ //
97
+ // A payload that holds a tuple is encoded element by element (see `TupleConversion`), so the types
98
+ // in it are asserted `JavaScriptEncodable`, which the encode closure needs, in its place.
88
99
  let name = event.swiftName
89
- let payload = event.payloadType.flatMap { assertableBoundaryType($0, constraint: jsConvertibleProtocolName) }
100
+ let tupleElementTypes = event.payloadType.flatMap { tupleConversion(of: $0)?.conformingTypes }
101
+ let payloadConstraint = tupleElementTypes == nil ? jsConvertibleProtocolName : javaScriptEncodableProtocolName
102
+ let payloads =
103
+ tupleElementTypes.map { distinctAssertableTypes($0, constraint: payloadConstraint) }
104
+ ?? event.payloadType.flatMap { assertableBoundaryType($0, constraint: payloadConstraint) }.map { [$0] } ?? []
90
105
  let owner = enclosingTypeName(in: context) ?? "Self"
91
106
  let emitterParameter = opaqueMetatypeParameter(constraint: eventEmitterProtocolName)
92
- let helper =
93
- payload != nil
94
- ? "func \(name)(\(opaqueMetatypeParameter(constraint: jsConvertibleProtocolName)), \(emitterParameter)) {}"
95
- : "func \(name)(\(emitterParameter)) {}"
96
- let call = payload.map { "\(name)(\($0).self, \(owner).self)" } ?? "\(name)(\(owner).self)"
107
+ let payloadParameters = payloads.map { _ in opaqueMetatypeParameter(constraint: payloadConstraint) }
108
+ let helper = "func \(name)(\((payloadParameters + [emitterParameter]).joined(separator: ", "))) {}"
109
+ let call = "\(name)(\((payloads + [owner]).map { "\($0).self" }.joined(separator: ", ")))"
97
110
  let peerName = derivedIdentifier(from: name, prefix: "_assertTypesConformance_")
98
111
  return [
99
112
  """
@@ -49,9 +49,15 @@ public struct ExpoModuleMacro: MemberMacro {
49
49
  // DSL entry.
50
50
  var functions: [JSFunction] = []
51
51
  var properties: [JSProperty] = []
52
+ var assertions: [MemberConformanceAssertion] = []
53
+ let aliases = TupleTypeAliases(in: classDecl.memberBlock)
52
54
 
53
55
  for member in classDecl.memberBlock.members {
54
- let decl = member.decl
56
+ // The types of a `@JS` member are read with the class's tuple aliases resolved.
57
+ let decl = aliases.resolving(member.decl)
58
+ if memberHasJSAttribute(decl), let assertion = jsConformanceAssertionBody(for: decl) {
59
+ assertions.append(assertion)
60
+ }
55
61
 
56
62
  if let funcDecl = decl.as(FunctionDeclSyntax.self),
57
63
  let attribute = funcDecl.attributes.firstAttribute(named: "JS")
@@ -126,6 +132,12 @@ public struct ExpoModuleMacro: MemberMacro {
126
132
  emitted.append(buildDecorateJavaScriptObject(functions: functions, properties: properties))
127
133
  }
128
134
 
135
+ // The conformance assertions of the `@JS` members, which `@JS` leaves to this macro (see
136
+ // `jsConformanceAssertionBody`).
137
+ if let assertion = classConformanceAssertions(assertions) {
138
+ emitted.append(assertion)
139
+ }
140
+
129
141
  return emitted
130
142
  }
131
143
  }