@expo/expo-modules-macros-plugin 0.0.9 → 0.1.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,116 @@
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
+ git tag "$version"
101
+ git push --follow-tags origin HEAD
102
+
103
+ - name: Create GitHub release
104
+ env:
105
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
106
+ # Tag prerelease versions (those containing a hyphen) as prereleases.
107
+ run: |
108
+ version="${{ steps.bump.outputs.version }}"
109
+ prerelease=""
110
+ case "$version" in
111
+ *-*) prerelease="--prerelease" ;;
112
+ esac
113
+ gh release create "$version" \
114
+ --title "$version" \
115
+ --generate-notes \
116
+ $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
+ }
@@ -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
  }
@@ -33,7 +43,7 @@ public struct ExpoModuleMacro: MemberMacro {
33
43
  var entries: [String] = ["Name(\"\(moduleName)\")"]
34
44
 
35
45
  for typeName in classListArgument(of: node, label: "classes") {
36
- entries.append("\(typeName)._exposedClassDefinition()")
46
+ entries.append("\(typeName)._synthesizedClassDefinition()")
37
47
  }
38
48
 
39
49
  for member in classDecl.memberBlock.members {
@@ -54,14 +64,153 @@ public struct ExpoModuleMacro: MemberMacro {
54
64
  let lines = entries.map { " \($0)" }.joined(separator: ",\n")
55
65
  let body = " return [\n\(lines)\n ]"
56
66
 
67
+ var emitted: [DeclSyntax] = []
68
+
69
+ // `Module`/`BaseModule` already provide `appContext` storage and the
70
+ // `init(appContext:)` requirement, so we only synthesize them for classes that
71
+ // inherit from neither. Each is skipped individually if the user wrote their own,
72
+ // to avoid emitting a duplicate declaration.
73
+ if !inheritsFromAny(classDecl, names: ["Module", "BaseModule"]) {
74
+ if !hasStoredProperty(classDecl, named: "appContext") {
75
+ emitted.append("public weak var appContext: AppContext?")
76
+ }
77
+ if !hasAppContextInitializer(classDecl) {
78
+ emitted.append(
79
+ """
80
+ public required init(appContext: AppContext) {
81
+ self.appContext = appContext
82
+ }
83
+ """
84
+ )
85
+ }
86
+ }
87
+
88
+ // Always emitted: the definition is derived from the class name and `@JS` members,
89
+ // independent of any `definition()` the user wrote.
57
90
  let method: DeclSyntax = """
58
- public func _exposedDefinition() -> [AnyDefinition] {
91
+ public func _synthesizedDefinition() -> [AnyDefinition] {
59
92
  \(raw: body)
60
93
  }
61
94
  """
95
+ emitted.append(method)
96
+
97
+ return emitted
98
+ }
99
+ }
100
+
101
+ extension ExpoModuleMacro: MemberAttributeMacro {
102
+ public static func expansion(
103
+ of node: AttributeSyntax,
104
+ attachedTo declaration: some DeclGroupSyntax,
105
+ providingAttributesFor member: some DeclSyntaxProtocol,
106
+ in context: some MacroExpansionContext
107
+ ) throws -> [AttributeSyntax] {
108
+ // This macro is invoked once per member and may attach more than one attribute,
109
+ // so we collect into an array rather than returning early. The two checks below are
110
+ // independent — a given member matches at most one in practice, but they're written
111
+ // so neither precludes the other.
112
+ var attributes: [AttributeSyntax] = []
113
+
114
+ // `@JS` sync members run on the JS thread; stamp `@JavaScriptActor` so isolation is
115
+ // checked at compile time. Skipped when the member already chose an isolation
116
+ // (`async`, `nonisolated`, or another global actor) — see `shouldStampJavaScriptActor`.
117
+ if memberHasJSAttribute(member),
118
+ shouldStampJavaScriptActor(on: member, enclosedBy: declaration) {
119
+ attributes.append("@JavaScriptActor")
120
+ }
121
+
122
+ // Apply the result builder to `definition()` so the user doesn't have to. Skipped if
123
+ // they already wrote `@ModuleDefinitionBuilder` themselves, which would otherwise be
124
+ // a duplicate attribute.
125
+ if isModuleDefinitionFunction(member),
126
+ !memberAttributes(of: member).contains(where: { hasAttributeName($0, "ModuleDefinitionBuilder") }) {
127
+ attributes.append("@ModuleDefinitionBuilder")
128
+ }
129
+
130
+ return attributes
131
+ }
132
+ }
133
+
134
+ private func isModuleDefinitionFunction(_ member: some DeclSyntaxProtocol) -> Bool {
135
+ guard let funcDecl = member.as(FunctionDeclSyntax.self),
136
+ funcDecl.name.text == "definition",
137
+ funcDecl.signature.parameterClause.parameters.isEmpty,
138
+ let returnType = funcDecl.signature.returnClause?.type else {
139
+ return false
140
+ }
141
+ return baseIdentifier(of: returnType) == "ModuleDefinition"
142
+ }
62
143
 
63
- return [method]
144
+ private func hasAttributeName(_ element: AttributeListSyntax.Element, _ name: String) -> Bool {
145
+ guard let attribute = element.as(AttributeSyntax.self) else {
146
+ return false
147
+ }
148
+ return attribute.attributeName.trimmedDescription == name
149
+ }
150
+
151
+ extension ExpoModuleMacro: ExtensionMacro {
152
+ public static func expansion(
153
+ of node: AttributeSyntax,
154
+ attachedTo declaration: some DeclGroupSyntax,
155
+ providingExtensionsOf type: some TypeSyntaxProtocol,
156
+ conformingTo protocols: [TypeSyntax],
157
+ in context: some MacroExpansionContext
158
+ ) throws -> [ExtensionDeclSyntax] {
159
+ // `protocols` is empty when the compiler already sees the conformance (e.g. it's
160
+ // spelled in the declaration), in which case there's nothing for us to add.
161
+ guard !protocols.isEmpty else {
162
+ return []
163
+ }
164
+ // These base types already supply `AnyModule`; emitting a second conformance here
165
+ // would be redundant and error.
166
+ if let classDecl = declaration.as(ClassDeclSyntax.self),
167
+ inheritsFromAny(classDecl, names: ["Module", "BaseModule", "AnyModule"]) {
168
+ return []
169
+ }
170
+ let conformance: DeclSyntax = "extension \(type.trimmed): AnyModule {}"
171
+ return [conformance.cast(ExtensionDeclSyntax.self)]
172
+ }
173
+ }
174
+
175
+ // MARK: - Synthesized-member detection
176
+
177
+ private func hasStoredProperty(_ classDecl: ClassDeclSyntax, named name: String) -> Bool {
178
+ for member in classDecl.memberBlock.members {
179
+ guard let varDecl = member.decl.as(VariableDeclSyntax.self) else {
180
+ continue
181
+ }
182
+ for binding in varDecl.bindings {
183
+ if let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
184
+ identifier.identifier.text == name {
185
+ return true
186
+ }
187
+ }
188
+ }
189
+ return false
190
+ }
191
+
192
+ /**
193
+ True if the class declares an initializer that satisfies the `init(appContext:)` protocol
194
+ requirement: a single parameter labeled `appContext` whose type is written as `AppContext`
195
+ (or `…​.AppContext`). Both must match — the label is part of the requirement, so
196
+ `init(c: AppContext)` does *not* satisfy it (we still synthesize ours), and the type guards
197
+ against a same-labeled init of an unrelated type. A macro can't resolve types, so this is a
198
+ syntactic match on the spelled name.
199
+ */
200
+ private func hasAppContextInitializer(_ classDecl: ClassDeclSyntax) -> Bool {
201
+ for member in classDecl.memberBlock.members {
202
+ guard let initDecl = member.decl.as(InitializerDeclSyntax.self) else {
203
+ continue
204
+ }
205
+ let params = initDecl.signature.parameterClause.parameters
206
+ guard params.count == 1, let param = params.first else {
207
+ continue
208
+ }
209
+ if param.firstName.text == "appContext", baseIdentifier(of: param.type) == "AppContext" {
210
+ return true
211
+ }
64
212
  }
213
+ return false
65
214
  }
66
215
 
67
216
  // MARK: - Member builders
@@ -39,6 +39,122 @@ internal func classListArgument(of attribute: AttributeSyntax, label: String) ->
39
39
  return []
40
40
  }
