@expo/expo-modules-macros-plugin 0.7.0 → 0.9.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.
@@ -54,6 +54,9 @@ jobs:
54
54
  case "$archs" in *x86_64*) ;; *) echo "Missing x86_64 slice"; exit 1;; esac
55
55
  arch -arm64 ./ExpoModulesMacros-tool < /dev/null
56
56
  arch -x86_64 ./ExpoModulesMacros-tool < /dev/null
57
+ # With arguments the same binary runs the scanner CLI instead of the plugin server.
58
+ arch -arm64 ./ExpoModulesMacros-tool --help > /dev/null
59
+ arch -x86_64 ./ExpoModulesMacros-tool --help > /dev/null
57
60
 
58
61
  - name: Test
59
62
  run: swift test -v
Binary file
@@ -8,19 +8,19 @@ import PackageDescription
8
8
  let package = Package(
9
9
  name: "ExpoModulesMacros",
10
10
  platforms: [.macOS(.v13)],
11
- products: [
12
- // The scanner CLI. Named `ExpoModulesScanner` (the user-facing tool name) while its target is
13
- // `ExpoModulesScannerCLI`; the detection logic lives in the importable `ExpoModulesScanner`
14
- // library that both the CLI and the tests depend on.
15
- .executable(name: "ExpoModulesScanner", targets: ["ExpoModulesScannerCLI"]),
16
- ],
17
11
  dependencies: [
18
12
  .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest")
19
13
  ],
20
14
  targets: [
15
+ // The plugin executable doubles as the scanner CLI: the compiler launches it without arguments
16
+ // and speaks the plugin protocol over stdin, while an invocation with arguments dispatches into
17
+ // `ScannerCLI` (see the entry point in Plugin.swift). Sharing the binary keeps the package to a
18
+ // single shipped executable; the scanner adds little on top of the SwiftSyntax the macros
19
+ // already link.
21
20
  .macro(
22
21
  name: "ExpoModulesMacros",
23
22
  dependencies: [
23
+ "ExpoModulesScanner",
24
24
  .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
25
25
  .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
26
26
  ]
@@ -30,12 +30,9 @@ let package = Package(
30
30
  dependencies: [
31
31
  .product(name: "SwiftSyntax", package: "swift-syntax"),
32
32
  .product(name: "SwiftParser", package: "swift-syntax"),
33
+ .product(name: "SwiftIfConfig", package: "swift-syntax"),
33
34
  ]
34
35
  ),
35
- .executableTarget(
36
- name: "ExpoModulesScannerCLI",
37
- dependencies: ["ExpoModulesScanner"]
38
- ),
39
36
  ]
40
37
  )
41
38
 
@@ -4,12 +4,15 @@ import SwiftSyntax
4
4
  /// `Function(...)` / `AsyncFunction(...)` DSL entry that the runtime interprets per call, the enclosing
5
5
  /// macro synthesizes a decorator (`_decorateModule(object:)` / `_decorateSharedObject(prototype:)`) that binds each such
6
6
  /// function into the JS object via the closure-taking `JavaScriptObject.setProperty(_:)`, with the
7
- /// decode-call-encode body inlined into the closure. This omits the `[Any]`/`toTuple` dynamic-call path:
7
+ /// decode-call-encode body inlined into the closure. This omits the dynamic-call path entirely:
8
8
  /// every argument is decoded individually by its static type.
9
9
  ///
10
10
  /// The receiver (see `Receiver`) is the module's `self` for a module binding, or the per-call `_self`
11
- /// unwrapped from the JS `this` for a shared-object binding. An `async` `@JS func` produces an `async`
12
- /// closure body and is installed through the async `setProperty(_:)` overload (so JS gets a promise).
11
+ /// unwrapped from the JS `this` for a shared-object binding. An `async` `@JS func` binds through the
12
+ /// two-phase async `setProperty(_:)` overload: the closure is the synchronous decode phase (receiver
13
+ /// unwrap, arity guard, argument decode, all while `this` and the arguments are still valid)
14
+ /// and returns the async body (call and result encode), so nothing JSI-owned crosses the asynchronous
15
+ /// boundary and JS gets a promise.
13
16
  internal struct JSFunction {
14
17
  let swiftName: String
15
18
  let jsName: String
@@ -60,7 +63,7 @@ internal struct JSFunction {
60
63
  let maximum = parameters.count
61
64
  var lines: [String] = []
62
65
 
63
- if let unwrap = receiver.unwrapStatement(isAsync: isAsync) {
66
+ if let unwrap = receiver.unwrapStatement {
64
67
  lines.append(unwrap)
65
68
  }
66
69
 
@@ -87,8 +90,31 @@ internal struct JSFunction {
87
90
  }
88
91
 
89
92
  if required == maximum {
90
- // No omittable trailing run: a single flat call with every argument decoded.
91
- lines.append(contentsOf: callAndEncodeLines(receiver: receiver, arity: maximum, decodingFrom: required))
93
+ // No omittable trailing run: a single flat call with every argument decoded. An async
94
+ // function decodes any remaining slots here (still the synchronous phase) and returns its
95
+ // body; a sync one calls and encodes directly.
96
+ if isAsync {
97
+ for index in required..<maximum {
98
+ lines.append(decodeStatement(at: index))
99
+ }
100
+ lines.append(contentsOf: asyncBodyLines(receiver: receiver, arity: maximum))
101
+ } else {
102
+ lines.append(contentsOf: callAndEncodeLines(receiver: receiver, arity: maximum, decodingFrom: required))
103
+ }
104
+ } else if isAsync {
105
+ // One body per accepted arity, branching on `arguments.count`. A branch decodes its trailing
106
+ // slots synchronously and returns the async body for that call shape, so each `return` ends
107
+ // the decode phase for its arity.
108
+ lines.append("switch arguments.count {")
109
+ for arity in required...maximum {
110
+ let label = arity == maximum ? "default:" : "case \(arity):"
111
+ lines.append(label)
112
+ for index in required..<arity {
113
+ lines.append(" " + decodeStatement(at: index))
114
+ }
115
+ lines.append(contentsOf: asyncBodyLines(receiver: receiver, arity: arity).map { " " + $0 })
116
+ }
117
+ lines.append("}")
92
118
  } else {
93
119
  // One call shape per accepted arity, branching on `arguments.count`. A branch decodes a
94
120
  // trailing slot before the call, so this is a `switch` statement (not an expression): a
@@ -116,14 +142,32 @@ internal struct JSFunction {
116
142
  .joined(separator: "\n")
117
143
  }
118
144
 
145
+ /// The `return { … }` statement closing an async binding's decode phase: the returned closure is
146
+ /// the function's async body (`AsyncFunctionBody`), awaiting the call and encoding the result. It
147
+ /// captures only what the call needs — the decoded `arg<i>` locals, the receiver, and `runtime`
148
+ /// for the encode — never `this` or the arguments buffer, which are only valid for the duration
149
+ /// of the host call.
150
+ private func asyncBodyLines(receiver: Receiver, arity: Int) -> [String] {
151
+ var lines: [String] = ["return {"]
152
+ if returnType != nil {
153
+ lines.append(" let result = \(callExpression(receiver: receiver, arity: arity))")
154
+ lines.append(contentsOf: encodeResultLines().map { " " + $0 })
155
+ } else {
156
+ lines.append(" \(callExpression(receiver: receiver, arity: arity))")
157
+ lines.append(" return .undefined")
158
+ }
159
+ lines.append("}")
160
+ return lines
161
+ }
162
+
119
163
  /// `let arg<index> = …` decoding the slot at `index` by its static type through
120
164
  /// `JavaScriptDecodable.decode` on the borrowed `JavaScriptUnownedValue` — no owning value, no
121
165
  /// `jsi::Value` copy, no `Any` boxing, no force-cast; it returns the concrete type directly. A
122
166
  /// primitive's `decode` is `@inlinable` and lowers to the same direct accessor a hand-rolled fast
123
167
  /// path would use.
124
168
  private func decodeStatement(at index: Int) -> String {
125
- let exprType = expressionType(parameters[index].type.trimmedDescription)
126
- return "let arg\(index) = try \(exprType).decode(arguments.unownedValue(at: \(index)), in: runtime)"
169
+ let type = parameters[index].type.trimmedDescription
170
+ return "let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))"
127
171
  }
128
172
 
129
173
  /// The `<callee>.<name>(...)` call for the given arity. Slots `0..<arity` are passed their decoded
@@ -202,28 +246,25 @@ internal struct JSFunction {
202
246
  /// The `setProperty` statement that installs this function on the JS object. The decode-call-encode
203
247
  /// body is inlined directly into the closure passed to the closure-taking `setProperty` overload
204
248
  /// (which creates the host function under the hood) — no separate named binding. For an `async`
205
- /// function the body `await`s the call, which selects the async `setProperty` overload (so JS
206
- /// receives a promise).
249
+ /// function the closure is the synchronous decode phase and returns the async body, which selects
250
+ /// the async `setProperty` overload (so JS receives a promise).
207
251
  ///
208
- /// Capture mirrors core's `SyncFunctionDefinition.build`: a module captures its `self` **strong** —
252
+ /// A module captures its `self` **strong** —
209
253
  /// the host-function closure is what keeps the native callable alive for as long as JS can invoke
210
254
  /// it; its lifetime is bounded by the JS VM's garbage collection of the object. A shared object
211
255
  /// captures nothing of the instance: it recovers the typed receiver from the JS `this` per call.
212
256
  func decorateStatements(object: String, receiver: Receiver) -> String {
213
- // Synchronous `@JS` bindings bind through the unowned-`this` `setProperty` overload, which hands
214
- // `this` in as a borrowed `JavaScriptUnownedValue` instead of allocating an owning
215
- // `JavaScriptValue` and forming its `weak`-runtime reference on every call. A module ignores
216
- // `this`; a shared object unwraps it (still borrowed). The first parameter is typed `borrowing
217
- // JavaScriptUnownedValue` to select that (otherwise `@_disfavoredOverload`) overload which
218
- // requires the *parenthesized, fully typed* parameter list, since Swift rejects a type annotation
219
- // on a shorthand `{ [capture] name, name in }` parameter. Async functions keep the untyped
220
- // shorthand and the owning-`this` overload: there is no unowned-`this` async variant and the buffer
221
- // escapes into the task anyway.
257
+ // Every `@JS` binding hands `this` in as a borrowed `JavaScriptUnownedValue` instead of
258
+ // allocating an owning `JavaScriptValue` and forming its `weak`-runtime reference on every call,
259
+ // and consumes the arguments buffer sync and async closures share one parameter shape and
260
+ // differ only in what they return. A module ignores `this`; a shared object unwraps it (still
261
+ // borrowed) an async binding does so in its synchronous decode phase, before the borrow ends.
262
+ // The parameter list is parenthesized and fully typed, since Swift rejects a type annotation on
263
+ // a shorthand `{ [capture] name, name in }` parameter; for a sync binding the explicit
264
+ // `JavaScriptUnownedValue` also selects the (otherwise `@_disfavoredOverload`) unowned-`this`
265
+ // overload.
222
266
  let captures = receiver.captureClause
223
- let parameters =
224
- isAsync
225
- ? "this, arguments"
226
- : "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
267
+ let parameters = "(this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer)"
227
268
 
228
269
  return """
229
270
  \(object).setProperty("\(jsName)") { \(captures)\(parameters) in
@@ -237,7 +278,7 @@ internal struct JSFunction {
237
278
  /// `Property(...)` DSL entry, the enclosing macro synthesizes a get/set accessor into the JS object
238
279
  /// inside its decorator (`_decorateModule(object:)` / `_decorateSharedObject(prototype:)`): it builds a descriptor object
239
280
  /// (`enumerable` + `get`, and `set` when the property is settable) and installs it with
240
- /// `object.defineProperty(name, descriptor:)`, mirroring core's `PropertyDefinition.buildDescriptor`.
281
+ /// `object.defineProperty(name, descriptor:)`.
241
282
  /// The `get`/`set` host functions are installed the same way `@JS func`s are — the closure-taking
242
283
  /// `setProperty(_:)` overload, with the read/write body inlined into the closure.
243
284
  ///
@@ -268,7 +309,7 @@ internal struct JSProperty {
268
309
  // A shared object's accessors unwrap the JS `this` into `_self` before reading/writing; a module
269
310
  // reads `self` directly. The unwrap leads each accessor body. Property accessors are synchronous, so
270
311
  // they take the borrowed unowned `this`.
271
- let unwrap = receiver.unwrapStatement(isAsync: false).map { "\($0)\n" } ?? ""
312
+ let unwrap = receiver.unwrapStatement.map { "\($0)\n" } ?? ""
272
313
  var lines: [String] = []
273
314
 
274
315
  lines.append("let \(descriptorName) = runtime.createObject()")
@@ -291,8 +332,7 @@ internal struct JSProperty {
291
332
  // getter-only (a settable var with neither an annotation nor a literal default is rare and can't
292
333
  // be decoded).
293
334
  if isSettable, let valueType {
294
- let exprType = expressionType(valueType)
295
- let setDecode = "\(callee).\(swiftName) = try \(exprType).decode(arguments.unownedValue(at: 0), in: runtime)"
335
+ let setDecode = "\(callee).\(swiftName) = try \(decodeCall(valueType, from: "arguments.unownedValue(at: 0)"))"
296
336
  lines.append(
297
337
  accessorClosure(
298
338
  descriptorName, "set", receiver: receiver, body: "\(unwrap)\(setDecode)\nreturn .undefined"))
@@ -348,9 +388,9 @@ private func decorateBody(
348
388
  /// The module decorator: a `_decorateModule(object:)` that binds every `@JS func` (via an inlined
349
389
  /// `setProperty` closure) and every `@JS var` (via a `defineProperty` accessor) into the module's own
350
390
  /// JS object. Core supplies the object; the bindings call into the module `self`. It's an *instance*
351
- /// method (a module is a singleton, satisfying the `AnyModule` requirement of the same name), mirroring
352
- /// core's `ObjectDefinition.decorate(object:)` including the `borrowing` object parameter (it mutates
353
- /// through the reference without reassigning or taking ownership). Only emitted when there's at least
391
+ /// method (a module is a singleton, satisfying the `AnyModule` requirement of the same name), with a
392
+ /// `borrowing` object parameter (it mutates through the reference without reassigning or taking
393
+ /// ownership). Only emitted when there's at least
354
394
  /// one member to bind. Uses the shared body generation with the module `object:` phase and `self`
355
395
  /// receiver; the shared-object counterpart is `buildDecorateSharedObjectPhase`.
356
396
  internal func buildDecorateJavaScriptObject(functions: [JSFunction], properties: [JSProperty]) -> DeclSyntax {
@@ -0,0 +1,59 @@
1
+ /// Recognition of the free-form (`Any`-bearing) boundary types and the spellings the macros emit or
2
+ /// suggest for them. None of these types can conform to the JS codable protocols (`Any` can't conform
3
+ /// to a protocol, and the container conditional conformances require the element/value to conform), so
4
+ /// they can't cross the boundary through the usual `T.decode` / `T.encode` path. Instead they're
5
+ /// accepted **only** in decode position (function/constructor arguments), decoded through a dedicated
6
+ /// `JavaScriptValue.decodeAny…` entry point; a free-form return or getter is rejected, since there's
7
+ /// no free-form encode.
8
+ ///
9
+ /// Shared across the macro pipeline: `TypeConformanceAssertion` skips them, `MacroHelpers.decodeCall`
10
+ /// reroutes their decode, and `JSMacro` uses them for the steering diagnostics.
11
+
12
+ /// The free-form boundary types: an untyped value (`Any`), an untyped array (`[Any]`), and an untyped
13
+ /// string-keyed dictionary (`[String: Any]`). Matched by whitespace-normalized spelling so
14
+ /// `[String: Any]` and `[String : Any]` both count.
15
+ private let freeFormBoundaryTypes: Set<String> = ["Any", "[Any]", "[String:Any]"]
16
+
17
+ /// The `JavaScriptValue.decodeAny…` method the binding calls to decode a free-form argument, keyed by
18
+ /// the free-form type's normalized spelling. `nil` for any non-free-form type.
19
+ private let freeFormDecodeMethods: [String: String] = [
20
+ "Any": "decodeAny",
21
+ "[Any]": "decodeAnyArray",
22
+ "[String:Any]": "decodeAnyDictionary",
23
+ ]
24
+
25
+ /// The type-safe alternative a free-form type's diagnostic steers to: the same shape with the `Any`
26
+ /// element replaced by `JavaScriptValue`, which conforms to the codable protocols. Keyed by the
27
+ /// free-form type's normalized spelling; spelled with conventional spacing for the message.
28
+ private let typedFreeFormReplacements: [String: String] = [
29
+ "Any": "JavaScriptValue",
30
+ "[Any]": "[JavaScriptValue]",
31
+ "[String:Any]": "[String: JavaScriptValue]",
32
+ ]
33
+
34
+ /// True when a boundary type (as written) is one of the free-form spellings, ignoring internal
35
+ /// whitespace so `[String: Any]` and `[String :Any]` both match. Optionals are not free-form here: a
36
+ /// trailing `?` would route through `Optional.decode`, which free-form can't satisfy, so an optional
37
+ /// free-form type isn't recognized and stays a normal (failing) assertion.
38
+ internal func isFreeFormBoundaryType(_ type: String) -> Bool {
39
+ return freeFormBoundaryTypes.contains(normalizedTypeSpelling(type))
40
+ }
41
+
42
+ /// The `JavaScriptValue.decodeAny…` method name for a free-form boundary type, or `nil` when the type
43
+ /// isn't free-form. The binding emits `JavaScriptValue.<method>(arguments.unownedValue(at:), in:)` in
44
+ /// place of the type's own `.decode`.
45
+ internal func freeFormDecodeMethod(for type: String) -> String? {
46
+ return freeFormDecodeMethods[normalizedTypeSpelling(type)]
47
+ }
48
+
49
+ /// The type-safe alternative to suggest in place of a free-form type (its `Any` element replaced by
50
+ /// `JavaScriptValue`), or `nil` when the type isn't free-form.
51
+ internal func typedFreeFormReplacement(for type: String) -> String? {
52
+ return typedFreeFormReplacements[normalizedTypeSpelling(type)]
53
+ }
54
+
55
+ /// A type spelling with all whitespace removed, so spelling variations of the same type
56
+ /// (`[String: Any]` vs `[String :Any]`) compare equal.
57
+ private func normalizedTypeSpelling(_ type: String) -> String {
58
+ return type.filter { !$0.isWhitespace }
59
+ }
@@ -27,8 +27,8 @@ internal struct JSConstructor {
27
27
 
28
28
  var callArguments: [String] = []
29
29
  for (index, parameter) in parameters.enumerated() {
30
- let exprType = expressionType(parameter.type.trimmedDescription)
31
- lines.append("let arg\(index) = try \(exprType).decode(arguments.unownedValue(at: \(index)), in: runtime)")
30
+ let type = parameter.type.trimmedDescription
31
+ lines.append("let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))")
32
32
 
33
33
  let label = parameter.firstName.text
34
34
  callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
@@ -1,3 +1,4 @@
1
+ import SwiftDiagnostics
1
2
  import SwiftSyntax
2
3
  import SwiftSyntaxMacros
3
4
 
@@ -30,6 +31,8 @@ public struct JSMacro: PeerMacro {
30
31
  providingPeersOf declaration: some DeclSyntaxProtocol,
31
32
  in context: some MacroExpansionContext
32
33
  ) throws -> [DeclSyntax] {
34
+ diagnoseFreeFormTypes(in: declaration, in: context)
35
+
33
36
  guard let member = boundaryMember(of: declaration),
34
37
  let assertion = directionalConformanceAssertion(
35
38
  name: member.name,
@@ -43,6 +46,115 @@ public struct JSMacro: PeerMacro {
43
46
  }
44
47
  }
45
48
 
49
+ /// Emits the free-form (`Any` / `[Any]` / `[String: Any]`) diagnostics for a `@JS` declaration.
50
+ ///
51
+ /// A free-form type is only supported crossing the boundary as an **argument**, decoded through
52
+ /// `JavaScriptValue.decodeAny…`; there is no free-form encode, so any position that encodes is a hard
53
+ /// error. That means:
54
+ /// - a function/constructor **parameter** typed free-form gets a warning steering to
55
+ /// `[String: JavaScriptValue]` (the type-safe alternative), but compiles;
56
+ /// - a function **return** typed free-form is an error (it would need to encode);
57
+ /// - a **property** typed free-form is an error regardless of settability, because its getter always
58
+ /// encodes.
59
+ ///
60
+ /// Types are matched by their written spelling on the type node, so the diagnostic points at the
61
+ /// offending type in the user's source.
62
+ private func diagnoseFreeFormTypes(
63
+ in declaration: some DeclSyntaxProtocol,
64
+ in context: some MacroExpansionContext
65
+ ) {
66
+ if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
67
+ warnFreeFormArguments(funcDecl.signature.parameterClause.parameters, in: context)
68
+ if let returnType = funcDecl.signature.returnClause?.type,
69
+ isFreeFormBoundaryType(returnType.trimmedDescription) {
70
+ context.diagnose(
71
+ Diagnostic(node: returnType, message: freeFormReturnError(for: returnType.trimmedDescription)))
72
+ }
73
+ return
74
+ }
75
+
76
+ // A constructor decodes its arguments exactly like a function; it has no return value to encode, so
77
+ // only the argument warning applies.
78
+ if let initDecl = declaration.as(InitializerDeclSyntax.self) {
79
+ warnFreeFormArguments(initDecl.signature.parameterClause.parameters, in: context)
80
+ return
81
+ }
82
+
83
+ if let varDecl = declaration.as(VariableDeclSyntax.self),
84
+ let type = varDecl.bindings.first?.typeAnnotation?.type,
85
+ isFreeFormBoundaryType(type.trimmedDescription) {
86
+ context.diagnose(
87
+ Diagnostic(node: type, message: freeFormPropertyError(for: type.trimmedDescription)))
88
+ }
89
+ }
90
+
91
+ /// The tail of a free-form encode error: the reshaped `JavaScriptValue` alternative when the type is a
92
+ /// container (`[String: Any]` -> `[String: JavaScriptValue]`), otherwise the passthrough suggestion
93
+ /// for a bare `Any`. Both conform to the codable protocols, so either is a valid fix.
94
+ private func suggestedAlternative(for freeFormType: String) -> String {
95
+ if let reshaped = typedFreeFormReplacement(for: freeFormType), reshaped != "JavaScriptValue" {
96
+ return "Use '\(reshaped)', or 'JavaScriptValue' to pass a JS value through unchanged."
97
+ }
98
+ return "Use a concrete type, or 'JavaScriptValue' to pass a JS value through unchanged."
99
+ }
100
+
101
+ /// Emits the steering warning for each free-form parameter in a list. Shared by the function and
102
+ /// constructor cases, which both decode their arguments through the same path.
103
+ private func warnFreeFormArguments(
104
+ _ parameters: FunctionParameterListSyntax,
105
+ in context: some MacroExpansionContext
106
+ ) {
107
+ for parameter in parameters {
108
+ let type = parameter.type.trimmedDescription
109
+ guard let suggested = typedFreeFormReplacement(for: type) else {
110
+ continue
111
+ }
112
+ context.diagnose(
113
+ Diagnostic(
114
+ node: parameter.type,
115
+ message: freeFormArgumentWarning(for: type, suggesting: suggested)))
116
+ }
117
+ }
118
+
119
+ private func freeFormArgumentWarning(
120
+ for freeFormType: String,
121
+ suggesting suggestedType: String
122
+ ) -> JSDiagnosticMessage {
123
+ return JSDiagnosticMessage(
124
+ "Prefer '\(suggestedType)' over the free-form '\(freeFormType)' for a @JS argument. Free-form decoding boxes every value as 'Any' (slower, no static typing); the 'JavaScriptValue' element keeps each value inspectable without erasing it.",
125
+ id: "js-free-form-argument",
126
+ severity: .warning
127
+ )
128
+ }
129
+
130
+ private func freeFormReturnError(for freeFormType: String) -> JSDiagnosticMessage {
131
+ return JSDiagnosticMessage(
132
+ "A @JS function can't return the free-form '\(freeFormType)': there's no way to encode an untyped value back to JavaScript. \(suggestedAlternative(for: freeFormType))",
133
+ id: "js-free-form-return",
134
+ severity: .error
135
+ )
136
+ }
137
+
138
+ private func freeFormPropertyError(for freeFormType: String) -> JSDiagnosticMessage {
139
+ return JSDiagnosticMessage(
140
+ "A @JS property can't have the free-form '\(freeFormType)': its getter would have to encode an untyped value back to JavaScript, which isn't supported. \(suggestedAlternative(for: freeFormType))",
141
+ id: "js-free-form-property",
142
+ severity: .error
143
+ )
144
+ }
145
+
146
+ private struct JSDiagnosticMessage: DiagnosticMessage {
147
+ let message: String
148
+ let diagnosticID: MessageID
149
+ let severity: DiagnosticSeverity
150
+
151
+ init(_ message: String, id: String, severity: DiagnosticSeverity) {
152
+ self.message = message
153
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
154
+ self.severity = severity
155
+ }
156
+ }
157
+
46
158
  /// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer unique
47
159
  /// among siblings), the boundary types split by conversion direction, and whether the member is
48
160
  /// type-level. Arguments (and a settable property's incoming value) are decoded; return values (and a
@@ -261,6 +261,18 @@ internal func expressionType(_ type: String) -> String {
261
261
  return type.dropLast() + "?"
262
262
  }
263
263
 
264
+ /// The decode expression for a boundary type read from `valueExpression` (a `JavaScriptUnownedValue`),
265
+ /// as it appears after `try`. A free-form type (`Any`, `[Any]`, `[String: Any]`) can't conform to
266
+ /// `JavaScriptDecodable`, so it decodes through the dedicated `JavaScriptValue.decodeAny…` entry point
267
+ /// keyed to its shape; every other type decodes through its own static `decode`, spelled in expression
268
+ /// position (`T!` rewritten to `T?`).
269
+ internal func decodeCall(_ type: String, from valueExpression: String) -> String {
270
+ if let method = freeFormDecodeMethod(for: type) {
271
+ return "JavaScriptValue.\(method)(\(valueExpression), in: runtime)"
272
+ }
273
+ return "\(expressionType(type)).decode(\(valueExpression), in: runtime)"
274
+ }
275
+
264
276
  // MARK: - @JS property collection
265
277
 
266
278
  /// Collects the `@JS var` bindings of a declaration into `JSProperty` values for direct JSI binding.
@@ -1,7 +1,8 @@
1
+ import ExpoModulesScanner
2
+ import Foundation
1
3
  import SwiftCompilerPlugin
2
4
  import SwiftSyntaxMacros
3
5
 
4
- @main
5
6
  struct ExpoModulesMacrosPlugin: CompilerPlugin {
6
7
  let providingMacros: [Macro.Type] = [
7
8
  OptimizedFunctionAttachedMacro.self,
@@ -12,3 +13,19 @@ struct ExpoModulesMacrosPlugin: CompilerPlugin {
12
13
  RecordMacro.self,
13
14
  ]
14
15
  }
16
+
17
+ /// The executable doubles as the scanner CLI. The compiler always launches a plugin executable
18
+ /// without arguments and speaks the plugin protocol over stdin, so any argument means a scanner
19
+ /// invocation (`ExpoModulesMacros-tool scan-modules <path>...`); with none, this starts the plugin
20
+ /// server exactly as `@main` on the `CompilerPlugin` type would.
21
+ @main
22
+ enum EntryPoint {
23
+ static func main() throws {
24
+ let arguments = Array(CommandLine.arguments.dropFirst())
25
+ if arguments.isEmpty {
26
+ try ExpoModulesMacrosPlugin.main()
27
+ } else {
28
+ exit(ScannerCLI.run(arguments: arguments))
29
+ }
30
+ }
31
+ }
@@ -39,16 +39,15 @@ internal enum Receiver {
39
39
  /// `native(from:as:)` recovers the typed instance from `this`, throwing on a foreign object or a type
40
40
  /// mismatch.
41
41
  ///
42
- /// The `this` object comes from the borrowed `JavaScriptUnownedValue` in a sync binding (`asObject(in:)`)
43
- /// and from the owning `JavaScriptValue` in an async one (`asObject()`). An async binding must take an
44
- /// owning `this` because a borrowed unowned value can't survive the closure's suspension points.
45
- func unwrapStatement(isAsync: Bool) -> String? {
42
+ /// The `this` object always comes from the borrowed `JavaScriptUnownedValue` (`asObject(in:)`): an
43
+ /// async binding unwraps in its synchronous decode phase, before the borrowed value's lifetime ends,
44
+ /// and only the recovered native instance crosses into the async body.
45
+ var unwrapStatement: String? {
46
46
  switch self {
47
47
  case .module, .staticMember:
48
48
  return nil
49
49
  case .sharedObject(let typeName):
50
- let thisObject = isAsync ? "this.asObject()" : "this.asObject(in: runtime)"
51
- return "let _self = try SharedObject.native(from: \(thisObject), as: \(typeName).self)"
50
+ return "let _self = try SharedObject.native(from: this.asObject(in: runtime), as: \(typeName).self)"
52
51
  }
53
52
  }
54
53
 
@@ -67,7 +66,7 @@ internal enum Receiver {
67
66
  /// Which JS object a set of bindings is installed on: the second orthogonal axis alongside `Receiver`.
68
67
  /// It selects the decorator entry point's argument label and the local name the body binds members onto,
69
68
  /// mirroring JS class semantics (a class has a constructor function whose `.prototype` carries instance
70
- /// members) and core's `ClassDefinition.decorate`.
69
+ /// members).
71
70
  /// The raw value is the argument label of the decorator entry point, which is also the local name the
72
71
  /// body binds members onto.
73
72
  internal enum Phase: String {
@@ -116,11 +116,15 @@ internal func typeConformanceAssertions(
116
116
  """
117
117
  }
118
118
 
119
- /// The type to assert for a boundary type as written: trailing optional markers are unwrapped to the
120
- /// core type, and a known-conforming primitive returns `nil` (nothing to assert). Shared with
121
- /// `@Event`, which folds its single payload type into a combined assertion of its own shape rather
122
- /// than reusing the whole body fragment below.
119
+ /// The type to assert for a boundary type as written, or `nil` when nothing needs asserting: an
120
+ /// exactly-free-form type (handled by the binding's `decodeAny…` reroute) and a known-conforming
121
+ /// primitive both return `nil`; everything else is unwrapped of trailing optionals and asserted. An
122
+ /// *optional* free-form (`[String: Any]?`) isn't rerouted and doesn't conform, so it's kept and
123
+ /// asserted for a clean diagnostic. Shared with `@Event`.
123
124
  internal func assertableBoundaryType(_ type: String) -> String? {
125
+ guard !isFreeFormBoundaryType(type) else {
126
+ return nil
127
+ }
124
128
  let unwrapped = unwrappedOptional(type)
125
129
  return knownConformingPrimitives.contains(unwrapped) ? nil : unwrapped
126
130
  }
@@ -144,14 +148,12 @@ private func conformanceAssertionBody(
144
148
  return lines.joined(separator: "\n")
145
149
  }
146
150
 
147
- /// Normalizes a list of boundary types for assertion: unwraps each to its core type (trailing
148
- /// optionals stripped), drops the known-conforming primitives that never need asserting, and dedups
149
- /// while preserving first-seen order so a type is asserted once even when it appears more than once.
151
+ /// The distinct types to assert from a list, mapping each through `assertableBoundaryType` (which
152
+ /// drops what needs no assertion) and deduping in first-seen order.
150
153
  private func distinctAssertableTypes(_ types: [String]) -> [String] {
151
154
  var seen: Set<String> = []
152
155
  var distinct: [String] = []
153
- for type in types.map(unwrappedOptional)
154
- where !knownConformingPrimitives.contains(type) && seen.insert(type).inserted {
156
+ for type in types.compactMap(assertableBoundaryType) where seen.insert(type).inserted {
155
157
  distinct.append(type)
156
158
  }
157
159
  return distinct
@@ -0,0 +1,110 @@
1
+ import Foundation
2
+
3
+ /// Command-line front end for the scanner: parses the subcommand and paths, then delegates to the
4
+ /// matching `Scanner` entry (which runs the scan and writes its JSON output). Lives in the library
5
+ /// so the macro plugin executable can dispatch into it: the compiler always launches that executable
6
+ /// without arguments and speaks the plugin protocol over stdin, so any argument means a scanner
7
+ /// invocation. Each path may be a `.swift` file or a directory (scanned recursively for `.swift`
8
+ /// files).
9
+ ///
10
+ /// Subcommands:
11
+ /// scan-modules <path>... fast: top-level `@ExpoModule` types, for autolinking
12
+ /// scan-exports <path>... deep: full JS-exported surface, for TS type generation
13
+ public enum ScannerCLI {
14
+ /// Runs the CLI for the given arguments (argv without the executable path) and returns the
15
+ /// process exit code: `0` on success, `1` if encoding the report fails, `2` on a usage error.
16
+ public static func run(arguments: [String]) -> Int32 {
17
+ // `-h`/`--help` anywhere is treated as a help request: print usage to stdout and exit 0.
18
+ if arguments.contains(where: { $0 == "-h" || $0 == "--help" }) {
19
+ printUsage(to: .standardOutput)
20
+ return 0
21
+ }
22
+
23
+ guard let subcommand = arguments.first else {
24
+ printUsage()
25
+ return 2
26
+ }
27
+
28
+ var paths: [String] = []
29
+ var platform: String?
30
+ var defines: [String] = []
31
+
32
+ var rest = arguments.dropFirst().makeIterator()
33
+ while let argument = rest.next() {
34
+ switch argument {
35
+ case "--platform":
36
+ guard let value = rest.next() else {
37
+ return usageError("--platform requires a value")
38
+ }
39
+ platform = value
40
+ case "--define":
41
+ guard let value = rest.next() else {
42
+ return usageError("--define requires a value")
43
+ }
44
+ defines.append(value)
45
+ default:
46
+ paths.append(argument)
47
+ }
48
+ }
49
+
50
+ switch subcommand {
51
+ case "scan-modules":
52
+ guard !paths.isEmpty else {
53
+ return usageError("scan-modules requires at least one path")
54
+ }
55
+ return Scanner.runModules(paths: paths, platform: platform, defines: defines)
56
+
57
+ case "scan-exports":
58
+ // The exports surface visitor doesn't evaluate `#if` blocks yet, so accepting the options
59
+ // here would silently do nothing.
60
+ guard platform == nil, defines.isEmpty else {
61
+ return usageError("scan-exports does not support --platform or --define")
62
+ }
63
+ guard !paths.isEmpty else {
64
+ return usageError("scan-exports requires at least one path")
65
+ }
66
+ return Scanner.runExports(paths: paths)
67
+
68
+ default:
69
+ return usageError("unknown subcommand '\(subcommand)'")
70
+ }
71
+ }
72
+ }
73
+
74
+ /// The invoked executable's basename, so the usage text matches however the tool was launched
75
+ /// (the `ExpoModulesMacros-tool` shipped in the package, or a locally built copy).
76
+ private var toolName: String {
77
+ return (CommandLine.arguments.first as NSString?)?.lastPathComponent ?? "ExpoModulesScanner"
78
+ }
79
+
80
+ private var usageText: String {
81
+ """
82
+ usage: \(toolName) <subcommand> [options] <path> [<path> ...]
83
+
84
+ subcommands:
85
+ scan-modules fast scan for top-level @ExpoModule types (autolinking)
86
+ scan-exports deep scan of the full JS-exported surface (type generation)
87
+
88
+ options (scan-modules only):
89
+ --platform <os> evaluate '#if os(...)' against this platform (iOS, macOS, tvOS, ...);
90
+ without it, os-conditional declarations are skipped with a warning
91
+ --define <flag> treat a conditional compilation flag (e.g. DEBUG) as set; repeatable
92
+
93
+ options:
94
+ -h, --help print this help and exit
95
+
96
+ """
97
+ }
98
+
99
+ /// Prints the usage text to the given handle. Goes to stdout when help was explicitly requested
100
+ /// (a successful action), stderr when it accompanies a usage error.
101
+ private func printUsage(to handle: FileHandle = .standardError) {
102
+ handle.write(Data(usageText.utf8))
103
+ }
104
+
105
+ /// Reports a usage error on stderr, followed by the usage text, and returns the usage exit code.
106
+ private func usageError(_ message: String) -> Int32 {
107
+ FileHandle.standardError.write(Data("error: \(message)\n".utf8))
108
+ printUsage()
109
+ return 2
110
+ }
@@ -33,6 +33,12 @@ struct Detection: Codable, Equatable {
33
33
  /// The kind of declaration the macro was attached to: `class`, `struct`, `func`, `var`, `init`, …
34
34
  let declarationKind: String
35
35
 
36
+ /// The declaration's spelled access modifier (`open`, `public`, `package`, `fileprivate`,
37
+ /// `private`), or `internal` when none is written. Consumers that reference the declaration from
38
+ /// another Swift module (the generated modules provider does) need `public`/`open` and can reject
39
+ /// the rest up front instead of failing at compile time.
40
+ let accessLevel: String
41
+
36
42
  /// The explicit JS name override when written as `@ExpoModule("Foo")` / `@JS("bar")` /
37
43
  /// `@SharedObject("Baz")`, otherwise `nil` (the name defaults to `name` at expansion time).
38
44
  let jsName: String?
@@ -47,6 +53,16 @@ struct Detection: Codable, Equatable {
47
53
  let column: Int
48
54
  }
49
55
 
56
+ /// A non-fatal problem found while scanning, tied to the source location that caused it — today,
57
+ /// an `#if` condition the scan's static configuration cannot answer (`canImport`, `arch`, an
58
+ /// `os(...)` check with no `--platform` given, …). The affected region is treated as inactive, so
59
+ /// the warning tells the consumer which declarations may have been skipped and why.
60
+ struct ScanWarning: Codable, Equatable {
61
+ let message: String
62
+ let file: String
63
+ let line: Int
64
+ }
65
+
50
66
  /// Counts describing how much work the scan did, so callers can see the pre-filter's effect: of all
51
67
  /// the `.swift` files read, how many actually needed parsing, and how long the run took.
52
68
  struct ScanStats: Codable, Equatable {
@@ -1,3 +1,4 @@
1
+ import SwiftIfConfig
1
2
  import SwiftSyntax
2
3
 
3
4
  /// Walks a parsed source file and records top-level declarations carrying `@ExpoModule`, `@JS`,
@@ -6,7 +7,12 @@ import SwiftSyntax
6
7
  /// Recognition mirrors the macros themselves — a purely syntactic match on the spelled attribute
7
8
  /// name — so it sees the same declarations the compiler would hand the plugin, without compiling
8
9
  /// anything.
9
- final class DetectionVisitor: SyntaxVisitor {
10
+ ///
11
+ /// `#if` blocks are handled by the `ActiveSyntaxVisitor` base: only clauses active under the scan's
12
+ /// `ScanBuildConfiguration` are visited, so a declaration inside `#if os(tvOS)` is recorded exactly
13
+ /// when the scan targets tvOS. Conditions the configuration cannot answer make their region
14
+ /// inactive and land in the inherited `diagnostics`, which the scan surfaces as warnings.
15
+ final class DetectionVisitor: ActiveSyntaxVisitor {
10
16
  private let file: String
11
17
  private let converter: SourceLocationConverter
12
18
  /// Only these macros are recorded; the rest are ignored. Lets a `modules` scan report just
@@ -14,16 +20,30 @@ final class DetectionVisitor: SyntaxVisitor {
14
20
  private let detectedMacros: Set<DetectedMacro>
15
21
  private(set) var detections: [Detection] = []
16
22
 
17
- init(file: String, tree: SourceFileSyntax, detectedMacros: Set<DetectedMacro>) {
23
+ init(
24
+ file: String,
25
+ tree: SourceFileSyntax,
26
+ detectedMacros: Set<DetectedMacro>,
27
+ configuration: ScanBuildConfiguration
28
+ ) {
18
29
  self.file = file
19
30
  self.converter = SourceLocationConverter(fileName: file, tree: tree)
20
31
  self.detectedMacros = detectedMacros
21
- super.init(viewMode: .sourceAccurate)
32
+ super.init(viewMode: .sourceAccurate, configuration: configuration)
33
+ }
34
+
35
+ /// The accumulated `#if` warnings as `Detection`-style locations plus the message, ready for the
36
+ /// scan report. These come from conditions the configuration cannot answer statically.
37
+ var warnings: [ScanWarning] {
38
+ return diagnostics.map { diagnostic in
39
+ let location = diagnostic.location(converter: converter)
40
+ return ScanWarning(message: diagnostic.message, file: file, line: location.line)
41
+ }
22
42
  }
23
43
 
24
44
  override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
25
45
  if isTopLevel(node) {
26
- record(attributes: node.attributes, name: node.name.text, kind: "class", at: node)
46
+ record(attributes: node.attributes, modifiers: node.modifiers, name: node.name.text, kind: "class", at: node)
27
47
  }
28
48
  // Members live in the type body; we never report them, so there's no reason to descend.
29
49
  return .skipChildren
@@ -31,29 +51,45 @@ final class DetectionVisitor: SyntaxVisitor {
31
51
 
32
52
  override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
33
53
  if isTopLevel(node) {
34
- record(attributes: node.attributes, name: node.name.text, kind: "struct", at: node)
54
+ record(attributes: node.attributes, modifiers: node.modifiers, name: node.name.text, kind: "struct", at: node)
35
55
  }
36
56
  return .skipChildren
37
57
  }
38
58
 
39
- /// True when the declaration sits at file scope: its parent is a `CodeBlockItemSyntax` directly
40
- /// under the source file's top-level item list. Members of a type are nested in a
41
- /// `MemberBlockItemSyntax` instead, so they don't match.
59
+ /// True when the declaration sits at file scope: its parent chain reaches the source file through
60
+ /// only code-block items and `#if` structure. Members of a type are nested in a
61
+ /// `MemberBlockItemSyntax` instead, so they don't match. The `#if` wrappers are allowed because a
62
+ /// top-level declaration inside an (active) `#if` clause is still a top-level declaration.
42
63
  ///
43
64
  /// TODO: decide whether to support nested types. A macro on a type nested in another type/enum/
44
65
  /// extension is valid Swift but missed here; supporting it means descending into type bodies and
45
66
  /// recording the enclosing path for a qualified name (e.g. `Namespace.InnerModule`).
46
67
  private func isTopLevel(_ node: some SyntaxProtocol) -> Bool {
47
- guard let item = node.parent?.as(CodeBlockItemSyntax.self) else {
68
+ guard node.parent?.is(CodeBlockItemSyntax.self) == true else {
48
69
  return false
49
70
  }
50
- return item.parent?.parent?.is(SourceFileSyntax.self) == true
71
+ var current = node.parent?.parent
72
+ while let node = current {
73
+ if node.is(SourceFileSyntax.self) {
74
+ return true
75
+ }
76
+ guard node.is(CodeBlockItemListSyntax.self)
77
+ || node.is(CodeBlockItemSyntax.self)
78
+ || node.is(IfConfigClauseSyntax.self)
79
+ || node.is(IfConfigClauseListSyntax.self)
80
+ || node.is(IfConfigDeclSyntax.self) else {
81
+ return false
82
+ }
83
+ current = node.parent
84
+ }
85
+ return false
51
86
  }
52
87
 
53
88
  /// Emits one detection per recognized Expo attribute on the declaration. A declaration can in
54
89
  /// principle carry more than one (uncommon), so each is recorded independently.
55
90
  private func record(
56
91
  attributes: AttributeListSyntax,
92
+ modifiers: DeclModifierListSyntax,
57
93
  name: String,
58
94
  kind: String,
59
95
  at node: some SyntaxProtocol
@@ -70,6 +106,7 @@ final class DetectionVisitor: SyntaxVisitor {
70
106
  macro: macro,
71
107
  name: name,
72
108
  declarationKind: kind,
109
+ accessLevel: accessLevel(of: modifiers),
73
110
  jsName: stringArgument(of: attribute),
74
111
  arguments: arguments(of: attribute),
75
112
  file: file,
@@ -81,6 +118,19 @@ final class DetectionVisitor: SyntaxVisitor {
81
118
  }
82
119
  }
83
120
 
121
+ /// The spelled access modifiers, in the order Swift defines them. Other modifiers (`final`,
122
+ /// `static`, …) are not access levels and are skipped when resolving one.
123
+ private let accessModifierNames: Set<String> = ["open", "public", "package", "internal", "fileprivate", "private"]
124
+
125
+ /// The declaration's access level: the first spelled access modifier, or Swift's default of
126
+ /// `internal` when none is written. A declaration can spell at most one, so first is the only one.
127
+ private func accessLevel(of modifiers: DeclModifierListSyntax) -> String {
128
+ for modifier in modifiers where accessModifierNames.contains(modifier.name.text) {
129
+ return modifier.name.text
130
+ }
131
+ return "internal"
132
+ }
133
+
84
134
  /// Every argument passed to the attribute, in source order, each as a label (or `nil` when
85
135
  /// positional) plus the value expression's source text. Returns an empty array when the attribute
86
136
  /// is written bare (`@ExpoModule`) or with empty parens.
@@ -0,0 +1,121 @@
1
+ import SwiftIfConfig
2
+ import SwiftSyntax
3
+
4
+ /// The build configuration that `#if` conditions are evaluated against during a scan, built from
5
+ /// the CLI's `--platform` and `--define` options. The scan is static: it knows the target OS and
6
+ /// the spelled compilation flags, and nothing else. Every condition it cannot answer throws, which
7
+ /// SwiftIfConfig turns into an inactive region plus a diagnostic, so an unanswerable `#if` skips
8
+ /// its declarations and surfaces a warning instead of guessing.
9
+ struct ScanBuildConfiguration: BuildConfiguration {
10
+ /// The target OS name to answer `os(...)` with, as spelled in the condition (`iOS`, `macOS`,
11
+ /// `tvOS`, `watchOS`, `visionOS`; compared case-insensitively), or `nil` when no `--platform`
12
+ /// was given, in which case `os(...)` conditions are unanswerable.
13
+ let platform: String?
14
+
15
+ /// The conditional compilation flags treated as set, from repeated `--define` options.
16
+ let defines: Set<String>
17
+
18
+ func isCustomConditionSet(name: String) throws -> Bool {
19
+ return defines.contains(name)
20
+ }
21
+
22
+ func isActiveTargetOS(name: String) throws -> Bool {
23
+ guard let platform else {
24
+ throw ScanConfigurationError("cannot evaluate 'os(\(name))': no --platform was given")
25
+ }
26
+ return name.lowercased() == platform.lowercased()
27
+ }
28
+
29
+ // MARK: - Unanswerable conditions
30
+
31
+ // These vary within a single platform's build (device vs simulator, arm64 vs x86_64) or depend
32
+ // on the consumer's toolchain, so a static scan has no correct answer. Throwing makes the region
33
+ // inactive and emits a warning naming the condition.
34
+
35
+ func hasFeature(name: String) throws -> Bool {
36
+ throw ScanConfigurationError("cannot evaluate 'hasFeature(\(name))' in a static scan")
37
+ }
38
+
39
+ func hasAttribute(name: String) throws -> Bool {
40
+ throw ScanConfigurationError("cannot evaluate 'hasAttribute(\(name))' in a static scan")
41
+ }
42
+
43
+ func canImport(importPath: [(TokenSyntax, String)], version: CanImportVersion) throws -> Bool {
44
+ let module = importPath.map(\.1).joined(separator: ".")
45
+
46
+ // A curated set of SDK frameworks is answerable from the target platform alone. Everything
47
+ // else (arbitrary modules, submodule paths, versioned checks) stays unanswerable: a wrong
48
+ // "yes" would surface a declaration that doesn't exist in the real build.
49
+ guard importPath.count == 1,
50
+ case .unversioned = version,
51
+ let frameworkPlatforms = sdkFrameworkPlatforms[module] else {
52
+ throw ScanConfigurationError("cannot evaluate 'canImport(\(module))' in a static scan")
53
+ }
54
+ guard let platform else {
55
+ throw ScanConfigurationError("cannot evaluate 'canImport(\(module))': no --platform was given")
56
+ }
57
+ return frameworkPlatforms.contains(platform.lowercased())
58
+ }
59
+
60
+ func isActiveTargetArchitecture(name: String) throws -> Bool {
61
+ throw ScanConfigurationError("cannot evaluate 'arch(\(name))' in a static scan")
62
+ }
63
+
64
+ func isActiveTargetEnvironment(name: String) throws -> Bool {
65
+ throw ScanConfigurationError("cannot evaluate 'targetEnvironment(\(name))' in a static scan")
66
+ }
67
+
68
+ func isActiveTargetRuntime(name: String) throws -> Bool {
69
+ throw ScanConfigurationError("cannot evaluate '_runtime(\(name))' in a static scan")
70
+ }
71
+
72
+ func isActiveTargetPointerAuthentication(name: String) throws -> Bool {
73
+ throw ScanConfigurationError("cannot evaluate '_ptrauth(\(name))' in a static scan")
74
+ }
75
+
76
+ // MARK: - Fixed answers
77
+
78
+ // Non-throwing protocol requirements, so they need a value. These are constant across Apple
79
+ // targets (the only ones Expo modules compile for), except the versions, which assume a current
80
+ // toolchain; a module class gated on a *lower* Swift version would be wrongly included, which is
81
+ // rare enough to accept for a scan.
82
+
83
+ var targetPointerBitWidth: Int { 64 }
84
+ var targetAtomicBitWidths: [Int] { [32, 64, 128] }
85
+ var endianness: Endianness { .little }
86
+ var languageVersion: VersionTuple { VersionTuple(6) }
87
+ var compilerVersion: VersionTuple { VersionTuple(6, 2) }
88
+ }
89
+
90
+ /// The platforms (lowercased, as compared against `--platform`) that ship each of a curated set of
91
+ /// Apple SDK frameworks, so `canImport` of one is answerable from the platform alone. The list is
92
+ /// deliberately small and high-confidence: it covers the frameworks realistically used to gate a
93
+ /// module class, and a framework missing here degrades to the skip-with-warning path rather than a
94
+ /// wrong answer.
95
+ private let sdkFrameworkPlatforms: [String: Set<String>] = [
96
+ "UIKit": ["ios", "tvos", "watchos", "visionos"],
97
+ "AppKit": ["macos"],
98
+ "SwiftUI": ["ios", "macos", "tvos", "watchos", "visionos"],
99
+ "WatchKit": ["watchos"],
100
+ "TVUIKit": ["tvos"],
101
+ "WebKit": ["ios", "macos", "visionos"],
102
+ "SafariServices": ["ios", "macos", "visionos"],
103
+ "ARKit": ["ios", "visionos"],
104
+ "RealityKit": ["ios", "macos", "visionos"],
105
+ "CarPlay": ["ios"],
106
+ "MessageUI": ["ios"],
107
+ "CoreNFC": ["ios"],
108
+ "HealthKit": ["ios", "watchos", "visionos"],
109
+ "HomeKit": ["ios", "tvos", "watchos", "visionos"],
110
+ "WidgetKit": ["ios", "macos", "watchos", "visionos"],
111
+ ]
112
+
113
+ /// An unanswerable `#if` condition. SwiftIfConfig converts the thrown error into a diagnostic on
114
+ /// the condition's node and treats the region as inactive.
115
+ struct ScanConfigurationError: Error, CustomStringConvertible {
116
+ let description: String
117
+
118
+ init(_ description: String) {
119
+ self.description = description
120
+ }
121
+ }
@@ -44,23 +44,36 @@ func scanFiles(
44
44
  }
45
45
 
46
46
  /// Walks `paths`, parses each `.swift` file that might contain one of `macros`, and returns every
47
- /// detection (in file then source order) with the run's stats — the shape `scan-modules` projects.
48
- /// A thin layer over `scanFiles` that accumulates the per-file detections.
49
- func collectDetections(paths: [String], macros: Set<DetectedMacro>) -> (detections: [Detection], stats: ScanStats) {
47
+ /// detection (in file then source order) with the accumulated `#if` warnings and the run's stats —
48
+ /// the shape `scan-modules` projects. A thin layer over `scanFiles` that accumulates the per-file
49
+ /// results.
50
+ func collectDetections(
51
+ paths: [String],
52
+ macros: Set<DetectedMacro>,
53
+ configuration: ScanBuildConfiguration = .init(platform: nil, defines: [])
54
+ ) -> (detections: [Detection], warnings: [ScanWarning], stats: ScanStats) {
50
55
  var detections: [Detection] = []
56
+ var warnings: [ScanWarning] = []
51
57
  let stats = scanFiles(paths: paths, macros: macros) { source, file in
52
- detections.append(contentsOf: detect(source: source, file: file, macros: macros))
58
+ let result = detect(source: source, file: file, macros: macros, configuration: configuration)
59
+ detections.append(contentsOf: result.detections)
60
+ warnings.append(contentsOf: result.warnings)
53
61
  }
54
- return (detections, stats)
62
+ return (detections, warnings, stats)
55
63
  }
56
64
 
57
- /// Parses one source string and returns its detections for the given macro set. The unit of work the
58
- /// tests exercise.
59
- func detect(source: String, file: String, macros: Set<DetectedMacro>) -> [Detection] {
65
+ /// Parses one source string and returns its detections for the given macro set, plus the warnings
66
+ /// for `#if` conditions the configuration couldn't answer. The unit of work the tests exercise.
67
+ func detect(
68
+ source: String,
69
+ file: String,
70
+ macros: Set<DetectedMacro>,
71
+ configuration: ScanBuildConfiguration
72
+ ) -> (detections: [Detection], warnings: [ScanWarning]) {
60
73
  let tree = Parser.parse(source: source)
61
- let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros)
74
+ let visitor = DetectionVisitor(file: file, tree: tree, detectedMacros: macros, configuration: configuration)
62
75
  visitor.walk(tree)
63
- return visitor.detections
76
+ return (visitor.detections, visitor.warnings)
64
77
  }
65
78
 
66
79
  // MARK: - Pre-filter
@@ -89,8 +102,9 @@ func mightContainMacro(in source: String, prefilter: NSRegularExpression) -> Boo
89
102
 
90
103
  /// Directory names skipped during the recursive walk. These hold build products, dependencies, and
91
104
  /// git internals — never source worth scanning — and pruning them keeps the walk from descending
92
- /// into the bulk of a monorepo's files.
93
- private let prunedDirectoryNames: Set<String> = [".build", "Pods", ".git"]
105
+ /// into the bulk of a monorepo's files. `node_modules` makes any npm package root safe to pass as a
106
+ /// scan path: nested dependencies are separate packages and get scanned on their own.
107
+ private let prunedDirectoryNames: Set<String> = [".build", "Pods", ".git", "node_modules"]
94
108
 
95
109
  /// Expands the given paths into the list of `.swift` files to parse: a file path passes through,
96
110
  /// a directory is enumerated recursively (skipping `prunedDirectoryNames`). Order is deterministic
@@ -9,8 +9,12 @@ public enum Scanner {
9
9
  /// Runs the `scan-modules` command over `paths`, prints the JSON report to stdout, and returns a
10
10
  /// process exit code: `0` on success, `1` if encoding fails. (The deep `scan-exports` command has
11
11
  /// its own `runExports` entry returning its own result type.)
12
- public static func runModules(paths: [String]) -> Int32 {
13
- let result = scanModules(paths: paths)
12
+ ///
13
+ /// `platform` and `defines` (the `--platform` and `--define` options) form the configuration that
14
+ /// `#if` conditions are evaluated against; see `ScanBuildConfiguration`.
15
+ public static func runModules(paths: [String], platform: String? = nil, defines: [String] = []) -> Int32 {
16
+ let configuration = ScanBuildConfiguration(platform: platform, defines: Set(defines))
17
+ let result = scanModules(paths: paths, configuration: configuration)
14
18
 
15
19
  do {
16
20
  let encoder = JSONEncoder()
@@ -40,29 +44,51 @@ struct ScannedModule: Codable, Equatable {
40
44
  /// and the consumer never has to apply the fallback itself.
41
45
  let jsName: String
42
46
 
47
+ /// The class's spelled access modifier (`open`, `public`, `package`, `fileprivate`, `private`),
48
+ /// or `internal` when none is written. The generated modules provider references the class from
49
+ /// the app target, which requires `public`/`open`, so the consumer uses this to skip inaccessible
50
+ /// classes with a diagnostic instead of emitting a provider that fails to compile.
51
+ let accessLevel: String
52
+
43
53
  /// Source file the module was found in, relative to the path the scanner was invoked with.
44
54
  let file: String
45
55
  }
46
56
 
57
+ /// Version of the `scan-modules` output shape. Bumped on any breaking change to the envelope or to
58
+ /// `ScannedModule`, so `expo-modules-autolinking` can verify it understands the output before
59
+ /// trusting it (and fall back to config-declared modules when it doesn't).
60
+ let scanModulesSchemaVersion = 1
61
+
47
62
  /// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
48
63
  /// command's JSON output. (`scan-exports` returns its own `ScanExportsResult` shape; the two commands
49
64
  /// serve different consumers and don't share an envelope.)
50
65
  struct ScanModulesResult: Codable, Equatable {
66
+ let schemaVersion: Int
51
67
  let modules: [ScannedModule]
68
+
69
+ /// Warnings for `#if` conditions the scan couldn't answer statically (see `ScanWarning`). Carried
70
+ /// in the report rather than on stderr so the consumer can attach them to its own output.
71
+ let warnings: [ScanWarning]
72
+
52
73
  let stats: ScanStats
53
74
  }
54
75
 
55
76
  /// Scans the given paths for top-level `@ExpoModule` types and returns the modules (in file then
56
77
  /// source order) plus the stats for the run — the `scan-modules` command. Kept separate from the
57
78
  /// public entry (and `internal`) so tests can drive it without going through argv/stdout.
58
- func scanModules(paths: [String]) -> ScanModulesResult {
59
- let scan = collectDetections(paths: paths, macros: [.expoModule])
79
+ func scanModules(paths: [String], configuration: ScanBuildConfiguration = .init(platform: nil, defines: [])) -> ScanModulesResult {
80
+ let scan = collectDetections(paths: paths, macros: [.expoModule], configuration: configuration)
60
81
 
61
82
  let modules = scan.detections.map {
62
83
  // Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` override, else the
63
84
  // class name.
64
- ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, file: $0.file)
85
+ ScannedModule(name: $0.name, jsName: $0.jsName ?? $0.name, accessLevel: $0.accessLevel, file: $0.file)
65
86
  }
66
87
 
67
- return ScanModulesResult(modules: modules, stats: scan.stats)
88
+ return ScanModulesResult(
89
+ schemaVersion: scanModulesSchemaVersion,
90
+ modules: modules,
91
+ warnings: scan.warnings,
92
+ stats: scan.stats
93
+ )
68
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/expo-modules-macros-plugin",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Swift macro plugin for Expo modules",
5
5
  "license": "MIT",
6
6
  "author": "650 Industries, Inc.",
@@ -1,71 +0,0 @@
1
- import ExpoModulesScanner
2
- import Foundation
3
-
4
- /// Command-line front end for the scanner. Parses the subcommand and paths, then delegates to the
5
- /// matching library entry (which runs the scan and writes its JSON output). Each path may be a
6
- /// `.swift` file or a directory (scanned recursively for `.swift` files).
7
- ///
8
- /// Subcommands:
9
- /// scan-modules <path>... fast: top-level `@ExpoModule` types, for autolinking
10
- /// scan-exports <path>... deep: full JS-exported surface, for TS type generation
11
-
12
- let toolName = "ExpoModulesScanner"
13
-
14
- let usageText = """
15
- usage: \(toolName) <subcommand> <path> [<path> ...]
16
-
17
- subcommands:
18
- scan-modules fast scan for top-level @ExpoModule types (autolinking)
19
- scan-exports deep scan of the full JS-exported surface (type generation)
20
-
21
- options:
22
- -h, --help print this help and exit
23
-
24
- """
25
-
26
- /// Prints the usage text to the given handle. Goes to stdout when help was explicitly requested
27
- /// (a successful action), stderr when it accompanies a usage error.
28
- func printUsage(to handle: FileHandle = .standardError) {
29
- handle.write(Data(usageText.utf8))
30
- }
31
-
32
- func fail(_ message: String, usage: Bool = false, code: Int32 = 2) -> Never {
33
- FileHandle.standardError.write(Data("error: \(message)\n".utf8))
34
- if usage {
35
- printUsage()
36
- }
37
- exit(code)
38
- }
39
-
40
- var arguments = Array(CommandLine.arguments.dropFirst())
41
-
42
- // `-h`/`--help` anywhere is treated as a help request: print usage to stdout and exit 0.
43
- if arguments.contains(where: { $0 == "-h" || $0 == "--help" }) {
44
- printUsage(to: .standardOutput)
45
- exit(0)
46
- }
47
-
48
- guard !arguments.isEmpty else {
49
- printUsage()
50
- exit(2)
51
- }
52
-
53
- let subcommand = arguments.removeFirst()
54
- let paths = arguments
55
-
56
- switch subcommand {
57
- case "scan-modules":
58
- guard !paths.isEmpty else {
59
- fail("scan-modules requires at least one path", usage: true)
60
- }
61
- exit(Scanner.runModules(paths: paths))
62
-
63
- case "scan-exports":
64
- guard !paths.isEmpty else {
65
- fail("scan-exports requires at least one path", usage: true)
66
- }
67
- exit(Scanner.runExports(paths: paths))
68
-
69
- default:
70
- fail("unknown subcommand '\(subcommand)'", usage: true)
71
- }