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,162 @@
1
+ import SwiftSyntax
2
+ import SwiftSyntaxBuilder
3
+ import SwiftSyntaxMacros
4
+
5
+ /**
6
+ Member macro applied to a `SharedObject` subclass. Scans the class body for
7
+ declarations marked with `@JS` and synthesizes `_synthesizedClassDefinition()`,
8
+ returning a `ClassDefinition` ready to drop into a module's `Class { ... }` slot.
9
+
10
+ @SharedObject
11
+ final class Cache: SharedObject {
12
+ @JS
13
+ init(name: String) { self.name = name }
14
+
15
+ @JS
16
+ func get(_ key: String) -> String? { ... }
17
+
18
+ @JS
19
+ var size: Int { 42 }
20
+ }
21
+
22
+ The companion `@ExpoModule(classes: [Cache.self])` wires the resulting
23
+ definition into the module's exposed surface.
24
+ */
25
+ public struct SharedObjectMacro: MemberMacro {
26
+ public static func expansion(
27
+ of node: AttributeSyntax,
28
+ providingMembersOf declaration: some DeclGroupSyntax,
29
+ conformingTo protocols: [TypeSyntax],
30
+ in context: some MacroExpansionContext
31
+ ) throws -> [DeclSyntax] {
32
+ guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
33
+ throw MacroExpansionErrorMessage("@SharedObject can only be applied to a class")
34
+ }
35
+
36
+ guard inheritsFromSharedObject(classDecl) else {
37
+ throw MacroExpansionErrorMessage(
38
+ "@SharedObject class must inherit from SharedObject. Add `: SharedObject` to the class declaration.")
39
+ }
40
+
41
+ let typeName = classDecl.name.text
42
+ let jsName = jsNameArgument(of: node) ?? typeName
43
+
44
+ // `@JS func`s/`var`s and the `@JS init` are bound directly into the shared object's JS object by
45
+ // the synthesized `_decorateSharedObject(prototype:)` / `_decorateSharedObject(constructor:)` / `_constructSharedObject`
46
+ // rather than described with a `Function(...)` / `Property(...)` / `Constructor { … }` DSL entry, so
47
+ // they're collected here instead of appended to the `Class` block. The block keeps only non-`@JS`
48
+ // definitions (none are collected today), so it's empty when every member is `@JS`.
49
+ //
50
+ // Members split by the `static`/`class` modifier onto two different JS objects: instance members
51
+ // decorate the prototype (receiver recovered from JS `this`), static members decorate the
52
+ // constructor (called on the metatype). A JS instance and static member may share a name without
53
+ // colliding: they live on different objects.
54
+ let entries: [String] = []
55
+ var instanceFunctions: [JSFunction] = []
56
+ var instanceProperties: [JSProperty] = []
57
+ var staticFunctions: [JSFunction] = []
58
+ var staticProperties: [JSProperty] = []
59
+ var constructor: JSConstructor?
60
+
61
+ for member in classDecl.memberBlock.members {
62
+ let decl = member.decl
63
+
64
+ if let initDecl = decl.as(InitializerDeclSyntax.self),
65
+ initDecl.attributes.firstAttribute(named: "JS") != nil {
66
+ if constructor != nil {
67
+ throw MacroExpansionErrorMessage(
68
+ "@SharedObject classes can have at most one @JS initializer; JavaScript classes have a single constructor.")
69
+ }
70
+ constructor = JSConstructor(initDecl: initDecl)
71
+ continue
72
+ }
73
+
74
+ if let funcDecl = decl.as(FunctionDeclSyntax.self),
75
+ let attribute = funcDecl.attributes.firstAttribute(named: "JS") {
76
+ let function = JSFunction(funcDecl: funcDecl, attribute: attribute)
77
+ if isTypeLevel(funcDecl.modifiers) {
78
+ staticFunctions.append(function)
79
+ } else {
80
+ instanceFunctions.append(function)
81
+ }
82
+ continue
83
+ }
84
+
85
+ if let varDecl = decl.as(VariableDeclSyntax.self),
86
+ let attribute = varDecl.attributes.firstAttribute(named: "JS") {
87
+ let collected = collectProperties(varDecl: varDecl, attribute: attribute)
88
+ if isTypeLevel(varDecl.modifiers) {
89
+ staticProperties.append(contentsOf: collected)
90
+ } else {
91
+ instanceProperties.append(contentsOf: collected)
92
+ }
93
+ }
94
+ }
95
+
96
+ let lines = entries.map { " \($0)" }.joined(separator: "\n")
97
+ let body = entries.isEmpty
98
+ ? " return Class(\"\(jsName)\", \(typeName).self) {\n }"
99
+ : " return Class(\"\(jsName)\", \(typeName).self) {\n\(lines)\n }"
100
+
101
+ var emitted: [DeclSyntax] = [
102
+ """
103
+ public static func _synthesizedClassDefinition() -> ClassDefinition {
104
+ \(raw: body)
105
+ }
106
+ """
107
+ ]
108
+
109
+ // Direct JSI binding, split by which JS object each member decorates:
110
+ // `_decorateSharedObject(prototype:)` binds instance `@JS func`/`var`s (unwrapping the per-call receiver from
111
+ // `this`); `_decorateSharedObject(constructor:)` binds `static`/`class` ones (called on the metatype); and
112
+ // `_constructSharedObject` builds an instance from the `@JS init` arguments. Each is emitted only
113
+ // when it has something to bind.
114
+ if !instanceFunctions.isEmpty || !instanceProperties.isEmpty {
115
+ emitted.append(
116
+ buildDecorateSharedObjectPhase(
117
+ phase: .prototype, functions: instanceFunctions, properties: instanceProperties,
118
+ typeName: typeName))
119
+ }
120
+ if !staticFunctions.isEmpty || !staticProperties.isEmpty {
121
+ emitted.append(
122
+ buildDecorateSharedObjectPhase(
123
+ phase: .constructor, functions: staticFunctions, properties: staticProperties,
124
+ typeName: typeName))
125
+ }
126
+ if let constructor {
127
+ emitted.append(constructor.buildConstructor(typeName: typeName))
128
+ }
129
+
130
+ return emitted
131
+ }
132
+ }
133
+
134
+ extension SharedObjectMacro: MemberAttributeMacro {
135
+ public static func expansion(
136
+ of node: AttributeSyntax,
137
+ attachedTo declaration: some DeclGroupSyntax,
138
+ providingAttributesFor member: some DeclSyntaxProtocol,
139
+ in context: some MacroExpansionContext
140
+ ) throws -> [AttributeSyntax] {
141
+ // `@Event(sync: true)` members are stamped alongside `@JS` ones: a sync event dispatches
142
+ // inline, so the isolation forces its call site onto the JS thread. Async events (the
143
+ // default) stay unstamped — their `emit` schedules onto the JS thread itself.
144
+ guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
145
+ return []
146
+ }
147
+ // `@JS(.concurrent)` opts the member out of the JS-thread stamp and onto the concurrent pool.
148
+ if isConcurrentJSMember(member) {
149
+ return ["@concurrent"]
150
+ }
151
+ guard shouldStampJavaScriptActor(on: member, enclosedBy: declaration) else {
152
+ return []
153
+ }
154
+ return ["@JavaScriptActor"]
155
+ }
156
+ }
157
+
158
+ // MARK: - Inheritance check
159
+
160
+ private func inheritsFromSharedObject(_ classDecl: ClassDeclSyntax) -> Bool {
161
+ return inheritsFromAny(classDecl, names: ["SharedObject"])
162
+ }
@@ -0,0 +1,190 @@
1
+ import SwiftSyntax
2
+
3
+ /// The protocol that every type crossing the JS boundary must conform to. Centralized here so the
4
+ /// eventual rename (this is a placeholder name) is a single edit, and shared by the macros whose
5
+ /// generated conversions go through the dynamic-type API (`@Record`, `@Event`).
6
+ internal let jsConvertibleProtocolName = "AnyArgument"
7
+
8
+ /// The constraint a `@JS` boundary type must satisfy, by direction: an argument (and a settable
9
+ /// property's incoming value) is decoded, so it must be `JavaScriptDecodable`; a return value (and a
10
+ /// property's outgoing value) is encoded, so it must be `JavaScriptEncodable`. Asserting each
11
+ /// direction on its own keeps a decode-only or encode-only type from being over-constrained, and
12
+ /// surfaces a missing conformance as a clear "requires that '…' conform to …" diagnostic on the
13
+ /// member instead of an opaque error inside the generated closure.
14
+ internal let javaScriptDecodableProtocolName = "JavaScriptDecodable"
15
+ internal let javaScriptEncodableProtocolName = "JavaScriptEncodable"
16
+
17
+ /// The constraint a `@Record` property type must satisfy: it must be both `AnyArgument` (the
18
+ /// `from(dictionary:)` / `toDictionary(appContext:)` paths still convert native `Any` through the
19
+ /// dynamic-type API) and `JavaScriptDecodable & JavaScriptEncodable` (the `from(object:)` /
20
+ /// `toObject(appContext:)` paths convert JS values through `decode`/`encode`). A record field has to
21
+ /// support both directions, so the assertion requires the intersection.
22
+ ///
23
+ /// The exception is a property whose type mentions `Any` (`[String: Any]?`, `[Any]`, …): it can't
24
+ /// conform to the JS codable protocols, so the JS-value paths convert it through the `JavaScriptValue`
25
+ /// free-form methods, and it's asserted against `jsConvertibleProtocolName` alone for the dictionary
26
+ /// paths.
27
+ internal let recordFieldProtocolName = "AnyArgument & JavaScriptDecodable & JavaScriptEncodable"
28
+
29
+ /// The constraint a `@Union` case payload type must satisfy: the union decodes by trying each payload's
30
+ /// `decode` and encodes through the matching payload's `encode`, so every alternative must convert in
31
+ /// both directions. Unlike a `@Record` field, a union never crosses the native-`Any` dictionary path, so
32
+ /// `AnyArgument` is not required.
33
+ internal let unionPayloadProtocolName = "JavaScriptDecodable & JavaScriptEncodable"
34
+
35
+ /// The protocol a type must conform to for `self.emit(event:…)` to resolve; core conforms
36
+ /// `BaseModule` and `SharedObject` to it. Asserted by `@Event` so attaching it to a type that can't
37
+ /// emit fails with a conformance diagnostic instead of an opaque "no member 'emit'" error.
38
+ internal let eventEmitterProtocolName = "EventEmitter"
39
+
40
+ /// Types we never assert because they're statically known to conform: the JS primitives. Asserting
41
+ /// them would only add noise to the expansion.
42
+ private let knownConformingPrimitives: Set<String> = ["Bool", "Int", "Double", "String"]
43
+
44
+ /// One member's worth of conformance assertion: a name (the member it stands for) and the declared
45
+ /// types crossing the JS boundary for it. The name surfaces verbatim in the compiler's conformance
46
+ /// diagnostic ("local function '<name>' requires that '<Type>' conform to …"), so it identifies the
47
+ /// offending member in the error message on top of the location pointing at the user's declaration.
48
+ internal struct ConformanceAssertion {
49
+ let name: String
50
+ /// Declared types as written. Composed types (`[Int]`, `String?`, …) are kept verbatim — their
51
+ /// conditional conformances transitively constrain the elements.
52
+ let types: [String]
53
+ /// The constraint for this member's types, overriding the one passed to `typeConformanceAssertions`.
54
+ /// `@Record` uses it to assert only `AnyArgument` for a property that mentions `Any`, which can't be
55
+ /// `JavaScriptDecodable & JavaScriptEncodable`.
56
+ var constraint: String? = nil
57
+ }
58
+
59
+ /// A directional conformance-assertion peer for a `@JS` member: a never-called `private func` whose
60
+ /// body asserts each argument type is `JavaScriptDecodable` and the return/getter type is
61
+ /// `JavaScriptEncodable`, matching how the generated binding converts each (decode on the way in,
62
+ /// encode on the way out). The check is a single member-named nested helper with one `A0…` generic
63
+ /// parameter per non-primitive argument and a single `Return` parameter for the value, called once with
64
+ /// every type's metatype; naming the helper after the member puts the member in the compiler's
65
+ /// diagnostic, and the per-slot constraint reports the offending type against the protocol for *its*
66
+ /// direction.
67
+ ///
68
+ /// `decodableTypes` are the argument types (and a settable property's value type); `encodableType` is
69
+ /// the single return type (or a property's value type on read), or `nil` when there's none. Primitives
70
+ /// are dropped from each; when nothing is left in either, returns `nil` so the caller emits no peer.
71
+ /// `isStatic` mirrors a `static`/`class` member so the peer sits in the right metatype context.
72
+ internal func directionalConformanceAssertion(
73
+ name: String,
74
+ decodableTypes: [String],
75
+ encodableType: String?,
76
+ isStatic: Bool
77
+ ) -> DeclSyntax? {
78
+ let decodables = distinctAssertableTypes(decodableTypes)
79
+ let encodable = encodableType.flatMap(assertableBoundaryType)
80
+ guard !decodables.isEmpty || encodable != nil else {
81
+ return nil
82
+ }
83
+
84
+ // Build the generic parameter list and the matching call arguments in lockstep: one `A0…` slot
85
+ // constrained `JavaScriptDecodable` per argument, and a single `Return` slot constrained
86
+ // `JavaScriptEncodable` for the return/getter value (there's only ever one, so it isn't indexed).
87
+ var parameters: [String] = []
88
+ var typeParameters: [String] = []
89
+ var arguments: [String] = []
90
+ for (index, type) in decodables.enumerated() {
91
+ parameters.append("A\(index): \(javaScriptDecodableProtocolName)")
92
+ typeParameters.append("_: A\(index).Type")
93
+ arguments.append("\(type).self")
94
+ }
95
+ if let encodable {
96
+ parameters.append("Return: \(javaScriptEncodableProtocolName)")
97
+ typeParameters.append("_: Return.Type")
98
+ arguments.append("\(encodable).self")
99
+ }
100
+
101
+ let helper = "func \(name)<\(parameters.joined(separator: ", "))>(\(typeParameters.joined(separator: ", "))) {}"
102
+ let call = "\(name)(\(arguments.joined(separator: ", ")))"
103
+ let staticKeyword = isStatic ? "static " : ""
104
+ return """
105
+ private \(raw: staticKeyword)func _assertTypesConformance_\(raw: name)() {
106
+ \(raw: helper)
107
+ \(raw: call)
108
+ }
109
+ """
110
+ }
111
+
112
+ /// One conformance-assertion peer covering several members' assertions at once — used by `@Record`,
113
+ /// which folds every property into a single `_assertTypesConformance()` rather than emitting a peer
114
+ /// per property. Each assertion keeps its own named nested helper, so the per-member naming in the
115
+ /// diagnostic is preserved even though they share one peer.
116
+ ///
117
+ /// Returns `nil` when no assertion has anything left to verify (all primitives / empty), so the
118
+ /// caller emits nothing.
119
+ internal func typeConformanceAssertions(
120
+ for assertions: [ConformanceAssertion],
121
+ constraint: String = jsConvertibleProtocolName
122
+ ) -> DeclSyntax? {
123
+ let bodies = assertions.compactMap { conformanceAssertionBody($0, constraint: $0.constraint ?? constraint) }
124
+ guard !bodies.isEmpty else {
125
+ return nil
126
+ }
127
+ return """
128
+ private func _assertTypesConformance() {
129
+ \(raw: bodies.joined(separator: "\n"))
130
+ }
131
+ """
132
+ }
133
+
134
+ /// The type to assert for a boundary type as written, or `nil` when nothing needs asserting: an
135
+ /// exactly-free-form type (handled by the binding's `decodeAny…` reroute) and a known-conforming
136
+ /// primitive both return `nil`; everything else is unwrapped of trailing optionals and asserted. An
137
+ /// *optional* free-form (`[String: Any]?`) isn't rerouted and doesn't conform, so it's kept and
138
+ /// asserted for a clean diagnostic. Shared with `@Event`.
139
+ internal func assertableBoundaryType(_ type: String) -> String? {
140
+ guard !isFreeFormBoundaryType(type) else {
141
+ return nil
142
+ }
143
+ let unwrapped = unwrappedOptional(type)
144
+ return knownConformingPrimitives.contains(unwrapped) ? nil : unwrapped
145
+ }
146
+
147
+ /// The assertion's body fragment: a nested generic helper named after the member, plus one call per
148
+ /// distinct non-primitive type. Nesting the helper keeps the constraint entirely local — no shared
149
+ /// symbol, nothing to collide, nothing left in the type's namespace — and naming it after the member
150
+ /// puts the member's name in the compiler's conformance diagnostic. Returns `nil` when every type was
151
+ /// a known-conforming primitive or the list was empty.
152
+ private func conformanceAssertionBody(
153
+ _ assertion: ConformanceAssertion,
154
+ constraint: String = jsConvertibleProtocolName
155
+ ) -> String? {
156
+ let distinct = distinctAssertableTypes(assertion.types)
157
+ guard !distinct.isEmpty else {
158
+ return nil
159
+ }
160
+
161
+ var lines = ["func \(assertion.name)<T: \(constraint)>(_: T.Type) {}"]
162
+ lines.append(contentsOf: distinct.map { "\(assertion.name)(\($0).self)" })
163
+ return lines.joined(separator: "\n")
164
+ }
165
+
166
+ /// The distinct types to assert from a list, mapping each through `assertableBoundaryType` (which
167
+ /// drops what needs no assertion) and deduping in first-seen order.
168
+ private func distinctAssertableTypes(_ types: [String]) -> [String] {
169
+ var seen: Set<String> = []
170
+ var distinct: [String] = []
171
+ for type in types.compactMap(assertableBoundaryType) where seen.insert(type).inserted {
172
+ distinct.append(type)
173
+ }
174
+ return distinct
175
+ }
176
+
177
+ /// Strips every trailing optional marker (`?`/`!`) so the assertion targets the core wrapped type.
178
+ /// `Optional<W>: AnyArgument` holds exactly when `W: AnyArgument` (and `T!` is just `T?`), so each
179
+ /// layer is conformance-equivalent to its wrapped type. Asserting the core gives a cleaner diagnostic
180
+ /// (the direct `requires that '<type>' conform`, not the conditional-conformance phrasing through
181
+ /// `Optional`) and sidesteps that `T!.self` is invalid in metatype position. Only *trailing* markers
182
+ /// are stripped, so `[Int?]` keeps its inner `?`; a longhand `Optional<W>` isn't peeled but is still
183
+ /// asserted whole, which remains correct.
184
+ private func unwrappedOptional(_ type: String) -> String {
185
+ var result = Substring(type)
186
+ while result.hasSuffix("?") || result.hasSuffix("!") {
187
+ result = result.dropLast()
188
+ }
189
+ return String(result)
190
+ }