expo-modules-macros 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/AGENTS.md +8 -8
  2. package/README.md +40 -3
  3. package/apple/ExpoModulesMacros +0 -0
  4. package/apple/Package.swift +10 -4
  5. package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/CLI.swift +45 -2
  6. package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +120 -0
  7. package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +183 -0
  8. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ExportedSurface.swift +3 -2
  9. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ResolveRefs.swift +9 -0
  10. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/SurfaceVisitor.swift +10 -3
  11. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/TupleTypeAliases.swift +126 -0
  12. package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/TypeNode.swift +47 -4
  13. package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/SourceScan.swift +8 -2
  14. package/apple/Sources/ExpoModulesMacros/Expand/MacroExpander.swift +484 -0
  15. package/apple/Sources/ExpoModulesMacros/{EventMacro.swift → Macros/EventMacro.swift} +23 -10
  16. package/apple/Sources/ExpoModulesMacros/{ExpoModuleMacro.swift → Macros/ExpoModuleMacro.swift} +13 -1
  17. package/apple/Sources/ExpoModulesMacros/{JSMacro.swift → Macros/JSMacro.swift} +71 -54
  18. package/apple/Sources/ExpoModulesMacros/{RecordMacro.swift → Macros/RecordMacro.swift} +18 -5
  19. package/apple/Sources/ExpoModulesMacros/{SharedObjectMacro.swift → Macros/SharedObjectMacro.swift} +13 -1
  20. package/apple/Sources/ExpoModulesMacros/{UnionMacro.swift → Macros/UnionMacro.swift} +16 -5
  21. package/apple/Sources/ExpoModulesMacros/Plugin.swift +9 -14
  22. package/apple/Sources/ExpoModulesMacros/ProvidedMacros.swift +31 -0
  23. package/apple/Sources/ExpoModulesMacros/{ClosureArguments.swift → Support/ClosureArguments.swift} +30 -4
  24. package/apple/Sources/ExpoModulesMacros/{DecorateModuleBuilder.swift → Support/DecorateModuleBuilder.swift} +5 -1
  25. package/apple/Sources/ExpoModulesMacros/{MacroHelpers.swift → Support/MacroHelpers.swift} +11 -4
  26. package/apple/Sources/ExpoModulesMacros/Support/TupleTypes.swift +272 -0
  27. package/apple/Sources/ExpoModulesMacros/Support/TypeAliases.swift +75 -0
  28. package/apple/Sources/ExpoModulesMacros/{TypeConformanceAssertion.swift → Support/TypeConformanceAssertion.swift} +104 -41
  29. package/build/index.d.ts +31 -1
  30. package/build/index.js +34 -0
  31. package/build/types.d.ts +77 -2
  32. package/build/types.js +4 -3
  33. package/package.json +5 -5
  34. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/Availability.swift +0 -0
  35. /package/apple/Sources/{ExpoModulesScanner/Exports → ExpoModulesCLI/Commands/ScanExports}/ScanExports.swift +0 -0
  36. /package/apple/Sources/{ExpoModulesScanner/Modules → ExpoModulesCLI/Commands/ScanModules}/ScanModules.swift +0 -0
  37. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/Detection.swift +0 -0
  38. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/DetectionVisitor.swift +0 -0
  39. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/ScanBuildConfiguration.swift +0 -0
  40. /package/apple/Sources/{ExpoModulesScanner → ExpoModulesCLI}/Core/StandardStreams.swift +0 -0
  41. /package/apple/Sources/ExpoModulesMacros/{ExpoModulesOptimizedMacro.swift → Macros/ExpoModulesOptimizedMacro.swift} +0 -0
  42. /package/apple/Sources/ExpoModulesMacros/{ExpoViewMacro.swift → Macros/ExpoViewMacro.swift} +0 -0
  43. /package/apple/Sources/ExpoModulesMacros/{ViewPropsMacro.swift → Macros/ViewPropsMacro.swift} +0 -0
  44. /package/apple/Sources/ExpoModulesMacros/{FreeFormTypes.swift → Support/FreeFormTypes.swift} +0 -0
  45. /package/apple/Sources/ExpoModulesMacros/{JSConstructor.swift → Support/JSConstructor.swift} +0 -0
  46. /package/apple/Sources/ExpoModulesMacros/{JSNameCollisions.swift → Support/JSNameCollisions.swift} +0 -0
  47. /package/apple/Sources/ExpoModulesMacros/{OptimizedFunctionHelpers.swift → Support/OptimizedFunctionHelpers.swift} +0 -0
  48. /package/apple/Sources/ExpoModulesMacros/{Receiver.swift → Support/Receiver.swift} +0 -0
