@expo/expo-modules-macros-plugin 0.0.9 → 0.2.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.
@@ -0,0 +1,118 @@
1
+ name: Publish
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ release_type:
7
+ description: Release type
8
+ type: choice
9
+ required: true
10
+ default: patch
11
+ options:
12
+ - patch
13
+ - minor
14
+ - major
15
+ - prepatch
16
+ - preminor
17
+ - premajor
18
+ - prerelease
19
+
20
+ concurrency:
21
+ group: publish-${{ github.workflow }}
22
+ cancel-in-progress: false
23
+
24
+ jobs:
25
+ publish:
26
+ name: Build and publish to npm
27
+ # Builds the native macOS tool binary, so it must run on macOS.
28
+ # The package declares swift-tools-version 6.2, which requires Xcode 26+.
29
+ runs-on: macos-26
30
+ permissions:
31
+ # Required for npm trusted publishing (OIDC) and provenance.
32
+ id-token: write
33
+ # Required to push the version bump commit/tag and create the release.
34
+ contents: write
35
+ steps:
36
+ - name: Checkout
37
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
38
+ with:
39
+ # The default token lets gh create the release and push the bump
40
+ # commit/tag. Note: commits pushed with GITHUB_TOKEN do NOT trigger
41
+ # other workflows (e.g. the Swift CI), by GitHub's design.
42
+ token: ${{ secrets.GITHUB_TOKEN }}
43
+
44
+ - name: Setup Node
45
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
46
+ with:
47
+ node-version: 22
48
+ registry-url: https://registry.npmjs.org
49
+
50
+ - name: Update npm
51
+ # Trusted publishing requires npm >= 11.5.1.
52
+ run: npm install -g npm@latest
53
+
54
+ - name: Configure git
55
+ run: |
56
+ git config user.name "expo-bot"
57
+ git config user.email "expo-bot@users.noreply.github.com"
58
+
59
+ - name: Bump version
60
+ id: bump
61
+ # Only edit package.json here; the commit, tag and release are created
62
+ # at the end so a failed build/publish leaves the branch untouched.
63
+ run: |
64
+ version=$(npm version "${{ inputs.release_type }}" --no-git-tag-version)
65
+ echo "version=$version" >> "$GITHUB_OUTPUT"
66
+ echo "Bumping to $version"
67
+
68
+ - name: Select Xcode
69
+ # Pin to an Xcode that ships Swift 6.2 (required by Package.swift).
70
+ run: sudo xcode-select -switch /Applications/Xcode_26.4.1.app
71
+
72
+ - name: Cache SwiftPM build
73
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
74
+ with:
75
+ path: apple/.build
76
+ key: spm-${{ runner.os }}-${{ hashFiles('apple/Package.resolved') }}
77
+ restore-keys: |
78
+ spm-${{ runner.os }}-
79
+
80
+ - name: Resolve dependencies
81
+ run: swift package resolve
82
+ working-directory: apple
83
+
84
+ - name: Build release tool
85
+ # Runs apple/build.js: builds the release plugin binary,
86
+ # copies it to apple/ExpoModulesMacros-tool, and strips it.
87
+ run: npm run build
88
+
89
+ - name: Publish
90
+ # Authentication is handled via OIDC trusted publishing — no token needed.
91
+ # npm generates provenance automatically when publishing via OIDC.
92
+ run: npm publish --access public
93
+
94
+ # Everything below only runs once the publish above has succeeded.
95
+
96
+ - name: Commit, tag and push
97
+ run: |
98
+ version="${{ steps.bump.outputs.version }}"
99
+ git commit -am "Release $version"
100
+ # Use an annotated tag so `git push --follow-tags` actually pushes it
101
+ # (--follow-tags ignores lightweight tags).
102
+ git tag -a "$version" -m "Release $version"
103
+ git push --follow-tags origin HEAD
104
+
105
+ - name: Create GitHub release
106
+ env:
107
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
108
+ # Tag prerelease versions (those containing a hyphen) as prereleases.
109
+ run: |
110
+ version="${{ steps.bump.outputs.version }}"
111
+ prerelease=""
112
+ case "$version" in
113
+ *-*) prerelease="--prerelease" ;;
114
+ esac
115
+ gh release create "$version" \
116
+ --title "$version" \
117
+ --generate-notes \
118
+ $prerelease
@@ -0,0 +1,59 @@
1
+ name: Swift
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_dispatch: {}
9
+
10
+ concurrency:
11
+ group: ci-${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ defaults:
15
+ run:
16
+ working-directory: apple
17
+
18
+ jobs:
19
+ build-and-test:
20
+ name: Build & test
21
+ # The package declares swift-tools-version 6.2, which requires Xcode 26+.
22
+ runs-on: macos-26
23
+ steps:
24
+ - name: Checkout
25
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
26
+
27
+ - name: Select Xcode
28
+ # Pin to an Xcode that ships Swift 6.2 (required by Package.swift).
29
+ run: sudo xcode-select -switch /Applications/Xcode_26.4.1.app
30
+
31
+ - name: Cache SwiftPM build
32
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
33
+ with:
34
+ path: apple/.build
35
+ key: spm-${{ runner.os }}-${{ hashFiles('apple/Package.resolved') }}
36
+ restore-keys: |
37
+ spm-${{ runner.os }}-
38
+
39
+ - name: Resolve dependencies
40
+ run: swift package resolve
41
+
42
+ - name: Build release tool
43
+ # Runs `npm run build` (apple/build.js): builds the universal
44
+ # (arm64 + x86_64) release plugin binary, copies it to
45
+ # apple/ExpoModulesMacros-tool, and strips it.
46
+ working-directory: ${{ github.workspace }}
47
+ run: npm run build
48
+
49
+ - name: Verify universal binary
50
+ run: |
51
+ archs="$(lipo -archs ExpoModulesMacros-tool)"
52
+ echo "Architectures: $archs"
53
+ case "$archs" in *arm64*) ;; *) echo "Missing arm64 slice"; exit 1;; esac
54
+ case "$archs" in *x86_64*) ;; *) echo "Missing x86_64 slice"; exit 1;; esac
55
+ arch -arm64 ./ExpoModulesMacros-tool < /dev/null
56
+ arch -x86_64 ./ExpoModulesMacros-tool < /dev/null
57
+
58
+ - name: Test
59
+ run: swift test -v
Binary file
@@ -2,6 +2,7 @@
2
2
  // The swift-tools-version declares the minimum version of Swift required to build this package.