41
41
 
42
+ /**
43
+ Returns true if the class's inheritance clause names any of the given identifiers.
44
+ Matches by base identifier only, so `Module`, `ExpoModulesCore.Module`, and
45
+ `Module<Foo>` all match an entry of "Module".
46
+ */
47
+ internal func inheritsFromAny(_ classDecl: ClassDeclSyntax, names: Set<String>) -> Bool {
48
+ guard let inherited = classDecl.inheritanceClause?.inheritedTypes else {
49
+ return false
50
+ }
51
+ for entry in inherited {
52
+ if let name = baseIdentifier(of: entry.type), names.contains(name) {
53
+ return true
54
+ }
55
+ }
56
+ return false
57
+ }
58
+
59
+ /**
60
+ Returns the rightmost identifier of a type, e.g. `Foo` for `Foo`, `Foo` for
61
+ `Module.Foo`, and nil for composed or generic shapes the macro doesn't need to handle.
62
+ */
63
+ internal func baseIdentifier(of type: TypeSyntax) -> String? {
64
+ if let identifier = type.as(IdentifierTypeSyntax.self) {
65
+ return identifier.name.text
66
+ }
67
+ if let member = type.as(MemberTypeSyntax.self) {
68
+ return member.name.text
69
+ }
70
+ return nil
71
+ }
72
+
73
+ /**
74
+ True if the declaration carries a `@JS` attribute. Works for functions, properties, and inits;
75
+ returns false for any other decl kind.
76
+ */
77
+ internal func memberHasJSAttribute(_ decl: DeclSyntaxProtocol) -> Bool {
78
+ if let funcDecl = decl.as(FunctionDeclSyntax.self) {
79
+ return funcDecl.attributes.firstAttribute(named: "JS") != nil
80
+ }
81
+ if let varDecl = decl.as(VariableDeclSyntax.self) {
82
+ return varDecl.attributes.firstAttribute(named: "JS") != nil
83
+ }
84
+ if let initDecl = decl.as(InitializerDeclSyntax.self) {
85
+ return initDecl.attributes.firstAttribute(named: "JS") != nil
86
+ }
87
+ return false
88
+ }
89
+
90
+ /**
91
+ Decides whether the macro should stamp `@JavaScriptActor` on a `@JS`-marked member.
92
+ The macro defers to the user when they've already chosen an isolation:
93
+ - the `nonisolated` modifier is present on the member
94
+ - any attribute whose name matches a known global actor (`@MainActor`, `@JavaScriptActor`)
95
+ or follows the `*Actor` naming convention is present on the member or its enclosing type
96
+ Async members never get the stamp because `AsyncFunction` controls their dispatch separately.
97
+ */
98
+ internal func shouldStampJavaScriptActor(
99
+ on member: DeclSyntaxProtocol,
100
+ enclosedBy enclosing: some DeclGroupSyntax
101
+ ) -> Bool {
102
+ let modifiers = memberModifiers(of: member)
103
+ if modifiers.contains(where: { $0.name.text == "nonisolated" }) {
104
+ return false
105
+ }
106
+
107
+ if let funcDecl = member.as(FunctionDeclSyntax.self),
108
+ funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil {
109
+ return false
110
+ }
111
+
112
+ let memberAttributes = memberAttributes(of: member)
113
+ if memberAttributes.contains(where: hasGlobalActorShape) {
114
+ return false
115
+ }
116
+
117
+ if enclosing.attributes.contains(where: hasGlobalActorShape) {
118
+ return false
119
+ }
120
+
121
+ return true
122
+ }
123
+
124
+ private func memberModifiers(of decl: DeclSyntaxProtocol) -> DeclModifierListSyntax {
125
+ if let funcDecl = decl.as(FunctionDeclSyntax.self) {
126
+ return funcDecl.modifiers
127
+ }
128
+ if let varDecl = decl.as(VariableDeclSyntax.self) {
129
+ return varDecl.modifiers
130
+ }
131
+ if let initDecl = decl.as(InitializerDeclSyntax.self) {
132
+ return initDecl.modifiers
133
+ }
134
+ return DeclModifierListSyntax()
135
+ }
136
+
137
+ internal func memberAttributes(of decl: DeclSyntaxProtocol) -> AttributeListSyntax {
138
+ if let funcDecl = decl.as(FunctionDeclSyntax.self) {
139
+ return funcDecl.attributes
140
+ }
141
+ if let varDecl = decl.as(VariableDeclSyntax.self) {
142
+ return varDecl.attributes
143
+ }
144
+ if let initDecl = decl.as(InitializerDeclSyntax.self) {
145
+ return initDecl.attributes
146
+ }
147
+ return AttributeListSyntax()
148
+ }
149
+
150
+ private func hasGlobalActorShape(_ element: AttributeListSyntax.Element) -> Bool {
151
+ guard let attribute = element.as(AttributeSyntax.self) else {
152
+ return false
153
+ }
154
+ let name = attribute.attributeName.trimmedDescription
155
+ return name.hasSuffix("Actor")
156
+ }
157
+
42
158
  extension AttributeListSyntax {
43
159
  internal func firstAttribute(named name: String) -> AttributeSyntax? {
44
160
  for element in self {