@@ -0,0 +1,272 @@
1
+ import SwiftSyntax
2
+
3
+ /// Recognition of boundary types that hold a tuple, and the conversions the macros emit for them. A
4
+ /// tuple can't conform to a protocol, so neither it nor an array, optional or dictionary of tuples can
5
+ /// convert through the usual `T.decode` / `T.encode` path. Such a type converts through the
6
+ /// `JavaScriptValue` tuple and container methods instead, which take one closure per element, so the
7
+ /// conversion follows the structure of the type: a tuple, optional, array or `String`-keyed dictionary
8
+ /// that holds a tuple converts through the matching method, and every other type in it through its own
9
+ /// `decode` / `encode`.
10
+ ///
11
+ /// An unlabeled tuple maps to a JavaScript array with one value per element, and a labeled tuple to an
12
+ /// object with one property per label. Recognized are tuples of two or more elements where either every
13
+ /// element is labeled or none is. `TypeConformanceAssertion` asserts the types that convert through
14
+ /// their own `decode` / `encode` (see `conformingTypes`) in place of the whole type.
15
+ internal enum TupleConversion {
16
+ /// A type that holds no tuple, which converts through its own `decode` / `encode`.
17
+ case conforming(type: String)
18
+ /// A tuple: the conversion of each element, with one label per element for a labeled tuple.
19
+ case tuple(elements: [TupleConversion], labels: [String]?)
20
+ /// An optional of a type that holds a tuple.
21
+ indirect case optional(wrapped: TupleConversion)
22
+ /// An array of a type that holds a tuple.
23
+ indirect case array(element: TupleConversion)
24
+ /// A `String`-keyed dictionary whose values hold a tuple.
25
+ indirect case dictionary(value: TupleConversion)
26
+
27
+ /// The decode expression, as it appears after `try`, for a value read from `valueExpression` (a
28
+ /// `JavaScriptUnownedValue`). A tuple whose elements all conform passes the element types; any other
29
+ /// tuple and every container pass closures, which take the value and the runtime as parameters that
30
+ /// shadow the outer ones, so a closure captures nothing.
31
+ func decodeExpression(of valueExpression: String) -> String {
32
+ switch self {
33
+ case .conforming(let type):
34
+ return "\(expressionType(type)).decode(\(valueExpression), in: runtime)"
35
+ case .tuple(let elements, let labels):
36
+ if let types = conformingElementTypes(elements) {
37
+ let metatypes = types.map { "\(expressionType($0)).self" }.joined(separator: ", ")
38
+ let arguments = [valueExpression] + labelsArgument(labels) + ["as: \(metatypes)", "in: runtime"]
39
+ return "JavaScriptValue.decodeTuple(\(arguments.joined(separator: ", ")))"
40
+ }
41
+ let closures = elements.map { elementClosure(returning: $0.decodeExpression(of: "value")) }
42
+ let arguments = [valueExpression] + labelsArgument(labels) + ["in: runtime"] + closures
43
+ return "JavaScriptValue.decodeTuple(\(arguments.joined(separator: ", ")))"
44
+ case .optional(let wrapped):
45
+ return containerDecode("decodeOptional", label: "wrapped", inner: wrapped, of: valueExpression)
46
+ case .array(let element):
47
+ return containerDecode("decodeArray", label: "element", inner: element, of: valueExpression)
48
+ case .dictionary(let value):
49
+ return containerDecode("decodeDictionary", label: "value", inner: value, of: valueExpression)
50
+ }
51
+ }
52
+
53
+ /// The call, as it appears after `try`, that encodes the value `valueExpression` into the slot named
54
+ /// `slot`, in the runtime named `runtime`. A tuple whose elements all conform needs no closures. The
55
+ /// tuple method takes an unlabeled tuple, so a labeled one is coerced to its unlabeled type first.
56
+ func encodeCall(of valueExpression: String, into slot: String, in runtime: String = "runtime") -> String {
57
+ switch self {
58
+ case .conforming(let type):
59
+ return "\(expressionType(type)).encode(\(valueExpression), into: \(slot), in: \(runtime))"
60
+ case .tuple(let elements, let labels):
61
+ let value = labels == nil ? valueExpression : "\(valueExpression) as \(unlabeledType)"
62
+ let closures =
63
+ conformingElementTypes(elements) == nil
64
+ ? elements.map { encoderClosure(encoding: $0.encodeCall(of: "value", into: "slot")) } : []
65
+ let arguments = [value] + labelsArgument(labels) + ["into: \(slot)", "in: \(runtime)"] + closures
66
+ return "JavaScriptValue.encodeTuple(\(arguments.joined(separator: ", ")))"
67
+ case .optional(let wrapped):
68
+ return containerEncode(
69
+ "encodeOptional",
70
+ label: "wrapped",
71
+ inner: wrapped,
72
+ of: valueExpression,
73
+ into: slot,
74
+ in: runtime
75
+ )
76
+ case .array(let element):
77
+ return containerEncode(
78
+ "encodeArray",
79
+ label: "element",
80
+ inner: element,
81
+ of: valueExpression,
82
+ into: slot,
83
+ in: runtime
84
+ )
85
+ case .dictionary(let value):
86
+ return containerEncode(
87
+ "encodeDictionary",
88
+ label: "value",
89
+ inner: value,
90
+ of: valueExpression,
91
+ into: slot,
92
+ in: runtime
93
+ )
94
+ }
95
+ }
96
+
97
+ /// The encode expression, as it appears after `try`, that creates a `JavaScriptValue` from the value
98
+ /// `valueExpression`, for a place that has no slot to encode into, such as an async result. A type that
99
+ /// holds a tuple is encoded into the slot of `JavaScriptValue.encoding(in:_:)`.
100
+ func encodeExpression(of valueExpression: String) -> String {
101
+ if case .conforming(let type) = self {
102
+ return "\(expressionType(type)).encode(\(valueExpression), in: runtime)"
103
+ }
104
+ return "JavaScriptValue.encoding(in: runtime) { slot in try \(encodeCall(of: valueExpression, into: "slot")) }"
105
+ }
106
+
107
+ /// The JavaScript value kinds the decode accepts, as an expression of type `JavaScriptValueKinds`, for
108
+ /// a `@Union` that picks a payload by kind. Mirrors the kinds of the matching `JavaScriptCodable`
109
+ /// conformances: an array decodes a non-array value as a single element.
110
+ var decodableKindsExpression: String {
111
+ switch self {
112
+ case .conforming(let type):
113
+ return "\(expressionType(type)).decodableKinds"
114
+ case .tuple:
115
+ return "JavaScriptValueKinds.object"
116
+ case .optional(let wrapped):
117
+ return "\(wrapped.decodableKindsExpression).union([.null, .undefined])"
118
+ case .array(let element):
119
+ return "\(element.decodableKindsExpression).union(JavaScriptValueKinds.object)"
120
+ case .dictionary:
121
+ return "JavaScriptValueKinds.object"
122
+ }
123
+ }
124
+
125
+ /// The types in the structure that convert through their own `decode` / `encode`, in order, as
126
+ /// written.
127
+ var conformingTypes: [String] {
128
+ switch self {
129
+ case .conforming(let type):
130
+ return [type]
131
+ case .tuple(let elements, _):
132
+ return elements.flatMap(\.conformingTypes)
133
+ case .optional(let inner), .array(let inner), .dictionary(let inner):
134
+ return inner.conformingTypes
135
+ }
136
+ }
137
+
138
+ /// The type spelled in expression position, with tuple labels kept for nested tuples.
139
+ private var spelledType: String {
140
+ switch self {
141
+ case .conforming(let type):
142
+ return expressionType(type)
143
+ case .tuple(let elements, let labels):
144
+ let spelled = elements.enumerated().map { index, element in
145
+ labels.map { "\($0[index]): \(element.spelledType)" } ?? element.spelledType
146
+ }
147
+ return "(\(spelled.joined(separator: ", ")))"
148
+ case .optional(let wrapped):
149
+ return "\(wrapped.spelledType)?"
150
+ case .array(let element):
151
+ return "[\(element.spelledType)]"
152
+ case .dictionary(let value):
153
+ return "[String: \(value.spelledType)]"
154
+ }
155
+ }
156
+
157
+ /// The type of a tuple with its own labels dropped. Labels of nested tuples are kept, since the
158
+ /// nested conversion handles them.
159
+ private var unlabeledType: String {
160
+ guard case .tuple(let elements, _) = self else {
161
+ return spelledType
162
+ }
163
+ return "(\(elements.map(\.spelledType).joined(separator: ", ")))"
164
+ }
165
+
166
+ private func containerDecode(
167
+ _ method: String,
168
+ label: String,
169
+ inner: TupleConversion,
170
+ of valueExpression: String
171
+ ) -> String {
172
+ return
173
+ "JavaScriptValue.\(method)(\(valueExpression), in: runtime, \(label): \(elementClosure(returning: inner.decodeExpression(of: "value"))))"
174
+ }
175
+
176
+ private func containerEncode(
177
+ _ method: String,
178
+ label: String,
179
+ inner: TupleConversion,
180
+ of valueExpression: String,
181
+ into slot: String,
182
+ in runtime: String
183
+ ) -> String {
184
+ let closure = encoderClosure(encoding: inner.encodeCall(of: "value", into: "slot"))
185
+ return "JavaScriptValue.\(method)(\(valueExpression), into: \(slot), in: \(runtime), \(label): \(closure))"
186
+ }
187
+
188
+ /// The types of a tuple's elements when every element converts through its own `decode` /
189
+ /// `encode`, so the tuple can use the overloads that take no closures; `nil` otherwise.
190
+ private func conformingElementTypes(_ elements: [TupleConversion]) -> [String]? {
191
+ var types: [String] = []
192
+ for element in elements {
193
+ guard case .conforming(let type) = element else {
194
+ return nil
195
+ }
196
+ types.append(type)
197
+ }
198
+ return types
199
+ }
200
+
201
+ private func labelsArgument(_ labels: [String]?) -> [String] {
202
+ guard let labels else {
203
+ return []
204
+ }
205
+ return ["labels: [\(labels.map { "\"\($0)\"" }.joined(separator: ", "))]"]
206
+ }
207
+ }
208
+
209
+ /// The conversion for a boundary type that holds a tuple, or `nil` when the type holds no tuple the
210
+ /// tuple methods convert (a single parenthesized type, a tuple that labels only some elements, or one
211
+ /// with a variadic element converts as it's written, and fails its conformance assertion).
212
+ internal func tupleConversion(of type: TypeSyntax) -> TupleConversion? {
213
+ let conversion = structure(of: type)
214
+ guard case .conforming = conversion else {
215
+ return conversion
216
+ }
217
+ return nil
218
+ }
219
+
220
+ /// The conversion for a boundary type written as a string. See `tupleConversion(of:)`.
221
+ internal func tupleConversion(of type: String) -> TupleConversion? {
222
+ return tupleConversion(of: TypeSyntax(stringLiteral: type))
223
+ }
224
+
225
+ /// The conversion of `type`, which is `.conforming` when the type holds no recognized tuple.
226
+ private func structure(of type: TypeSyntax) -> TupleConversion {
227
+ let conforming = TupleConversion.conforming(type: type.trimmedDescription)
228
+ if let tuple = type.as(TupleTypeSyntax.self) {
229
+ // `(T)` is the same type as `T`.
230
+ if tuple.elements.count == 1, let only = tuple.elements.first, only.firstName == nil {
231
+ return structure(of: only.type)
232
+ }
233
+ guard tuple.elements.count >= 2,
234
+ tuple.elements.allSatisfy({ $0.ellipsis == nil && $0.inoutKeyword == nil })
235
+ else {
236
+ return conforming
237
+ }
238
+ let labels = tuple.elements.compactMap { $0.firstName?.text }
239
+ guard labels.isEmpty || labels.count == tuple.elements.count else {
240
+ return conforming
241
+ }
242
+ return .tuple(elements: tuple.elements.map { structure(of: $0.type) }, labels: labels.isEmpty ? nil : labels)
243
+ }
244
+ let inner: TupleConversion
245
+ let wrap: (TupleConversion) -> TupleConversion
246
+ if let wrapped = optionalWrappedType(type) {
247
+ (inner, wrap) = (structure(of: wrapped), TupleConversion.optional)
248
+ } else if let element = arrayElementType(type) {
249
+ (inner, wrap) = (structure(of: element), TupleConversion.array)
250
+ } else if let value = stringKeyedDictionaryValueType(type) {
251
+ (inner, wrap) = (structure(of: value), TupleConversion.dictionary)
252
+ } else {
253
+ return conforming
254
+ }
255
+ // A container that holds no tuple conforms as a whole.
256
+ if case .conforming = inner {
257
+ return conforming
258
+ }
259
+ return wrap(inner)
260
+ }
261
+
262
+ /// An element closure that returns `expression`, as it appears after `try`. It takes the value and
263
+ /// the runtime as parameters, which shadow the outer ones, so it captures nothing.
264
+ internal func elementClosure(returning expression: String) -> String {
265
+ return "{ value, runtime in return try \(expression) }"
266
+ }
267
+
268
+ /// An element closure that encodes its value into its slot with `call`, as it appears after `try`. It takes
269
+ /// the value, the slot and the runtime as parameters, which shadow the outer ones, so it captures nothing.
270
+ internal func encoderClosure(encoding call: String) -> String {
271
+ return "{ value, slot, runtime in try \(call) }"
272
+ }
@@ -0,0 +1,75 @@
1
+ import SwiftSyntax
2
+
3
+ /// The type aliases declared in the body of a class whose aliased type holds a tuple (see
4
+ /// `TupleConversion`), resolved to that type. A macro sees only the declaration it's attached to, so
5
+ /// `@ExpoModule` and `@SharedObject` collect the aliases from their class body, and resolve them in
6
+ /// the `@JS` members they bind before reading the members' types: an alias of a tuple can't convert
7
+ /// through `decode` / `encode` under its own name, because the tuple can't conform.
8
+ ///
9
+ /// An alias declared anywhere else (at file level, in an extension or in another type) isn't visible
10
+ /// to the macros, so it converts under its own name and fails its conformance assertion. A generic
11
+ /// alias isn't resolved either.
12
+ internal struct TupleTypeAliases {
13
+ /// The resolved type of each alias, by name.
14
+ private let resolved: [String: TypeSyntax]
15
+
16
+ init(in memberBlock: MemberBlockSyntax) {
17
+ var declared: [String: TypeSyntax] = [:]
18
+ for member in memberBlock.members {
19
+ guard let alias = member.decl.as(TypeAliasDeclSyntax.self), alias.genericParameterClause == nil else {
20
+ continue
21
+ }
22
+ declared[alias.name.text] = alias.initializer.value
23
+ }
24
+ // Aliases may refer to each other, so each one is resolved through the others. An alias that refers
25
+ // back to itself is invalid Swift, and is left as written after `declared.count` rounds.
26
+ var resolved = declared
27
+ for _ in 0..<declared.count {
28
+ let rewriter = AliasRewriter(aliases: resolved)
29
+ resolved = resolved.mapValues { rewriter.rewrite($0).cast(TypeSyntax.self) }
30
+ }
31
+ self.resolved = resolved.filter { tupleConversion(of: $0.value) != nil }
32
+ }
33
+
34
+ /// True when the class body declares no alias that holds a tuple.
35
+ var isEmpty: Bool {
36
+ return resolved.isEmpty
37
+ }
38
+
39
+ /// `node` with each reference to an alias that holds a tuple replaced by the aliased type.
40
+ func resolving<Node: SyntaxProtocol>(_ node: Node) -> Node {
41
+ guard !resolved.isEmpty else {
42
+ return node
43
+ }
44
+ return AliasRewriter(aliases: resolved).rewrite(node).cast(Node.self)
45
+ }
46
+ }
47
+
48
+ /// Replaces each type reference to an alias by the aliased type: a bare name (`Pair`) or one qualified
49
+ /// with `Self` (`Self.Pair`). A name with generic arguments isn't an alias reference, since the
50
+ /// resolved aliases aren't generic.
51
+ private final class AliasRewriter: SyntaxRewriter {
52
+ let aliases: [String: TypeSyntax]
53
+
54
+ init(aliases: [String: TypeSyntax]) {
55
+ self.aliases = aliases
56
+ super.init(viewMode: .sourceAccurate)
57
+ }
58
+
59
+ override func visit(_ node: IdentifierTypeSyntax) -> TypeSyntax {
60
+ guard node.genericArgumentClause == nil, let aliased = aliases[node.name.text] else {
61
+ return TypeSyntax(node)
62
+ }
63
+ return aliased.with(\.leadingTrivia, node.leadingTrivia).with(\.trailingTrivia, node.trailingTrivia)
64
+ }
65
+
66
+ override func visit(_ node: MemberTypeSyntax) -> TypeSyntax {
67
+ guard node.baseType.as(IdentifierTypeSyntax.self)?.name.text == "Self",
68
+ node.genericArgumentClause == nil,
69
+ let aliased = aliases[node.name.text]
70
+ else {
71
+ return super.visit(node)
72
+ }
73
+ return aliased.with(\.leadingTrivia, node.leadingTrivia).with(\.trailingTrivia, node.trailingTrivia)
74
+ }
75
+ }
@@ -40,57 +40,78 @@ internal struct ConformanceAssertion {
40
40
  let types: [String]
41
41
  }
