expo-modules-macros 0.0.1 → 0.15.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 (46) hide show
  1. package/.github/resources/expo-modules-macros.svg +23 -0
  2. package/.github/workflows/publish.yml +122 -0
  3. package/.github/workflows/swift.yml +68 -0
  4. package/AGENTS.md +59 -0
  5. package/CLAUDE.md +1 -0
  6. package/LICENSE +21 -0
  7. package/README.md +119 -0
  8. package/apple/ExpoModulesMacros +0 -0
  9. package/apple/Package.resolved +15 -0
  10. package/apple/Package.swift +61 -0
  11. package/apple/PodTests/StubTest.swift +5 -0
  12. package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +444 -0
  13. package/apple/Sources/ExpoModulesMacros/EventMacro.swift +330 -0
  14. package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +263 -0
  15. package/apple/Sources/ExpoModulesMacros/ExpoModulesOptimizedMacro.swift +117 -0
  16. package/apple/Sources/ExpoModulesMacros/ExpoViewMacro.swift +259 -0
  17. package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +214 -0
  18. package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +55 -0
  19. package/apple/Sources/ExpoModulesMacros/JSMacro.swift +294 -0
  20. package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +433 -0
  21. package/apple/Sources/ExpoModulesMacros/OptimizedFunctionHelpers.swift +55 -0
  22. package/apple/Sources/ExpoModulesMacros/Plugin.swift +34 -0
  23. package/apple/Sources/ExpoModulesMacros/Receiver.swift +79 -0
  24. package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +590 -0
  25. package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +162 -0
  26. package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +190 -0
  27. package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +389 -0
  28. package/apple/Sources/ExpoModulesMacros/ViewPropsMacro.swift +477 -0
  29. package/apple/Sources/ExpoModulesOptimized/ExpoModulesOptimized.swift +19 -0
  30. package/apple/Sources/ExpoModulesScanner/CLI.swift +114 -0
  31. package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +79 -0
  32. package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +181 -0
  33. package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +115 -0
  34. package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +193 -0
  35. package/apple/Sources/ExpoModulesScanner/Exports/ExportedSurface.swift +319 -0
  36. package/apple/Sources/ExpoModulesScanner/Exports/ResolveRefs.swift +199 -0
  37. package/apple/Sources/ExpoModulesScanner/Exports/ScanExports.swift +66 -0
  38. package/apple/Sources/ExpoModulesScanner/Exports/SurfaceVisitor.swift +616 -0
  39. package/apple/Sources/ExpoModulesScanner/Exports/TypeNode.swift +284 -0
  40. package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +161 -0
  41. package/apple/build.js +138 -0
  42. package/build/index.d.ts +51 -0
  43. package/build/index.js +153 -0
  44. package/build/types.d.ts +264 -0
  45. package/build/types.js +15 -0
  46. package/package.json +22 -5