3
3
 
4
4
  import CompilerPluginSupport
5
+ import Foundation
5
6
  import PackageDescription
6
7
 
7
8
  let package = Package(
@@ -18,8 +19,14 @@ let package = Package(
18
19
  .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
19
20
  .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
20
21
  ]
21
- ),
22
+ )
23
+ ]
24
+ )
22
25
 
26
+ // The Tests directory is excluded from the published npm package,
27
+ // so only declare the test target when building from the repository.
28
+ if FileManager.default.fileExists(atPath: Context.packageDirectory + "/Tests") {
29
+ package.targets.append(
23
30
  .testTarget(
24
31
  name: "ExpoModulesMacrosTests",
25
32
  dependencies: [
@@ -27,6 +34,6 @@ let package = Package(
27
34
  .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
28
35
  .product(name: "SwiftSyntaxMacrosGenericTestSupport", package: "swift-syntax"),
29
36
  ]
30
- ),
31
- ]
32
- )
37
+ )
38
+ )
39
+ }
@@ -0,0 +1,174 @@
1
+ import SwiftSyntax
2
+
3
+ /**
4
+ A `@JS func` collected for **direct JSI binding**. Instead of describing the function with a
5
+ `Function(...)` / `AsyncFunction(...)` DSL entry that the runtime interprets per call,
6
+ `@ExpoModule` synthesizes a `_decorateModule` that binds each such function into the module's JS object
7
+ via the closure-taking `JavaScriptObject.setProperty(_:)`, with the decode-call-encode body
8
+ inlined into the closure. This omits the `[Any]`/`toTuple` dynamic-call path: every argument is
9
+ decoded individually by its static type.
10
+
11
+ The receiver is the module's real `self` (a module is a singleton instance), so the body calls
12
+ `self.<name>(...)` directly and ignores the JS `this`. An `async` `@JS func` produces an `async`
13
+ closure body and is installed through the async `setProperty(_:)` overload (so JS gets a promise).
14
+ */
15
+ internal struct JSFunction {
16
+ let swiftName: String
17
+ let jsName: String
18
+ let parameters: [FunctionParameterSyntax]
19
+ /// The declared return type as written, or `nil` when the function returns `Void`/nothing.
20
+ let returnType: String?
21
+ let isThrowing: Bool
22
+ let isAsync: Bool
23
+
24
+ init(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) {
25
+ self.swiftName = funcDecl.name.text
26
+ self.jsName = jsNameArgument(of: attribute) ?? funcDecl.name.text
27
+ self.parameters = Array(funcDecl.signature.parameterClause.parameters)
28
+
29
+ let declaredReturnType = funcDecl.signature.returnClause?.type
30
+ self.returnType = isVoidType(declaredReturnType) ? nil : declaredReturnType?.trimmedDescription
31
+
32
+ let effectSpecifiers = funcDecl.signature.effectSpecifiers
33
+ self.isThrowing = effectSpecifiers?.throwsClause?.throwsSpecifier != nil
34
+ self.isAsync = effectSpecifiers?.asyncSpecifier != nil
35
+ }
36
+
37
+ /**
38
+ The `#name` host-function body: a `@JavaScriptActor private func` matching the
39
+ `createFunction` closure shape `(this, arguments) throws -> JavaScriptValue`, threading
40
+ `appContext`/`runtime` in as parameters. It checks arity, decodes each argument by its static
41
+ type — primitives through a direct typed accessor (`asDouble()`, …) on a borrowed
42
+ `JavaScriptUnownedValue`, other types through the
43
+ `T.getDynamicType()` converter — calls `self.<name>(...)`, and converts the result back to JS.
44
+ */
45
+ /// The decode-call-encode statements that form the host-function body, indented with the given
46
+ /// prefix. Arity guard, then per-argument decode (primitives via a direct typed accessor like
47
+ /// `asDouble()` on a zero-copy `arguments.unownedValue(at:)`, others via `getDynamicType().cast(...)`),
48
+ /// the `self.<name>(...)` call, and the
49
+ /// result encode (primitives via `toJavaScriptValue(in:)`, others via `castToJS(...)`).
50
+ private func bodyStatements(indent: String) -> String {
51
+ var lines: [String] = []
52
+
53
+ lines.append(
54
+ """
55
+ guard arguments.count == \(parameters.count) else {
56
+ throw Exception(name: "InvalidArgumentCount", description: "Function '\(jsName)' expects \(parameters.count) argument(s), but got \\(arguments.count)")
57
+ }
58
+ """)
59
+
60
+ var callArguments: [String] = []
61
+ for (index, parameter) in parameters.enumerated() {
62
+ let type = parameter.type.trimmedDescription
63
+
64
+ // Primitives decode through a direct typed accessor (`asDouble()`, etc.) on a borrowed
65
+ // `JavaScriptUnownedValue` — no owning `JavaScriptValue` allocation, no `jsi::Value` copy, no
66
+ // `getDynamicType()` allocation, no `Any` boxing, no force-cast — while still validating and
67
+ // throwing `TypeError` on a mismatch. Other types fall back to the dynamic converter, which
68
+ // needs an owning value, so they index the buffer directly.
69
+ if let accessor = fastDecodeAccessor(for: type) {
70
+ lines.append("let arg\(index) = try arguments.unownedValue(at: \(index)).\(accessor)()")
71
+ } else {
72
+ lines.append(
73
+ "let arg\(index) = try \(type).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(type)")
74
+ }
75
+
76
+ let label = parameter.firstName.text
77
+ callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
78
+ }
79
+
80
+ let tryKeyword = (isThrowing || isAsync) ? "try " : ""
81
+ let awaitKeyword = isAsync ? "await " : ""
82
+ let callExpression =
83
+ "\(tryKeyword)\(awaitKeyword)self.\(swiftName)(\(callArguments.joined(separator: ", ")))"
84
+
85
+ if let returnType {
86
+ lines.append("let result = \(callExpression)")
87
+ // Primitives encode through `toJavaScriptValue(in:)` (the typed `JavaScriptRepresentable`
88
+ // conversion) — no `Any`, no dynamic-type allocation. Others go through the dynamic converter.
89
+ if fastDecodeAccessor(for: returnType) != nil {
90
+ lines.append("return result.toJavaScriptValue(in: runtime)")
91
+ } else {
92
+ lines.append("return try \(returnType).getDynamicType().castToJS(result, appContext: appContext, in: runtime)")
93
+ }
94
+ } else {
95
+ lines.append(callExpression)
96
+ lines.append("return .undefined")
97
+ }
98
+
99
+ return lines
100
+ .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
101
+ .map { indent + $0 }
102
+ .joined(separator: "\n")
103
+ }
104
+
105
+ /// The `setProperty` statement that installs this function on the JS object. The decode-call-encode
106
+ /// body is inlined directly into the closure passed to the closure-taking `setProperty` overload
107
+ /// (which creates the host function under the hood) — no separate named binding. For an `async`
108
+ /// function the body `await`s the call, which selects the async `setProperty` overload (so JS
109
+ /// receives a promise).
110
+ ///
111
+ /// Capture mirrors core's `SyncFunctionDefinition.build`: `self` (the module) is captured
112
+ /// **strong** — the host-function closure is what keeps the native callable alive for as long as
113
+ /// JS can invoke it; its lifetime is bounded by the JS VM's garbage collection of the object.
114
+ /// `appContext` is captured **weak** (and guarded) so it doesn't form a real retain cycle through
115
+ /// the app context.
116
+ var decorateStatements: String {
117
+ return """
118
+ object.setProperty("\(jsName)") { [weak appContext, self] this, arguments in
119
+ guard let appContext else {
120
+ throw Exceptions.AppContextLost()
121
+ }
122
+ \(bodyStatements(indent: " "))
123
+ }
124
+ """
125
+ }
126
+ }
127
+
128
+ /**
129
+ The single generated function that decorates the module's JS object. Core supplies the object;
130
+ this binds every `@JS func` into it via one inlined `setProperty` closure per function. Mirrors
131
+ core's `ObjectDefinition.decorate(object:)`, including its `borrowing` object parameter (it
132
+ mutates through the reference without reassigning or taking ownership). Named `_decorateModule`
133
+ with the leading-underscore convention for synthesized members the **runtime calls by name**; the
134
+ `ExpoModule` suffix names the `@ExpoModule` macro it came from (a shared object's counterpart is
135
+ `_decorateSharedObject`).
136
+ */
137
+ internal func buildDecorateJavaScriptObject(functions: [JSFunction]) -> DeclSyntax {
138
+ let body = functions.map { $0.decorateStatements }.joined(separator: "\n")
139
+ return """
140
+ @JavaScriptActor
141
+ public func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws {
142
+ \(raw: body)
143
+ }
144
+ """
145
+ }
146
+
147
+ /// The throwing `JavaScriptUnownedValue` accessor that decodes the given primitive type directly,
148
+ /// bypassing the dynamic-type converter (`asDouble()` for `Double`, etc.). Returns `nil` for
149
+ /// types without a dedicated accessor — arrays, records, optionals, shared objects, other numeric
150
+ /// widths — which decode through `getDynamicType().cast(...)`.
151
+ private func fastDecodeAccessor(for type: String) -> String? {
152
+ switch type {
153
+ case "Bool":
154
+ return "asBool"
155
+ case "Int":
156
+ return "asInt"
157
+ case "Double":
158
+ return "asDouble"
159
+ case "String":
160
+ return "asString"
161
+ default:
162
+ return nil
163
+ }
164
+ }
165
+
166
+ /// True when a return clause is absent or written as `Void` / `()` — i.e. the function returns
167
+ /// nothing JS-visible, so the binding returns `.undefined`.
168
+ private func isVoidType(_ type: TypeSyntax?) -> Bool {
169
+ guard let type else {
170
+ return true
171
+ }
172
+ let text = type.trimmedDescription
173
+ return text == "Void" || text == "()"
174
+ }
@@ -3,13 +3,23 @@ import SwiftSyntaxBuilder
3
3
  import SwiftSyntaxMacros