42
42
 
43
+ /// One type crossing the boundary for a member, as a parameter of the member's assertion helper: the
44
+ /// type as written, the protocol it must conform to for the direction it travels, and the argument label
45
+ /// it's passed under.
46
+ internal struct AssertedType {
47
+ /// The argument label, or `nil` for an unlabeled parameter.
48
+ let label: String?
49
+ /// The type as written.
50
+ let type: String
51
+ /// `JavaScriptDecodable` for a type decoded from JavaScript, `JavaScriptEncodable` for one encoded to it.
52
+ let constraint: String
53
+ }
54
+
43
55
  /// A directional conformance-assertion peer for a `@JS` member: a never-called `private func` whose
44
- /// body asserts each argument type is `JavaScriptDecodable` and the return/getter type is
56
+ /// body asserts that each argument type is `JavaScriptDecodable` and the return or getter type is
45
57
  /// `JavaScriptEncodable`, matching how the generated binding converts each (decode on the way in,
46
- /// encode on the way out). The check is a single member-named nested helper with one opaque metatype
47
- /// parameter per asserted type, called once with every type's metatype; naming the helper after the
48
- /// member puts the member in the compiler's diagnostic, and the per-parameter constraint reports the
49
- /// offending type against the protocol for *its* direction.
58
+ /// encode on the way out). The body is a local helper named after the member, with one opaque metatype
59
+ /// parameter per asserted type, called once with every type's metatype (see
60
+ /// `memberConformanceAssertion`). Naming the helper after the member and its labels puts the member in
61
+ /// the compiler's diagnostic, and the per-parameter constraint reports the offending type against the
62
+ /// protocol for its direction.
50
63
  ///
