expo-modules-macros 0.28.0 → 0.30.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 (47) hide show
  1. package/.github/workflows/linux.yml +3 -7
  2. package/.github/workflows/publish.yml +2 -3
  3. package/.github/workflows/swift.yml +7 -13
  4. package/.github/workflows/windows.yml +3 -10
  5. package/AGENTS.md +11 -8
  6. package/Package.swift +110 -0
  7. package/README.md +56 -8
  8. package/apple/ExpoModulesMacros +0 -0
  9. package/apple/Sources/ExpoModulesCLI/CLI.swift +47 -10
  10. package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +37 -1
  11. package/apple/Sources/{ExpoModulesMacros → ExpoModulesCLI/Commands}/Expand/MacroExpander.swift +118 -27
  12. package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +20 -10
  13. package/apple/Sources/ExpoModulesCLI/Commands/Fix/RunFix.swift +228 -0
  14. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +1 -1
  15. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +8 -10
  16. package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +48 -34
  17. package/apple/Sources/ExpoModulesMacros/main.swift +6 -0
  18. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/EventMacro.swift +3 -3
  19. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoModuleMacro.swift +8 -1
  20. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/JSMacro.swift +54 -1
  21. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/SharedObjectMacro.swift +9 -2
  22. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ViewPropsMacro.swift +36 -6
  23. package/apple/Sources/ExpoModulesMacrosCore/PluginServer.swift +40 -0
  24. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/ProvidedMacros.swift +5 -5
  25. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/MacroHelpers.swift +12 -26
  26. package/apple/Sources/ExpoModulesMacrosCore/Support/PublicAccess.swift +105 -0
  27. package/apple/Sources/ExpoModulesMacrosTool/main.swift +27 -0
  28. package/build/types.d.ts +15 -6
  29. package/package.json +5 -5
  30. package/apple/Package.swift +0 -66
  31. package/apple/Sources/ExpoModulesMacros/Plugin.swift +0 -36
  32. /package/{apple/Package.resolved → Package.resolved} +0 -0
  33. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoModulesOptimizedMacro.swift +0 -0
  34. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoViewMacro.swift +0 -0
  35. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/RecordMacro.swift +0 -0
  36. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/UnionMacro.swift +0 -0
  37. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/ClosureArguments.swift +0 -0
  38. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/DecorateModuleBuilder.swift +0 -0
  39. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/FreeFormTypes.swift +0 -0
  40. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/JSConstructor.swift +0 -0
  41. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/JSNameCollisions.swift +0 -0
  42. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/OptimizedFunctionHelpers.swift +0 -0
  43. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/Receiver.swift +0 -0
  44. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/SendableConformance.swift +0 -0
  45. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TupleTypes.swift +0 -0
  46. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TypeAliases.swift +0 -0
  47. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TypeConformanceAssertion.swift +0 -0
@@ -34,19 +34,19 @@ public enum Scanner {
34
34
  }
35
35
  }
36
36
 