@@ -0,0 +1,117 @@
1
+ import Foundation
2
+ import SwiftSyntax
3
+ import SwiftSyntaxBuilder
4
+ import SwiftSyntaxMacros
5
+
6
+ // MARK: - ExpoModulesOptimizedMacro
7
+
8
+ /// Implementation of the `@OptimizedFunction` attached macro.
9
+ /// Generates a peer function that returns `OptimizedFunctionDescriptor` for use with
10
+ /// the `Function("name", descriptor)` or `AsyncFunction("name", descriptor)` overload.
11
+ ///
12
+ /// Usage:
13
+ ///
14
+ /// @OptimizedFunction
15
+ /// private func addNumbers(a: Double, b: Double) -> Double {
16
+ /// return a + b
17
+ /// }
18
+ ///
19
+ /// generates a peer function:
20
+ ///
21
+ /// private func addNumbers() -> OptimizedFunctionDescriptor {
22
+ /// return OptimizedSyncFunctionDefinition.createDescriptor(
23
+ /// typeEncoding: "d@?dd",
24
+ /// argsCount: 2,
25
+ /// block: (addNumbers as @convention(block) (Double, Double) -> Double) as AnyObject
26
+ /// )
27
+ /// }
28
+ ///
29
+ /// Then use in definition(): `Function("addNumbers", addNumbers())`
30
+ public struct OptimizedFunctionAttachedMacro: PeerMacro {
31
+ public static func expansion(
32
+ of node: AttributeSyntax,
33
+ providingPeersOf declaration: some DeclSyntaxProtocol,
34
+ in context: some MacroExpansionContext
35
+ ) throws -> [DeclSyntax] {
36
+ guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
37
+ throw MacroExpansionErrorMessage(
38
+ "@OptimizedFunction can only be attached to function declarations")
39
+ }
40
+
41
+ let functionName = funcDecl.name.text
42
+
43
+ var paramTypes: [String] = []
44
+ for param in funcDecl.signature.parameterClause.parameters {
45
+ paramTypes.append(param.type.trimmedDescription)
46
+ }
47
+
48
+ let returnType = funcDecl.signature.returnClause?.type.trimmedDescription ?? "Void"
49
+ let functionThrows = funcDecl.signature.effectSpecifiers?.throwsClause?.throwsSpecifier != nil
50
+ let typeEncoding = try generateTypeEncoding(returnType: returnType, paramTypes: paramTypes)
51
+ let implFuncName = funcDecl.name.text
52
+ let blockParamTypes = paramTypes.joined(separator: ", ")
53
+ let argNames = (0..<paramTypes.count).map { "arg\($0)" }.joined(separator: ", ")
54
+
55
+ let peerFunc: DeclSyntax
56
+
57
+ if functionThrows {
58
+ let isVoid = returnType == "Void" || returnType == "()"
59
+ let implType =
60
+ paramTypes.isEmpty
61
+ ? "() throws -> \(returnType)" : "(\(blockParamTypes)) throws -> \(returnType)"
62
+ let wrapperType =
63
+ paramTypes.isEmpty
64
+ ? "@convention(block) () -> \(returnType)"
65
+ : "@convention(block) (\(blockParamTypes)) -> \(returnType)"
66
+ let closureArgs = paramTypes.isEmpty ? "" : " \(argNames) in"
67
+ let implCall = paramTypes.isEmpty ? "impl()" : "impl(\(argNames))"
68
+ let tryExpr = isVoid ? "try \(implCall)" : "return try \(implCall)"
69
+ let unreachable = isVoid ? "" : "\n fatalError(\"Unreachable\")"
70
+
71
+ peerFunc = """
72
+ private func \(raw: functionName)() -> OptimizedFunctionDescriptor {
73
+ let impl: \(raw: implType) = \(raw: implFuncName)
74
+ let wrapper: \(raw: wrapperType) = {\(raw: closureArgs)
75
+ do {
76
+ \(raw: tryExpr)
77
+ } catch {
78
+ let nsError: NSError
79
+ if let expoError = error as? Exception {
80
+ nsError = NSError(domain: "dev.expo.modules", code: 0, userInfo: [
81
+ "name": expoError.name,
82
+ "code": expoError.code,
83
+ "message": expoError.debugDescription,
84
+ ])
85
+ } else {
86
+ nsError = error as NSError
87
+ }
88
+ let exception = NSException(
89
+ name: NSExceptionName(nsError.userInfo["name"] as? String ?? "SwiftError"),
90
+ reason: nsError.userInfo["message"] as? String ?? nsError.localizedDescription,
91
+ userInfo: nsError.userInfo
92
+ )
93
+ exception.raise()\(raw: unreachable)
94
+ }
95
+ }
96
+ return OptimizedSyncFunctionDefinition.createDescriptor(
97
+ typeEncoding: "\(raw: typeEncoding)",
98
+ argsCount: \(raw: String(paramTypes.count)),
99
+ block: wrapper as AnyObject
100
+ )
101
+ }
102
+ """
103
+ } else {
104
+ peerFunc = """
105
+ private func \(raw: functionName)() -> OptimizedFunctionDescriptor {
106
+ return OptimizedSyncFunctionDefinition.createDescriptor(
107
+ typeEncoding: "\(raw: typeEncoding)",
108
+ argsCount: \(raw: String(paramTypes.count)),
109
+ block: (\(raw: implFuncName) as @convention(block) (\(raw: blockParamTypes)) -> \(raw: returnType)) as AnyObject
110
+ )
111
+ }
112
+ """
113
+ }
114
+
115
+ return [peerFunc]
116
+ }
117
+ }
@@ -0,0 +1,259 @@
1
+ import SwiftDiagnostics
2
+ import SwiftSyntax
3
+ import SwiftSyntaxBuilder
4
+ import SwiftSyntaxMacros
5
+
6
+ /// Member macro applied to an `ExpoView` subclass, binding it to its props type:
7
+ ///
8
+ /// @ExpoView<CardProps>
9
+ /// final class CardView: ExpoView {
10
+ /// override func didUpdateProps(_ diff: CardProps.Diff) {
11
+ /// if diff.changed(.color) {
12
+ /// backgroundColor = props.color
13
+ /// }
14
+ /// }
15
+ /// }
16
+ ///
17
+ /// The props type is the attribute's **generic argument**, so it sits in type position: no
18
+ /// `.self` metatype to write, and the macro reads it straight off the attribute's
19
+ /// `genericArgumentClause`. Core's declaration constrains the parameter
20
+ /// (`macro ExpoView<Props: AnyViewProps>()`), so passing a type that isn't a props type is a
21
+ /// compile error at the author's own line ("requires that 'X' conform to 'AnyViewProps'")
22
+ /// rather than a macro diagnostic. This macro therefore validates nothing about the props type.
23
+ ///
24
+ /// The whole expansion is one line:
25
+ ///
26
+ /// public typealias Props = CardProps
27
+ ///
28
+ /// That is deliberately all of it. There is **no view definition**: no `ViewDefinition`, no
29
+ /// `View(…) { }` builder, no `Props(_:)` or `Events(_:)` element. `_synthesizedViewDefinition()`
30
+ /// existed only to hand core a DSL value the old registration path could consume, and modules and
31
+ /// shared objects already moved to direct hooks. Core reads the props type from this typealias and
32
+ /// the event names from the props type's own `_eventNames` (synthesized by `@ViewProps`), both
33
+ /// static, neither needing a builder to be evaluated.
34
+ ///
35
+ /// Views also get no `_decorateView` hook in its place: `_decorateModule` and `_decorateSharedObject`
36
+ /// exist to bind members into a JS object, and a view has no such object. Props reach a view through
37
+ /// Fabric's mounting layer, not a JS call, so a view's runtime entry point is the props update hook.
38
+ ///
39
+ /// `didUpdateProps` is a core-called override, not synthesized here.
40
+ public struct ExpoViewMacro: MemberMacro {
41
+ public static func expansion(
42
+ of node: AttributeSyntax,
43
+ providingMembersOf declaration: some DeclGroupSyntax,
44
+ conformingTo protocols: [TypeSyntax],
45
+ in context: some MacroExpansionContext
46
+ ) throws -> [DeclSyntax] {
47
+ guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
48
+ throw MacroExpansionErrorMessage("@ExpoView can only be applied to a class")
49
+ }
50
+
51
+ guard inheritsFromAny(classDecl, names: expoViewBaseClassNames) else {
52
+ throw DiagnosticsError(diagnostics: [missingBaseClassDiagnostic(for: classDecl)])
53
+ }
54
+
55
+ // A generic view can't be registered: `@ExpoModule(views: [CardView.self])` on a generic type
56
+ // fails with "generic parameter 'T' could not be inferred", and the props type ignores the
57
+ // parameter anyway. Reject it here, at the author's line, rather than at the registration site.
58
+ // `@ViewProps` rejects generic props types for the same reason.
59
+ if classDecl.genericParameterClause != nil {
60
+ throw MacroExpansionErrorMessage(
61
+ "@ExpoView does not support generic classes — a view registered with @ExpoModule(views:) must be concrete"
62
+ )
63
+ }
64
+
65
+ // The expansion is a `Props` typealias, so a member of that name already on the class collides.
66
+ // Left alone, the compiler reports "invalid redeclaration of 'Props'" positioned inside the
67
+ // macro expansion, which is code the author never wrote. Name it here instead.
68
+ if let existing = existingPropsMemberKind(in: classDecl) {
69
+ throw MacroExpansionErrorMessage(
70
+ "@ExpoView synthesizes a `Props` typealias, but this class already declares a \(existing) named 'Props'. Rename it"
71
+ )
72
+ }
73
+
74
+ // The props type is the attribute's generic argument. Core's declaration is generic, so a bare
75
+ // `@ExpoView` never reaches expansion: it fails type-checking first with "generic parameter
76
+ // 'Props' could not be inferred". This diagnostic covers the case where the macro is declared
77
+ // differently than core declares it, so the author still gets a message naming the attribute.
78
+ guard let propsType = genericArgument(of: node) else {
79
+ throw MacroExpansionErrorMessage(
80
+ "@ExpoView requires its props type as a generic argument, as in `@ExpoView<MyViewProps>`"
81
+ )
82
+ }
83
+
84
+ return [
85
+ """
86
+ public typealias Props = \(raw: propsType)
87
+ """
88
+ ]
89
+ }
90
+ }
91
+
92
+ // MARK: - Diagnostics
93
+
94
+ /// The error for a class that doesn't name `ExpoView` in its inheritance clause, carrying a fix-it
95
+ /// that adds it.
96
+ ///
97
+ /// The fix-it only applies where the edit is unambiguous. With no inheritance clause at all it
98
+ /// inserts `: ExpoView`; with an existing clause it prepends `ExpoView` as the first entry, since
99
+ /// Swift requires the superclass to come before any protocol. A class that already names some other
100
+ /// superclass gets the diagnostic without a fix-it: replacing that superclass is a decision the macro
101
+ /// can't make (see the indirect-inheritance note on `expoViewBaseClassNames`).
102
+ private func missingBaseClassDiagnostic(for classDecl: ClassDeclSyntax) -> Diagnostic {
103
+ let message = ExpoViewDiagnosticMessage(
104
+ "@ExpoView class must inherit from ExpoView. Add `: ExpoView` to the class declaration.",
105
+ id: "expoview-missing-base-class"
106
+ )
107
+ var fixIts: [FixIt] = []
108
+
109
+ if classDecl.inheritanceClause == nil {
110
+ // `class CardView {` → `class CardView: ExpoView {`. The name carries the space before `{` as
111
+ // trailing trivia, so move it onto the clause to keep the spacing.
112
+ let name = classDecl.name
113
+ let clause = InheritanceClauseSyntax(
114
+ colon: .colonToken(trailingTrivia: .space),
115
+ inheritedTypes: InheritedTypeListSyntax([
116
+ InheritedTypeSyntax(type: TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoView"))))
117
+ ])
118
+ )
119
+ fixIts.append(
120
+ FixIt(
121
+ message: ExpoViewFixItMessage("Inherit from 'ExpoView'", id: "expoview-add-base-class"),
122
+ changes: [
123
+ .replace(
124
+ oldNode: Syntax(classDecl),
125
+ newNode: Syntax(
126
+ classDecl
127
+ .with(\.name, name.with(\.trailingTrivia, []))
128
+ .with(\.inheritanceClause, clause.with(\.trailingTrivia, name.trailingTrivia))
129
+ )
130
+ )
131
+ ]
132
+ )
133
+ )
134
+ } else if let clause = classDecl.inheritanceClause, inheritsOnlyProtocolsByConvention(clause) {
135
+ // `class CardView: Identifiable {` → `class CardView: ExpoView, Identifiable {`.
136
+ var inherited = clause.inheritedTypes
137
+ if var first = inherited.first {
138
+ first.trailingComma = .commaToken(trailingTrivia: .space)
139
+ first.type = TypeSyntax(IdentifierTypeSyntax(name: .identifier("ExpoView")))
140
+ inherited.insert(first, at: inherited.startIndex)
141
+ }
142
+ fixIts.append(
143
+ FixIt(
144
+ message: ExpoViewFixItMessage("Inherit from 'ExpoView'", id: "expoview-add-base-class"),
145
+ changes: [
146
+ .replace(
147
+ oldNode: Syntax(clause),
148
+ newNode: Syntax(clause.with(\.inheritedTypes, inherited))
149
+ )
150
+ ]
151
+ )
152
+ )
153
+ }
154
+
155
+ return Diagnostic(node: classDecl.name, message: message, fixIts: fixIts)
156
+ }
157
+
158
+ /// Whether every entry in the clause looks like a protocol rather than a superclass, by the
159
+ /// capitalization-free heuristic that a Swift superclass must come first: if the first entry is one
160
+ /// the macro would be replacing, prepending `ExpoView` would produce two superclasses. A macro can't
161
+ /// resolve names, so this only reports true when the clause is empty of anything that could be a
162
+ /// base class the author chose deliberately. Conservative by design: a wrong guess here would emit a
163
+ /// fix-it that doesn't compile.
164
+ private func inheritsOnlyProtocolsByConvention(_ clause: InheritanceClauseSyntax) -> Bool {
165
+ // Any entry at all could be a superclass, so only offer the prepend when the author wrote
166
+ // something that is definitely not one: a known protocol-shaped name from the standard library.
167
+ let knownProtocols: Set<String> = [
168
+ "Identifiable", "Equatable", "Hashable", "Codable", "Encodable", "Decodable",
169
+ "Sendable", "CustomStringConvertible", "ObservableObject",
170
+ ]
171
+ return clause.inheritedTypes.allSatisfy { entry in
172
+ guard let name = baseIdentifier(of: entry.type) else {
173
+ return false
174
+ }
175
+ return knownProtocols.contains(name)
176
+ }
177
+ }
178
+
179
+ private struct ExpoViewDiagnosticMessage: DiagnosticMessage {
180
+ let message: String
181
+ let diagnosticID: MessageID
182
+ let severity: DiagnosticSeverity = .error
183
+
184
+ init(_ message: String, id: String) {
185
+ self.message = message
186
+ self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
187
+ }
188
+ }
189
+
190
+ private struct ExpoViewFixItMessage: FixItMessage {
191
+ let message: String
192
+ let fixItID: MessageID
193
+
194
+ init(_ message: String, id: String) {
195
+ self.message = message
196
+ self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
197
+ }
198
+ }
199
+
200
+ /// Describes an existing `Props` member on the class, for the collision diagnostic, or `nil` when the
201
+ /// name is free. Covers the shapes that would clash with the synthesized typealias: another typealias,
202
+ /// and a nested type of any kind.
203
+ private func existingPropsMemberKind(in classDecl: ClassDeclSyntax) -> String? {
204
+ for member in classDecl.memberBlock.members {
205
+ let decl = member.decl
206
+ if let typealiasDecl = decl.as(TypeAliasDeclSyntax.self), typealiasDecl.name.text == "Props" {
207
+ return "typealias"
208
+ }
209
+ if let structDecl = decl.as(StructDeclSyntax.self), structDecl.name.text == "Props" {
210
+ return "struct"
211
+ }
212
+ if let nestedClass = decl.as(ClassDeclSyntax.self), nestedClass.name.text == "Props" {
213
+ return "class"
214
+ }
215
+ if let enumDecl = decl.as(EnumDeclSyntax.self), enumDecl.name.text == "Props" {
216
+ return "enum"
217
+ }
218
+ if let actorDecl = decl.as(ActorDeclSyntax.self), actorDecl.name.text == "Props" {
219
+ return "actor"
220
+ }
221
+ }
222
+ return nil
223
+ }
224
+
225
+ /// Base classes an `@ExpoView` may inherit from. The check is syntactic, matching the name in the
226
+ /// class's own inheritance clause: both `: ExpoView` and a qualified `: ExpoModulesCore.ExpoView`
227
+ /// pass, since `baseIdentifier` reads the trailing name.
228
+ ///
229
+ /// Indirect inheritance does not: a view whose superclass is itself an `ExpoView` subclass
230
+ /// (`class Base: ExpoView` then `class CardView: Base`) is rejected, because a macro can't resolve
231
+ /// the superclass to check. `@SharedObject` has the same limitation, through the same helper. The
232
+ /// workaround is to apply `@ExpoView` to the class that names `ExpoView` directly.
233
+ private let expoViewBaseClassNames: Set<String> = ["ExpoView"]
234
+
235
+ /// The attribute's single generic argument, verbatim (`@ExpoView<CardProps>` → `"CardProps"`), or
236
+ /// `nil` when the attribute carries no generic argument clause.
237
+ ///
238
+ /// The type is available syntactically, with no type resolution. Two spellings carry it, mirroring
239
+ /// `baseIdentifier`'s handling of the inheritance clause: a bare `@ExpoView<CardProps>` parses as an
240
+ /// `IdentifierTypeSyntax`, and a qualified `@ExpoModulesCore.ExpoView<CardProps>` as a
241
+ /// `MemberTypeSyntax`. Both have a `genericArgumentClause`, and both are legal at the use site, so
242
+ /// reading only the bare form would reject valid code while claiming the author omitted the argument.
243
+ ///
244
+ /// Only the first argument is read: core's declaration has exactly one parameter, so a second is
245
+ /// already a compile error at the use site ("specialized with too many type parameters").
246
+ private func genericArgument(of attribute: AttributeSyntax) -> String? {
247
+ let arguments: GenericArgumentListSyntax?
248
+ if let identifier = attribute.attributeName.as(IdentifierTypeSyntax.self) {
249
+ arguments = identifier.genericArgumentClause?.arguments
250
+ } else if let member = attribute.attributeName.as(MemberTypeSyntax.self) {
251
+ arguments = member.genericArgumentClause?.arguments
252
+ } else {
253
+ arguments = nil
254
+ }
255
+ guard let first = arguments?.first, case .type(let type) = first.argument else {
256
+ return nil
257
+ }
258
+ return type.trimmedDescription
259
+ }
@@ -0,0 +1,214 @@
1
+ import SwiftSyntax
2
+
3
+ /// Recognition of the free-form (`Any`-bearing) boundary types and the spellings the macros emit or
4
+ /// suggest for them. None of these types can conform to the JS codable protocols (`Any` can't conform
5
+ /// to a protocol, and the container conditional conformances require the element/value to conform), so
6
+ /// they can't cross the boundary through the usual `T.decode` / `T.encode` path. Instead they're
7
+ /// accepted **only** in decode position (function/constructor arguments), decoded through a dedicated
8
+ /// `JavaScriptValue.decodeAny…` entry point; a free-form return or getter is rejected, since there's
9
+ /// no free-form encode.
10
+ ///
11
+ /// Shared across the macro pipeline: `TypeConformanceAssertion` skips them, `MacroHelpers.decodeCall`
12
+ /// reroutes their decode, and `JSMacro` uses them for the steering diagnostics.
13
+ ///
14
+ /// `@Record` accepts a property whose type mentions `Any` anywhere (`Any?`, `[String: Any]?`,
15
+ /// `[String: [Any]]`, …) and converts it on the JS-value paths without `decode`/`encode`: the three
16
+ /// free-form shapes, optionally wrapped in one optional, use the dedicated `JavaScriptValue.decodeAny…`
17
+ /// and `encodeAny…` methods, and every other type uses the generic `decodeAny(_:as:in:)` and
18
+ /// `encodeAny(_:in:)`.
19
+
20
+ /// The free-form boundary types: an untyped value (`Any`), an untyped array (`[Any]`), and an untyped
21
+ /// string-keyed dictionary (`[String: Any]`). Matched by whitespace-normalized spelling so
22
+ /// `[String: Any]` and `[String : Any]` both count.
23
+ private let freeFormBoundaryTypes: Set<String> = ["Any", "[Any]", "[String:Any]"]
24
+
25
+ /// The `JavaScriptValue.decodeAny…` method the binding calls to decode a free-form argument, keyed by
26
+ /// the free-form type's normalized spelling. `nil` for any non-free-form type.
27
+ private let freeFormDecodeMethods: [String: String] = [
28
+ "Any": "decodeAny",
29
+ "[Any]": "decodeAnyArray",
30
+ "[String:Any]": "decodeAnyDictionary",
31
+ ]
32
+
33
+ /// The type-safe alternative a free-form type's diagnostic steers to: the same shape with the `Any`
34
+ /// element replaced by `JavaScriptValue`, which conforms to the codable protocols. Keyed by the
35
+ /// free-form type's normalized spelling; spelled with conventional spacing for the message.
36
+ private let typedFreeFormReplacements: [String: String] = [
37
+ "Any": "JavaScriptValue",
38
+ "[Any]": "[JavaScriptValue]",
39
+ "[String:Any]": "[String: JavaScriptValue]",
40
+ ]
41
+
42
+ /// True when a boundary type (as written) is one of the free-form spellings, ignoring internal
43
+ /// whitespace so `[String: Any]` and `[String :Any]` both match. Optionals are not free-form here: a
44
+ /// trailing `?` would route through `Optional.decode`, which free-form can't satisfy, so an optional
45
+ /// free-form type isn't recognized and stays a normal (failing) assertion.
46
+ internal func isFreeFormBoundaryType(_ type: String) -> Bool {
47
+ return freeFormBoundaryTypes.contains(normalizedTypeSpelling(type))
48
+ }
49
+
50
+ /// The `JavaScriptValue.decodeAny…` method name for a free-form boundary type, or `nil` when the type
51
+ /// isn't free-form. The binding emits `JavaScriptValue.<method>(arguments.unownedValue(at:), in:)` in
52
+ /// place of the type's own `.decode`.
53
+ internal func freeFormDecodeMethod(for type: String) -> String? {
54
+ return freeFormDecodeMethods[normalizedTypeSpelling(type)]
55
+ }
56
+
57
+ /// The type-safe alternative to suggest in place of a free-form type (its `Any` element replaced by
58
+ /// `JavaScriptValue`), or `nil` when the type isn't free-form.
59
+ internal func typedFreeFormReplacement(for type: String) -> String? {
60
+ return typedFreeFormReplacements[normalizedTypeSpelling(type)]
61
+ }
62
+
63
+ /// A type spelling with all whitespace removed, so spelling variations of the same type
64
+ /// (`[String: Any]` vs `[String :Any]`) compare equal.
65
+ private func normalizedTypeSpelling(_ type: String) -> String {
66
+ return type.filter { !$0.isWhitespace }
67
+ }
68
+
69
+ /// True when a type mentions `Any` anywhere in its spelling, at any nesting depth: `Any`, `[Any]?`,
70
+ /// `[String: Any]`, `Dictionary<String, [Any]>`, `[Int: Any]`, and so on. Such a type can't conform to
71
+ /// the JS codable protocols.
72
+ internal func mentionsFreeFormAny(_ type: TypeSyntax) -> Bool {
73
+ let finder = AnyTypeFinder(viewMode: .sourceAccurate)
74
+ finder.walk(type)
75
+ return finder.found
76
+ }
77
+
78
+ /// True when a type mentioning `Any` is built only from `Any`, arrays, `String`-keyed dictionaries and
79
+ /// optionals, in any nesting (`[String: [Any]]?`, `[[String: Any]]`, …). Those are the only shapes the
80
+ /// free-form conversions produce and accept, so any other type holding `Any` (`[Int: Any]`,
81
+ /// `Set<[String: Any]>`, a user generic `Box<Any>`) could never convert.
82
+ internal func isConvertibleFreeFormType(_ type: TypeSyntax) -> Bool {
83
+ if isAnyType(type) {
84
+ return true
85
+ }
86
+ if let wrapped = optionalWrappedType(type) {
87
+ return isConvertibleFreeFormType(wrapped)
88
+ }
89
+ if let element = arrayElementType(type) {
90
+ return isConvertibleFreeFormType(element)
91
+ }
92
+ if let value = stringKeyedDictionaryValueType(type) {
93
+ return isConvertibleFreeFormType(value)
94
+ }
95
+ return false
96
+ }
97
+
98
+ /// The three free-form shapes that have dedicated conversions.
99
+ internal enum FreeFormShape {
100
+ case any
101
+ case array
102
+ case dictionary
103
+ }
104
+
105
+ /// The free-form shape a type has exactly (`Any`, `[Any]` or `[String: Any]`, in any spelling), or
106
+ /// `nil` for every other type, including nested and optional ones.
107
+ internal func freeFormShape(of type: TypeSyntax) -> FreeFormShape? {
108
+ if isAnyType(type) {
109
+ return .any
110
+ }
111
+ if let element = arrayElementType(type), isAnyType(element) {
112
+ return .array
113
+ }
114
+ if let value = stringKeyedDictionaryValueType(type), isAnyType(value) {
115
+ return .dictionary
116
+ }
117
+ return nil
118
+ }
119
+
120
+ /// True when the type is `Any` itself, spelled `Any` or `Swift.Any` (not `AnyObject`, `AnyHashable` or
121
+ /// a generic).
122
+ internal func isAnyType(_ type: some SyntaxProtocol) -> Bool {
123
+ if let identifier = type.as(IdentifierTypeSyntax.self) {
124
+ return identifier.name.text == "Any" && identifier.genericArgumentClause == nil
125
+ }
126
+ if let member = type.as(MemberTypeSyntax.self),
127
+ member.name.text == "Any",
128
+ member.genericArgumentClause == nil,
129
+ let base = member.baseType.as(IdentifierTypeSyntax.self) {
130
+ return base.name.text == "Swift"
131
+ }
132
+ return false
133
+ }
134
+
135
+ /// The element type of `[T]` or `Array<T>`, or `nil` for any other type.
136
+ private func arrayElementType(_ type: TypeSyntax) -> TypeSyntax? {
137
+ if let array = type.as(ArrayTypeSyntax.self) {
138
+ return array.element
139
+ }
140
+ guard let arguments = genericArguments(of: type, named: "Array"), arguments.count == 1 else {
141
+ return nil
142
+ }
143
+ return arguments[0]
144
+ }
145
+
146
+ /// The value type of `[String: V]` or `Dictionary<String, V>`, or `nil` for any other type, including a
147
+ /// dictionary with a different key type.
148
+ private func stringKeyedDictionaryValueType(_ type: TypeSyntax) -> TypeSyntax? {
149
+ if let dictionary = type.as(DictionaryTypeSyntax.self) {
150
+ return isStringType(dictionary.key) ? dictionary.value : nil
151
+ }
152
+ guard let arguments = genericArguments(of: type, named: "Dictionary"), arguments.count == 2 else {
153
+ return nil
154
+ }
155
+ return isStringType(arguments[0]) ? arguments[1] : nil
156
+ }
157
+
158
+ /// The type arguments of a generic type spelled `<name><…>` (or `Swift.<name><…>`), or `nil` when the
159
+ /// type has another name or no type arguments.
160
+ private func genericArguments(of type: TypeSyntax, named name: String) -> [TypeSyntax]? {
161
+ let clause: GenericArgumentClauseSyntax?
162
+ if let identifier = type.as(IdentifierTypeSyntax.self), identifier.name.text == name {
163
+ clause = identifier.genericArgumentClause
164
+ } else if let member = type.as(MemberTypeSyntax.self),
165
+ member.name.text == name,
166
+ member.baseType.as(IdentifierTypeSyntax.self)?.name.text == "Swift" {
167
+ clause = member.genericArgumentClause
168
+ } else {
169
+ return nil
170
+ }
171
+ guard let clause else {
172
+ return nil
173
+ }
174
+ var types: [TypeSyntax] = []
175
+ for argument in clause.arguments {
176
+ guard case .type(let type) = argument.argument else {
177
+ return nil
178
+ }
179
+ types.append(type)
180
+ }
181
+ return types
182
+ }
183
+
184
+ /// True when the type is `String` or `Swift.String`.
185
+ private func isStringType(_ type: TypeSyntax) -> Bool {
186
+ if let identifier = type.as(IdentifierTypeSyntax.self) {
187
+ return identifier.name.text == "String" && identifier.genericArgumentClause == nil
188
+ }
189
+ if let member = type.as(MemberTypeSyntax.self), member.name.text == "String" {
190
+ return member.baseType.as(IdentifierTypeSyntax.self)?.name.text == "Swift"
191
+ }
192
+ return false
193
+ }
194
+
195
+ /// Walks a type looking for `Any` (or `Swift.Any`) anywhere inside it.
196
+ private final class AnyTypeFinder: SyntaxVisitor {
197
+ var found = false
198
+
199
+ override func visit(_ node: IdentifierTypeSyntax) -> SyntaxVisitorContinueKind {
200
+ if isAnyType(node) {
201
+ found = true
202
+ return .skipChildren
203
+ }
204
+ return .visitChildren
205
+ }
206
+
207
+ override func visit(_ node: MemberTypeSyntax) -> SyntaxVisitorContinueKind {
208
+ if isAnyType(node) {
209
+ found = true
210
+ return .skipChildren
211
+ }
212
+ return .visitChildren
213
+ }
214
+ }
@@ -0,0 +1,55 @@
1
+ import SwiftSyntax
2
+
3
+ /// A `@JS init` collected for direct JSI binding. A shared-object type has at most one (JS classes
4
+ /// have a single constructor). Instead of a `Constructor { … }` DSL entry, the macro synthesizes an
5
+ /// override of `SharedObject._constructSharedObject(...)` that decodes the JS arguments and returns a
6
+ /// fresh instance; unlike the method/property bindings it produces the native instance rather than
7
+ /// recovering one.
8
+ internal struct JSConstructor {
9
+ let parameters: [FunctionParameterSyntax]
10
+
11
+ init(initDecl: InitializerDeclSyntax) {
12
+ self.parameters = Array(initDecl.signature.parameterClause.parameters)
13
+ }
14
+
15
+ /// The body statements, indented with `indent`: arity guard, per-argument decode through
16
+ /// `JavaScriptDecodable.decode` on a zero-copy `arguments.unownedValue(at:)` (the arity guard proves
17
+ /// each index is in bounds), then `return <Type>(label: arg0, …)`.
18
+ private func bodyStatements(typeName: String, indent: String) -> String {
19
+ var lines: [String] = []
20
+
21
+ lines.append(
22
+ """
23
+ guard arguments.count == \(parameters.count) else {
24
+ throw Exceptions.ArgumentsRangeMismatch((functionName: "\(typeName)", received: arguments.count, required: \(parameters.count), maximum: \(parameters.count)))
25
+ }
26
+ """)
27
+
28
+ var callArguments: [String] = []
29
+ for (index, parameter) in parameters.enumerated() {
30
+ let type = parameter.type.trimmedDescription
31
+ lines.append("let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))")
32
+
33
+ let label = parameter.firstName.text
34
+ callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
35
+ }
36
+
37
+ lines.append("return \(typeName)(\(callArguments.joined(separator: ", ")))")
38
+
39
+ return lines
40
+ .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
41
+ .map { indent + $0 }
42
+ .joined(separator: "\n")
43
+ }
44
+
45
+ /// Builds an instance from the JS arguments. Overrides the base `SharedObject` class method; returns
46
+ /// the concrete instance, which promotes to the base `SharedObject?` return type.
47
+ func buildConstructor(typeName: String) -> DeclSyntax {
48
+ return """
49
+ @JavaScriptActor
50
+ public override class func _constructSharedObject(this: JavaScriptValue, arguments: borrowing JavaScriptValuesBuffer, in runtime: JavaScriptRuntime) throws -> SharedObject? {
51
+ \(raw: bodyStatements(typeName: typeName, indent: " "))
52
+ }
53
+ """
54
+ }
55
+ }