4
4
 
5
5
  /**
6
- Member macro applied to a `Module` subclass. Scans the class body for declarations
7
- marked with `@JS` and synthesizes a framework-internal `_exposedDefinition()`
8
- method returning `[AnyDefinition]`. `expo-modules-core` calls it automatically
9
- and merges the result into the module's definition.
6
+ Macro applied to a module class. It plays three roles, each implemented in its own
7
+ extension below:
8
+
9
+ - `MemberMacro`: scans the class body for `@JS`-marked declarations and synthesizes a
10
+ framework-internal `_synthesizedDefinition()` returning `[AnyDefinition]`, which
11
+ `expo-modules-core` calls automatically and merges into the module's definition. When
12
+ the class doesn't already inherit `Module`/`BaseModule`, it also synthesizes the
13
+ `appContext` storage and `init(appContext:)` those base classes would have provided.
14
+ - `MemberAttributeMacro`: stamps `@JavaScriptActor` on `@JS` sync members and
15
+ `@ModuleDefinitionBuilder` on a `definition()` method.
16
+ - `ExtensionMacro`: adds the `AnyModule` conformance when the class doesn't inherit it.
17
+
18
+ Inheriting from `Module` is therefore optional — a class can carry any superclass (or
19
+ none) and still be a module, because everything inheritance used to provide is synthesized.
10
20
 
11
21
  @ExpoModule
12
- public final class MyModule: Module {
22
+ public final class MyModule {
13
23
  public func definition() -> ModuleDefinition {
14
24
  Name("MyModule")
15
25
  }
@@ -32,8 +42,14 @@ public struct ExpoModuleMacro: MemberMacro {
32
42
  let moduleName = jsNameArgument(of: node) ?? classDecl.name.text
33
43
  var entries: [String] = ["Name(\"\(moduleName)\")"]
34
44
 
45
+ // `@JS func`s (sync and async) are bound directly into the JS object by the synthesized
46
+ // `_decorateModule` rather than described with a `Function(...)` / `AsyncFunction(...)` DSL entry,
47
+ // so they're collected here instead of appended to `entries`. Properties still go through
48
+ // the DSL for now.
49
+ var functions: [JSFunction] = []
50
+
35
51
  for typeName in classListArgument(of: node, label: "classes") {
36
- entries.append("\(typeName)._exposedClassDefinition()")
52
+ entries.append("\(typeName)._synthesizedClassDefinition()")
37
53
  }
38
54
 
39
55
  for member in classDecl.memberBlock.members {
@@ -41,7 +57,7 @@ public struct ExpoModuleMacro: MemberMacro {
41
57
 
42
58
  if let funcDecl = decl.as(FunctionDeclSyntax.self),
43
59
  let attribute = funcDecl.attributes.firstAttribute(named: "JS") {
44
- entries.append(buildFunctionEntry(funcDecl: funcDecl, attribute: attribute))
60
+ functions.append(JSFunction(funcDecl: funcDecl, attribute: attribute))
45
61
  continue
46
62
  }
47
63
 
@@ -54,29 +70,164 @@ public struct ExpoModuleMacro: MemberMacro {
54
70
  let lines = entries.map { " \($0)" }.joined(separator: ",\n")
55
71
  let body = " return [\n\(lines)\n ]"
56
72
 
73
+ var emitted: [DeclSyntax] = []
74
+
75
+ // `Module`/`BaseModule` already provide `appContext` storage and the
76
+ // `init(appContext:)` requirement, so we only synthesize them for classes that
77
+ // inherit from neither. Each is skipped individually if the user wrote their own,
78
+ // to avoid emitting a duplicate declaration.
79
+ if !inheritsFromAny(classDecl, names: ["Module", "BaseModule"]) {
80
+ if !hasStoredProperty(classDecl, named: "appContext") {
81
+ emitted.append("public weak var appContext: AppContext?")
82
+ }
83
+ if !hasAppContextInitializer(classDecl) {
84
+ emitted.append(
85
+ """
86
+ public required init(appContext: AppContext) {
87
+ self.appContext = appContext
88
+ }
89
+ """
90
+ )
91
+ }
92
+ }
93
+
94
+ // Always emitted: the definition is derived from the class name and `@JS` members,
95
+ // independent of any `definition()` the user wrote.
57
96
  let method: DeclSyntax = """
58
- public func _exposedDefinition() -> [AnyDefinition] {
97
+ public func _synthesizedDefinition() -> [AnyDefinition] {
59
98
  \(raw: body)
60
99
  }
61
100
  """
101
+ emitted.append(method)
102
+
103
+ // Direct JSI binding: one `_decorateModule` that binds each `@JS func` into the module's JS object,
104
+ // with the decode-call-encode body inlined into each closure. Only emitted when there are
105
+ // functions to bind.
106
+ if !functions.isEmpty {
107
+ emitted.append(buildDecorateJavaScriptObject(functions: functions))
108
+ }
62
109
 
63
- return [method]
110
+ return emitted
64
111
  }
65
112
  }
66
113
 
67
- // MARK: - Member builders
114
+ extension ExpoModuleMacro: MemberAttributeMacro {
115
+ public static func expansion(
116
+ of node: AttributeSyntax,
117
+ attachedTo declaration: some DeclGroupSyntax,
118
+ providingAttributesFor member: some DeclSyntaxProtocol,
119
+ in context: some MacroExpansionContext
120
+ ) throws -> [AttributeSyntax] {
121
+ // This macro is invoked once per member and may attach more than one attribute,
122
+ // so we collect into an array rather than returning early. The two checks below are
123
+ // independent — a given member matches at most one in practice, but they're written
124
+ // so neither precludes the other.
125
+ var attributes: [AttributeSyntax] = []
126
+
127
+ // `@JS` sync members run on the JS thread; stamp `@JavaScriptActor` so isolation is
128
+ // checked at compile time. Skipped when the member already chose an isolation
129
+ // (`async`, `nonisolated`, or another global actor) — see `shouldStampJavaScriptActor`.
130
+ if memberHasJSAttribute(member),
131
+ shouldStampJavaScriptActor(on: member, enclosedBy: declaration) {
132
+ attributes.append("@JavaScriptActor")
133
+ }
68
134
 
69
- private func buildFunctionEntry(
70
- funcDecl: FunctionDeclSyntax,
71
- attribute: AttributeSyntax
72
- ) -> String {
73
- let swiftName = funcDecl.name.text
74
- let jsName = jsNameArgument(of: attribute) ?? swiftName
75
- let isAsync = funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil
76
- let dslEntry = isAsync ? "AsyncFunction" : "Function"
77
- return "\(dslEntry)(\"\(jsName)\", \(swiftName))"
135
+ // Apply the result builder to `definition()` so the user doesn't have to. Skipped if
136
+ // they already wrote `@ModuleDefinitionBuilder` themselves, which would otherwise be
137
+ // a duplicate attribute.
138
+ if isModuleDefinitionFunction(member),
139
+ !memberAttributes(of: member).contains(where: { hasAttributeName($0, "ModuleDefinitionBuilder") }) {
140
+ attributes.append("@ModuleDefinitionBuilder")
141
+ }
142
+
143
+ return attributes
144
+ }
78
145
  }
79
146
 
147
+ private func isModuleDefinitionFunction(_ member: some DeclSyntaxProtocol) -> Bool {
148
+ guard let funcDecl = member.as(FunctionDeclSyntax.self),
149
+ funcDecl.name.text == "definition",
150
+ funcDecl.signature.parameterClause.parameters.isEmpty,
151
+ let returnType = funcDecl.signature.returnClause?.type else {
152
+ return false
153
+ }
154
+ return baseIdentifier(of: returnType) == "ModuleDefinition"
155
+ }
156
+
157
+ private func hasAttributeName(_ element: AttributeListSyntax.Element, _ name: String) -> Bool {
158
+ guard let attribute = element.as(AttributeSyntax.self) else {
159
+ return false
160
+ }
161
+ return attribute.attributeName.trimmedDescription == name
162
+ }
163
+
164
+ extension ExpoModuleMacro: ExtensionMacro {
165
+ public static func expansion(
166
+ of node: AttributeSyntax,
167
+ attachedTo declaration: some DeclGroupSyntax,
168
+ providingExtensionsOf type: some TypeSyntaxProtocol,
169
+ conformingTo protocols: [TypeSyntax],
170
+ in context: some MacroExpansionContext
171
+ ) throws -> [ExtensionDeclSyntax] {
172
+ // `protocols` is empty when the compiler already sees the conformance (e.g. it's
173
+ // spelled in the declaration), in which case there's nothing for us to add.
174
+ guard !protocols.isEmpty else {
175
+ return []
176
+ }
177
+ // These base types already supply `AnyModule`; emitting a second conformance here
178
+ // would be redundant and error.
179
+ if let classDecl = declaration.as(ClassDeclSyntax.self),
180
+ inheritsFromAny(classDecl, names: ["Module", "BaseModule", "AnyModule"]) {
181
+ return []
182
+ }
183
+ let conformance: DeclSyntax = "extension \(type.trimmed): AnyModule {}"
184
+ return [conformance.cast(ExtensionDeclSyntax.self)]
185
+ }
186
+ }
187
+
188
+ // MARK: - Synthesized-member detection
189
+
190
+ private func hasStoredProperty(_ classDecl: ClassDeclSyntax, named name: String) -> Bool {
191
+ for member in classDecl.memberBlock.members {
192
+ guard let varDecl = member.decl.as(VariableDeclSyntax.self) else {
193
+ continue
194
+ }
195
+ for binding in varDecl.bindings {
196
+ if let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
197
+ identifier.identifier.text == name {
198
+ return true
199
+ }
200
+ }
201
+ }
202
+ return false
203
+ }
204
+
205
+ /**
206
+ True if the class declares an initializer that satisfies the `init(appContext:)` protocol
207
+ requirement: a single parameter labeled `appContext` whose type is written as `AppContext`
208
+ (or `…​.AppContext`). Both must match — the label is part of the requirement, so
209
+ `init(c: AppContext)` does *not* satisfy it (we still synthesize ours), and the type guards
210
+ against a same-labeled init of an unrelated type. A macro can't resolve types, so this is a
211
+ syntactic match on the spelled name.
212
+ */
213
+ private func hasAppContextInitializer(_ classDecl: ClassDeclSyntax) -> Bool {
214
+ for member in classDecl.memberBlock.members {
215
+ guard let initDecl = member.decl.as(InitializerDeclSyntax.self) else {
216
+ continue
217
+ }
218
+ let params = initDecl.signature.parameterClause.parameters
219
+ guard params.count == 1, let param = params.first else {
220
+ continue
221
+ }
222
+ if param.firstName.text == "appContext", baseIdentifier(of: param.type) == "AppContext" {
223
+ return true
224
+ }
225
+ }
226
+ return false
227
+ }
228
+
229
+ // MARK: - Member builders
230
+
80
231
  private func buildPropertyEntries(
81
232
  varDecl: VariableDeclSyntax,
82
233
  attribute: AttributeSyntax