37
- /// One module in the `scan-modules` output. Trimmed to what `expo-modules-autolinking` needs to
38
- /// register a module: the Swift class name, the JS name it registers under, the platforms that
39
- /// include it, and the file it's in. The richer fields the visitor captures (declaration kind, raw
40
- /// macro arguments, line/column) are dropped here — they're redundant for this command (the macro
41
- /// is always `@ExpoModule` on a class) and the deep `scan-exports` surface carries the richer
42
- /// per-member detail instead.
43
- struct ScannedModule: Codable, Equatable {
44
- /// The Swift class name the module is declared as.
37
+ /// One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, or a `@SharedObject`
38
+ /// class in `sharedObjects`. Trimmed to what `expo-modules-autolinking` needs to register the class:
39
+ /// the Swift class name, the JS name it registers under, the platforms that include it, and the file
40
+ /// it's in. The richer fields the visitor captures (declaration kind, raw macro arguments,
41
+ /// line/column) are dropped here, because this command doesn't need them (the macro is always on a
42
+ /// class) and the deep `scan-exports` surface carries the richer per-member detail instead.
43
+ struct ScannedClass: Codable, Equatable {
44
+ /// The Swift class name.
45
45
  let name: String
46
46
 
47
- /// The fully-resolved JS module name: the `@ExpoModule("Foo")` override when present, otherwise the
48
- /// class name. Resolved here (rather than left `nil`) so it matches how the macro derives the name
49
- /// and the consumer never has to apply the fallback itself.
47
+ /// The fully-resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override when
48
+ /// present, otherwise the class name. Resolved here (rather than left `nil`) so it matches how the
49
+ /// macro derives the name and the consumer never has to apply the fallback itself.
50
50
  let jsName: String
51
51
 
52
52
  /// The class's spelled access modifier (`open`, `public`, `package`, `fileprivate`, `private`),
@@ -56,20 +56,21 @@ struct ScannedModule: Codable, Equatable {
56
56
  let accessLevel: String
57
57
 
58
58
  /// The Apple OSes whose builds include this class, spelled as `os(...)` spells them and compared
59
- /// case-sensitively. An unconditional module lists every OS. Empty means no build is known to
59
+ /// case-sensitively. An unconditional class lists every OS. Empty means no build is known to
60
60
  /// include it, because the enclosing conditions depend on flags not asserted with `--define` or
61
61
  /// on conditions a static scan cannot answer (those are reported in `warnings`). The consumer
62
62
  /// must not assume such a class exists.
63
63
  let platforms: [String]
64
64
 
65
- /// Source file the module was found in, relative to the path the scanner was invoked with.
65
+ /// Source file the class was found in, relative to the path the scanner was invoked with.
66
66
  let file: String
67
67
  }
68
68
 
69
69
  /// Version of the `scan-modules` output shape. Bumped on any breaking change to the envelope or to
70
- /// `ScannedModule`, so `expo-modules-autolinking` can verify it understands the output before
70
+ /// `ScannedClass`, so `expo-modules-autolinking` can verify it understands the output before
71
71
  /// trusting it (and fall back to config-declared modules when it doesn't). Version 2 added the
72
- /// per-module `platforms` list and made the module list platform-agnostic.
72
+ /// per-module `platforms` list and made the module list platform-agnostic. A new field that older
73
+ /// consumers can ignore, such as `sharedObjects`, doesn't change the version.
73
74
  let scanModulesSchemaVersion = 2
74
75
 
75
76
  /// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
@@ -77,7 +78,11 @@ let scanModulesSchemaVersion = 2
77
78
  /// serve different consumers and don't share an envelope.)
78
79
  struct ScanModulesResult: Codable, Equatable {
79
80
  let schemaVersion: Int
80
- let modules: [ScannedModule]
81
+ let modules: [ScannedClass]
82
+
83
+ /// The top-level `@SharedObject` classes, in the same order and with the same platform rules as
84
+ /// `modules`.
85
+ let sharedObjects: [ScannedClass]
81
86
 
82
87
  /// Warnings for `#if` conditions the scan couldn't answer statically (see `ScanWarning`). Carried
83
88
  /// in the report rather than on stderr so the consumer can attach them to its own output.
@@ -97,25 +102,28 @@ private let platformUniverse: [(condition: String, reported: String)] = [
97
102
  (condition: "visionOS", reported: "visionOS"),
98
103
  ]
99
104
 
100
- /// Scans the given paths for top-level `@ExpoModule` types and returns every module found in any
105
+ /// Scans the given paths for top-level `@ExpoModule` and `@SharedObject` types and returns every one
106
+ /// found in any
101
107
  /// `#if` branch (in file then source order), each with the platforms whose builds include it, plus
102
108
  /// the stats for the run — the `scan-modules` command. Kept separate from the public entry (and
103
109
  /// `internal`) so tests can drive it without going through argv/stdout.
104
110
  ///
105
111
  /// Each file is parsed once and walked once per platform (plus once unconditionally to enumerate
106
- /// every module): a module's `platforms` are the OSes whose evaluated walk reached it, given the
112
+ /// every type): a type's `platforms` are the OSes whose evaluated walk reached it, given the
107
113
  /// asserted `defines`. The scanner reports the facts; filtering to the platform being linked is the
108
114
  /// consumer's call.
109
115
  func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResult {
110
- var modules: [ScannedModule] = []
116
+ var modules: [ScannedClass] = []
117
+ var sharedObjects: [ScannedClass] = []
111
118
  var warnings: [ScanWarning] = []
112
119
  var seenWarnings = Set<ScanWarning>()
113
120
 
114
- let stats = scanFiles(paths: paths, macros: [.expoModule]) { source, file in
121
+ let macros: Set<DetectedMacro> = [.expoModule, .sharedObject]
122
+ let stats = scanFiles(paths: paths, macros: macros) { source, file in
115
123
  let tree = Parser.parse(source: source)
116
124
 
117
- // The unconditional walk enumerates every module in the file, in source order.
118
- let allModules = DetectionVisitor(file: file, tree: tree, detectedMacros: [.expoModule], configuration: nil)
125
+ // The unconditional walk enumerates every type in the file, in source order.
126
+ let allModules = DetectionVisitor(file: file, tree: tree, detectedMacros: macros, configuration: nil)
119
127
  allModules.walk(tree)
120
128
 
121
129
  // One evaluated walk per OS attributes each module to the platforms that include it. The walks
@@ -126,7 +134,7 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
126
134
  let visitor = DetectionVisitor(
127
135
  file: file,
128
136
  tree: tree,
129
- detectedMacros: [.expoModule],
137
+ detectedMacros: macros,
130
138
  configuration: configuration
131
139
  )
132
140
  visitor.walk(tree)
@@ -140,24 +148,30 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
140
148
  }
141
149
  }
142
150
 
143
- for detection in allModules.detections {
144
- // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else
145
- // the class name.
146
- modules.append(
147
- ScannedModule(
148
- name: detection.name,
149
- jsName: detection.jsName ?? detection.name,
150
- accessLevel: detection.accessLevel,
151
- platforms: platformsByDetection[detectionKey(detection)] ?? [],
152
- file: detection.file
153
- )
151
+ // The macros reject anything but a class, so a struct with one of them is never registered.
152
+ for detection in allModules.detections where detection.declarationKind == "class" {
153
+ // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` or
154
+ // `@SharedObject("Foo")` override, else the class name.
155
+ let scanned = ScannedClass(
156
+ name: detection.name,
157
+ jsName: detection.jsName ?? detection.name,
158
+ accessLevel: detection.accessLevel,
159
+ platforms: platformsByDetection[detectionKey(detection)] ?? [],
160
+ file: detection.file
154
161
  )
162
+ switch detection.macro {
163
+ case .sharedObject:
164
+ sharedObjects.append(scanned)
165
+ default:
166
+ modules.append(scanned)
167
+ }
155
168
  }
156
169
  }
157
170
 
158
171
  return ScanModulesResult(
159
172
  schemaVersion: scanModulesSchemaVersion,
160
173
  modules: modules,
174
+ sharedObjects: sharedObjects,
161
175
  warnings: warnings,
162
176
  stats: stats
163
177
  )
@@ -0,0 +1,6 @@
1
+ import ExpoModulesMacrosCore
2
+
3
+ // The plugin executable that SwiftPM builds for the packages that depend on the
4
+ // `ExpoModulesMacrosPlugin` product. It only serves the macros, so it doesn't link the CLI; the
5
+ // binary that this package ships is `ExpoModulesMacrosTool`, which does both.
6
+ try runPluginServer()
@@ -32,7 +32,7 @@ import SwiftSyntaxMacros
32
32
  /// also self-contained: `@ExpoModule`/`@SharedObject` neither collect `@Event` members nor register
33
33
  /// their names anywhere.
34
34
  ///
35
- /// `@Event(sync: true)` opts into **synchronous dispatch**: the closure calls `emitSync` (inline
35
+ /// `@Event(.sync)` opts into **synchronous dispatch**: the closure calls `emitSync` (inline
36
36
  /// conversion + dispatch, no scheduling) instead of `emit`, and `@ExpoModule`/`@SharedObject` stamp
37
37
  /// the member `@JavaScriptActor` so the compiler forces the call site onto the JS thread, the
38
38
  /// inverse of the async default. The isolation is on the property access, so it guards the inline
@@ -127,7 +127,7 @@ private struct EventMember {
127
127
  let jsName: String
128
128
  /// The payload type as written, or `nil` for a no-payload `() -> Void` event.
129
129
  let payloadType: String?
130
- /// Whether the event dispatches synchronously (`@Event(sync: true)`) via `emitSync`.
130
+ /// Whether the event dispatches synchronously (`@Event(.sync)`) via `emitSync`.
131
131
  let isSync: Bool
132
132
 
133
133
  var hasPayload: Bool {
@@ -212,7 +212,7 @@ private func validatedEvent(
212
212
  swiftName: swiftName,
213
213
  jsName: jsNameArgument(of: node) ?? defaultEventName(for: withoutBackticks(swiftName)),
214
214
  payloadType: functionType.parameters.first?.type.trimmedDescription,
215
- isSync: boolArgument(of: node, label: "sync") == true
215
+ isSync: hasOption(node, named: "sync")
216
216
  )
217
217
  }
218
218
 
@@ -38,6 +38,13 @@ public struct ExpoModuleMacro: MemberMacro {
38
38
  guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
39
39
  throw MacroExpansionErrorMessage("@ExpoModule can only be applied to a class")
40
40
  }
41
+ diagnoseNonPublicClass(
42
+ classDecl,
43
+ macroName: "@ExpoModule",
44
+ reason: "register it",
45
+ idPrefix: "expomodule",
46
+ in: context
47
+ )
41
48
 
42
49
  // The module name is fully resolved here (the explicit `@ExpoModule("…")` argument, else the
43
50
  // class name) and emitted as a non-optional `_jsName` static below — so the
@@ -160,7 +167,7 @@ extension ExpoModuleMacro: MemberAttributeMacro {
160
167
  // checked at compile time. Skipped when the member already chose an isolation
161
168
  // (`nonisolated` or another global actor), or when `.concurrent` or `.withAsync("name")` runs
162
169
  // it on the concurrent pool. See `shouldStampJavaScriptActor`.
163
- // `@Event(sync: true)` members get the stamp too: a sync event dispatches inline, so the
170
+ // `@Event(.sync)` members get the stamp too: a sync event dispatches inline, so the
164
171
  // isolation forces its call site onto the JS thread. Async events (the default) are
165
172
  // deliberately left unstamped — their `emit` schedules onto the JS thread itself.
166
173
  if memberHasJSAttribute(member) || isSyncEventMember(member),
@@ -36,6 +36,7 @@ public struct JSMacro: PeerMacro {
36
36
  diagnoseClosureTypes(in: declaration, in: context)
37
37
  diagnoseConcurrentOption(of: node, on: declaration, in: context)
38
38
  diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
39
+ diagnoseJavaScriptValuesOffThread(of: node, on: declaration, in: context)
39
40
 
40
41
  // In an `@ExpoModule` or `@SharedObject` class, the class macro emits the assertion instead: it sees
41
42
  // the type aliases declared in the class body, which this macro can't (see `TupleTypeAliases`).
@@ -83,7 +84,7 @@ private func diagnoseConcurrentOption(
83
84
  on declaration: some DeclSyntaxProtocol,
84
85
  in context: some MacroExpansionContext
85
86
  ) {
86
- guard hasJSOption(node, named: "concurrent") else {
87
+ guard hasOption(node, named: "concurrent") else {
87
88
  return
88
89
  }
89
90
  if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
@@ -248,6 +249,58 @@ private func insertAsyncFixIt(for funcDecl: FunctionDeclSyntax) -> FixIt {
248
249
  /// `consuming`, `sending`) are fine: the binding passes the decoded value under whichever convention
249
250
  /// the parameter asks for. The specifier is left off the decode and the assertion (see
250
251
  /// `SpecifiedType`); a parameter reported here is also left out of the assertion.
252
+ /// The JSI types that own an engine value and release it wherever they are dropped. Such a value is
253
+ /// valid only on the JavaScript thread. `JavaScriptPromise` releases its value on the JavaScript
254
+ /// thread, and `JavaScriptRef` and `JavaScriptCallback` exist to carry a value across threads, so they
255
+ /// aren't listed.
256
+ private let javaScriptThreadBoundTypes: Set<String> = [
257
+ "JavaScriptValue", "JavaScriptObject", "JavaScriptFunction", "JavaScriptArray", "JavaScriptArrayBuffer",
258
+ "JavaScriptTypedArray", "JavaScriptBigInt", "JavaScriptWeakObject",
259
+ ]
260
+
261
+ /// Emits the diagnostic for a JSI value parameter of a `@JS(.concurrent)` function or of the variant
262
+ /// that `@JS(.withAsync("name"))` adds. Both run the body on the concurrent thread pool, never on the
263
+ /// JavaScript thread, so the body can't use the value, and the value is released off the JavaScript
264
+ /// thread too.
265
+ private func diagnoseJavaScriptValuesOffThread(
266
+ of node: AttributeSyntax,
267
+ on declaration: some DeclSyntaxProtocol,
268
+ in context: some MacroExpansionContext
269
+ ) {
270
+ guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
271
+ return
272
+ }
273
+ let option: String
274
+ let reason: String
275
+ if hasOption(node, named: "concurrent") {
276
+ option = ".concurrent"
277
+ reason = "'.concurrent' runs the body on the concurrent thread pool"
278
+ } else if asyncVariantOption(of: node) != nil {
279
+ option = ".withAsync"
280
+ reason = "the variant that '.withAsync' adds runs the function on the concurrent thread pool"
281
+ } else {
282
+ return
283
+ }
284
+ for parameter in funcDecl.signature.parameterClause.parameters {
285
+ let type = SpecifiedType(parameter.type).type
286
+ guard let name = baseIdentifier(of: optionalWrappedType(type) ?? type), javaScriptThreadBoundTypes.contains(name)
287
+ else {
288
+ continue
289
+ }
290
+ let parameterName = (parameter.secondName ?? parameter.firstName).text
291
+ context.diagnose(
292
+ Diagnostic(
293
+ node: parameter.type,
294
+ message: JSDiagnosticMessage(
295
+ "'\(parameterName)' can't be a '\(name)' in a '\(option)' function: \(reason), and a JavaScript value is valid only on the JavaScript thread. Take a native type that the value decodes to, such as a @Record or [String: Any], or remove '\(option)'.",
296
+ id: "js-javascript-value-off-thread",
297
+ severity: .error
298
+ )
299
+ )
300
+ )
301
+ }
302
+ }
303
+
251
304
  private func diagnoseParameterSpecifiers(
252
305
  in declaration: some DeclSyntaxProtocol,
253
306
  in context: some MacroExpansionContext
@@ -8,7 +8,7 @@ import SwiftSyntaxMacros
8
8
  /// is emitted as a `_jsName` override.
9
9
  ///
10
10
  /// @SharedObject
11
- /// final class Cache: SharedObject {
11
+ /// public final class Cache: SharedObject {
12
12
  /// @JS
13
13
  /// init(name: String) { self.name = name }
14
14
  ///
@@ -34,6 +34,13 @@ public struct SharedObjectMacro: MemberMacro {
34
34
  guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
35
35
  throw MacroExpansionErrorMessage("@SharedObject can only be applied to a class")
36
36
  }
37
+ diagnoseNonPublicClass(
38
+ classDecl,
39
+ macroName: "@SharedObject",
40
+ reason: "export it",
41
+ idPrefix: "sharedobject",
42
+ in: context
43
+ )
37
44
 
38
45
  // The superclass isn't checked here: the macro sees only this declaration, so it can't tell an
39
46
  // indirect subclass (`class Thumbnail: ImageRef`) from an unrelated class. The compiler reports a
@@ -164,7 +171,7 @@ extension SharedObjectMacro: MemberAttributeMacro {
164
171
  providingAttributesFor member: some DeclSyntaxProtocol,
165
172
  in context: some MacroExpansionContext
166
173
  ) throws -> [AttributeSyntax] {
167
- // `@Event(sync: true)` members are stamped alongside `@JS` ones: a sync event dispatches
174
+ // `@Event(.sync)` members are stamped alongside `@JS` ones: a sync event dispatches
168
175
  // inline, so the isolation forces its call site onto the JS thread. Async events (the
169
176
  // default) stay unstamped — their `emit` schedules onto the JS thread itself.
170
177
  guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
@@ -139,23 +139,37 @@ private func fieldAttributeDiagnostic(
139
139
  for attribute: AttributeSyntax,
140
140
  on varDecl: VariableDeclSyntax
141
141
  ) -> Diagnostic {
142
- // Rebuild the attribute list without this entry, so the fix-it removes the attribute and the
143
- // trivia it carried rather than leaving a blank line behind.
144
- var attributes = varDecl.attributes
145
- if let index = attributes.firstIndex(where: { element in
142
+ // The token after the attribute takes over the trivia of the attribute: the line break,
143
+ // indentation and comments before it, and the comments after it. Its own leading whitespace is
144
+ // left out unless a comment comes before it, so `@Field var size` and `@Field` on its own line both
145
+ // become `var size` on the line of the attribute, and `@Field // note` keeps the line break after
146
+ // the comment.
147
+ var newDecl = varDecl
148
+ if let next = attribute.lastToken(viewMode: .sourceAccurate)?.nextToken(viewMode: .sourceAccurate) {
149
+ let comments = Trivia(pieces: attribute.trailingTrivia.pieces.drop(while: \.isWhitespace))
150
+ let nextTrivia =
151
+ comments.isEmpty ? Trivia(pieces: next.leadingTrivia.pieces.drop(while: \.isWhitespace)) : next.leadingTrivia
152
+ newDecl =
153
+ LeadingTriviaRewriter(token: next.id, trivia: attribute.leadingTrivia + comments + nextTrivia)
154
+ .rewrite(varDecl).cast(VariableDeclSyntax.self)
155
+ }
156
+ // Then remove the attribute with its trivia, so it doesn't leave a blank line behind. The rewritten
157
+ // declaration is a new tree, so the attribute is found by its place in the list.
158
+ if let index = varDecl.attributes.firstIndex(where: { element in
146
159
  guard case .attribute(let candidate) = element else {
147
160
  return false
148
161
  }
149
162
  return candidate == attribute
150
163
  }) {
151
- attributes.remove(at: index)
164
+ let offset = varDecl.attributes.distance(from: varDecl.attributes.startIndex, to: index)
165
+ newDecl.attributes.remove(at: newDecl.attributes.index(newDecl.attributes.startIndex, offsetBy: offset))
152
166
  }
153
167
  let fixIt = FixIt(
154
168
  message: ViewPropsFixItMessage("Remove the '@Field' attribute", id: "viewprops-remove-field"),
155
169
  changes: [
156
170
  .replace(
157
171
  oldNode: Syntax(varDecl),
158
- newNode: Syntax(varDecl.with(\.attributes, attributes))
172
+ newNode: Syntax(newDecl)
159
173
  )
160
174
  ]
161
175
  )
@@ -166,6 +180,22 @@ private func fieldAttributeDiagnostic(
166
180
  return Diagnostic(node: attribute, message: message, fixIts: [fixIt])
167
181
  }
168
182
 
183
+ /// Replaces the leading trivia of one token.
184
+ private final class LeadingTriviaRewriter: SyntaxRewriter {
185
+ private let token: SyntaxIdentifier
186
+ private let trivia: Trivia
187
+
188
+ init(token: SyntaxIdentifier, trivia: Trivia) {
189
+ self.token = token
190
+ self.trivia = trivia
191
+ super.init(viewMode: .sourceAccurate)
192
+ }
193
+
194
+ override func visit(_ token: TokenSyntax) -> TokenSyntax {
195
+ token.id == self.token ? token.with(\.leadingTrivia, trivia) : token
196
+ }
197
+ }
198
+
169
199
  /// The error for an optional event prop, attached to the declared type and carrying a fix-it that
170
200
  /// unwraps it. Events are registered once at view creation from a static name list, so there is no
171
201
  /// shape that could express "sometimes emitted".
@@ -0,0 +1,40 @@
1
+ @_spi(PluginMessage) import SwiftCompilerPluginMessageHandling
2
+ import SwiftSyntaxMacros
3
+
4
+ /// The module name that `expo-modules-core` passes to `#externalMacro`, and the name of the macro
5
+ /// target that SwiftPM loads as the plugin.
6
+ private let pluginModuleName = "ExpoModulesMacros"
7
+
8
+ /// Runs the compiler plugin server on stdin and stdout until the compiler closes the connection.
9
+ ///
10
+ /// `CompilerPlugin.main()` finds a macro by comparing `String(reflecting:)` of each type with
11
+ /// `<module>.<type>` from `#externalMacro`. The macro types live in this module, not in a module
12
+ /// named `ExpoModulesMacros`, so both executables that serve the plugin (the SwiftPM macro target and
13
+ /// the shipped tool) use this provider, which matches the type name under the plugin module name.
14
+ public func runPluginServer() throws {
15
+ let connection = try StandardIOMessageConnection()
16
+ let listener = CompilerPluginMessageListener(connection: connection, provider: ProvidedMacroProvider())
17
+ try listener.main()
18
+ }
19
+
20
+ private struct ProvidedMacroProvider: PluginProvider {
21
+ let macros: [String: Macro.Type] = Dictionary(
22
+ uniqueKeysWithValues: ProvidedMacro.all.map { (String(describing: $0.type), $0.type) }
23
+ )
24
+
25
+ func resolveMacro(moduleName: String, typeName: String) throws -> Macro.Type {
26
+ guard moduleName == pluginModuleName, let type = macros[typeName] else {
27
+ throw UnknownMacroError(moduleName: moduleName, typeName: typeName)
28
+ }
29
+ return type
30
+ }
31
+ }
32
+
33
+ private struct UnknownMacroError: Error, CustomStringConvertible {
34
+ let moduleName: String
35
+ let typeName: String
36
+
37
+ var description: String {
38
+ "macro implementation type '\(moduleName).\(typeName)' could not be found in the Expo Modules macro plugin"
39
+ }
40
+ }
@@ -3,15 +3,15 @@ import SwiftSyntaxMacros
3
3
 
4
4
  /// A macro that the plugin provides, with the attribute name and the extension conformances of its
5
5
  /// declaration in `expo-modules-core`.
6
- struct ProvidedMacro {
7
- let name: String
8
- let type: Macro.Type
9
- var conformances: [TypeSyntax] = []
6
+ public struct ProvidedMacro {
7
+ public let name: String
8
+ public let type: Macro.Type
9
+ public var conformances: [TypeSyntax] = []
10
10
 
11
11
  /// Every macro of the plugin. The compiler gets the types from `providingMacros`, and `expand`
12
12
  /// matches the attributes by name and passes the same conformances as the compiler, so both read
13
13
  /// this list.
14
- static var all: [ProvidedMacro] {
14
+ public static var all: [ProvidedMacro] {
15
15
  [
16
16
  ProvidedMacro(name: "OptimizedFunction", type: OptimizedFunctionAttachedMacro.self),
17
17
  ProvidedMacro(name: "JS", type: JSMacro.self),
@@ -13,26 +13,12 @@ internal func jsNameArgument(of attribute: AttributeSyntax) -> String? {
13
13
  return segment.content.text
14
14
  }
15
15
 
16
- /// Reads a labeled boolean-literal argument of an attribute, e.g. `@Event(sync: true)` -> true.
17
- /// Returns nil if the attribute has no argument with that label or its value isn't a boolean literal.
18
- internal func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
19
- guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
20
- return nil
21
- }
22
- for arg in args where arg.label?.text == label {
23
- guard let literal = arg.expression.as(BooleanLiteralExprSyntax.self) else {
24
- return nil
25
- }
26
- return literal.literal.tokenKind == .keyword(.true)
27
- }
28
- return nil
29
- }
30
-
31
- /// True if the attribute lists the given `JSOptions` member, e.g. `@JS(.concurrent)` or
32
- /// `@JS("name", [.concurrent])` -> true for "concurrent". Options are written as member-access
33
- /// expressions (`.concurrent`), optionally inside an array literal when more than one is combined, so
34
- /// both spellings are scanned. The leading string literal, when present, is the JS name and is skipped.
35
- internal func hasJSOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
16
+ /// True if the attribute lists the given option member, e.g. `@JS(.concurrent)`, `@Event(.sync)` or
17
+ /// `@Event("name", EventOptions.sync)` -> true for the matching name. Options are written as
18
+ /// member-access expressions, one per variadic argument. Array literals are scanned too, though the
19
+ /// variadic declarations in `expo-modules-core` don't accept them. The leading string literal, when
20
+ /// present, is the JS name and is skipped.
21
+ internal func hasOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
36
22
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
37
23
  return false
38
24
  }
@@ -49,7 +35,7 @@ internal func hasJSOption(_ attribute: AttributeSyntax, named option: String) ->
49
35
  return false
50
36
  }
51
37
 
52
- /// True when the expression is the member access `.<option>` (or `JSOptions.<option>`).
38
+ /// True when the expression is the member access `.<option>` (or `JSOptions.<option>`, `EventOptions.<option>`).
53
39
  private func namesOption(_ expression: ExprSyntax, _ option: String) -> Bool {
54
40
  guard let member = expression.as(MemberAccessExprSyntax.self) else {
55
41
  return false
@@ -64,7 +50,7 @@ internal func isConcurrentJSMember(_ decl: DeclSyntaxProtocol) -> Bool {
64
50
  guard let attribute = memberAttributes(of: decl).firstAttribute(named: "JS") else {
65
51
  return false
66
52
  }
67
- return hasJSOption(attribute, named: "concurrent")
53
+ return hasOption(attribute, named: "concurrent")
68
54
  }
69
55
 
70
56
  /// The `.withAsync("name")` option of a `@JS` attribute, e.g. `@JS(.withAsync("loadAsync"))` or
@@ -74,7 +60,7 @@ internal func asyncVariantOption(of attribute: AttributeSyntax) -> FunctionCallE
74
60
  return asyncVariantOptions(of: attribute).first
75
61
  }
76
62
 
77
- /// Every `.withAsync(...)` option of a `@JS` attribute, in source order. Like `hasJSOption`, it scans
63
+ /// Every `.withAsync(...)` option of a `@JS` attribute, in source order. Like `hasOption`, it scans
78
64
  /// the arguments and the elements of an array literal.
79
65
  internal func asyncVariantOptions(of attribute: AttributeSyntax) -> [FunctionCallExprSyntax] {
80
66
  guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
@@ -290,7 +276,7 @@ internal func hasDefaultValue(_ parameter: FunctionParameterSyntax) -> Bool {
290
276
  return parameter.defaultValue != nil
291
277
  }
292
278
 
293
- /// True if the declaration is a `@Event(sync: true)` property. A sync event dispatches inline on the
279
+ /// True if the declaration is a `@Event(.sync)` property. A sync event dispatches inline on the
294
280
  /// JS thread instead of scheduling, so `@ExpoModule`/`@SharedObject` stamp it with `@JavaScriptActor`,
295
281
  /// making "must be called on the JS thread" a compile-time guarantee at the call site. Async events
296
282
  /// (the default) are deliberately not stamped: their `emit` schedules onto the JS thread itself, so
@@ -301,7 +287,7 @@ internal func isSyncEventMember(_ decl: DeclSyntaxProtocol) -> Bool {
301
287
  else {
302
288
  return false
303
289
  }
304
- return boolArgument(of: attribute, label: "sync") == true
290
+ return hasOption(attribute, named: "sync")
305
291
  }
306
292
 
307
293
  /// Reads a labeled array-literal argument of an attribute, e.g. `@ExpoModule(classes: [Foo.self, Bar.self])`,
@@ -524,7 +510,7 @@ internal func encodeCall(_ type: String, freeForm: FreeFormConversion?, of value
524
510
  /// `name` without the backticks that escape it: `` `default` `` becomes `default`. Swift needs the
525
511
  /// backticks in every identifier position, but a name JavaScript sees (a JS member name, an event
526
512
  /// name, a record key) is the bare name.
527
- internal func withoutBackticks(_ name: String) -> String {
513
+ public func withoutBackticks(_ name: String) -> String {
528
514
  var name = Substring(name)
529
515
  while name.first == "`" {
530
516
  name.removeFirst()
@@ -0,0 +1,105 @@
1
+ import SwiftDiagnostics
2
+ import SwiftSyntax
3
+ import SwiftSyntaxMacros
4
+
5
+ /// Warns when a class that autolinking exports isn't `public` or `open`. Autolinking generates the modules
6
+ /// provider in the app target, so it can reference only public classes of other modules. The warning has a
7
+ /// fix-it that makes the class `public`. A local class is skipped, because it can't have an access modifier.
8
+ internal func diagnoseNonPublicClass(
9
+ _ classDecl: ClassDeclSyntax,
10
+ macroName: String,
11
+ reason: String,
12
+ idPrefix: String,
13
+ in context: some MacroExpansionContext
14
+ ) {
15
+ guard !isLocal(context: context) else {
16
+ return
17
+ }
18
+ let accessModifier = classDecl.modifiers.first { accessLevelKeywords.contains($0.name.tokenKind) }
19
+ if let accessModifier,
20
+ [.keyword(.public), .keyword(.open)].contains(accessModifier.name.tokenKind)
21
+ {
22
+ return
23
+ }
24
+ let name = classDecl.name.text
25
+ context.diagnose(
26
+ Diagnostic(
27
+ node: accessModifier.map(Syntax.init) ?? Syntax(classDecl.classKeyword),
28
+ message: PublicAccessDiagnosticMessage(
29
+ "\(macroName) class '\(name)' should be public or open, so that the modules provider that autolinking generates in the app target can \(reason)",
30
+ id: "\(idPrefix)-class-not-public"
31
+ ),
32
+ fixIts: [makePublicFixIt(for: classDecl, replacing: accessModifier, idPrefix: idPrefix)]
33
+ )
34
+ )
35
+ }
36
+
37
+ private let accessLevelKeywords: [TokenKind] = [
38
+ .keyword(.open), .keyword(.public), .keyword(.package), .keyword(.internal), .keyword(.fileprivate),
39
+ .keyword(.private),
40
+ ]
41
+
42
+ /// True when the declaration is inside a function, closure or another scope that isn't a type or an extension.
43
+ private func isLocal(context: some MacroExpansionContext) -> Bool {
44
+ return context.lexicalContext.contains { node in
45
+ return !node.is(ClassDeclSyntax.self) && !node.is(StructDeclSyntax.self) && !node.is(EnumDeclSyntax.self)
46
+ && !node.is(ActorDeclSyntax.self) && !node.is(ExtensionDeclSyntax.self)
47
+ }
48
+ }
49
+
50
+ /// The fix-it for a class that isn't public: replace its access modifier with `public`, or add `public` before
51
+ /// its other modifiers.
52
+ private func makePublicFixIt(
53
+ for classDecl: ClassDeclSyntax,
54
+ replacing accessModifier: DeclModifierSyntax?,
55
+ idPrefix: String
56
+ ) -> FixIt {
57
+ let message = PublicAccessFixItMessage("Make the class 'public'", id: "\(idPrefix)-class-insert-public")
58
+ if let accessModifier {
59
+ let newModifier = accessModifier.with(\.name, accessModifier.name.with(\.tokenKind, .keyword(.public)))
60
+ return FixIt(message: message, changes: [.replace(oldNode: Syntax(accessModifier), newNode: Syntax(newModifier))])
61
+ }
62
+ // The new modifier takes over the leading trivia of the first modifier or of `class` (the line break after the
63
+ // attribute), so it lands where the declaration started.
64
+ let newDecl: ClassDeclSyntax
65
+ if let firstModifier = classDecl.modifiers.first {
66
+ let publicModifier = DeclModifierSyntax(
67
+ name: .keyword(.public, leadingTrivia: firstModifier.leadingTrivia, trailingTrivia: .space)
68
+ )
69
+ var modifiers = classDecl.modifiers
70
+ modifiers[modifiers.startIndex] = firstModifier.with(\.leadingTrivia, [])
71
+ modifiers.insert(publicModifier, at: modifiers.startIndex)
72
+ newDecl = classDecl.with(\.modifiers, modifiers)
73
+ } else {
74
+ let classKeyword = classDecl.classKeyword
75
+ let publicModifier = DeclModifierSyntax(
76
+ name: .keyword(.public, leadingTrivia: classKeyword.leadingTrivia, trailingTrivia: .space)
77
+ )
78
+ newDecl =
79
+ classDecl
80
+ .with(\.modifiers, [publicModifier])
81
+ .with(\.classKeyword, classKeyword.with(\.leadingTrivia, []))
82
+ }
83
+ return FixIt(message: message, changes: [.replace(oldNode: Syntax(classDecl), newNode: Syntax(newDecl))])
84
+ }
85
+
86
+ private struct PublicAccessDiagnosticMessage: DiagnosticMessage {
87
+ let message: String
88
+ let diagnosticID: MessageID
89
+ let severity: DiagnosticSeverity = .warning
90
+
91
+ init(_ message: String, id: String) {
92
+ self.message = message
93
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
94
+ }
95
+ }
96
+
97
+ private struct PublicAccessFixItMessage: FixItMessage {
98
+ let message: String
99
+ let fixItID: MessageID
100
+
101
+ init(_ message: String, id: String) {
102
+ self.message = message
103
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
104
+ }
105
+ }