51
- /// `decodableTypes` are the argument types (and a settable property's value type); `encodableType` is
52
- /// the single return type (or a property's value type on read), or `nil` when there's none.
53
- /// `callbackArgumentTypes` are the parameter types of closure arguments, which are encoded when native
54
- /// code calls the closure. Types known to conform are dropped from each; when nothing is left in any of
55
- /// them, returns `nil` so the caller emits no peer. `isStatic` mirrors a `static`/`class` member so the
56
- /// peer sits in the right metatype context.
57
- internal func directionalConformanceAssertion(
58
- name: String,
59
- decodableTypes: [String],
60
- encodableType: String?,
61
- callbackArgumentTypes: [String] = [],
62
- isStatic: Bool
63
- ) -> DeclSyntax? {
64
- let decodables = distinctAssertableTypes(decodableTypes, constraint: javaScriptDecodableProtocolName)
65
- let callbackArguments = distinctAssertableTypes(callbackArgumentTypes, constraint: javaScriptEncodableProtocolName)
66
- let encodable = encodableType.flatMap { assertableBoundaryType($0, constraint: javaScriptEncodableProtocolName) }
67
- guard !decodables.isEmpty || !callbackArguments.isEmpty || encodable != nil else {
64
+ /// Returns `nil` when every type is known to conform, so the caller emits no peer. `isStatic` mirrors a
65
+ /// `static`/`class` member so the peer sits in the right metatype context.
66
+ internal func directionalConformanceAssertion(name: String, types: [AssertedType], isStatic: Bool) -> DeclSyntax? {
67
+ guard let assertion = memberConformanceAssertion(name: name, types: types) else {
68
68
  return nil
69
69
  }
70
-
71
- // Arguments are constrained `JavaScriptDecodable`, and closure-argument parameters and the
72
- // return/getter value `JavaScriptEncodable`. Every parameter suppresses `Copyable`, so a
73
- // non-copyable boundary type such as `JavaScriptValue` can fill it.
74
- let encodables = callbackArguments + [encodable].compactMap { $0 }
75
- let decodableParameter = opaqueMetatypeParameter(constraint: "\(javaScriptDecodableProtocolName) & ~Copyable")
76
- let encodableParameter = opaqueMetatypeParameter(constraint: "\(javaScriptEncodableProtocolName) & ~Copyable")
77
- let parameters =
78
- Array(repeating: decodableParameter, count: decodables.count)
79
- + Array(repeating: encodableParameter, count: encodables.count)
80
- let arguments = (decodables + encodables).map { "\($0).self" }
81
-
82
- let helper = "func \(name)(\(parameters.joined(separator: ", "))) {}"
83
- let call = "\(name)(\(arguments.joined(separator: ", ")))"
84
70
  let staticKeyword = isStatic ? "static " : ""
85
71
  let peerName = derivedIdentifier(from: name, prefix: "_assertTypesConformance_")
86
72
  return """
87
73
  private \(raw: staticKeyword)func \(raw: peerName)() {
88
- \(raw: helper)
89
- \(raw: call)
74
+ \(raw: assertion.helper)
75
+ \(raw: assertion.call)
90
76
  }
91
77
  """
92
78
  }
93
79
 
80
+ /// The local helper of a member's conformance assertion and its call, or `nil` when every type is known
81
+ /// to conform. The helper is named after the member and takes the labels of the types, so the
82
+ /// diagnostic reads like the member's signature, such as `local function 'move(_:by:returning:)'
83
+ /// requires that 'Point' conform to 'JavaScriptEncodable'`. A type that holds a tuple contributes the
84
+ /// types in it (see `distinctAssertableTypes`), under its label. Every parameter suppresses `Copyable`,
85
+ /// so a non-copyable boundary type such as `JavaScriptValue` can fill it.
86
+ internal func memberConformanceAssertion(name: String, types: [AssertedType]) -> MemberConformanceAssertion? {
87
+ var seen: Set<String> = []
88
+ var parameters: [AssertedType] = []
89
+ for asserted in types {
90
+ for type in distinctAssertableTypes([asserted.type], constraint: asserted.constraint) {
91
+ let key = "\(asserted.label ?? "_") \(type) \(asserted.constraint)"
92
+ if seen.insert(key).inserted {
93
+ parameters.append(AssertedType(label: asserted.label, type: type, constraint: asserted.constraint))
94
+ }
95
+ }
96
+ }
97
+ guard !parameters.isEmpty else {
98
+ return nil
99
+ }
100
+ let declared = parameters.map { parameter in
101
+ let metatype = "(some \(parameter.constraint) & ~Copyable).Type"
102
+ return parameter.label.map { "\($0) _: \(metatype)" } ?? "_: \(metatype)"
103
+ }
104
+ let passed = parameters.map { parameter in
105
+ return parameter.label.map { "\($0): \(parameter.type).self" } ?? "\(parameter.type).self"
106
+ }
107
+ let labels = parameters.map { "\($0.label ?? "_"):" }.joined()
108
+ return MemberConformanceAssertion(
109
+ signature: "\(name)(\(labels))",
110
+ helper: "func \(name)(\(declared.joined(separator: ", "))) {}",
111
+ call: "\(name)(\(passed.joined(separator: ", ")))"
112
+ )
113
+ }
114
+
94
115
  /// One conformance-assertion peer covering several members' assertions at once — used by `@Record`,
95
116
  /// which folds every property into a single `_assertTypesConformance()` rather than emitting a peer
96
117
  /// per property. Each assertion keeps its own named nested helper, so the per-member naming in the
@@ -182,11 +203,16 @@ internal func opaqueMetatypeParameter(constraint: String) -> String {
182
203
  }
183
204
 
184
205
  /// The distinct types to assert against `constraint` from a list, mapping each through
185
- /// `assertableBoundaryType` (which drops what needs no assertion) and deduping in first-seen order.
186
- private func distinctAssertableTypes(_ types: [String], constraint: String) -> [String] {
206
+ /// `assertableBoundaryType` (which drops what needs no assertion) and deduping in first-seen order. A
207
+ /// type that holds a tuple converts element by element (see `TupleConversion`), so the types in it that
208
+ /// convert through their own `decode` / `encode` are asserted in its place.
209
+ internal func distinctAssertableTypes(_ types: [String], constraint: String) -> [String] {
187
210
  var seen: Set<String> = []
188
211
  var distinct: [String] = []
189
- let assertable = types.compactMap { assertableBoundaryType($0, constraint: constraint) }
212
+ let assertable = types.flatMap { type in
213
+ let elementTypes = tupleConversion(of: type)?.conformingTypes ?? [type]
214
+ return elementTypes.compactMap { assertableBoundaryType($0, constraint: constraint) }
215
+ }
190
216
  for type in assertable where seen.insert(type).inserted {
191
217
  distinct.append(type)
192
218
  }
@@ -207,3 +233,40 @@ private func unwrappedOptional(_ type: String) -> String {
207
233
  }
208
234
  return String(result)
209
235
  }
236
+
237
+ /// The conformance assertion of one member: a local helper function named after the member, which takes
238
+ /// the metatype of each asserted type, and its call.
239
+ internal struct MemberConformanceAssertion {
240
+ /// The helper's full name with its argument labels, such as `load(_:)`.
241
+ let signature: String
242
+ /// The helper's declaration.
243
+ let helper: String
244
+ /// The call of the helper with each asserted type's metatype.
245
+ let call: String
246
+ }
247
+
248
+ /// One assertion member covering the `@JS` members of an `@ExpoModule` or `@SharedObject` class, which
249
+ /// the class macro emits in place of the `@JS` peers: each member's helper and its call, in member order.
250
+ /// The helpers are local functions, so two helpers with the same name and labels (members that differ
251
+ /// only by argument types) would redeclare the same function: such a helper sits in its own `do` block.
252
+ /// Returns `nil` when no member has a type to assert.
253
+ internal func classConformanceAssertions(_ assertions: [MemberConformanceAssertion]) -> DeclSyntax? {
254
+ guard !assertions.isEmpty else {
255
+ return nil
256
+ }
257
+ var signatureCounts: [String: Int] = [:]
258
+ for assertion in assertions {
259
+ signatureCounts[assertion.signature, default: 0] += 1
260
+ }
261
+ let lines = assertions.flatMap { assertion in
262
+ guard signatureCounts[assertion.signature, default: 0] > 1 else {
263
+ return [assertion.helper, assertion.call]
264
+ }
265
+ return ["do {", " \(assertion.helper)", " \(assertion.call)", "}"]
266
+ }
267
+ return """
268
+ private func _assertTypesConformance() {
269
+ \(raw: lines.joined(separator: "\n"))
270
+ }
271
+ """
272
+ }
package/build/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ScanExportsResult, ScanModulesResult } from './types';
1
+ import { ExpandResult, ScanExportsResult, ScanModulesResult } from './types';
2
2
  export * from './types';
3
3
  /** Options accepted by `scanModules`, mirroring the CLI's `scan-modules` flags. */
4
4
  export interface ScanModulesOptions {
@@ -19,6 +19,21 @@ export interface ScanExportsOptions {
19
19
  /** Milliseconds after which the scanner is killed and the scan fails. No limit by default. */
20
20
  timeout?: number;
21
21
  }
22
+ /** Options accepted by `expandMacros`, mirroring the CLI's `expand` flags. */
23
+ export interface ExpandMacrosOptions {
24
+ /**
25
+ * Limits the result to these symbols: a type (`'MyModule'`, `'MyModule.Options'`) or a member
26
+ * (`'MyModule.greet'`). A member expands its type with only the declarations of that name. Each
27
+ * symbol must be declared in one of the files, or the call fails with a `ScannerError`.
28
+ */
29
+ symbols?: string[];
30
+ /** Returns only the diagnostics, with no expansions. */
31
+ diagnosticsOnly?: boolean;
32
+ /** Overrides the scanner binary path. Defaults to the one shipped with this package. */
33
+ binaryPath?: string;
34
+ /** Milliseconds after which the scanner is killed and the call fails. No limit by default. */
35
+ timeout?: number;
36
+ }
22
37
  /**
23
38
  * Thrown when the scanner exits non-zero. Exit code 2 is a usage error (bad subcommand or flags) and
24
39
  * 1 means the report couldn't be encoded; both put a message on stderr, carried here as `stderr`.
@@ -68,3 +83,18 @@ export declare function scanModules(paths: string[], options?: ScanModulesOption
68
83
  * what it guards is not reported.
69
84
  */
70
85
  export declare function scanExports(paths: string[], options?: ScanExportsOptions): Promise<ScanExportsResult>;
86
+ /**
87
+ * Expands the Expo macros in process, without a build, and returns the generated
88
+ * code with the syntax errors and the macro diagnostics. The macros only read the syntax tree, so
89
+ * the code is what the compiler gets, except for the names that a macro makes unique. Errors that
90
+ * need type checking, for example a type that doesn't conform to a protocol, still need a build.
91
+ *
92
+ * Each path is a `.swift` file, which is always expanded and keeps its path in the result, or a
93
+ * directory. A directory is walked like the scans walk it, and only its files that use one of the
94
+ * macros are expanded, with their absolute paths.
95
+ *
96
+ * Error diagnostics don't make the call fail: check `severity` in `diagnostics`. Only an input that
97
+ * can't be expanded (a missing path, a file that isn't Swift, a symbol that no file declares) throws
98
+ * a `ScannerError`.
99
+ */
100
+ export declare function expandMacros(paths: string[], options?: ExpandMacrosOptions): Promise<ExpandResult>;
package/build/index.js CHANGED
@@ -21,6 +21,7 @@ exports.ScannerSchemaVersionError = exports.ScannerError = void 0;
21
21
  exports.getScannerBinaryPath = getScannerBinaryPath;
22
22
  exports.scanModules = scanModules;
23
23
  exports.scanExports = scanExports;
24
+ exports.expandMacros = expandMacros;
24
25
  const node_child_process_1 = require("node:child_process");
25
26
  const node_path_1 = __importDefault(require("node:path"));
26
27
  const types_1 = require("./types");
@@ -210,3 +211,36 @@ async function scanExports(paths, options = {}) {
210
211
  }
211
212
  return result;
212
213
  }
214
+ /**
215
+ * Expands the Expo macros in process, without a build, and returns the generated
216
+ * code with the syntax errors and the macro diagnostics. The macros only read the syntax tree, so
217
+ * the code is what the compiler gets, except for the names that a macro makes unique. Errors that
218
+ * need type checking, for example a type that doesn't conform to a protocol, still need a build.
219
+ *
220
+ * Each path is a `.swift` file, which is always expanded and keeps its path in the result, or a
221
+ * directory. A directory is walked like the scans walk it, and only its files that use one of the
222
+ * macros are expanded, with their absolute paths.
223
+ *
224
+ * Error diagnostics don't make the call fail: check `severity` in `diagnostics`. Only an input that
225
+ * can't be expanded (a missing path, a file that isn't Swift, a symbol that no file declares) throws
226
+ * a `ScannerError`.
227
+ */
228
+ async function expandMacros(paths, options = {}) {
229
+ if (paths.length === 0) {
230
+ throw new TypeError('expandMacros requires at least one path');
231
+ }
232
+ assertRepresentablePaths('expandMacros', paths);
233
+ const args = ['expand', '--json'];
234
+ for (const symbol of options.symbols ?? []) {
235
+ args.push('--symbol', symbol);
236
+ }
237
+ if (options.diagnosticsOnly) {
238
+ args.push('--diagnostics-only');
239
+ }
240
+ args.push(...paths);
241
+ const result = await runScanner(options.binaryPath ?? getScannerBinaryPath(), args, options.timeout);
242
+ if (result.schemaVersion !== types_1.SUPPORTED_EXPAND_SCHEMA_VERSION) {
243
+ throw new ScannerSchemaVersionError('expand', result.schemaVersion, types_1.SUPPORTED_EXPAND_SCHEMA_VERSION);
244
+ }
245
+ return result;
246
+ }