expo-modules-macros 0.26.0 → 0.27.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.
Files changed (48) hide show
  1. package/AGENTS.md +8 -8
  2. package/README.md +37 -2
  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 → ExpoModulesCLI}/Core/SourceScan.swift +8 -2
  9. package/apple/Sources/ExpoModulesMacros/Expand/MacroExpander.swift +484 -0
  10. package/apple/Sources/ExpoModulesMacros/{EventMacro.swift → Macros/EventMacro.swift} +1 -1
  11. package/apple/Sources/ExpoModulesMacros/{RecordMacro.swift → Macros/RecordMacro.swift} +4 -2
  12. package/apple/Sources/ExpoModulesMacros/{UnionMacro.swift → Macros/UnionMacro.swift} +2 -2
  13. package/apple/Sources/ExpoModulesMacros/Plugin.swift +9 -14
  14. package/apple/Sources/ExpoModulesMacros/ProvidedMacros.swift +31 -0
  15. package/apple/Sources/ExpoModulesMacros/{ClosureArguments.swift → Support/ClosureArguments.swift} +4 -2
  16. package/apple/Sources/ExpoModulesMacros/{DecorateModuleBuilder.swift → Support/DecorateModuleBuilder.swift} +14 -6
  17. package/apple/Sources/ExpoModulesMacros/{TupleTypes.swift → Support/TupleTypes.swift} +50 -12
  18. package/build/index.d.ts +31 -1
  19. package/build/index.js +34 -0
  20. package/build/types.d.ts +45 -1
  21. package/build/types.js +3 -2
  22. package/package.json +5 -5
  23. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/Availability.swift +0 -0
  24. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ExportedSurface.swift +0 -0
  25. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ResolveRefs.swift +0 -0
  26. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ScanExports.swift +0 -0
  27. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/SurfaceVisitor.swift +0 -0
  28. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/TupleTypeAliases.swift +0 -0
  29. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/TypeNode.swift +0 -0
  30. /package/apple/Sources/{ExpoModulesScanner/Modules → ExpoModulesCLI/Commands/ScanModules}/ScanModules.swift +0 -0
  31. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/Detection.swift +0 -0
  32. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/DetectionVisitor.swift +0 -0
  33. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/ScanBuildConfiguration.swift +0 -0
  34. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/StandardStreams.swift +0 -0
  35. /package/apple/Sources/ExpoModulesMacros/{ExpoModuleMacro.swift → Macros/ExpoModuleMacro.swift} +0 -0
  36. /package/apple/Sources/ExpoModulesMacros/{ExpoModulesOptimizedMacro.swift → Macros/ExpoModulesOptimizedMacro.swift} +0 -0
  37. /package/apple/Sources/ExpoModulesMacros/{ExpoViewMacro.swift → Macros/ExpoViewMacro.swift} +0 -0
  38. /package/apple/Sources/ExpoModulesMacros/{JSMacro.swift → Macros/JSMacro.swift} +0 -0
  39. /package/apple/Sources/ExpoModulesMacros/{SharedObjectMacro.swift → Macros/SharedObjectMacro.swift} +0 -0
  40. /package/apple/Sources/ExpoModulesMacros/{ViewPropsMacro.swift → Macros/ViewPropsMacro.swift} +0 -0
  41. /package/apple/Sources/ExpoModulesMacros/{FreeFormTypes.swift → Support/FreeFormTypes.swift} +0 -0
  42. /package/apple/Sources/ExpoModulesMacros/{JSConstructor.swift → Support/JSConstructor.swift} +0 -0
  43. /package/apple/Sources/ExpoModulesMacros/{JSNameCollisions.swift → Support/JSNameCollisions.swift} +0 -0
  44. /package/apple/Sources/ExpoModulesMacros/{MacroHelpers.swift → Support/MacroHelpers.swift} +0 -0
  45. /package/apple/Sources/ExpoModulesMacros/{OptimizedFunctionHelpers.swift → Support/OptimizedFunctionHelpers.swift} +0 -0
  46. /package/apple/Sources/ExpoModulesMacros/{Receiver.swift → Support/Receiver.swift} +0 -0
  47. /package/apple/Sources/ExpoModulesMacros/{TypeAliases.swift → Support/TypeAliases.swift} +0 -0
  48. /package/apple/Sources/ExpoModulesMacros/{TypeConformanceAssertion.swift → Support/TypeConformanceAssertion.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
+ }
@@ -58,7 +58,7 @@ public struct EventMacro: AccessorMacro {
58
58
  let emitMethod = event.isSync ? "emitSync" : "emit"
59
59
  let closure: String
60
60
  if let tuple = event.payloadType.flatMap({ tupleConversion(of: $0) }) {
61
- let encode = elementClosure(returning: tuple.encodeExpression(of: "value"))
61
+ let encode = encoderClosure(encoding: tuple.encodeCall(of: "value", into: "slot"))
62
62
  closure =
63
63
  "{ [weak self] payload in self?.\(emitMethod)(event: \"\(event.jsName)\", payload: payload, encode: \(encode)) }"
64
64
  } else if event.hasPayload {
@@ -551,9 +551,11 @@ private func objectWriteLines(properties: [RecordProperty], of record: String, c
551
551
  encode = "try JavaScriptValue.encodeAny(\(value) as Any, in: runtime)"
552
552
  case nil:
553
553
  let propName = propNameArgument(for: property, at: index, cached: cachesPropNames)
554
- // A type that holds a tuple has no `encode(_:into:in:)`, so its encoded value is written instead.
554
+ // A type that holds a tuple has no `encode(_:into:in:)`, so the tuple methods encode it into the
555
+ // property's slot.
555
556
  if let tuple = tupleConversion(of: property.type) {
556
- return " object.setProperty(\(propName), value: try \(tuple.encodeExpression(of: value)))"
557
+ return
558
+ " try object.setProperty(\(propName)) { slot, runtime in try \(tuple.encodeCall(of: value, into: "slot")) }"
557
559
  }
558
560
  return " try object.setProperty(\(propName), encoding: \(value))"
559
561
  }
@@ -332,8 +332,8 @@ private func encodeIntoSlotMethod(union: UnionType) -> DeclSyntax {
332
332
  for unionCase in union.cases {
333
333
  lines.append(" case \(unionCase.pattern):")
334
334
  if let tuple = tupleConversion(of: unionCase.payloadType) {
335
- // A payload that holds a tuple has no `encode(_:into:in:)`, so its encoded value is moved in.
336
- lines.append(" slot.emplace(try \(tuple.encodeExpression(of: "payload")))")
335
+ // A payload that holds a tuple has no `encode(_:into:in:)`, so the tuple methods encode it into the slot.
336
+ lines.append(" try \(tuple.encodeCall(of: "payload", into: "slot"))")
337
337
  } else {
338
338
  lines.append(" try \(expressionType(unionCase.payloadType)).encode(payload, into: slot, in: runtime)")
339
339
  }
@@ -1,4 +1,4 @@
1
- import ExpoModulesScanner
1
+ import ExpoModulesCLI
2
2
  import SwiftCompilerPlugin
3
3
  import SwiftSyntaxMacros
4
4
 
@@ -11,23 +11,14 @@ import CRT
11
11
  #endif
12
12
 
13
13
  struct ExpoModulesMacrosPlugin: CompilerPlugin {
14
- let providingMacros: [Macro.Type] = [
15
- OptimizedFunctionAttachedMacro.self,
16
- JSMacro.self,
17
- EventMacro.self,
18
- ExpoModuleMacro.self,
19
- SharedObjectMacro.self,
20
- RecordMacro.self,
21
- UnionMacro.self,
22
- ViewPropsMacro.self,
23
- ExpoViewMacro.self,
24
- ]
14
+ let providingMacros: [Macro.Type] = ProvidedMacro.all.map(\.type)
25
15
  }
26
16
 
27
17
  /// The executable doubles as the scanner CLI. The compiler always launches a plugin executable
28
18
  /// without arguments and speaks the plugin protocol over stdin, so any argument means a scanner
29
19
  /// invocation (`ExpoModulesMacros scan-modules <path>...`); with none, this starts the plugin
30
- /// server exactly as `@main` on the `CompilerPlugin` type would.
20
+ /// server exactly as `@main` on the `CompilerPlugin` type would. The CLI gets `expandMacros`, so
21
+ /// `expand` runs the macros of this executable.
31
22
  @main
32
23
  enum EntryPoint {
33
24
  static func main() throws {
@@ -35,7 +26,11 @@ enum EntryPoint {
35
26
  if arguments.isEmpty {
36
27
  try ExpoModulesMacrosPlugin.main()
37
28
  } else {
38
- exit(ScannerCLI.run(arguments: arguments))
29
+ let expander = MacroExpander(
30
+ attributeNames: Set(ProvidedMacro.all.map(\.name)),
31
+ expand: expandMacros
32
+ )
33
+ exit(ScannerCLI.run(arguments: arguments, expander: expander))
39
34
  }
40
35
  }
41
36
  }
@@ -0,0 +1,31 @@
1
+ import SwiftSyntax
2
+ import SwiftSyntaxMacros
3
+
4
+ /// A macro that the plugin provides, with the attribute name and the extension conformances of its
5
+ /// declaration in `expo-modules-core`.
6
+ struct ProvidedMacro {
7
+ let name: String
8
+ let type: Macro.Type
9
+ var conformances: [TypeSyntax] = []
10
+
11
+ /// Every macro of the plugin. The compiler gets the types from `providingMacros`, and `expand`
12
+ /// matches the attributes by name and passes the same conformances as the compiler, so both read
13
+ /// this list.
14
+ static var all: [ProvidedMacro] {
15
+ [
16
+ ProvidedMacro(name: "OptimizedFunction", type: OptimizedFunctionAttachedMacro.self),
17
+ ProvidedMacro(name: "JS", type: JSMacro.self),
18
+ ProvidedMacro(name: "Event", type: EventMacro.self),
19
+ ProvidedMacro(name: "ExpoModule", type: ExpoModuleMacro.self, conformances: ["AnyModule"]),
20
+ ProvidedMacro(name: "SharedObject", type: SharedObjectMacro.self),
21
+ ProvidedMacro(name: "Record", type: RecordMacro.self, conformances: ["Record"]),
22
+ ProvidedMacro(
23
+ name: "Union",
24
+ type: UnionMacro.self,
25
+ conformances: ["JavaScriptDecodable", "JavaScriptEncodable"]
26
+ ),
27
+ ProvidedMacro(name: "ViewProps", type: ViewPropsMacro.self, conformances: ["AnyViewProps"]),
28
+ ProvidedMacro(name: "ExpoView", type: ExpoViewMacro.self),
29
+ ]
30
+ }
31
+ }
@@ -177,9 +177,11 @@ private func closureLiteralLines(_ closure: JSClosureType, callback: String) ->
177
177
  var lines = [header, " \(call) { _values, _arguments, _runtime in"]
178
178
  for (index, parameter) in closure.parameters.enumerated() {
179
179
  let value = parameters.count == 1 ? "_values" : "_values.\(index)"
180
- // A parameter that holds a tuple has no `encode(_:into:in:)`, so its encoded value is moved in.
180
+ // A parameter that holds a tuple has no `encode(_:into:in:)`, so the tuple methods encode it into the
181
+ // argument's slot.
181
182
  if let tuple = tupleConversion(of: parameter.type) {
182
- lines.append(" _arguments.emplace(try \(tuple.encodeExpression(of: value, in: "_runtime")), at: \(index))")
183
+ let encode = tuple.encodeCall(of: value, into: "slot", in: "_runtime")
184
+ lines.append(" try _arguments.emplace(at: \(index)) { slot in try \(encode) }")
183
185
  } else {
184
186
  lines.append(" try _arguments.emplace(\(value), at: \(index), in: _runtime)")
185
187
  }
@@ -334,13 +334,18 @@ internal struct JSFunction {
334
334
  // The parameter list is parenthesized and fully typed, since Swift rejects a type annotation on
335
335
  // a shorthand `{ [capture] name, name in }` parameter.
336
336
  // A sync function binds through the result-slot overload (`JavaScriptRuntime.HostFunctionClosure`):
337
- // it encodes its result into the slot and gets the runtime as a parameter. A function that returns
338
- // nothing writes nothing, so JS gets `undefined` without a value being built.
337
+ // it encodes its result into the slot and gets the runtime as a parameter. A sync function that
338
+ // returns nothing binds through the `Void` overload (`JavaScriptRuntime.VoidFunctionClosure`)
339
+ // instead, which leaves JS's result as `undefined`. A call through it is faster than through a
340
+ // result-slot closure that writes nothing. The closure declares `-> Void` because a body of several
341
+ // statements without a `return` is otherwise ambiguous between the overloads.
339
342
  let captures = receiver.captureClause
340
343
  let parameters =
341
344
  isAsync
342
345
  ? "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
343
- : resultSlotClosureParameters
346
+ : returnType == nil
347
+ ? "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) -> Void"
348
+ : resultSlotClosureParameters
344
349
 
345
350
  return """
346
351
  \(object).setProperty("\(jsName)") { \(captures)\(parameters) in
@@ -499,14 +504,17 @@ private let resultSlotClosureParameters =
499
504
  + "result: borrowing JavaScriptValue.Slot, runtime: borrowing JavaScriptRuntime)"
500
505
 
501
506
  /// The statement that encodes `valueExpression` of a boundary type into the result slot `result`. A type
502
- /// that mentions `Any` and a tuple have no `encode(_:into:in:)`, so their encode result is moved into
503
- /// the slot.
507
+ /// that mentions `Any` has no `encode(_:into:in:)`, so its free-form encode result is moved into the slot,
508
+ /// and a type that holds a tuple is encoded into the slot by the tuple methods.
504
509
  private func encodeIntoSlotStatement(_ type: String, freeForm: FreeFormConversion?, of valueExpression: String)
505
510
  -> String
506
511
  {
507
- if freeForm != nil || tupleConversion(of: type) != nil {
512
+ if freeForm != nil {
508
513
  return "result.emplace(try \(encodeCall(type, freeForm: freeForm, of: valueExpression)))"
509
514
  }
515
+ if let tuple = tupleConversion(of: type) {
516
+ return "try \(tuple.encodeCall(of: valueExpression, into: "result"))"
517
+ }
510
518
  return "try \(expressionType(type)).encode(\(valueExpression), into: result, in: runtime)"
511
519
  }
512
520